feat(avatar): add pixel rendering across studio

Persist the avatar render style and apply it consistently to previews, snapshots, animations, and standalone exports.
This commit is contained in:
smontlouis 2026-08-15 11:41:30 +02:00
parent 8207a2d6aa
commit e205c57687
21 changed files with 716 additions and 51 deletions

View File

@ -9,7 +9,9 @@ import { Separator } from '@/components/ui/separator'
import { useStudioLanguage } from '@/i18n' import { useStudioLanguage } from '@/i18n'
import { type PlaybackStatus } from '@/app/studio-utils' import { type PlaybackStatus } from '@/app/studio-utils'
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import { type SnapshotBackground } from '@/features/export/snapshotExporter' import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene' import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
export function ControlSection({ export function ControlSection({
title, title,
@ -41,12 +43,14 @@ export function SnapshotPreview({
background, background,
colorFrom, colorFrom,
colorTo, colorTo,
renderStyle,
}: { }: {
scene: RenderedScene scene: RenderedScene
colors: RenderedColors colors: RenderedColors
background: SnapshotBackground background: SnapshotBackground
colorFrom: string colorFrom: string
colorTo: string colorTo: string
renderStyle: AvatarRenderStyle
}) { }) {
const id = useId().replace(/:/g, '') const id = useId().replace(/:/g, '')
const clipId = `${id}-clip` const clipId = `${id}-clip`
@ -58,10 +62,29 @@ export function SnapshotPreview({
: background === 'linear' : background === 'linear'
? `url(#${linearId})` ? `url(#${linearId})`
: `url(#${radialId})` : `url(#${radialId})`
const pixelBackground =
background === 'solid'
? colorFrom
: background === 'linear'
? `linear-gradient(135deg, ${colorFrom}, ${colorTo})`
: background === 'radial'
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
: undefined
return ( return (
<div className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}> <div
<svg viewBox="-150 -150 300 300" aria-hidden="true"> className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
style={renderStyle.type === 'pixel' ? { background: pixelBackground } : undefined}
>
{renderStyle.type === 'pixel' ? (
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-preview"
/>
) : (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs> <defs>
<clipPath id={clipId}> <clipPath id={clipId}>
<motion.path d={scene.headPath} /> <motion.path d={scene.headPath} />
@ -92,6 +115,7 @@ export function SnapshotPreview({
))} ))}
</motion.g> </motion.g>
</svg> </svg>
)}
</div> </div>
) )
} }

View File

@ -323,6 +323,8 @@ input {
} }
.avatar { .avatar {
position: relative;
z-index: 2;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -330,6 +332,25 @@ input {
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
} }
.avatar-pixel-canvas {
position: absolute;
z-index: 1;
inset: 0;
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
pointer-events: none;
}
.is-pixel-rendered .avatar-head,
.is-pixel-rendered .avatar-eye {
fill: transparent;
transition: none;
}
.is-pixel-rendered .avatar-head.cyan-outline,
.is-pixel-rendered .avatar-eye.cyan-outline {
stroke: #51ddff;
}
.avatar:active { .avatar:active {
cursor: grabbing; cursor: grabbing;
} }
@ -344,7 +365,7 @@ input {
background: var(--muted); background: var(--muted);
} }
.export-avatar-summary > svg { .export-avatar-summary > .avatar-preview {
width: 62px; width: 62px;
height: 62px; height: 62px;
flex: none; flex: none;
@ -560,7 +581,7 @@ input {
-6px 0; -6px 0;
background-size: 12px 12px; background-size: 12px 12px;
} }
.snapshot-preview svg { .snapshot-preview .avatar-preview {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -1238,7 +1259,7 @@ p {
background: white; background: white;
box-shadow: 0 3px 10px rgb(24 28 35 / 5%); box-shadow: 0 3px 10px rgb(24 28 35 / 5%);
} }
.state-playback-step svg { .state-playback-step .avatar-preview {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@ -1370,7 +1391,7 @@ p {
.avatar-shelf .avatar-card:active { .avatar-shelf .avatar-card:active {
cursor: grabbing; cursor: grabbing;
} }
.avatar-shelf .avatar-card svg { .avatar-shelf .avatar-card .avatar-preview {
height: auto; height: auto;
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
@ -1433,7 +1454,7 @@ p {
background: #edf1ff; background: #edf1ff;
color: #4168d5; color: #4168d5;
} }
.mobile-avatar-tab > svg { .mobile-avatar-tab > .avatar-preview {
width: 27px; width: 27px;
height: 27px; height: 27px;
margin-block: -4px -1px; margin-block: -4px -1px;
@ -1604,6 +1625,18 @@ p {
.color-panel { .color-panel {
position: relative; position: relative;
} }
.render-style-field > [data-slot='select-trigger'] {
width: 150px;
}
.pixel-render-options {
display: grid;
margin-top: 14px;
padding-top: 4px;
border-top: 1px solid #eceef2;
}
.pixel-render-options .switch {
margin-top: 0;
}
.inherit-colors { .inherit-colors {
position: absolute; position: absolute;
top: 14px; top: 14px;
@ -2066,7 +2099,7 @@ p {
padding: 7px; padding: 7px;
cursor: pointer; cursor: pointer;
} }
.avatar-card svg { .avatar-card .avatar-preview {
display: block; display: block;
width: 100%; width: 100%;
height: 105px; height: 105px;
@ -2095,11 +2128,15 @@ p {
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.expression-card svg { .expression-card .avatar-preview {
display: block; display: block;
width: 100%; width: 100%;
height: 64px; height: 64px;
} }
.avatar-preview:is(canvas) {
image-rendering: pixelated;
object-fit: contain;
}
.expression-card > span { .expression-card > span {
position: absolute; position: absolute;
right: 5px; right: 5px;

View File

@ -29,7 +29,11 @@ import {
type AvatarSequence, type AvatarSequence,
type SequenceStep, type SequenceStep,
} from '@/features/animation/sequences' } from '@/features/animation/sequences'
import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars' import {
type AvatarColors,
type AvatarEyeDefaults,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { type Expression } from '@/features/avatar/geometry' import { type Expression } from '@/features/avatar/geometry'
@ -41,6 +45,7 @@ export function SequenceWorkspace({
bodyNodes, bodyNodes,
colors, colors,
avatarEyes, avatarEyes,
renderStyle,
selectedStepId, selectedStepId,
backButtonRef, backButtonRef,
reduceMotion, reduceMotion,
@ -64,6 +69,7 @@ export function SequenceWorkspace({
bodyNodes: BodyNode[] bodyNodes: BodyNode[]
colors: AvatarColors colors: AvatarColors
avatarEyes: AvatarEyeDefaults avatarEyes: AvatarEyeDefaults
renderStyle: AvatarRenderStyle
selectedStepId: string | null selectedStepId: string | null
backButtonRef: RefObject<HTMLButtonElement | null> backButtonRef: RefObject<HTMLButtonElement | null>
reduceMotion: boolean reduceMotion: boolean
@ -252,6 +258,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={colors} colors={colors}
avatarEyes={avatarEyes} avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={`sequence-${editing.draft.id}-${step.id}`} id={`sequence-${editing.draft.id}-${step.id}`}
/> />
<span>{String(expressionIndex).padStart(2, '0')}</span> <span>{String(expressionIndex).padStart(2, '0')}</span>
@ -389,6 +396,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={colors} colors={colors}
avatarEyes={avatarEyes} avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={`sequence-library-${index}`} id={`sequence-library-${index}`}
/> />
<span>{String(index).padStart(2, '0')}</span> <span>{String(index).padStart(2, '0')}</span>

View File

@ -6,6 +6,7 @@ import {
createAvatar, createAvatar,
defaultAvatarEyes, defaultAvatarEyes,
parseAvatarEyeDefaults, parseAvatarEyeDefaults,
parseAvatarRenderStyle,
resolveAvatarBehavior, resolveAvatarBehavior,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import { initialExpressions } from '@/features/avatar/presets' import { initialExpressions } from '@/features/avatar/presets'
@ -35,6 +36,28 @@ describe('avatar eye defaults', () => {
}) })
}) })
describe('avatar render style', () => {
it('keeps vector rendering as the compatible default', () => {
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
})
it('sanitizes pixel settings', () => {
expect(
parseAvatarRenderStyle({
type: 'pixel',
resolution: 500,
})
).toEqual({
type: 'pixel',
resolution: 192,
})
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
type: 'pixel',
resolution: 8,
})
})
})
describe('avatar behavior library', () => { describe('avatar behavior library', () => {
const base = { const base = {
expressions: initialExpressions, expressions: initialExpressions,

View File

@ -20,10 +20,16 @@ export type StudioAvatar = {
body: AvatarBody body: AvatarBody
colors: AvatarColors colors: AvatarColors
eyes: AvatarEyeDefaults eyes: AvatarEyeDefaults
renderStyle: AvatarRenderStyle
behavior?: AvatarBehaviorLibrary behavior?: AvatarBehaviorLibrary
} }
export type AvatarColors = { body: string; eyes: string } export type AvatarColors = { body: string; eyes: string }
export type PixelRenderStyle = {
type: 'pixel'
resolution: number
}
export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
export type AvatarEyeDefaults = Pick< export type AvatarEyeDefaults = Pick<
Expression, Expression,
| 'widthLeft' | 'widthLeft'
@ -39,6 +45,11 @@ export type AvatarEyeDefaults = Pick<
| 'rightAngle' | 'rightAngle'
> >
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' } export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
export const defaultPixelRenderStyle: PixelRenderStyle = {
type: 'pixel',
resolution: 64,
}
export const defaultAvatarEyes: AvatarEyeDefaults = { export const defaultAvatarEyes: AvatarEyeDefaults = {
widthLeft: defaultExpression.widthLeft, widthLeft: defaultExpression.widthLeft,
widthRight: defaultExpression.widthRight, widthRight: defaultExpression.widthRight,
@ -67,6 +78,22 @@ const parseColors = (value: unknown): AvatarColors => {
} }
} }
const finiteBounded = (value: unknown, fallback: number, min: number, max: number) =>
typeof value === 'number' && Number.isFinite(value)
? Math.min(max, Math.max(min, value))
: fallback
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as Partial<PixelRenderStyle> | null
if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
return {
type: 'pixel',
resolution: Math.round(
finiteBounded(candidate.resolution, defaultPixelRenderStyle.resolution, 8, 192)
),
}
}
const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[] const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[]
export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => { export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
const candidate = value as Partial<AvatarEyeDefaults> | null const candidate = value as Partial<AvatarEyeDefaults> | null
@ -174,6 +201,7 @@ export const createAvatar = (name: string): StudioAvatar => ({
body: { primary: { ...surfacePresets.sphere }, nodes: [] }, body: { primary: { ...surfacePresets.sphere }, nodes: [] },
colors: { ...defaultAvatarColors }, colors: { ...defaultAvatarColors },
eyes: { ...defaultAvatarEyes }, eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
}) })
export const parseAvatarLibrary = ( export const parseAvatarLibrary = (
@ -201,6 +229,7 @@ export const parseAvatarLibrary = (
body: parseAvatarBody(avatar.body, surfacePresets.sphere), body: parseAvatarBody(avatar.body, surfacePresets.sphere),
colors: parseColors(avatar.colors), colors: parseColors(avatar.colors),
eyes: parseAvatarEyeDefaults(avatar.eyes), eyes: parseAvatarEyeDefaults(avatar.eyes),
renderStyle: parseAvatarRenderStyle(avatar.renderStyle),
...(behavior ? { behavior } : {}), ...(behavior ? { behavior } : {}),
} }
}) })

View File

@ -15,12 +15,17 @@ import { useStudioLanguage } from '@/i18n'
import { ControlSection } from '@/app/components/common' import { ControlSection } from '@/app/components/common'
import { AmbientMotionField, ColorField, LinkButton, NumericField } from '@/app/components/controls' import { AmbientMotionField, ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { emptyBodyNodes, getPreviewGeometry, resolveColors, type Side } from '@/app/studio-utils' import { emptyBodyNodes, getPreviewGeometry, resolveColors, type Side } from '@/app/studio-utils'
import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars' import {
type AvatarColors,
type AvatarEyeDefaults,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
import { type Expression } from '@/features/avatar/geometry' import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
return ( return (
@ -39,6 +44,7 @@ export function ExpressionPreview({
bodyNodes, bodyNodes,
colors, colors,
avatarEyes, avatarEyes,
renderStyle,
id, id,
}: { }: {
expression: Expression expression: Expression
@ -46,13 +52,35 @@ export function ExpressionPreview({
bodyNodes: BodyNode[] bodyNodes: BodyNode[]
colors: AvatarColors colors: AvatarColors
avatarEyes: AvatarEyeDefaults avatarEyes: AvatarEyeDefaults
renderStyle: AvatarRenderStyle
id: string id: string
}) { }) {
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes) const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
const resolvedColors = resolveColors(expression, colors) const resolvedColors = resolveColors(expression, colors)
if (renderStyle.type === 'pixel') {
return (
<StaticPixelAvatarCanvas
className="avatar-preview"
style={renderStyle}
frame={{
headPath: geometry.headPath,
backPaths: geometry.backPaths,
frontPaths: geometry.frontPaths,
leftPath: geometry.leftPath,
rightPath: geometry.rightPath,
leftOpacity: geometry.leftVisible ? 1 : 0,
rightOpacity: geometry.rightVisible ? 1 : 0,
offsetX: 0,
offsetY: 0,
bodyColor: resolvedColors.body,
eyeColor: resolvedColors.eyes,
}}
/>
)
}
const clipId = `preview-${id}` const clipId = `preview-${id}`
return ( return (
<svg viewBox="-150 -150 300 300" aria-hidden="true"> <svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs> <defs>
<clipPath id={clipId}> <clipPath id={clipId}>
<path d={geometry.headPath} /> <path d={geometry.headPath} />
@ -101,6 +129,7 @@ export function ExpressionCard({
bodyNodes, bodyNodes,
colors, colors,
avatarEyes, avatarEyes,
renderStyle,
previewId, previewId,
onSelect, onSelect,
onEdit, onEdit,
@ -120,6 +149,7 @@ export function ExpressionCard({
bodyNodes: BodyNode[] bodyNodes: BodyNode[]
colors: AvatarColors colors: AvatarColors
avatarEyes: AvatarEyeDefaults avatarEyes: AvatarEyeDefaults
renderStyle: AvatarRenderStyle
previewId: string previewId: string
onSelect: () => void onSelect: () => void
onEdit?: () => void onEdit?: () => void
@ -154,6 +184,7 @@ export function ExpressionCard({
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={colors} colors={colors}
avatarEyes={avatarEyes} avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={previewId} id={previewId}
/> />
<span>{String(index).padStart(2, '0')}</span> <span>{String(index).padStart(2, '0')}</span>

View File

@ -28,6 +28,20 @@ describe('avatar export', () => {
) )
expect(payload).not.toHaveProperty('frames') expect(payload).not.toHaveProperty('frames')
expect(payload.avatar.name).toBe('Strobi') expect(payload.avatar.name).toBe('Strobi')
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
})
it('preserves pixel rendering in standalone exports', () => {
const pixelPayload = createAvatarExportPayload(
{ ...avatar, renderStyle: { type: 'pixel', resolution: 72 } },
initialExpressions,
animations
)
const source = generateJavaScriptAvatarModule(pixelPayload)
expect(pixelPayload.avatar.renderStyle).toEqual({ type: 'pixel', resolution: 72 })
expect(source).toContain('paintPixelGeometry')
expect(source).toContain('Math.max(8, Math.min(192')
}) })
it('generates a standalone JavaScript module without a Web Component', () => { it('generates a standalone JavaScript module without a Web Component', () => {

View File

@ -1,7 +1,11 @@
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry' import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene' import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene'
import { serializeAvatarSnapshot, snapshotFileName } from '@/features/export/snapshotExporter' import {
serializeAvatarSnapshot,
serializePixelSnapshot,
snapshotFileName,
} from '@/features/export/snapshotExporter'
import { surfacePresets } from '@/features/avatar/surfaces' import { surfacePresets } from '@/features/avatar/surfaces'
describe('avatar snapshot export', () => { describe('avatar snapshot export', () => {
@ -38,4 +42,13 @@ describe('avatar snapshot export', () => {
expect(snapshotFileName('Étoile du soir')).toBe('etoile-du-soir-snapshot.svg') expect(snapshotFileName('Étoile du soir')).toBe('etoile-du-soir-snapshot.svg')
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png') expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
}) })
it('embeds a pixel snapshot as a self-contained raster SVG', () => {
const svg = serializePixelSnapshot('Pixel & Co', 'data:image/png;base64,AAAA', 256)
expect(svg).toContain('viewBox="0 0 256 256"')
expect(svg).toContain('image-rendering="pixelated"')
expect(svg).toContain('data:image/png;base64,AAAA')
expect(svg).toContain('aria-label="Pixel &amp; Co"')
})
}) })

View File

@ -23,6 +23,7 @@ export type AvatarExportPayload = {
surface: StudioAvatar['body']['primary'] surface: StudioAvatar['body']['primary']
bodyNodes: StudioAvatar['body']['nodes'] bodyNodes: StudioAvatar['body']['nodes']
colors: StudioAvatar['colors'] colors: StudioAvatar['colors']
renderStyle: StudioAvatar['renderStyle']
} }
expressions: Record<string, Expression> expressions: Record<string, Expression>
animations: Record<string, AvatarExportAnimation> animations: Record<string, AvatarExportAnimation>
@ -92,6 +93,7 @@ export const createAvatarExportPayload = (
surface: avatar.body.primary, surface: avatar.body.primary,
bodyNodes: avatar.body.nodes, bodyNodes: avatar.body.nodes,
colors: avatar.colors, colors: avatar.colors,
renderStyle: avatar.renderStyle,
}, },
expressions: exportedExpressions, expressions: exportedExpressions,
animations, animations,

View File

@ -40,6 +40,51 @@ const resolveColors = expression => ({
eyes: expression.eyeColor || DATA.avatar.colors.eyes, eyes: expression.eyeColor || DATA.avatar.colors.eyes,
}); });
const svgElement = name => document.createElementNS(SVG_NS, name); const svgElement = name => document.createElementNS(SVG_NS, name);
const canvasPath = (context, value, color, opacity = 1) => {
if (!value || opacity <= 0) return;
context.globalAlpha = opacity;
context.fillStyle = color;
context.fill(new Path2D(value));
};
const paintPixelGeometry = (context, geometry, offset, colors, resolution) => {
const scale = resolution / 300;
context.clearRect(0, 0, resolution, resolution);
context.imageSmoothingEnabled = false;
context.save();
context.setTransform(scale, 0, 0, scale, resolution / 2, resolution / 2);
context.translate(offset.x, offset.y);
geometry.backPaths.forEach(value => canvasPath(context, value, colors.body));
canvasPath(context, geometry.headPath, colors.body);
context.save();
context.clip(new Path2D(geometry.headPath));
canvasPath(context, geometry.leftPath, colors.eyes, geometry.leftVisible ? 1 : 0);
canvasPath(context, geometry.rightPath, colors.eyes, geometry.rightVisible ? 1 : 0);
context.restore();
geometry.frontPaths.forEach(value => canvasPath(context, value, colors.body));
context.restore();
const image = context.getImageData(0, 0, resolution, resolution);
const body = colorChannels(colors.body);
const eyes = colorChannels(colors.eyes);
for (let index = 0; index < image.data.length; index += 4) {
if (image.data[index + 3] < 128) {
image.data[index] = 0;
image.data[index + 1] = 0;
image.data[index + 2] = 0;
image.data[index + 3] = 0;
continue;
}
const bodyDistance = (image.data[index] - body[0]) ** 2 +
(image.data[index + 1] - body[1]) ** 2 + (image.data[index + 2] - body[2]) ** 2;
const eyeDistance = (image.data[index] - eyes[0]) ** 2 +
(image.data[index + 1] - eyes[1]) ** 2 + (image.data[index + 2] - eyes[2]) ** 2;
const color = bodyDistance <= eyeDistance ? body : eyes;
image.data[index] = color[0];
image.data[index + 1] = color[1];
image.data[index + 2] = color[2];
image.data[index + 3] = 255;
}
context.putImageData(image, 0, 0);
};
function mountAvatar(target, options = {}) { function mountAvatar(target, options = {}) {
const host = typeof target === 'string' ? document.querySelector(target) : target; const host = typeof target === 'string' ? document.querySelector(target) : target;
@ -56,6 +101,18 @@ function mountAvatar(target, options = {}) {
svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%'; svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
svg.style.display = 'block'; svg.style.display = 'block';
svg.style.overflow = 'visible'; svg.style.overflow = 'visible';
const pixelStyle = DATA.avatar.renderStyle?.type === 'pixel' ? DATA.avatar.renderStyle : null;
const canvas = document.createElement('canvas');
const pixelResolution = pixelStyle ? Math.max(8, Math.min(192, Math.round(pixelStyle.resolution))) : 64;
canvas.width = pixelResolution;
canvas.height = pixelResolution;
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', DATA.avatar.name);
canvas.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
canvas.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
canvas.style.display = 'block';
canvas.style.imageRendering = 'pixelated';
const pixelContext = canvas.getContext('2d', { willReadFrequently: true });
const defs = svgElement('defs'); const defs = svgElement('defs');
const clipPath = svgElement('clipPath'); const clipPath = svgElement('clipPath');
const clipHead = svgElement('path'); const clipHead = svgElement('path');
@ -74,7 +131,8 @@ function mountAvatar(target, options = {}) {
eyesLayer.append(leftEye, rightEye); eyesLayer.append(leftEye, rightEye);
motionLayer.append(backLayer, head, eyesLayer, frontLayer); motionLayer.append(backLayer, head, eyesLayer, frontLayer);
svg.append(motionLayer); svg.append(motionLayer);
host.replaceChildren(svg); const renderElement = pixelStyle ? canvas : svg;
host.replaceChildren(renderElement);
const ensurePaths = (group, paths, fill) => { const ensurePaths = (group, paths, fill) => {
while (group.children.length < paths.length) group.append(svgElement('path')); while (group.children.length < paths.length) group.append(svgElement('path'));
@ -137,6 +195,10 @@ function mountAvatar(target, options = {}) {
eyeOffset, eyeOffset,
}); });
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, bodyElapsed, ambientStrength); const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, bodyElapsed, ambientStrength);
if (pixelStyle && pixelContext) {
paintPixelGeometry(pixelContext, geometry, offset, currentColors, pixelResolution);
return;
}
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')'); motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
ensurePaths(backLayer, geometry.backPaths, currentColors.body); ensurePaths(backLayer, geometry.backPaths, currentColors.body);
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
@ -271,7 +333,7 @@ function mountAvatar(target, options = {}) {
stepTimer = setTimeout(() => advance(animation), duration); stepTimer = setTimeout(() => advance(animation), duration);
}; };
const api = { const api = {
element: svg, element: renderElement,
get animation() { return currentAnimation; }, get animation() { return currentAnimation; },
get playing() { return playing; }, get playing() { return playing; },
play(animationName) { play(animationName) {
@ -353,7 +415,7 @@ function mountAvatar(target, options = {}) {
destroy() { destroy() {
clearSchedule(); clearSchedule();
if (frameRequest !== null) cancelAnimationFrame(frameRequest); if (frameRequest !== null) cancelAnimationFrame(frameRequest);
svg.remove(); renderElement.remove();
}, },
}; };
applyMotion(initialExpression); applyMotion(initialExpression);

View File

@ -71,6 +71,12 @@ export const serializeAvatarSnapshot = (
</svg>` </svg>`
} }
export const serializePixelSnapshot = (name: string, imageDataUrl: string, size: number) =>
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="${escapeXml(name)}">
<image href="${escapeXml(imageDataUrl)}" width="${size}" height="${size}" image-rendering="pixelated"/>
</svg>`
export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => { export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => {
const slug = const slug =
name name

View File

@ -0,0 +1,19 @@
import { quantizePixelArtPixels } from '@/features/rendering/pixelRenderer'
describe('pixel art renderer', () => {
it('removes antialiased transparency and mixed edge colors', () => {
const pixels = new Uint8ClampedArray([91, 127, 229, 90, 88, 122, 221, 220, 24, 25, 31, 255])
quantizePixelArtPixels(pixels, '#5b7fe5', '#111316')
expect([...pixels]).toEqual([0, 0, 0, 0, 91, 127, 229, 255, 17, 19, 22, 255])
})
it('uses a deterministic hard threshold for boundary pixels', () => {
const pixels = new Uint8ClampedArray([91, 127, 229, 127, 91, 127, 229, 128])
quantizePixelArtPixels(pixels, '#5b7fe5', '#111316')
expect([...pixels]).toEqual([0, 0, 0, 0, 91, 127, 229, 255])
})
})

View File

@ -13,7 +13,11 @@ import {
type Highlight, type Highlight,
type PlaybackStatus, type PlaybackStatus,
} from '@/app/studio-utils' } from '@/app/studio-utils'
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '@/features/avatar/avatars' import {
applyAvatarEyeDefaults,
type AvatarEyeDefaults,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
import { import {
@ -40,8 +44,14 @@ import {
} from '@/features/avatar/manipulationSession' } from '@/features/avatar/manipulationSession'
import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview' import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo' import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
import { findBodyNodePath, type RenderedScene } from '@/features/rendering/renderedScene' import {
findBodyNodePath,
type RenderedColors,
type RenderedScene,
} from '@/features/rendering/renderedScene'
export function RotationGizmo({ export function RotationGizmo({
expression, expression,
rendered, rendered,
@ -471,6 +481,8 @@ export function AvatarCanvas({
avatarEyes, avatarEyes,
surface, surface,
scene, scene,
colors,
renderStyle,
rotationGizmo, rotationGizmo,
showWire, showWire,
bodyEditing, bodyEditing,
@ -495,6 +507,8 @@ export function AvatarCanvas({
avatarEyes: AvatarEyeDefaults avatarEyes: AvatarEyeDefaults
surface: SurfaceConfig surface: SurfaceConfig
scene: RenderedScene scene: RenderedScene
colors: RenderedColors
renderStyle: AvatarRenderStyle
rotationGizmo: RenderedRotationGizmo rotationGizmo: RenderedRotationGizmo
showWire: boolean showWire: boolean
bodyEditing: boolean bodyEditing: boolean
@ -744,7 +758,7 @@ export function AvatarCanvas({
} }
useEscapeToCancel(cancelDrag) useEscapeToCancel(cancelDrag)
return ( return (
<div className="avatar-wrap"> <div className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}`}>
{playback && ( {playback && (
<motion.div <motion.div
className="stage-playback-status" className="stage-playback-status"
@ -755,6 +769,14 @@ export function AvatarCanvas({
<PlaybackIdentity name={playback.name} status={playback.status} /> <PlaybackIdentity name={playback.name} status={playback.status} />
</motion.div> </motion.div>
)} )}
{renderStyle.type === 'pixel' && (
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-pixel-canvas"
/>
)}
<svg <svg
ref={svgRef} ref={svgRef}
className="avatar" className="avatar"

View File

@ -0,0 +1,103 @@
import { useEffect, useRef } from 'react'
import type { PixelRenderStyle } from '@/features/avatar/avatars'
import { paintPixelAvatar, type PixelAvatarFrame } from '@/features/rendering/pixelRenderer'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
export function StaticPixelAvatarCanvas({
frame,
style,
className,
}: {
frame: PixelAvatarFrame
style: PixelRenderStyle
className: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
const context = canvas?.getContext('2d', { willReadFrequently: true })
if (!canvas || !context) return
canvas.width = style.resolution
canvas.height = style.resolution
paintPixelAvatar(context, frame, style)
}, [frame, style])
return <canvas ref={canvasRef} className={className} aria-hidden="true" />
}
export function LivePixelAvatarCanvas({
scene,
colors,
style,
className,
}: {
scene: RenderedScene
colors: RenderedColors
style: PixelRenderStyle
className: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
const context = canvas?.getContext('2d', { willReadFrequently: true })
if (!canvas || !context) return
canvas.width = style.resolution
canvas.height = style.resolution
let frameRequest: number | null = null
const readPaths = (paths: RenderedScene['backPaths']) => {
const values: string[] = []
paths.forEach(path => {
const value = path.get()
if (value) values.push(value)
})
return values
}
const paint = () => {
frameRequest = null
paintPixelAvatar(
context,
{
headPath: scene.headPath.get(),
backPaths: readPaths(scene.backPaths),
frontPaths: readPaths(scene.frontPaths),
leftPath: scene.leftPath.get(),
rightPath: scene.rightPath.get(),
leftOpacity: scene.leftOpacity.get(),
rightOpacity: scene.rightOpacity.get(),
offsetX: scene.offsetX.get(),
offsetY: scene.offsetY.get(),
bodyColor: colors.body.get(),
eyeColor: colors.eyes.get(),
},
style
)
}
const schedulePaint = () => {
if (frameRequest === null) frameRequest = requestAnimationFrame(paint)
}
const values = [
scene.headPath,
...scene.backPaths,
...scene.frontPaths,
scene.leftPath,
scene.rightPath,
scene.leftOpacity,
scene.rightOpacity,
scene.offsetX,
scene.offsetY,
colors.body,
colors.eyes,
]
const unsubscribers = values.map(value => value.on('change', schedulePaint))
paint()
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe())
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
}
}, [colors, scene, style])
return <canvas ref={canvasRef} className={className} aria-hidden="true" />
}

View File

@ -0,0 +1,93 @@
import type { PixelRenderStyle } from '@/features/avatar/avatars'
export type PixelAvatarFrame = {
headPath: string
backPaths: string[]
frontPaths: string[]
leftPath: string
rightPath: string
leftOpacity: number
rightOpacity: number
offsetX: number
offsetY: number
bodyColor: string
eyeColor: string
}
const fillPath = (context: CanvasRenderingContext2D, path: string, color: string, opacity = 1) => {
if (!path || opacity <= 0) return
context.globalAlpha = opacity
context.fillStyle = color
context.fill(new Path2D(path))
}
const colorChannels = (color: string): readonly [number, number, number] => [
Number.parseInt(color.slice(1, 3), 16),
Number.parseInt(color.slice(3, 5), 16),
Number.parseInt(color.slice(5, 7), 16),
]
const colorDistance = (
red: number,
green: number,
blue: number,
target: readonly [number, number, number]
) => (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2
export const quantizePixelArtPixels = (
pixels: Uint8ClampedArray,
bodyColor: string,
eyeColor: string,
alphaThreshold = 128
) => {
const body = colorChannels(bodyColor)
const eyes = colorChannels(eyeColor)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index + 3] < alphaThreshold) {
pixels[index] = 0
pixels[index + 1] = 0
pixels[index + 2] = 0
pixels[index + 3] = 0
continue
}
const color =
colorDistance(pixels[index], pixels[index + 1], pixels[index + 2], body) <=
colorDistance(pixels[index], pixels[index + 1], pixels[index + 2], eyes)
? body
: eyes
pixels[index] = color[0]
pixels[index + 1] = color[1]
pixels[index + 2] = color[2]
pixels[index + 3] = 255
}
}
export const paintPixelAvatar = (
context: CanvasRenderingContext2D,
frame: PixelAvatarFrame,
style: PixelRenderStyle
) => {
const resolution = style.resolution
const scale = resolution / 300
context.clearRect(0, 0, resolution, resolution)
context.imageSmoothingEnabled = false
context.save()
context.setTransform(scale, 0, 0, scale, resolution / 2, resolution / 2)
context.translate(frame.offsetX, frame.offsetY)
frame.backPaths.forEach(path => fillPath(context, path, frame.bodyColor))
fillPath(context, frame.headPath, frame.bodyColor)
context.save()
context.clip(new Path2D(frame.headPath))
fillPath(context, frame.leftPath, frame.eyeColor, frame.leftOpacity)
fillPath(context, frame.rightPath, frame.eyeColor, frame.rightOpacity)
context.restore()
frame.frontPaths.forEach(path => fillPath(context, path, frame.bodyColor))
context.restore()
const image = context.getImageData(0, 0, resolution, resolution)
quantizePixelArtPixels(image.data, frame.bodyColor, frame.eyeColor)
context.putImageData(image, 0, 0)
}

View File

@ -99,6 +99,7 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
bodyNodes={avatar.body.nodes} bodyNodes={avatar.body.nodes}
colors={avatar.colors} colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes} avatarEyes={avatar.eyes ?? defaultAvatarEyes}
renderStyle={avatar.renderStyle}
id={`avatar-${avatar.id}`} id={`avatar-${avatar.id}`}
/> />
<span>{avatar.name}</span> <span>{avatar.name}</span>

View File

@ -50,7 +50,11 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils' import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import {
defaultAvatarEyes,
defaultPixelRenderStyle,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body' import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import { import {
ExpressionCard, ExpressionCard,
@ -188,6 +192,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
toggleStatePlayback, toggleStatePlayback,
transitionToExpression, transitionToExpression,
updateAvatarColors, updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension, updateAvatarEyeDimension,
updateAvatarEyePosition, updateAvatarEyePosition,
updateAvatarEyeSize, updateAvatarEyeSize,
@ -203,6 +208,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility, updateWireVisibility,
workspaceBackButtonRef, workspaceBackButtonRef,
} = controller } = controller
const pixelRenderStyle =
activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null
const playbackFooterY = useMotionValue(0) const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0) const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value) const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
@ -289,6 +296,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
selectedStepId={selectedSequenceStepId} selectedStepId={selectedSequenceStepId}
backButtonRef={workspaceBackButtonRef} backButtonRef={workspaceBackButtonRef}
reduceMotion={Boolean(reduceMotion)} reduceMotion={Boolean(reduceMotion)}
@ -792,6 +800,62 @@ export function StudioInspector({ controller }: { controller: StudioController }
/> />
</InspectorCard> </InspectorCard>
</ControlSection> </ControlSection>
<ControlSection
title="Rendu"
subtitle="Choisis la finition visuelle propre à cet avatar."
>
<InspectorCard className="render-style-panel">
<PanelTitle
level={3}
title="Type de rendu"
subtitle="Pixel utilise une palette franche, sans lissage ni couleur intermédiaire."
/>
<Field className="render-style-field" orientation="horizontal">
<FieldTitle>{t('Style')}</FieldTitle>
<Select
value={activeAvatar.renderStyle.type}
items={[
{ value: 'vector', label: t('Vectoriel') },
{ value: 'pixel', label: t('Pixel') },
]}
onValueChange={next => {
if (!next) return
const renderStyle: AvatarRenderStyle =
next === 'pixel'
? { ...defaultPixelRenderStyle }
: { type: 'vector' }
updateAvatarRenderStyle(renderStyle)
}}
>
<SelectTrigger aria-label={t('Type de rendu')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vector">{t('Vectoriel')}</SelectItem>
<SelectItem value="pixel">{t('Pixel')}</SelectItem>
</SelectContent>
</Select>
</Field>
{pixelRenderStyle && (
<div className="pixel-render-options">
<NumericField
label="Définition de la grille"
value={pixelRenderStyle.resolution}
min={8}
max={192}
step={8}
unit="px"
onChange={resolution =>
updateAvatarRenderStyle({
...pixelRenderStyle,
resolution: Math.round(resolution),
})
}
/>
</div>
)}
</InspectorCard>
</ControlSection>
<ControlSection <ControlSection
title="Yeux" title="Yeux"
subtitle="Forme, placement, orientation et couleur du regard par défaut." subtitle="Forme, placement, orientation et couleur du regard par défaut."
@ -1349,6 +1413,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
previewId={String(index)} previewId={String(index)}
onSelect={() => transitionToExpression(preset, index)} onSelect={() => transitionToExpression(preset, index)}
onEdit={() => openExpressionEditor(index, preset)} onEdit={() => openExpressionEditor(index, preset)}
@ -1495,6 +1560,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`} id={`state-card-${sequence.id}`}
/> />
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span> <span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
@ -1578,6 +1644,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={activeAvatar.body.nodes} bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-avatar-${activeAvatar.id}`} id={`export-avatar-${activeAvatar.id}`}
/> />
<div> <div>
@ -1668,6 +1735,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-animation-${animation.id}`} id={`export-animation-${animation.id}`}
/> />
<span>{animation.builtIn ? t(animation.name) : animation.name}</span> <span>{animation.builtIn ? t(animation.name) : animation.name}</span>
@ -1704,6 +1772,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
background={snapshotBackground} background={snapshotBackground}
colorFrom={snapshotColorFrom} colorFrom={snapshotColorFrom}
colorTo={snapshotColorTo} colorTo={snapshotColorTo}
renderStyle={activeAvatar.renderStyle}
/> />
<div> <div>
<small>{t('Aperçu du mode photo')}</small> <small>{t('Aperçu du mode photo')}</small>
@ -1914,6 +1983,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`player-${activeSequence.id}-${position}`} id={`player-${activeSequence.id}-${position}`}
/> />
{playbackVisual.position === position && ( {playbackVisual.position === position && (
@ -2016,6 +2086,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={activeAvatar.body.nodes} bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`active-avatar-tab-${activeAvatar.id}`} id={`active-avatar-tab-${activeAvatar.id}`}
/> />
<span>{activeAvatar.name}</span> <span>{activeAvatar.name}</span>

View File

@ -12,6 +12,7 @@ import type { StudioController } from '@/features/studio/useStudioController'
export function StudioStage({ controller }: { controller: StudioController }) { export function StudioStage({ controller }: { controller: StudioController }) {
const [photoHelpOpen, setPhotoHelpOpen] = useState(false) const [photoHelpOpen, setPhotoHelpOpen] = useState(false)
const { const {
activeAvatar,
activeAvatarEyes, activeAvatarEyes,
activeSequenceLabel, activeSequenceLabel,
bodyEditing, bodyEditing,
@ -67,6 +68,8 @@ export function StudioStage({ controller }: { controller: StudioController }) {
avatarEyes={activeAvatarEyes} avatarEyes={activeAvatarEyes}
surface={surface} surface={surface}
scene={renderedScene} scene={renderedScene}
colors={renderedColors}
renderStyle={activeAvatar.renderStyle}
rotationGizmo={renderedRotationGizmo} rotationGizmo={renderedRotationGizmo}
showWire={showWire} showWire={showWire}
bodyEditing={bodyEditing} bodyEditing={bodyEditing}

View File

@ -53,6 +53,7 @@ import {
type AvatarBehaviorLibrary, type AvatarBehaviorLibrary,
type AvatarColors, type AvatarColors,
type AvatarEyeDefaults, type AvatarEyeDefaults,
type AvatarRenderStyle,
type StudioAvatar, type StudioAvatar,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import { import {
@ -84,6 +85,7 @@ import {
} from '@/features/export/exporter' } from '@/features/export/exporter'
import { import {
serializeAvatarSnapshot, serializeAvatarSnapshot,
serializePixelSnapshot,
snapshotFileName, snapshotFileName,
type SnapshotBackground, type SnapshotBackground,
} from '@/features/export/snapshotExporter' } from '@/features/export/snapshotExporter'
@ -102,6 +104,7 @@ import {
paintRenderedOffset, paintRenderedOffset,
paintRenderedScene, paintRenderedScene,
} from '@/features/rendering/renderedScene' } from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import { import {
createStudioDocumentStore, createStudioDocumentStore,
loadStudioDocument, loadStudioDocument,
@ -745,6 +748,10 @@ export function useStudioController() {
setDisplayColors(resolveColors(expression, colors)) setDisplayColors(resolveColors(expression, colors))
} }
const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => {
updateActiveAvatar(avatar => ({ ...avatar, renderStyle }))
}
const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => { const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => {
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (!avatar) return if (!avatar) return
@ -1524,13 +1531,92 @@ export function useStudioController() {
} }
) )
const createPixelSnapshotCanvas = () => {
const renderStyle = activeAvatar.renderStyle
if (renderStyle.type !== 'pixel') return null
const size = Number(snapshotSize)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
if (!context) return null
if (snapshotBackground === 'solid') {
context.fillStyle = snapshotColorFrom
context.fillRect(0, 0, size, size)
} else if (snapshotBackground === 'linear') {
const gradient = context.createLinearGradient(0, 0, size, size)
gradient.addColorStop(0, snapshotColorFrom)
gradient.addColorStop(1, snapshotColorTo)
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
} else if (snapshotBackground === 'radial') {
const gradient = context.createRadialGradient(
size * 0.5,
size * 0.42,
0,
size * 0.5,
size * 0.42,
size * 0.7
)
gradient.addColorStop(0, snapshotColorFrom)
gradient.addColorStop(1, snapshotColorTo)
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
}
const avatarCanvas = document.createElement('canvas')
avatarCanvas.width = renderStyle.resolution
avatarCanvas.height = renderStyle.resolution
const avatarContext = avatarCanvas.getContext('2d', { willReadFrequently: true })
if (!avatarContext) return null
paintPixelAvatar(
avatarContext,
{
headPath: renderedScene.headPath.get(),
backPaths: renderedScene.backPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
}),
frontPaths: renderedScene.frontPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
}),
leftPath: renderedScene.leftPath.get(),
rightPath: renderedScene.rightPath.get(),
leftOpacity: renderedScene.leftOpacity.get(),
rightOpacity: renderedScene.rightOpacity.get(),
offsetX: renderedScene.offsetX.get(),
offsetY: renderedScene.offsetY.get(),
bodyColor: renderedColors.body.get(),
eyeColor: renderedColors.eyes.get(),
},
renderStyle
)
context.imageSmoothingEnabled = false
context.drawImage(avatarCanvas, 0, 0, size, size)
return canvas
}
const downloadSnapshotSvg = () => { const downloadSnapshotSvg = () => {
const pixelCanvas = createPixelSnapshotCanvas()
const source = pixelCanvas
? serializePixelSnapshot(
activeAvatar.name,
pixelCanvas.toDataURL('image/png'),
Number(snapshotSize)
)
: currentSnapshotSvg()
downloadBlob( downloadBlob(
new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }), new Blob([source], { type: 'image/svg+xml;charset=utf-8' }),
snapshotFileName(activeAvatar.name) snapshotFileName(activeAvatar.name)
) )
} }
const downloadSnapshotPng = () => { const downloadSnapshotPng = () => {
const pixelCanvas = createPixelSnapshotCanvas()
if (pixelCanvas) {
pixelCanvas.toBlob(blob => {
if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png'))
}, 'image/png')
return
}
const size = Number(snapshotSize) const size = Number(snapshotSize)
const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' })
const sourceUrl = URL.createObjectURL(source) const sourceUrl = URL.createObjectURL(source)
@ -1802,6 +1888,7 @@ export function useStudioController() {
toggleStatePlayback, toggleStatePlayback,
transitionToExpression, transitionToExpression,
updateAvatarColors, updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension, updateAvatarEyeDimension,
updateAvatarEyePosition, updateAvatarEyePosition,
updateAvatarEyeSize, updateAvatarEyeSize,

View File

@ -64,6 +64,15 @@ const english: Record<string, string> = {
'L’avatar à gauche affiche cette expression en direct.': 'L’avatar à gauche affiche cette expression en direct.':
'The avatar on the left previews this expression live.', 'The avatar on the left previews this expression live.',
Corps: 'Body', Corps: 'Body',
Rendu: 'Rendering',
'Choisis la finition visuelle propre à cet avatar.':
'Choose the visual finish specific to this avatar.',
'Type de rendu': 'Rendering type',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
Vectoriel: 'Vector',
Pixel: 'Pixel',
'Définition de la grille': 'Grid resolution',
'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.', 'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.',
'Couleur du corps': 'Body color', 'Couleur du corps': 'Body color',
'Rotation de la tête': 'Head rotation', 'Rotation de la tête': 'Head rotation',

View File

@ -36,6 +36,14 @@ export const chinese: Record<string, string> = {
'Nouvelle expression': '新建表情', 'Nouvelle expression': '新建表情',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。', 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体', Corps: '身体',
Rendu: '渲染',
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
'Type de rendu': '渲染类型',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
Vectoriel: '矢量',
Pixel: '像素',
'Définition de la grille': '网格分辨率',
'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。', 'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。',
'Couleur du corps': '身体颜色', 'Couleur du corps': '身体颜色',
'Rotation de la tête': '头部旋转', 'Rotation de la tête': '头部旋转',