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:
parent
8207a2d6aa
commit
e205c57687
@ -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 (
|
||||
<div className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}>
|
||||
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<motion.path d={scene.headPath} />
|
||||
</clipPath>
|
||||
<linearGradient id={linearId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor={colorFrom} />
|
||||
<stop offset="1" stopColor={colorTo} />
|
||||
</linearGradient>
|
||||
<radialGradient id={radialId} cx="50%" cy="42%" r="70%">
|
||||
<stop offset="0" stopColor={colorFrom} />
|
||||
<stop offset="1" stopColor={colorTo} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{background !== 'transparent' && (
|
||||
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
||||
)}
|
||||
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||
{scene.backPaths.map((pathValue, index) => (
|
||||
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
||||
))}
|
||||
<motion.path d={scene.headPath} fill={colors.body} />
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
||||
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
|
||||
</g>
|
||||
{scene.frontPaths.map((pathValue, index) => (
|
||||
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
||||
))}
|
||||
</motion.g>
|
||||
</svg>
|
||||
<div
|
||||
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>
|
||||
<clipPath id={clipId}>
|
||||
<motion.path d={scene.headPath} />
|
||||
</clipPath>
|
||||
<linearGradient id={linearId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor={colorFrom} />
|
||||
<stop offset="1" stopColor={colorTo} />
|
||||
</linearGradient>
|
||||
<radialGradient id={radialId} cx="50%" cy="42%" r="70%">
|
||||
<stop offset="0" stopColor={colorFrom} />
|
||||
<stop offset="1" stopColor={colorTo} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{background !== 'transparent' && (
|
||||
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
||||
)}
|
||||
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||
{scene.backPaths.map((pathValue, index) => (
|
||||
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
||||
))}
|
||||
<motion.path d={scene.headPath} fill={colors.body} />
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
||||
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
|
||||
</g>
|
||||
{scene.frontPaths.map((pathValue, index) => (
|
||||
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
||||
))}
|
||||
</motion.g>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<HTMLButtonElement | null>
|
||||
reduceMotion: boolean
|
||||
@ -252,6 +258,7 @@ export function SequenceWorkspace({
|
||||
bodyNodes={bodyNodes}
|
||||
colors={colors}
|
||||
avatarEyes={avatarEyes}
|
||||
renderStyle={renderStyle}
|
||||
id={`sequence-${editing.draft.id}-${step.id}`}
|
||||
/>
|
||||
<span>{String(expressionIndex).padStart(2, '0')}</span>
|
||||
@ -389,6 +396,7 @@ export function SequenceWorkspace({
|
||||
bodyNodes={bodyNodes}
|
||||
colors={colors}
|
||||
avatarEyes={avatarEyes}
|
||||
renderStyle={renderStyle}
|
||||
id={`sequence-library-${index}`}
|
||||
/>
|
||||
<span>{String(index).padStart(2, '0')}</span>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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)[]
|
||||
export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
|
||||
const candidate = value as Partial<AvatarEyeDefaults> | 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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<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}`
|
||||
return (
|
||||
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path d={geometry.headPath} />
|
||||
@ -101,6 +129,7 @@ export function ExpressionCard({
|
||||
bodyNodes,
|
||||
colors,
|
||||
avatarEyes,
|
||||
renderStyle,
|
||||
previewId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
@ -120,6 +149,7 @@ export function ExpressionCard({
|
||||
bodyNodes: BodyNode[]
|
||||
colors: AvatarColors
|
||||
avatarEyes: AvatarEyeDefaults
|
||||
renderStyle: AvatarRenderStyle
|
||||
previewId: string
|
||||
onSelect: () => void
|
||||
onEdit?: () => void
|
||||
@ -154,6 +184,7 @@ export function ExpressionCard({
|
||||
bodyNodes={bodyNodes}
|
||||
colors={colors}
|
||||
avatarEyes={avatarEyes}
|
||||
renderStyle={renderStyle}
|
||||
id={previewId}
|
||||
/>
|
||||
<span>{String(index).padStart(2, '0')}</span>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,6 +23,7 @@ export type AvatarExportPayload = {
|
||||
surface: StudioAvatar['body']['primary']
|
||||
bodyNodes: StudioAvatar['body']['nodes']
|
||||
colors: StudioAvatar['colors']
|
||||
renderStyle: StudioAvatar['renderStyle']
|
||||
}
|
||||
expressions: Record<string, Expression>
|
||||
animations: Record<string, AvatarExportAnimation>
|
||||
@ -92,6 +93,7 @@ export const createAvatarExportPayload = (
|
||||
surface: avatar.body.primary,
|
||||
bodyNodes: avatar.body.nodes,
|
||||
colors: avatar.colors,
|
||||
renderStyle: avatar.renderStyle,
|
||||
},
|
||||
expressions: exportedExpressions,
|
||||
animations,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -71,6 +71,12 @@ export const serializeAvatarSnapshot = (
|
||||
</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') => {
|
||||
const slug =
|
||||
name
|
||||
|
||||
19
src/features/rendering/__tests__/pixel-renderer-test.ts
Normal file
19
src/features/rendering/__tests__/pixel-renderer-test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
@ -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 (
|
||||
<div className="avatar-wrap">
|
||||
<div className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}`}>
|
||||
{playback && (
|
||||
<motion.div
|
||||
className="stage-playback-status"
|
||||
@ -755,6 +769,14 @@ export function AvatarCanvas({
|
||||
<PlaybackIdentity name={playback.name} status={playback.status} />
|
||||
</motion.div>
|
||||
)}
|
||||
{renderStyle.type === 'pixel' && (
|
||||
<LivePixelAvatarCanvas
|
||||
scene={scene}
|
||||
colors={colors}
|
||||
style={renderStyle}
|
||||
className="avatar-pixel-canvas"
|
||||
/>
|
||||
)}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="avatar"
|
||||
|
||||
103
src/features/rendering/components/PixelAvatarCanvas.tsx
Normal file
103
src/features/rendering/components/PixelAvatarCanvas.tsx
Normal 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" />
|
||||
}
|
||||
93
src/features/rendering/pixelRenderer.ts
Normal file
93
src/features/rendering/pixelRenderer.ts
Normal 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)
|
||||
}
|
||||
@ -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}`}
|
||||
/>
|
||||
<span>{avatar.name}</span>
|
||||
|
||||
@ -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 }
|
||||
/>
|
||||
</InspectorCard>
|
||||
</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
|
||||
title="Yeux"
|
||||
subtitle="Forme, placement, orientation et couleur du regard par défaut."
|
||||
@ -1349,6 +1413,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
bodyNodes={bodyNodes}
|
||||
colors={activeAvatar.colors}
|
||||
avatarEyes={activeAvatarEyes}
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
previewId={String(index)}
|
||||
onSelect={() => 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}`}
|
||||
/>
|
||||
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
|
||||
@ -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}`}
|
||||
/>
|
||||
<div>
|
||||
@ -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}`}
|
||||
/>
|
||||
<span>{animation.builtIn ? t(animation.name) : animation.name}</span>
|
||||
@ -1704,6 +1772,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
background={snapshotBackground}
|
||||
colorFrom={snapshotColorFrom}
|
||||
colorTo={snapshotColorTo}
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
/>
|
||||
<div>
|
||||
<small>{t('Aperçu du mode photo')}</small>
|
||||
@ -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}`}
|
||||
/>
|
||||
<span>{activeAvatar.name}</span>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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<AvatarEyeDefaults>) => {
|
||||
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,
|
||||
|
||||
@ -64,6 +64,15 @@ const english: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@ -36,6 +36,14 @@ export const chinese: Record<string, string> = {
|
||||
'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': '头部旋转',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user