diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx index a6a8dfa..b3381bd 100644 --- a/src/app/components/common.tsx +++ b/src/app/components/common.tsx @@ -9,7 +9,9 @@ import { Separator } from '@/components/ui/separator' import { useStudioLanguage } from '@/i18n' import { type PlaybackStatus } from '@/app/studio-utils' +import type { AvatarRenderStyle } from '@/features/avatar/avatars' import { type SnapshotBackground } from '@/features/export/snapshotExporter' +import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene' export function ControlSection({ title, @@ -41,12 +43,14 @@ export function SnapshotPreview({ background, colorFrom, colorTo, + renderStyle, }: { scene: RenderedScene colors: RenderedColors background: SnapshotBackground colorFrom: string colorTo: string + renderStyle: AvatarRenderStyle }) { const id = useId().replace(/:/g, '') const clipId = `${id}-clip` @@ -58,40 +62,60 @@ export function SnapshotPreview({ : background === 'linear' ? `url(#${linearId})` : `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 ( -
- +
+ {renderStyle.type === 'pixel' ? ( + + ) : ( + + )}
) } diff --git a/src/app/styles.css b/src/app/styles.css index 70fcc7e..814b77b 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -323,6 +323,8 @@ input { } .avatar { + position: relative; + z-index: 2; display: block; width: 100%; height: 100%; @@ -330,6 +332,25 @@ input { cursor: grab; 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 { cursor: grabbing; } @@ -344,7 +365,7 @@ input { background: var(--muted); } -.export-avatar-summary > svg { +.export-avatar-summary > .avatar-preview { width: 62px; height: 62px; flex: none; @@ -560,7 +581,7 @@ input { -6px 0; background-size: 12px 12px; } -.snapshot-preview svg { +.snapshot-preview .avatar-preview { display: block; width: 100%; height: 100%; @@ -1238,7 +1259,7 @@ p { background: white; box-shadow: 0 3px 10px rgb(24 28 35 / 5%); } -.state-playback-step svg { +.state-playback-step .avatar-preview { width: 100%; height: 100%; } @@ -1370,7 +1391,7 @@ p { .avatar-shelf .avatar-card:active { cursor: grabbing; } -.avatar-shelf .avatar-card svg { +.avatar-shelf .avatar-card .avatar-preview { height: auto; min-height: 0; flex: 1 1 auto; @@ -1433,7 +1454,7 @@ p { background: #edf1ff; color: #4168d5; } -.mobile-avatar-tab > svg { +.mobile-avatar-tab > .avatar-preview { width: 27px; height: 27px; margin-block: -4px -1px; @@ -1604,6 +1625,18 @@ p { .color-panel { 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 { position: absolute; top: 14px; @@ -2066,7 +2099,7 @@ p { padding: 7px; cursor: pointer; } -.avatar-card svg { +.avatar-card .avatar-preview { display: block; width: 100%; height: 105px; @@ -2095,11 +2128,15 @@ p { width: 22px; height: 22px; } -.expression-card svg { +.expression-card .avatar-preview { display: block; width: 100%; height: 64px; } +.avatar-preview:is(canvas) { + image-rendering: pixelated; + object-fit: contain; +} .expression-card > span { position: absolute; right: 5px; diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx index c73762d..14f76bf 100644 --- a/src/features/animation/components/SequenceWorkspace.tsx +++ b/src/features/animation/components/SequenceWorkspace.tsx @@ -29,7 +29,11 @@ import { type AvatarSequence, type SequenceStep, } 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 { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { type Expression } from '@/features/avatar/geometry' @@ -41,6 +45,7 @@ export function SequenceWorkspace({ bodyNodes, colors, avatarEyes, + renderStyle, selectedStepId, backButtonRef, reduceMotion, @@ -64,6 +69,7 @@ export function SequenceWorkspace({ bodyNodes: BodyNode[] colors: AvatarColors avatarEyes: AvatarEyeDefaults + renderStyle: AvatarRenderStyle selectedStepId: string | null backButtonRef: RefObject reduceMotion: boolean @@ -252,6 +258,7 @@ export function SequenceWorkspace({ bodyNodes={bodyNodes} colors={colors} avatarEyes={avatarEyes} + renderStyle={renderStyle} id={`sequence-${editing.draft.id}-${step.id}`} /> {String(expressionIndex).padStart(2, '0')} @@ -389,6 +396,7 @@ export function SequenceWorkspace({ bodyNodes={bodyNodes} colors={colors} avatarEyes={avatarEyes} + renderStyle={renderStyle} id={`sequence-library-${index}`} /> {String(index).padStart(2, '0')} diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts index ded9e96..69835a8 100644 --- a/src/features/avatar/__tests__/avatars-test.ts +++ b/src/features/avatar/__tests__/avatars-test.ts @@ -6,6 +6,7 @@ import { createAvatar, defaultAvatarEyes, parseAvatarEyeDefaults, + parseAvatarRenderStyle, resolveAvatarBehavior, } from '@/features/avatar/avatars' 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', () => { const base = { expressions: initialExpressions, diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts index 03d5b96..72e2375 100644 --- a/src/features/avatar/avatars.ts +++ b/src/features/avatar/avatars.ts @@ -20,10 +20,16 @@ export type StudioAvatar = { body: AvatarBody colors: AvatarColors eyes: AvatarEyeDefaults + renderStyle: AvatarRenderStyle behavior?: AvatarBehaviorLibrary } export type AvatarColors = { body: string; eyes: string } +export type PixelRenderStyle = { + type: 'pixel' + resolution: number +} +export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle export type AvatarEyeDefaults = Pick< Expression, | 'widthLeft' @@ -39,6 +45,11 @@ export type AvatarEyeDefaults = Pick< | 'rightAngle' > 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 = { widthLeft: defaultExpression.widthLeft, 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 | 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)[] export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => { const candidate = value as Partial | null @@ -174,6 +201,7 @@ export const createAvatar = (name: string): StudioAvatar => ({ body: { primary: { ...surfacePresets.sphere }, nodes: [] }, colors: { ...defaultAvatarColors }, eyes: { ...defaultAvatarEyes }, + renderStyle: { ...defaultAvatarRenderStyle }, }) export const parseAvatarLibrary = ( @@ -201,6 +229,7 @@ export const parseAvatarLibrary = ( body: parseAvatarBody(avatar.body, surfacePresets.sphere), colors: parseColors(avatar.colors), eyes: parseAvatarEyeDefaults(avatar.eyes), + renderStyle: parseAvatarRenderStyle(avatar.renderStyle), ...(behavior ? { behavior } : {}), } }) diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx index 808dbdb..dde381f 100644 --- a/src/features/avatar/components/ExpressionWorkspace.tsx +++ b/src/features/avatar/components/ExpressionWorkspace.tsx @@ -15,12 +15,17 @@ import { useStudioLanguage } from '@/i18n' import { ControlSection } from '@/app/components/common' import { AmbientMotionField, ColorField, LinkButton, NumericField } from '@/app/components/controls' 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 { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { type Expression } from '@/features/avatar/geometry' import { defaultExpression } from '@/features/avatar/presets' import { type SurfaceConfig } from '@/features/avatar/surfaces' +import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) return ( @@ -39,6 +44,7 @@ export function ExpressionPreview({ bodyNodes, colors, avatarEyes, + renderStyle, id, }: { expression: Expression @@ -46,13 +52,35 @@ export function ExpressionPreview({ bodyNodes: BodyNode[] colors: AvatarColors avatarEyes: AvatarEyeDefaults + renderStyle: AvatarRenderStyle id: string }) { const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes) const resolvedColors = resolveColors(expression, colors) + if (renderStyle.type === 'pixel') { + return ( + + ) + } const clipId = `preview-${id}` return ( - {String(index).padStart(2, '0')} diff --git a/src/features/export/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts index 1fff5c1..c3808a6 100644 --- a/src/features/export/__tests__/exporter-test.ts +++ b/src/features/export/__tests__/exporter-test.ts @@ -28,6 +28,20 @@ describe('avatar export', () => { ) expect(payload).not.toHaveProperty('frames') 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', () => { diff --git a/src/features/export/__tests__/snapshot-exporter-test.ts b/src/features/export/__tests__/snapshot-exporter-test.ts index 5d60ad9..546464d 100644 --- a/src/features/export/__tests__/snapshot-exporter-test.ts +++ b/src/features/export/__tests__/snapshot-exporter-test.ts @@ -1,7 +1,11 @@ import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry' import { defaultExpression } from '@/features/avatar/presets' 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' 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', '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 & Co"') + }) }) diff --git a/src/features/export/exporter.ts b/src/features/export/exporter.ts index f373d42..9f3fc7a 100644 --- a/src/features/export/exporter.ts +++ b/src/features/export/exporter.ts @@ -23,6 +23,7 @@ export type AvatarExportPayload = { surface: StudioAvatar['body']['primary'] bodyNodes: StudioAvatar['body']['nodes'] colors: StudioAvatar['colors'] + renderStyle: StudioAvatar['renderStyle'] } expressions: Record animations: Record @@ -92,6 +93,7 @@ export const createAvatarExportPayload = ( surface: avatar.body.primary, bodyNodes: avatar.body.nodes, colors: avatar.colors, + renderStyle: avatar.renderStyle, }, expressions: exportedExpressions, animations, diff --git a/src/features/export/proceduralBrowserRuntime.ts b/src/features/export/proceduralBrowserRuntime.ts index 75b4e82..6b5c4e9 100644 --- a/src/features/export/proceduralBrowserRuntime.ts +++ b/src/features/export/proceduralBrowserRuntime.ts @@ -40,6 +40,51 @@ const resolveColors = expression => ({ eyes: expression.eyeColor || DATA.avatar.colors.eyes, }); 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 = {}) { 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.display = 'block'; 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 clipPath = svgElement('clipPath'); const clipHead = svgElement('path'); @@ -74,7 +131,8 @@ function mountAvatar(target, options = {}) { eyesLayer.append(leftEye, rightEye); motionLayer.append(backLayer, head, eyesLayer, frontLayer); svg.append(motionLayer); - host.replaceChildren(svg); + const renderElement = pixelStyle ? canvas : svg; + host.replaceChildren(renderElement); const ensurePaths = (group, paths, fill) => { while (group.children.length < paths.length) group.append(svgElement('path')); @@ -137,6 +195,10 @@ function mountAvatar(target, options = {}) { eyeOffset, }); 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 + ')'); ensurePaths(backLayer, geometry.backPaths, currentColors.body); ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); @@ -271,7 +333,7 @@ function mountAvatar(target, options = {}) { stepTimer = setTimeout(() => advance(animation), duration); }; const api = { - element: svg, + element: renderElement, get animation() { return currentAnimation; }, get playing() { return playing; }, play(animationName) { @@ -353,7 +415,7 @@ function mountAvatar(target, options = {}) { destroy() { clearSchedule(); if (frameRequest !== null) cancelAnimationFrame(frameRequest); - svg.remove(); + renderElement.remove(); }, }; applyMotion(initialExpression); diff --git a/src/features/export/snapshotExporter.ts b/src/features/export/snapshotExporter.ts index 2d454af..734f29d 100644 --- a/src/features/export/snapshotExporter.ts +++ b/src/features/export/snapshotExporter.ts @@ -71,6 +71,12 @@ export const serializeAvatarSnapshot = ( ` } +export const serializePixelSnapshot = (name: string, imageDataUrl: string, size: number) => + ` + + +` + export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => { const slug = name diff --git a/src/features/rendering/__tests__/pixel-renderer-test.ts b/src/features/rendering/__tests__/pixel-renderer-test.ts new file mode 100644 index 0000000..f377e74 --- /dev/null +++ b/src/features/rendering/__tests__/pixel-renderer-test.ts @@ -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]) + }) +}) diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx index 1d1f8c8..2ddbd8f 100644 --- a/src/features/rendering/components/AvatarCanvas.tsx +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -13,7 +13,11 @@ import { type Highlight, type PlaybackStatus, } 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 { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { @@ -40,8 +44,14 @@ import { } from '@/features/avatar/manipulationSession' import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview' +import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' 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({ expression, rendered, @@ -471,6 +481,8 @@ export function AvatarCanvas({ avatarEyes, surface, scene, + colors, + renderStyle, rotationGizmo, showWire, bodyEditing, @@ -495,6 +507,8 @@ export function AvatarCanvas({ avatarEyes: AvatarEyeDefaults surface: SurfaceConfig scene: RenderedScene + colors: RenderedColors + renderStyle: AvatarRenderStyle rotationGizmo: RenderedRotationGizmo showWire: boolean bodyEditing: boolean @@ -744,7 +758,7 @@ export function AvatarCanvas({ } useEscapeToCancel(cancelDrag) return ( -
+
{playback && ( )} + {renderStyle.type === 'pixel' && ( + + )} (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 +} + +export function LivePixelAvatarCanvas({ + scene, + colors, + style, + className, +}: { + scene: RenderedScene + colors: RenderedColors + style: PixelRenderStyle + className: string +}) { + const canvasRef = useRef(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 +} diff --git a/src/features/rendering/pixelRenderer.ts b/src/features/rendering/pixelRenderer.ts new file mode 100644 index 0000000..c20ccdb --- /dev/null +++ b/src/features/rendering/pixelRenderer.ts @@ -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) +} diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx index 38a8f08..f244ca9 100644 --- a/src/features/studio/components/AvatarDrawer.tsx +++ b/src/features/studio/components/AvatarDrawer.tsx @@ -99,6 +99,7 @@ export function AvatarPage({ controller }: { controller: StudioController }) { bodyNodes={avatar.body.nodes} colors={avatar.colors} avatarEyes={avatar.eyes ?? defaultAvatarEyes} + renderStyle={avatar.renderStyle} id={`avatar-${avatar.id}`} /> {avatar.name} diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index c3efbee..75ea493 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -50,7 +50,11 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls' import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' 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 { ExpressionCard, @@ -188,6 +192,7 @@ export function StudioInspector({ controller }: { controller: StudioController } toggleStatePlayback, transitionToExpression, updateAvatarColors, + updateAvatarRenderStyle, updateAvatarEyeDimension, updateAvatarEyePosition, updateAvatarEyeSize, @@ -203,6 +208,8 @@ export function StudioInspector({ controller }: { controller: StudioController } updateWireVisibility, workspaceBackButtonRef, } = controller + const pixelRenderStyle = + activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null const playbackFooterY = useMotionValue(0) const playbackHandleY = useMotionValue(0) const playbackHandleCounterY = useTransform(playbackHandleY, value => -value) @@ -289,6 +296,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={bodyNodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} selectedStepId={selectedSequenceStepId} backButtonRef={workspaceBackButtonRef} reduceMotion={Boolean(reduceMotion)} @@ -792,6 +800,62 @@ export function StudioInspector({ controller }: { controller: StudioController } /> + + + + + {t('Style')} + + + {pixelRenderStyle && ( +
+ + updateAvatarRenderStyle({ + ...pixelRenderStyle, + resolution: Math.round(resolution), + }) + } + /> +
+ )} +
+
transitionToExpression(preset, index)} onEdit={() => openExpressionEditor(index, preset)} @@ -1495,6 +1560,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={bodyNodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} id={`state-card-${sequence.id}`} /> {sequence.builtIn ? t(sequence.name) : sequence.name} @@ -1578,6 +1644,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={activeAvatar.body.nodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} id={`export-avatar-${activeAvatar.id}`} />
@@ -1668,6 +1735,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={bodyNodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} id={`export-animation-${animation.id}`} /> {animation.builtIn ? t(animation.name) : animation.name} @@ -1704,6 +1772,7 @@ export function StudioInspector({ controller }: { controller: StudioController } background={snapshotBackground} colorFrom={snapshotColorFrom} colorTo={snapshotColorTo} + renderStyle={activeAvatar.renderStyle} />
{t('Aperçu du mode photo')} @@ -1914,6 +1983,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={bodyNodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} id={`player-${activeSequence.id}-${position}`} /> {playbackVisual.position === position && ( @@ -2016,6 +2086,7 @@ export function StudioInspector({ controller }: { controller: StudioController } bodyNodes={activeAvatar.body.nodes} colors={activeAvatar.colors} avatarEyes={activeAvatarEyes} + renderStyle={activeAvatar.renderStyle} id={`active-avatar-tab-${activeAvatar.id}`} /> {activeAvatar.name} diff --git a/src/features/studio/components/StudioStage.tsx b/src/features/studio/components/StudioStage.tsx index 01b4492..75ff778 100644 --- a/src/features/studio/components/StudioStage.tsx +++ b/src/features/studio/components/StudioStage.tsx @@ -12,6 +12,7 @@ import type { StudioController } from '@/features/studio/useStudioController' export function StudioStage({ controller }: { controller: StudioController }) { const [photoHelpOpen, setPhotoHelpOpen] = useState(false) const { + activeAvatar, activeAvatarEyes, activeSequenceLabel, bodyEditing, @@ -67,6 +68,8 @@ export function StudioStage({ controller }: { controller: StudioController }) { avatarEyes={activeAvatarEyes} surface={surface} scene={renderedScene} + colors={renderedColors} + renderStyle={activeAvatar.renderStyle} rotationGizmo={renderedRotationGizmo} showWire={showWire} bodyEditing={bodyEditing} diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 0499c18..352ac67 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -53,6 +53,7 @@ import { type AvatarBehaviorLibrary, type AvatarColors, type AvatarEyeDefaults, + type AvatarRenderStyle, type StudioAvatar, } from '@/features/avatar/avatars' import { @@ -84,6 +85,7 @@ import { } from '@/features/export/exporter' import { serializeAvatarSnapshot, + serializePixelSnapshot, snapshotFileName, type SnapshotBackground, } from '@/features/export/snapshotExporter' @@ -102,6 +104,7 @@ import { paintRenderedOffset, paintRenderedScene, } from '@/features/rendering/renderedScene' +import { paintPixelAvatar } from '@/features/rendering/pixelRenderer' import { createStudioDocumentStore, loadStudioDocument, @@ -745,6 +748,10 @@ export function useStudioController() { setDisplayColors(resolveColors(expression, colors)) } + const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => { + updateActiveAvatar(avatar => ({ ...avatar, renderStyle })) + } + const updateAvatarEyes = (changes: Partial) => { const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) 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 pixelCanvas = createPixelSnapshotCanvas() + const source = pixelCanvas + ? serializePixelSnapshot( + activeAvatar.name, + pixelCanvas.toDataURL('image/png'), + Number(snapshotSize) + ) + : currentSnapshotSvg() downloadBlob( - new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }), + new Blob([source], { type: 'image/svg+xml;charset=utf-8' }), snapshotFileName(activeAvatar.name) ) } 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 source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) const sourceUrl = URL.createObjectURL(source) @@ -1802,6 +1888,7 @@ export function useStudioController() { toggleStatePlayback, transitionToExpression, updateAvatarColors, + updateAvatarRenderStyle, updateAvatarEyeDimension, updateAvatarEyePosition, updateAvatarEyeSize, diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 5b80408..b51513c 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -64,6 +64,15 @@ const english: Record = { 'L’avatar à gauche affiche cette expression en direct.': 'The avatar on the left previews this expression live.', 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.', 'Couleur du corps': 'Body color', 'Rotation de la tête': 'Head rotation', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c86ceb6..f5ddb9f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,6 +36,14 @@ export const chinese: Record = { 'Nouvelle expression': '新建表情', 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。', 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.': '头像的整体外观和方向。', 'Couleur du corps': '身体颜色', 'Rotation de la tête': '头部旋转',