feat(studio): add interactive photo mode

Add live pose and framing controls, composed SVG/PNG exports,
and contrast-aware random background palettes.
This commit is contained in:
smontlouis 2026-08-18 21:36:01 +02:00
parent 97127cf07a
commit fc7445002a
16 changed files with 1874 additions and 508 deletions

View File

@ -1,6 +1,6 @@
import { Pause, Play, Square } from 'lucide-react'
import { motion } from 'motion/react'
import { useId } from 'react'
import { motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'
import { useEffect, useEffectEvent, useId, useRef } from 'react'
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
@ -11,6 +11,11 @@ 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 {
normalizeSnapshotComposition,
snapshotCornerRadius,
type SnapshotComposition,
} from '@/features/export/snapshotComposition'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
export function ControlSection({
@ -44,6 +49,8 @@ export function SnapshotPreview({
colorFrom,
colorTo,
renderStyle,
composition,
onCompositionChange,
}: {
scene: RenderedScene
colors: RenderedColors
@ -51,11 +58,29 @@ export function SnapshotPreview({
colorFrom: string
colorTo: string
renderStyle: AvatarRenderStyle
composition: SnapshotComposition
onCompositionChange: (composition: SnapshotComposition) => void
}) {
const { t } = useStudioLanguage()
const id = useId().replace(/:/g, '')
const clipId = `${id}-clip`
const frameClipId = `${id}-frame-clip`
const linearId = `${id}-linear`
const radialId = `${id}-radial`
const positionX = useMotionValue(composition.x)
const positionY = useMotionValue(composition.y)
const scale = useMotionValue(composition.scale)
const pixelPositionX = useTransform(positionX, value => `${value / 3}%`)
const pixelPositionY = useTransform(positionY, value => `${value / 3}%`)
const frameRef = useRef<HTMLDivElement>(null)
const compositionGroupRef = useRef<SVGGElement>(null)
const dragRef = useRef<{
clientX: number
clientY: number
x: number
y: number
} | null>(null)
const wheelCommitRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const backgroundFill =
background === 'solid'
? colorFrom
@ -71,21 +96,158 @@ export function SnapshotPreview({
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
: undefined
const paintVectorTransform = () => {
compositionGroupRef.current?.setAttribute(
'transform',
`translate(${positionX.get()} ${positionY.get()}) scale(${scale.get()})`
)
}
useMotionValueEvent(positionX, 'change', paintVectorTransform)
useMotionValueEvent(positionY, 'change', paintVectorTransform)
useMotionValueEvent(scale, 'change', paintVectorTransform)
useEffect(() => {
positionX.set(composition.x)
positionY.set(composition.y)
scale.set(composition.scale)
compositionGroupRef.current?.setAttribute(
'transform',
`translate(${composition.x} ${composition.y}) scale(${composition.scale})`
)
}, [composition.x, composition.y, composition.scale, positionX, positionY, scale])
const commitComposition = () =>
onCompositionChange(
normalizeSnapshotComposition({
...composition,
x: positionX.get(),
y: positionY.get(),
scale: scale.get(),
})
)
const zoomFrame = useEffectEvent((event: WheelEvent) => {
event.preventDefault()
event.stopPropagation()
if (dragRef.current) return
scale.set(
normalizeSnapshotComposition({
...composition,
scale: scale.get() * Math.exp(-event.deltaY * 0.0015),
}).scale
)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
wheelCommitRef.current = setTimeout(commitComposition, 120)
})
useEffect(() => {
const frame = frameRef.current
if (!frame) return
frame.addEventListener('wheel', zoomFrame, { passive: false })
return () => {
frame.removeEventListener('wheel', zoomFrame)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
}
}, [])
const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
dragRef.current = {
clientX: event.clientX,
clientY: event.clientY,
x: positionX.get(),
y: positionY.get(),
}
event.currentTarget.dataset.dragging = 'true'
event.currentTarget.setPointerCapture(event.pointerId)
}
const drag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
const bounds = event.currentTarget.getBoundingClientRect()
const viewBoxPerPixel = 300 / Math.max(bounds.width, 1)
positionX.set(
normalizeSnapshotComposition({
...composition,
x: dragRef.current.x + (event.clientX - dragRef.current.clientX) * viewBoxPerPixel,
}).x
)
positionY.set(
normalizeSnapshotComposition({
...composition,
y: dragRef.current.y + (event.clientY - dragRef.current.clientY) * viewBoxPerPixel,
}).y
)
}
const stopDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
dragRef.current = null
delete event.currentTarget.dataset.dragging
commitComposition()
}
const nudge = (event: React.KeyboardEvent<HTMLDivElement>) => {
const distance = event.shiftKey ? 10 : 2
if (event.key === 'ArrowLeft') positionX.set(positionX.get() - distance)
else if (event.key === 'ArrowRight') positionX.set(positionX.get() + distance)
else if (event.key === 'ArrowUp') positionY.set(positionY.get() - distance)
else if (event.key === 'ArrowDown') positionY.set(positionY.get() + distance)
else if (event.key === '+' || event.key === '=') scale.set(scale.get() + 0.05)
else if (event.key === '-') scale.set(scale.get() - 0.05)
else return
event.preventDefault()
commitComposition()
}
return (
<div
ref={frameRef}
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
style={renderStyle.type === 'pixel' ? { background: pixelBackground } : undefined}
style={
{
'--snapshot-corner-radius': `${composition.cornerRadius}%`,
...(renderStyle.type === 'pixel' ? { background: pixelBackground } : {}),
} as React.CSSProperties
}
role="application"
tabIndex={0}
aria-label={t(
'Cadre du logo. Glisse pour déplacer l’avatar et utilise la molette pour zoomer.'
)}
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
onKeyDown={nudge}
>
{renderStyle.type === 'pixel' ? (
<motion.div
className="snapshot-pixel-transform"
style={{ x: pixelPositionX, y: pixelPositionY, scale }}
>
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-preview"
/>
</motion.div>
) : (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={frameClipId}>
<rect
x="-150"
y="-150"
width="300"
height="300"
rx={snapshotCornerRadius(composition.cornerRadius)}
/>
</clipPath>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
</clipPath>
@ -98,9 +260,11 @@ export function SnapshotPreview({
<stop offset="1" stopColor={colorTo} />
</radialGradient>
</defs>
<g clipPath={`url(#${frameClipId})`}>
{background !== 'transparent' && (
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
)}
<g ref={compositionGroupRef}>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => (
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
@ -108,12 +272,18 @@ export function SnapshotPreview({
<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} />
<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>
</g>
</g>
</svg>
)}
</div>

View File

@ -12,7 +12,8 @@ import { type BodyNode } from '@/features/avatar/body'
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
export type PhotoTool = 'pose' | 'frame'
export type ExportFormat = 'react' | 'javascript'
export type SnapshotFormat = 'svg' | 'png'
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'

View File

@ -94,6 +94,109 @@ input {
content: '';
pointer-events: none;
}
.photo-mode-active::before {
width: 82vw;
height: 82vw;
background: radial-gradient(circle, rgb(91 127 229 / 24%), transparent 66%);
}
.photo-live-frame {
position: relative;
z-index: 1;
width: min(68vh, 68vw, 640px);
max-width: 100%;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--photo-corner-radius, 0%);
outline: 1px solid rgb(255 255 255 / 12%);
outline-offset: -1px;
background: white;
box-shadow:
0 0 0 1px rgb(0 0 0 / 18%),
0 28px 80px rgb(0 0 0 / 38%);
transition-property: border-radius, box-shadow;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.photo-live-frame.is-transparent {
background-color: white;
background-image:
linear-gradient(45deg, #e7e9ee 25%, transparent 25%),
linear-gradient(-45deg, #e7e9ee 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e7e9ee 75%),
linear-gradient(-45deg, transparent 75%, #e7e9ee 75%);
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
background-size: 20px 20px;
}
.photo-live-avatar {
position: absolute;
inset: 0;
transform-origin: center;
}
.photo-live-avatar .avatar-wrap {
width: 100%;
height: 100%;
max-width: none;
}
.photo-live-frame .stage-playback-status {
display: none;
}
.photo-live-frame.is-frame-tool .gizmo-cluster,
.photo-live-frame.is-frame-tool .axis-key {
opacity: 0;
pointer-events: none;
}
.photo-frame-interaction {
position: absolute;
z-index: 12;
inset: 0;
cursor: grab;
outline: none;
touch-action: none;
}
.photo-frame-interaction:focus-visible {
box-shadow: inset 0 0 0 3px rgb(120 149 238 / 72%);
}
.photo-frame-interaction[data-dragging='true'] {
cursor: grabbing;
}
.photo-tool-bar {
position: absolute;
z-index: 14;
bottom: 112px;
left: 50%;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 16px;
background: rgb(22 26 31 / 88%);
box-shadow:
0 0 0 1px rgb(255 255 255 / 10%),
0 14px 34px rgb(0 0 0 / 28%);
backdrop-filter: blur(14px);
transform: translateX(-50%);
}
.photo-tool-bar [data-slot='button'] {
min-height: 40px;
border: 0;
background: transparent;
color: #aeb6c2;
transition-property: scale, background-color, color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.photo-tool-bar [data-slot='button']:active {
scale: 0.96;
}
.photo-tool-bar [data-slot='button'][aria-pressed='true'] {
background: #f6f8fc;
color: #17191d;
box-shadow: 0 4px 12px rgb(0 0 0 / 20%);
}
.photo-flash {
position: absolute;
z-index: 20;
@ -791,37 +894,67 @@ input {
font-size: 12px;
line-height: 1.45;
}
.snapshot-preview-card {
display: flex;
align-items: center;
.snapshot-composer-card {
display: grid;
gap: 14px;
}
.snapshot-preview-card > div:last-child {
.snapshot-composer-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.snapshot-preview-card small,
.snapshot-preview-card span,
.snapshot-composer-meta small,
.snapshot-composer-meta span,
.snapshot-background-field small {
color: var(--muted-foreground);
font-size: 10px;
}
.snapshot-preview-card strong {
.snapshot-composer-meta strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-composer-meta span {
font-variant-numeric: tabular-nums;
}
.snapshot-composer-meta p {
margin: 5px 0 0;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.45;
text-wrap: pretty;
}
.snapshot-preview {
width: 112px;
height: 112px;
flex: none;
position: relative;
width: min(100%, 360px);
aspect-ratio: 1;
margin-inline: auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--snapshot-corner-radius, 0%);
outline: 1px solid rgb(0 0 0 / 10%);
outline-offset: -1px;
background: white;
box-shadow:
0 1px 2px rgb(18 22 30 / 8%),
0 12px 28px rgb(18 22 30 / 9%);
cursor: grab;
touch-action: none;
transition-property: box-shadow;
transition-duration: 160ms;
transition-timing-function: ease-out;
}
.snapshot-preview:hover,
.snapshot-preview:focus-visible {
box-shadow:
0 0 0 3px rgb(91 127 229 / 18%),
0 1px 2px rgb(18 22 30 / 10%),
0 16px 34px rgb(18 22 30 / 13%);
outline: 1px solid rgb(0 0 0 / 10%);
}
.snapshot-preview[data-dragging='true'] {
cursor: grabbing;
}
.snapshot-preview.is-transparent {
background-color: white;
@ -842,15 +975,62 @@ input {
width: 100%;
height: 100%;
}
.snapshot-pixel-transform {
position: absolute;
inset: 0;
transform-origin: center;
}
.snapshot-composition-fields {
display: grid;
gap: 8px;
margin-top: 14px;
}
.snapshot-export-button {
width: 100%;
min-height: 46px;
padding-right: 10px;
transition-property: scale, background-color, box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.snapshot-export-button:active {
scale: 0.96;
}
.snapshot-export-button > span {
margin-left: auto;
padding: 3px 7px;
border-radius: 7px;
background: rgb(255 255 255 / 18%);
font-size: 9px;
font-weight: 850;
letter-spacing: 0.08em;
}
.snapshot-background-field {
align-items: center;
}
.snapshot-background-field > [data-slot='select-trigger'] {
width: 190px;
width: 170px;
margin-left: auto;
}
.snapshot-background-field > div:first-child {
display: grid;
gap: 3px;
.snapshot-background-field [data-slot='select-value'] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-random-button {
flex: 0 0 auto;
min-height: 28px;
padding-inline: 7px;
color: var(--muted-foreground);
font-size: 10px;
}
.snapshot-random-button svg {
width: 12px;
height: 12px;
}
.snapshot-random-button:not(:disabled):hover {
color: var(--foreground);
}
.snapshot-colors {
display: grid;
@ -861,6 +1041,144 @@ input {
.snapshot-settings-separator {
margin-block: 14px;
}
.photo-panel-stack {
padding-bottom: 24px;
}
.photo-tool-accordion {
gap: 8px;
}
.photo-tool-accordion-item {
border: 0;
border-radius: 18px;
box-shadow:
0 0 0 1px rgb(24 28 35 / 7%),
0 5px 16px rgb(24 28 35 / 5%);
transition-property: box-shadow;
transition-duration: 160ms;
transition-timing-function: ease-out;
}
.photo-tool-accordion-item[data-active-tool='true'] {
box-shadow:
0 0 0 2px rgb(91 127 229 / 38%),
0 8px 22px rgb(57 82 153 / 10%);
}
.photo-tool-accordion-trigger {
min-height: 66px;
padding: 11px 14px;
transition-property: scale, background-color, color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.photo-tool-accordion-trigger:active {
scale: 0.96;
}
.photo-tool-accordion-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
.photo-tool-accordion-heading > span:last-child {
display: grid;
min-width: 0;
gap: 2px;
}
.photo-tool-accordion-heading strong {
color: #252a33;
font-size: 12px;
font-weight: 850;
}
.photo-tool-accordion-heading small {
color: var(--muted-foreground);
font-size: 10px;
line-height: 1.4;
text-wrap: pretty;
}
.photo-tool-accordion-icon {
display: grid;
width: 40px;
height: 40px;
flex: 0 0 auto;
border-radius: 12px;
background: #f0f2f7;
color: #626d7e;
place-items: center;
transition-property: background-color, color;
transition-duration: 160ms;
transition-timing-function: ease-out;
}
.photo-tool-accordion-item[data-active-tool='true'] .photo-tool-accordion-icon {
background: #edf1ff;
color: #4168d5;
}
.photo-tool-accordion-icon svg {
width: 18px;
height: 18px;
}
.photo-tool-accordion-content > div {
padding: 0 12px 14px;
}
.photo-tool-accordion-content .control-section {
margin-top: 12px;
}
.photo-tool-accordion-content .control-section:first-child {
margin-top: 0;
}
.photo-frame-settings {
padding-top: 3px;
}
.photo-frame-settings .snapshot-composition-fields {
margin-top: 0;
}
.photo-expression-card {
display: grid;
gap: 14px;
}
.photo-expression-grid {
max-height: 330px;
overflow-y: auto;
padding: 3px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.photo-reset-frame {
width: 100%;
margin-top: 12px;
}
.photo-mode-launch-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.photo-mode-launch-icon {
display: grid;
width: 44px;
height: 44px;
border-radius: 14px;
background: #edf1ff;
color: #4168d5;
place-items: center;
}
.photo-mode-launch-icon svg {
width: 20px;
height: 20px;
}
.photo-mode-launch-card strong {
display: block;
font-size: 13px;
}
.photo-mode-launch-card p {
margin: 4px 0 0;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.45;
text-wrap: pretty;
}
.photo-mode-launch-card > [data-slot='button'] {
grid-column: 1 / -1;
min-height: 42px;
}
.avatar-head,
.preview-head {
fill: var(--avatar-body-color, #5b7fe5);
@ -3284,6 +3602,12 @@ p {
height: min(100%, 82vw, 620px);
max-height: 100%;
}
.photo-live-frame {
width: auto;
max-width: 82vw;
height: min(100%, 82vw, 620px);
max-height: 100%;
}
.workspace-footer,
.state-playback-footer,
.mobile-mode-tabs {
@ -3421,6 +3745,20 @@ p {
height: min(100%, 78vw, 340px);
max-height: 100%;
}
.photo-live-frame {
width: auto;
max-width: 100%;
height: min(100%, 74vw, 320px);
max-height: 100%;
}
.photo-tool-bar {
bottom: max(84px, calc(env(safe-area-inset-bottom) + 72px));
}
.photo-tool-bar [data-slot='button'] {
min-height: 36px;
padding-inline: 10px;
font-size: 10px;
}
.photo-capture-bar {
bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px));
left: 12px;

View File

@ -0,0 +1,22 @@
import {
normalizeSnapshotComposition,
snapshotCornerRadius,
} from '@/features/export/snapshotComposition'
describe('snapshot composition', () => {
it('keeps framing values inside the authoring limits', () => {
expect(
normalizeSnapshotComposition({
x: -400,
y: 400,
scale: 8,
cornerRadius: 80,
})
).toEqual({ x: -180, y: 180, scale: 3, cornerRadius: 50 })
})
it('converts a percentage radius into the 300-unit snapshot viewBox', () => {
expect(snapshotCornerRadius(18)).toBe(54)
expect(snapshotCornerRadius(50)).toBe(150)
})
})

View File

@ -26,7 +26,23 @@ describe('avatar snapshot export', () => {
expect(svg).toContain('transform="translate(3 -2)"')
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
expect(svg).toContain('fill="#111316"')
expect(svg).not.toContain('<rect')
expect(svg).not.toContain('width="300" height="300" fill=')
})
it('applies logo framing without changing the rendered avatar scene', () => {
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
background: 'solid',
colorFrom: '#818181',
colorTo: '#818181',
size: 1024,
composition: { x: 55, y: 65, scale: 1.3, cornerRadius: 18 },
})
expect(svg).toContain('<clipPath id="snapshot-frame-clip">')
expect(svg).toContain('rx="54"')
expect(svg).toContain('clip-path="url(#snapshot-frame-clip)"')
expect(svg).toContain('transform="translate(55 65) scale(1.3)"')
expect(svg).toContain('fill="#818181"')
})
it('embeds a radial background without external dependencies', () => {

View File

@ -0,0 +1,64 @@
import { randomSnapshotPalette, snapshotPaletteContrast } from '@/features/export/snapshotPalette'
describe('snapshot background palettes', () => {
it('returns a solid color for the solid style', () => {
const palette = randomSnapshotPalette(
'solid',
'#FFFFFF',
{ colorFrom: '#000000', colorTo: '#000000' },
() => 0
)
expect(palette.colorFrom).toBe(palette.colorTo)
expect(snapshotPaletteContrast(palette.colorFrom, '#FFFFFF')).toBeGreaterThanOrEqual(3)
})
it.each(['linear', 'radial'] as const)('returns two compatible colors for %s', style => {
const palette = randomSnapshotPalette(
style,
'#FFFFFF',
{ colorFrom: '#000000', colorTo: '#000000' },
() => 0
)
expect(palette.colorFrom).not.toBe(palette.colorTo)
expect(snapshotPaletteContrast(palette.colorFrom, '#FFFFFF')).toBeGreaterThanOrEqual(3)
expect(snapshotPaletteContrast(palette.colorTo, '#FFFFFF')).toBeGreaterThanOrEqual(3)
})
it('creates a visibly deeper edge for radial backgrounds', () => {
const palette = randomSnapshotPalette(
'radial',
'#FFFFFF',
{ colorFrom: '#000000', colorTo: '#000000' },
() => 0.35
)
expect(snapshotPaletteContrast(palette.colorFrom, palette.colorTo)).toBeGreaterThanOrEqual(1.5)
})
it('does not immediately repeat the current palette', () => {
const initial = randomSnapshotPalette(
'linear',
'#151515',
{ colorFrom: '#000000', colorTo: '#000000' },
() => 0
)
const next = randomSnapshotPalette('linear', '#151515', initial, () => 0)
expect(next).not.toEqual(initial)
})
it('produces broad hue variation instead of cycling through a short preset list', () => {
const palettes = Array.from({ length: 12 }, (_, index) =>
randomSnapshotPalette(
'solid',
'#FFFFFF',
{ colorFrom: '#000000', colorTo: '#000000' },
() => index / 12
)
)
expect(new Set(palettes.map(palette => palette.colorFrom)).size).toBe(12)
})
})

View File

@ -0,0 +1,31 @@
export type SnapshotComposition = Readonly<{
x: number
y: number
scale: number
cornerRadius: number
}>
export const defaultSnapshotComposition: SnapshotComposition = {
x: 0,
y: 0,
scale: 1,
cornerRadius: 0,
}
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, Number.isFinite(value) ? value : min))
export const normalizeSnapshotComposition = (
composition: SnapshotComposition
): SnapshotComposition => ({
x: clamp(composition.x, -180, 180),
y: clamp(composition.y, -180, 180),
scale: clamp(composition.scale, 0.4, 3),
cornerRadius: clamp(composition.cornerRadius, 0, 50),
})
export const snapshotCornerRadius = (cornerRadius: number) =>
normalizeSnapshotComposition({
...defaultSnapshotComposition,
cornerRadius,
}).cornerRadius * 3

View File

@ -1,5 +1,11 @@
import type { AvatarColors } from '../avatar/avatars'
import type { RenderedScene } from '../rendering/renderedScene'
import {
defaultSnapshotComposition,
normalizeSnapshotComposition,
snapshotCornerRadius,
type SnapshotComposition,
} from './snapshotComposition'
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
@ -8,6 +14,7 @@ export type SnapshotOptions = {
colorFrom: string
colorTo: string
size: number
composition?: SnapshotComposition
}
const escapeXml = (value: string) =>
@ -51,9 +58,18 @@ export const serializeAvatarSnapshot = (
colors: AvatarColors,
options: SnapshotOptions
) => {
const composition = normalizeSnapshotComposition(
options.composition ?? defaultSnapshotComposition
)
const headPath = scene.headPath.get()
const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean)
const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean)
const backPaths = scene.backPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
})
const frontPaths = scene.frontPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
})
const offsetX = scene.offsetX.get()
const offsetY = scene.offsetY.get()
const body = [
@ -65,9 +81,11 @@ export const serializeAvatarSnapshot = (
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
<defs>${gradientMarkup(options)}<clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
<defs>${gradientMarkup(options)}<clipPath id="snapshot-frame-clip"><rect x="-150" y="-150" width="300" height="300" rx="${snapshotCornerRadius(composition.cornerRadius)}"/></clipPath><clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
<g clip-path="url(#snapshot-frame-clip)">
${backgroundMarkup(options)}
<g transform="translate(${offsetX} ${offsetY})">${body}</g>
<g transform="translate(${composition.x} ${composition.y}) scale(${composition.scale})"><g transform="translate(${offsetX} ${offsetY})">${body}</g></g>
</g>
</svg>`
}

View File

@ -0,0 +1,148 @@
export type SnapshotPaletteStyle = 'solid' | 'linear' | 'radial'
export type SnapshotPalette = {
colorFrom: string
colorTo: string
}
type OklchColor = {
lightness: number
chroma: number
hue: number
}
const hexToRgb = (hex: string) => {
const normalized = hex.replace('#', '')
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null
return {
red: Number.parseInt(normalized.slice(0, 2), 16),
green: Number.parseInt(normalized.slice(2, 4), 16),
blue: Number.parseInt(normalized.slice(4, 6), 16),
}
}
const relativeLuminance = (hex: string) => {
const rgb = hexToRgb(hex)
if (!rgb) return 0
const channel = (value: number) => {
const normalized = value / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
}
export const snapshotPaletteContrast = (first: string, second: string) => {
const firstLuminance = relativeLuminance(first)
const secondLuminance = relativeLuminance(second)
const lightest = Math.max(firstLuminance, secondLuminance)
const darkest = Math.min(firstLuminance, secondLuminance)
return (lightest + 0.05) / (darkest + 0.05)
}
const clamp = (value: number, minimum = 0, maximum = 1) =>
Math.min(Math.max(value, minimum), maximum)
const linearToSrgb = (value: number) => {
const normalized = clamp(value)
return normalized <= 0.0031308 ? normalized * 12.92 : 1.055 * normalized ** (1 / 2.4) - 0.055
}
const channelToHex = (value: number) =>
Math.round(clamp(value) * 255)
.toString(16)
.padStart(2, '0')
.toUpperCase()
const oklchToHex = ({ lightness, chroma, hue }: OklchColor) => {
const radians = (hue * Math.PI) / 180
const a = chroma * Math.cos(radians)
const b = chroma * Math.sin(radians)
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b
const l = lPrime ** 3
const m = mPrime ** 3
const s = sPrime ** 3
const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)
const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)
const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
return `#${channelToHex(red)}${channelToHex(green)}${channelToHex(blue)}`
}
const samePalette = (first: SnapshotPalette, second: SnapshotPalette) =>
first.colorFrom.toLowerCase() === second.colorFrom.toLowerCase() &&
first.colorTo.toLowerCase() === second.colorTo.toLowerCase()
const paletteContrast = (palette: SnapshotPalette, foreground: string) =>
Math.min(
snapshotPaletteContrast(palette.colorFrom, foreground),
snapshotPaletteContrast(palette.colorTo, foreground)
)
const hueForAttempt = (random: () => number, attempt: number) =>
(random() * 360 + attempt * 137.508) % 360
const generatePalette = (
style: SnapshotPaletteStyle,
darkBackground: boolean,
random: () => number,
attempt: number
): SnapshotPalette => {
const hue = hueForAttempt(random, attempt)
const chroma = 0.065 + random() * 0.055
if (style === 'solid') {
const lightness = darkBackground ? 0.3 + random() * 0.14 : 0.78 + random() * 0.13
const color = oklchToHex({ lightness, chroma, hue })
return { colorFrom: color, colorTo: color }
}
const hueShift = 18 + random() * 38
const secondHue = (hue + (random() > 0.5 ? hueShift : -hueShift) + 360) % 360
if (style === 'radial') {
const centerLightness = darkBackground ? 0.36 : 0.94
const edgeLightness = darkBackground ? 0.12 : 0.7
return {
colorFrom: oklchToHex({ lightness: centerLightness, chroma: chroma * 0.82, hue }),
colorTo: oklchToHex({
lightness: edgeLightness,
chroma: chroma * (darkBackground ? 0.42 : 0.85),
hue: secondHue,
}),
}
}
const startLightness = darkBackground ? 0.38 : 0.92
const endLightness = darkBackground ? 0.2 : 0.74
return {
colorFrom: oklchToHex({ lightness: startLightness, chroma, hue }),
colorTo: oklchToHex({ lightness: endLightness, chroma: chroma * 0.9, hue: secondHue }),
}
}
export const randomSnapshotPalette = (
style: SnapshotPaletteStyle,
foreground: string,
current: SnapshotPalette,
random = Math.random
) => {
const darkContrast = snapshotPaletteContrast(foreground, '#15191F')
const lightContrast = snapshotPaletteContrast(foreground, '#F2E8D4')
const darkBackground =
darkContrast >= 3 && lightContrast >= 3 ? random() >= 0.5 : darkContrast >= lightContrast
let bestPalette: SnapshotPalette | null = null
let bestContrast = 0
for (let attempt = 0; attempt < 24; attempt += 1) {
const palette = generatePalette(style, darkBackground, random, attempt)
if (samePalette(palette, current)) continue
const contrast = paletteContrast(palette, foreground)
if (contrast >= 3) return palette
if (contrast > bestContrast) {
bestPalette = palette
bestContrast = contrast
}
}
return bestPalette ?? generatePalette(style, darkBackground, random, 25)
}

View File

@ -0,0 +1,177 @@
import { motion, useMotionValue, useTransform } from 'motion/react'
import { useEffect, useEffectEvent, useRef, type CSSProperties, type ReactNode } from 'react'
import { useStudioLanguage } from '@/i18n'
import type { PhotoTool } from '@/app/studio-utils'
import type { SnapshotBackground } from '@/features/export/snapshotExporter'
import {
normalizeSnapshotComposition,
type SnapshotComposition,
} from '@/features/export/snapshotComposition'
export function PhotoStageFrame({
background,
children,
colorFrom,
colorTo,
composition,
onCompositionChange,
tool,
}: {
background: SnapshotBackground
children: ReactNode
colorFrom: string
colorTo: string
composition: SnapshotComposition
onCompositionChange: (composition: SnapshotComposition) => void
tool: PhotoTool
}) {
const { t } = useStudioLanguage()
const frameRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{
clientX: number
clientY: number
x: number
y: number
} | null>(null)
const wheelCommitRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const positionX = useMotionValue(composition.x)
const positionY = useMotionValue(composition.y)
const scale = useMotionValue(composition.scale)
const translatedX = useTransform(positionX, value => `${value / 3}%`)
const translatedY = useTransform(positionY, value => `${value / 3}%`)
const backgroundValue =
background === 'solid'
? colorFrom
: background === 'linear'
? `linear-gradient(135deg, ${colorFrom}, ${colorTo})`
: background === 'radial'
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
: undefined
useEffect(() => {
positionX.set(composition.x)
positionY.set(composition.y)
scale.set(composition.scale)
}, [composition.x, composition.y, composition.scale, positionX, positionY, scale])
const commitComposition = useEffectEvent(() => {
onCompositionChange(
normalizeSnapshotComposition({
...composition,
x: positionX.get(),
y: positionY.get(),
scale: scale.get(),
})
)
})
const zoomFrame = useEffectEvent((event: WheelEvent) => {
event.preventDefault()
event.stopPropagation()
if (dragRef.current) return
scale.set(
normalizeSnapshotComposition({
...composition,
scale: scale.get() * Math.exp(-event.deltaY * 0.0015),
}).scale
)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
wheelCommitRef.current = setTimeout(commitComposition, 120)
})
useEffect(() => {
const frame = frameRef.current
if (!frame || tool !== 'frame') return
frame.addEventListener('wheel', zoomFrame, { passive: false })
return () => {
frame.removeEventListener('wheel', zoomFrame)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
}
}, [tool])
const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
dragRef.current = {
clientX: event.clientX,
clientY: event.clientY,
x: positionX.get(),
y: positionY.get(),
}
event.currentTarget.dataset.dragging = 'true'
event.currentTarget.setPointerCapture(event.pointerId)
}
const drag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
const bounds = event.currentTarget.getBoundingClientRect()
const viewBoxPerPixel = 300 / Math.max(bounds.width, 1)
positionX.set(
normalizeSnapshotComposition({
...composition,
x: dragRef.current.x + (event.clientX - dragRef.current.clientX) * viewBoxPerPixel,
}).x
)
positionY.set(
normalizeSnapshotComposition({
...composition,
y: dragRef.current.y + (event.clientY - dragRef.current.clientY) * viewBoxPerPixel,
}).y
)
}
const stopDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
dragRef.current = null
delete event.currentTarget.dataset.dragging
commitComposition()
}
const nudge = (event: React.KeyboardEvent<HTMLDivElement>) => {
const distance = event.shiftKey ? 10 : 2
if (event.key === 'ArrowLeft') positionX.set(positionX.get() - distance)
else if (event.key === 'ArrowRight') positionX.set(positionX.get() + distance)
else if (event.key === 'ArrowUp') positionY.set(positionY.get() - distance)
else if (event.key === 'ArrowDown') positionY.set(positionY.get() + distance)
else if (event.key === '+' || event.key === '=') scale.set(scale.get() + 0.05)
else if (event.key === '-') scale.set(scale.get() - 0.05)
else return
event.preventDefault()
commitComposition()
}
return (
<div
ref={frameRef}
className={`photo-live-frame${background === 'transparent' ? ' is-transparent' : ''}${tool === 'frame' ? ' is-frame-tool' : ''}`}
style={
{
'--photo-corner-radius': `${composition.cornerRadius}%`,
...(backgroundValue ? { background: backgroundValue } : {}),
} as CSSProperties
}
>
<motion.div className="photo-live-avatar" style={{ x: translatedX, y: translatedY, scale }}>
{children}
</motion.div>
{tool === 'frame' && (
<div
className="photo-frame-interaction"
role="application"
tabIndex={0}
aria-label={t(
'Cadre du logo. Glisse pour déplacer l’avatar et utilise la molette pour zoomer.'
)}
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
onKeyDown={nudge}
/>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,13 @@
import { Camera, Info } from 'lucide-react'
import { Camera, Info, Move3D, RotateCcw, Scan } from 'lucide-react'
import { motion } from 'motion/react'
import { type CSSProperties, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defaultExpression } from '@/features/avatar/presets'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
@ -24,8 +26,10 @@ export function StudioStage({ controller }: { controller: StudioController }) {
highlight,
linked,
mode,
openPhotoMode,
persistEditedEyeExpression,
photoFlash,
photoTool,
playbackStatus,
previewCanvasExpression,
previewExpressionDraft,
@ -38,8 +42,15 @@ export function StudioStage({ controller }: { controller: StudioController }) {
selectedBodyNodeId,
selectedEyeSide,
setEditing,
setPhotoPanelSections,
setPhotoTool,
setSelectedEyeSide,
setSnapshotComposition,
showWire,
snapshotBackground,
snapshotColorFrom,
snapshotColorTo,
snapshotComposition,
surface,
t,
takePicture,
@ -47,22 +58,16 @@ export function StudioStage({ controller }: { controller: StudioController }) {
updateHighlight,
updateImmediate,
} = controller
return (
<motion.section
className="stage-column"
style={
{
'--avatar-body-color': renderedColors.body,
'--avatar-eye-color': renderedColors.eyes,
} as CSSProperties
const photoMode = mode === 'photo'
const activatePhotoTool = (tool: typeof photoTool) => {
setPhotoTool(tool)
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
}
>
<StudioIdentity
className="stage-identity"
language={controller.language}
setLanguage={controller.setLanguage}
t={t}
/>
const resetPhotoSetup = () => {
updateImmediate({ ...defaultExpression })
setSnapshotComposition(current => ({ ...current, x: 0, y: 0, scale: 1 }))
}
const avatarCanvas = (
<AvatarCanvas
expression={canvasExpression}
avatarEyes={activeAvatarEyes}
@ -101,6 +106,37 @@ export function StudioStage({ controller }: { controller: StudioController }) {
}
onManipulationStart={freezeLivePreviewForManipulation}
/>
)
return (
<motion.section
className={`stage-column${photoMode ? ' photo-mode-active' : ''}`}
style={
{
'--avatar-body-color': renderedColors.body,
'--avatar-eye-color': renderedColors.eyes,
} as CSSProperties
}
>
<StudioIdentity
className="stage-identity"
language={controller.language}
setLanguage={controller.setLanguage}
t={t}
/>
{photoMode ? (
<PhotoStageFrame
background={snapshotBackground}
colorFrom={snapshotColorFrom}
colorTo={snapshotColorTo}
composition={snapshotComposition}
tool={photoTool}
onCompositionChange={setSnapshotComposition}
>
{avatarCanvas}
</PhotoStageFrame>
) : (
avatarCanvas
)}
{photoFlash > 0 && (
<motion.div
className="photo-flash"
@ -112,15 +148,48 @@ export function StudioStage({ controller }: { controller: StudioController }) {
/>
)}
<TooltipProvider>
{photoMode && (
<div className="photo-tool-bar" role="toolbar" aria-label={t('Outils du mode photo')}>
<Button
variant="secondary"
type="button"
aria-pressed={photoTool === 'pose'}
onClick={() => activatePhotoTool('pose')}
>
<Move3D />
{t('Pose')}
</Button>
<Button
variant="secondary"
type="button"
aria-pressed={photoTool === 'frame'}
onClick={() => activatePhotoTool('frame')}
>
<Scan />
{t('Cadrage')}
</Button>
<Button
variant="secondary"
size="icon"
type="button"
aria-label={t('Réinitialiser la pose et le cadrage')}
onClick={resetPhotoSetup}
>
<RotateCcw />
</Button>
</div>
)}
<div className="photo-capture-bar">
<Button
className="photo-capture-button"
type="button"
aria-label={t('Prendre une photo')}
onClick={takePicture}
aria-label={t(photoMode ? 'Prendre une photo' : 'Ouvrir le mode photo')}
onClick={() => (photoMode ? takePicture() : openPhotoMode())}
>
<Camera />
<span className="photo-capture-label">{t('Prendre une photo')}</span>
<span className="photo-capture-label">
{t(photoMode ? 'Prendre une photo' : 'Mode photo')}
</span>
</Button>
<Tooltip
open={photoHelpOpen}
@ -145,7 +214,11 @@ export function StudioStage({ controller }: { controller: StudioController }) {
<Info />
</TooltipTrigger>
<TooltipContent side="top" className="photo-help-tooltip">
{t('Tu peux modifier le format, le fond et la définition du mode photo dans Export.')}
{t(
photoMode
? 'Utilise Pose pour orienter l’avatar et Cadrage pour le déplacer ou le zoomer.'
: 'Ouvre le mode photo pour choisir une expression, cadrer puis exporter l’avatar.'
)}
</TooltipContent>
</Tooltip>
</div>

View File

@ -16,6 +16,7 @@ import {
type ExportFormat,
type Highlight,
type Mode,
type PhotoTool,
type PlaybackStatus,
type Side,
type SnapshotFormat,
@ -94,6 +95,10 @@ import {
snapshotFileName,
type SnapshotBackground,
} from '@/features/export/snapshotExporter'
import {
defaultSnapshotComposition,
normalizeSnapshotComposition,
} from '@/features/export/snapshotComposition'
import {
resetBodyEditorView,
resolveCanvasPreviewExpression,
@ -153,6 +158,12 @@ export function useStudioController() {
const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF')
const [snapshotSize, setSnapshotSize] = useState('1024')
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
const [snapshotComposition, setSnapshotComposition] = useState(() => ({
...defaultSnapshotComposition,
cornerRadius: 18,
}))
const [photoTool, setPhotoTool] = useState<PhotoTool>('frame')
const [photoPanelSections, setPhotoPanelSections] = useState<PhotoTool[]>([])
const [photoFlash, setPhotoFlash] = useState(0)
const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
status: 'idle' | 'success' | 'error'
@ -474,6 +485,13 @@ export function useStudioController() {
return renderedExpression
}
const openPhotoMode = () => {
freezeLivePreviewForManipulation()
setPhotoTool('frame')
setPhotoPanelSections([])
setMode('photo')
}
const updateImmediate = (next: Expression, preservePlayback = false) => {
if (!preservePlayback && statePlaying) pauseState()
stopTransition(true)
@ -1652,6 +1670,7 @@ export function useStudioController() {
colorFrom: snapshotColorFrom,
colorTo: snapshotColorTo,
size: Number(snapshotSize),
composition: snapshotComposition,
}
)
@ -1664,6 +1683,11 @@ export function useStudioController() {
canvas.height = size
const context = canvas.getContext('2d')
if (!context) return null
const composition = normalizeSnapshotComposition(snapshotComposition)
context.save()
context.beginPath()
context.roundRect(0, 0, size, size, (size * composition.cornerRadius) / 100)
context.clip()
if (snapshotBackground === 'solid') {
context.fillStyle = snapshotColorFrom
context.fillRect(0, 0, size, size)
@ -1716,7 +1740,13 @@ export function useStudioController() {
renderStyle
)
context.imageSmoothingEnabled = false
context.drawImage(avatarCanvas, 0, 0, size, size)
context.translate(
size / 2 + (composition.x / 300) * size,
size / 2 + (composition.y / 300) * size
)
context.scale(composition.scale, composition.scale)
context.drawImage(avatarCanvas, -size / 2, -size / 2, size, size)
context.restore()
return canvas
}
const downloadSnapshotSvg = () => {
@ -1938,11 +1968,14 @@ export function useStudioController() {
linked,
mode,
openExpressionEditor,
openPhotoMode,
openSequenceEditor,
pauseState,
pendingProjectImport,
persistEditedEyeExpression,
photoFlash,
photoPanelSections,
photoTool,
playbackStatus,
playbackVisual,
prepareStudioProjectImport,
@ -1989,12 +2022,15 @@ export function useStudioController() {
setLinked,
setMode,
setPendingProjectImport,
setPhotoPanelSections,
setPhotoTool,
setSelectedEyeSide,
setSelectedSequenceStepId,
setSequenceEditing,
setSnapshotBackground,
setSnapshotColorFrom,
setSnapshotColorTo,
setSnapshotComposition,
setSnapshotFormat,
setSnapshotSize,
setSpringSpeed,
@ -2003,6 +2039,7 @@ export function useStudioController() {
snapshotBackground,
snapshotColorFrom,
snapshotColorTo,
snapshotComposition,
snapshotFormat,
snapshotSize,
springSpeed,

View File

@ -29,6 +29,24 @@ describe('avatar studio translations', () => {
expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG')
expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照')
expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
expect(translateStudioText('Ouvrir le mode photo', 'en')).toBe('Open Photo Mode')
expect(translateStudioText('Ouvrir le mode photo', 'zh-CN')).toBe('打开照片模式')
expect(translateStudioText('Cadrage', 'en')).toBe('Framing')
expect(translateStudioText('Recentrer le cadrage', 'zh-CN')).toBe('重置构图')
expect(translateStudioText('Réinitialiser la pose et le cadrage', 'en')).toBe(
'Reset pose and framing'
)
expect(translateStudioText('Réinitialiser la pose et le cadrage', 'zh-CN')).toBe(
'重置姿态和构图'
)
expect(translateStudioText('Aléatoire', 'en')).toBe('Random')
expect(translateStudioText('Aléatoire', 'zh-CN')).toBe('随机')
expect(translateStudioText('Position, zoom et coins du cadre photo.', 'en')).toBe(
'Position, zoom and photo frame corners.'
)
expect(translateStudioText('Orientation, regard, couleurs et perspective.', 'zh-CN')).toBe(
'方向、视线、颜色和透视。'
)
})
it('keeps runtime-export authoring copy synchronized in all three languages', () => {

View File

@ -319,10 +319,42 @@ const english: Record<string, string> = {
'Prêt à exporter': 'Ready to export',
Snapshot: 'Snapshot',
'Mode photo': 'Photo Mode',
Cadrage: 'Framing',
Quitter: 'Exit',
'Outils du mode photo': 'Photo Mode tools',
'Orientation, regard, couleurs et perspective.': 'Orientation, gaze, colors and perspective.',
'Position, zoom et coins du cadre photo.': 'Position, zoom and photo frame corners.',
'Ouvrir le mode photo': 'Open Photo Mode',
'Recentrer le cadrage': 'Reset framing',
'Réinitialiser la pose et le cadrage': 'Reset pose and framing',
'Choisis l’expression visible sur la photo.': 'Choose the expression shown in the picture.',
'Utilise l’outil Cadrage dans l’aperçu ou saisis des valeurs précises.':
'Use the Framing tool in the preview or enter precise values.',
'Prépare une image statique directement dans l’aperçu principal.':
'Prepare a static image directly in the main preview.',
'Composer dans le live preview': 'Compose in the live preview',
'Choisis une expression, ajuste la pose et cadre l’avatar dans un espace dédié.':
'Choose an expression, adjust the pose and frame the avatar in a dedicated workspace.',
'Utilise Pose pour orienter l’avatar et Cadrage pour le déplacer ou le zoomer.':
'Use Pose to orient the avatar and Framing to move or zoom it.',
'Ouvre le mode photo pour choisir une expression, cadrer puis exporter l’avatar.':
'Open Photo Mode to choose an expression, frame the avatar and export it.',
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.',
'Aperçu du Snapshot': 'Snapshot preview',
'Aperçu du mode photo': 'Photo Mode preview',
'Cadre du logo. Glisse pour déplacer l’avatar et utilise la molette pour zoomer.':
'Logo frame. Drag to move the avatar and use the wheel to zoom.',
'Glisse l’avatar pour le placer. La molette zoome sans faire défiler la page.':
'Drag the avatar to position it. The wheel zooms without scrolling the page.',
'Cadrage du logo': 'Logo framing',
'Place, agrandis et recadre l’avatar sans modifier sa géométrie.':
'Position, enlarge and crop the avatar without changing its geometry.',
'Position X': 'X position',
'Position Y': 'Y position',
Zoom: 'Zoom',
'Coins arrondis': 'Rounded corners',
'Exporter le logo': 'Export logo',
'Arrière-plan': 'Background',
'Choisis un fond transparent, uni ou en dégradé.':
'Choose a transparent, solid or gradient background.',
@ -332,6 +364,7 @@ const english: Record<string, string> = {
'Dégradé linéaire': 'Linear gradient',
'Dégradé radial': 'Radial gradient',
'Style d’arrière-plan': 'Background style',
Aléatoire: 'Random',
Couleur: 'Color',
Départ: 'Start',
Arrivée: 'End',

View File

@ -280,10 +280,43 @@ export const chinese: Record<string, string> = {
'Prêt à exporter': '可以导出',
Snapshot: '快照',
'Mode photo': '照片模式',
Cadrage: '构图',
Quitter: '退出',
'Outils du mode photo': '照片模式工具',
'Orientation, regard, couleurs et perspective.': '方向、视线、颜色和透视。',
'Position, zoom et coins du cadre photo.': '照片画框的位置、缩放和圆角。',
'Ouvrir le mode photo': '打开照片模式',
'Recentrer le cadrage': '重置构图',
'Réinitialiser la pose et le cadrage': '重置姿态和构图',
'Choisis l’expression visible sur la photo.': '选择照片中显示的表情。',
'Utilise l’outil Cadrage dans l’aperçu ou saisis des valeurs précises。':
'使用预览中的构图工具,或输入精确数值。',
'Utilise l’outil Cadrage dans l’aperçu ou saisis des valeurs précises.':
'使用预览中的构图工具,或输入精确数值。',
'Prépare une image statique directement dans l’aperçu principal.': '直接在主预览中准备静态图像。',
'Composer dans le live preview': '在实时预览中构图',
'Choisis une expression, ajuste la pose et cadre l’avatar dans un espace dédié.':
'选择表情、调整姿态,并在专用空间中为头像构图。',
'Utilise Pose pour orienter l’avatar et Cadrage pour le déplacer ou le zoomer.':
'使用“姿态”调整头像方向,使用“构图”移动或缩放头像。',
'Ouvre le mode photo pour choisir une expression, cadrer puis exporter l’avatar.':
'打开照片模式,选择表情、构图并导出头像。',
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
'Les options de capture seront configurées ici.': '快照选项将在此配置。',
'Aperçu du Snapshot': '快照预览',
'Aperçu du mode photo': '照片模式预览',
'Cadre du logo. Glisse pour déplacer l’avatar et utilise la molette pour zoomer.':
'徽标画框。拖动以移动头像,使用滚轮缩放。',
'Glisse l’avatar pour le placer. La molette zoome sans faire défiler la page.':
'拖动头像以调整位置。滚轮缩放时页面不会滚动。',
'Cadrage du logo': '徽标构图',
'Place, agrandis et recadre l’avatar sans modifier sa géométrie.':
'在不改变头像几何结构的情况下移动、缩放和裁剪。',
'Position X': 'X 位置',
'Position Y': 'Y 位置',
Zoom: '缩放',
'Coins arrondis': '圆角',
'Exporter le logo': '导出徽标',
'Arrière-plan': '背景',
'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。',
Style: '样式',
@ -292,6 +325,7 @@ export const chinese: Record<string, string> = {
'Dégradé linéaire': '线性渐变',
'Dégradé radial': '径向渐变',
'Style d’arrière-plan': '背景样式',
Aléatoire: '随机',
Couleur: '颜色',
Départ: '起始颜色',
Arrivée: '结束颜色',