From fc7445002a2798648c5092101e0372c7731bc35c Mon Sep 17 00:00:00 2001 From: smontlouis Date: Tue, 18 Aug 2026 21:36:01 +0200 Subject: [PATCH] feat(studio): add interactive photo mode Add live pose and framing controls, composed SVG/PNG exports, and contrast-aware random background palettes. --- src/app/components/common.tsx | 218 +++- src/app/studio-utils.ts | 3 +- src/app/styles.css | 370 +++++- .../__tests__/snapshot-composition-test.ts | 22 + .../__tests__/snapshot-exporter-test.ts | 18 +- .../export/__tests__/snapshot-palette-test.ts | 64 ++ src/features/export/snapshotComposition.ts | 31 + src/features/export/snapshotExporter.ts | 28 +- src/features/export/snapshotPalette.ts | 148 +++ .../studio/components/PhotoStageFrame.tsx | 177 +++ .../studio/components/StudioInspector.tsx | 1018 ++++++++++------- .../studio/components/StudioStage.tsx | 161 ++- src/features/studio/useStudioController.ts | 39 +- src/i18n/__tests__/i18n-test.ts | 18 + src/i18n/index.ts | 33 + src/i18n/zh.ts | 34 + 16 files changed, 1874 insertions(+), 508 deletions(-) create mode 100644 src/features/export/__tests__/snapshot-composition-test.ts create mode 100644 src/features/export/__tests__/snapshot-palette-test.ts create mode 100644 src/features/export/snapshotComposition.ts create mode 100644 src/features/export/snapshotPalette.ts create mode 100644 src/features/studio/components/PhotoStageFrame.tsx diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx index 1268775..8c9c221 100644 --- a/src/app/components/common.tsx +++ b/src/app/components/common.tsx @@ -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(null) + const compositionGroupRef = useRef(null) + const dragRef = useRef<{ + clientX: number + clientY: number + x: number + y: number + } | null>(null) + const wheelCommitRef = useRef | 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) => { + 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) => { + 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) => { + if (!dragRef.current) return + dragRef.current = null + delete event.currentTarget.dataset.dragging + commitComposition() + } + + const nudge = (event: React.KeyboardEvent) => { + 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 (
{renderStyle.type === 'pixel' ? ( - + + + ) : ( )}
diff --git a/src/app/studio-utils.ts b/src/app/studio-utils.ts index a94a60f..30fc1c6 100644 --- a/src/app/studio-utils.ts +++ b/src/app/studio-utils.ts @@ -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' diff --git a/src/app/styles.css b/src/app/styles.css index b852a98..4a83a9b 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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; diff --git a/src/features/export/__tests__/snapshot-composition-test.ts b/src/features/export/__tests__/snapshot-composition-test.ts new file mode 100644 index 0000000..bc45064 --- /dev/null +++ b/src/features/export/__tests__/snapshot-composition-test.ts @@ -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) + }) +}) diff --git a/src/features/export/__tests__/snapshot-exporter-test.ts b/src/features/export/__tests__/snapshot-exporter-test.ts index 546464d..975bc3e 100644 --- a/src/features/export/__tests__/snapshot-exporter-test.ts +++ b/src/features/export/__tests__/snapshot-exporter-test.ts @@ -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(' { + 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('') + 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', () => { diff --git a/src/features/export/__tests__/snapshot-palette-test.ts b/src/features/export/__tests__/snapshot-palette-test.ts new file mode 100644 index 0000000..8ae388f --- /dev/null +++ b/src/features/export/__tests__/snapshot-palette-test.ts @@ -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) + }) +}) diff --git a/src/features/export/snapshotComposition.ts b/src/features/export/snapshotComposition.ts new file mode 100644 index 0000000..6b3ea4e --- /dev/null +++ b/src/features/export/snapshotComposition.ts @@ -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 diff --git a/src/features/export/snapshotExporter.ts b/src/features/export/snapshotExporter.ts index 734f29d..16f5e73 100644 --- a/src/features/export/snapshotExporter.ts +++ b/src/features/export/snapshotExporter.ts @@ -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 ` - ${gradientMarkup(options)} - ${backgroundMarkup(options)} - ${body} + ${gradientMarkup(options)} + + ${backgroundMarkup(options)} + ${body} + ` } diff --git a/src/features/export/snapshotPalette.ts b/src/features/export/snapshotPalette.ts new file mode 100644 index 0000000..6681b78 --- /dev/null +++ b/src/features/export/snapshotPalette.ts @@ -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) +} diff --git a/src/features/studio/components/PhotoStageFrame.tsx b/src/features/studio/components/PhotoStageFrame.tsx new file mode 100644 index 0000000..2130fdd --- /dev/null +++ b/src/features/studio/components/PhotoStageFrame.tsx @@ -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(null) + const dragRef = useRef<{ + clientX: number + clientY: number + x: number + y: number + } | null>(null) + const wheelCommitRef = useRef | 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) => { + 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) => { + 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) => { + if (!dragRef.current) return + dragRef.current = null + delete event.currentTarget.dataset.dragging + commitComposition() + } + + const nudge = (event: React.KeyboardEvent) => { + 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 ( +
+ + {children} + + {tool === 'frame' && ( +
+ )} +
+ ) +} diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 1996a1b..09191e2 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowRight, + Camera, ChevronDown, ChevronUp, Copy, @@ -11,6 +12,8 @@ import { Play, Plus, RotateCcw, + Scan, + Shuffle, Smile, Trash2, TriangleAlert, @@ -19,7 +22,12 @@ import { import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react' import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react' -import { Accordion } from '@/components/ui/accordion' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -47,7 +55,6 @@ import { ExportSection, InspectorCard, PanelTitle, - SnapshotPreview, StatePlayer, } from '@/app/components/common' import { ColorField, LinkButton, NumericField } from '@/app/components/controls' @@ -61,6 +68,7 @@ import { ExpressionWorkspace, } from '@/features/avatar/components/ExpressionWorkspace' import { defaultExpression } from '@/features/avatar/presets' +import { randomSnapshotPalette } from '@/features/export/snapshotPalette' import { type SnapshotBackground } from '@/features/export/snapshotExporter' import { AvatarPage } from '@/features/studio/components/AvatarDrawer' import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion' @@ -93,6 +101,300 @@ const avatar = createAvatar('#avatar', { ${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`} })` +function PoseControls({ controller }: { controller: StudioController }) { + const { + activeAvatar, + expression, + linked, + setLinked, + showWire, + t, + updateDimension, + updateHighlight, + updateImmediate, + updateSize, + updateSpacing, + updateWireVisibility, + } = controller + + return ( + <> + + + + updateImmediate({ ...expression, bodyColor })} + /> + {expression.bodyColor && ( + + )} + + + + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headX: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headY: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headZ: value })} + /> + + + + + + updateImmediate({ ...expression, eyeColor })} + /> + {expression.eyeColor && ( + + )} + + {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = expression[`width${side}`] + const height = expression[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + + +
+
+

{t('Œil gauche')}

+ updateHighlight(active ? 'left' : null)} + onChange={value => updateImmediate({ ...expression, positionXLeft: value })} + /> + updateHighlight(active ? 'left' : null)} + onChange={value => updateImmediate({ ...expression, positionYLeft: value })} + /> +
+
+

{t('Œil droit')}

+ updateHighlight(active ? 'right' : null)} + onChange={value => updateImmediate({ ...expression, positionXRight: value })} + /> + updateHighlight(active ? 'right' : null)} + onChange={value => updateImmediate({ ...expression, positionYRight: value })} + /> +
+
+
+ updateHighlight(active ? 'both' : null)} + onChange={updateSpacing} + /> +
+
+ +
+ + setLinked(current => ({ ...current, rotation: !current.rotation }))} + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={value => + updateImmediate({ + ...expression, + leftAngle: value, + ...(linked.rotation ? { rightAngle: -value } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={value => + updateImmediate({ + ...expression, + rightAngle: value, + ...(linked.rotation ? { leftAngle: -value } : {}), + }) + } + /> +
+
+
+ + + + updateImmediate({ ...expression, perspective: value })} + /> +
+ {t('Afficher le maillage')} + +
+
+
+ + ) +} + export function StudioInspector({ controller }: { controller: StudioController }) { const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false) const [guideOpen, setGuideOpen] = useState(false) @@ -153,8 +455,11 @@ export function StudioInspector({ controller }: { controller: StudioController } linked, mode, openExpressionEditor, + openPhotoMode, openSequenceEditor, pauseState, + photoPanelSections, + photoTool, playbackStatus, playbackVisual, prepareStudioProjectImport, @@ -166,8 +471,6 @@ export function StudioInspector({ controller }: { controller: StudioController } projectImportRef, reduceMotion, renameActiveAvatar, - renderedColors, - renderedScene, runtimeDefinitionResult, runtimeCopyStatus, runtimeExportErrors, @@ -192,11 +495,14 @@ export function StudioInspector({ controller }: { controller: StudioController } setLinked, setLanguage, setMode, + setPhotoPanelSections, + setPhotoTool, setSelectedSequenceStepId, setSequenceEditing, setSnapshotBackground, setSnapshotColorFrom, setSnapshotColorTo, + setSnapshotComposition, setSnapshotFormat, setSnapshotSize, setSpringSpeed, @@ -205,6 +511,7 @@ export function StudioInspector({ controller }: { controller: StudioController } snapshotBackground, snapshotColorFrom, snapshotColorTo, + snapshotComposition, snapshotFormat, snapshotSize, springSpeed, @@ -216,6 +523,7 @@ export function StudioInspector({ controller }: { controller: StudioController } stopState, surface, t, + takePicture, toggleExportAnimation, toggleStatePlayback, transitionToExpression, @@ -236,6 +544,8 @@ export function StudioInspector({ controller }: { controller: StudioController } const runtimePreviewAnimation = runtimeDefinitionResult.ok ? runtimeDefinitionResult.value.animationOrder[0] : undefined + const updateSnapshotComposition = (patch: Partial) => + setSnapshotComposition(current => ({ ...current, ...patch })) const playbackFooterY = useMotionValue(0) const playbackHandleY = useMotionValue(0) const playbackHandleCounterY = useTransform(playbackHandleY, value => -value) @@ -298,7 +608,7 @@ export function StudioInspector({ controller }: { controller: StudioController } return (
@@ -443,6 +755,12 @@ export function StudioInspector({ controller }: { controller: StudioController } )} + {mode === 'photo' && ( + + )} )} @@ -688,302 +1006,7 @@ export function StudioInspector({ controller }: { controller: StudioController } )} - {!bodyEditing && ( - <> - - - - updateImmediate({ ...expression, bodyColor })} - /> - {expression.bodyColor && ( - - )} - - - - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headX: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headY: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headZ: value })} - /> - - - - - - updateImmediate({ ...expression, eyeColor })} - /> - {expression.eyeColor && ( - - )} - - {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { - width: 'Largeur', - height: 'Hauteur', - size: 'Taille proportionnelle', - }[dimension] - )} -

- - setLinked(current => ({ - ...current, - [dimension]: !current[dimension], - })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = expression[`width${side}`] - const height = expression[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - updateHighlight( - active - ? linked[dimension] - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={next => - dimension === 'size' - ? updateSize(side, next) - : updateDimension(side, dimension, next) - } - /> - ) - })} -
-
- ))} - - -
-
-

{t('Œil gauche')}

- updateHighlight(active ? 'left' : null)} - onChange={value => - updateImmediate({ ...expression, positionXLeft: value }) - } - /> - updateHighlight(active ? 'left' : null)} - onChange={value => - updateImmediate({ ...expression, positionYLeft: value }) - } - /> -
-
-

{t('Œil droit')}

- updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionXRight: value }) - } - /> - updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionYRight: value }) - } - /> -
-
-
- updateHighlight(active ? 'both' : null)} - onChange={updateSpacing} - /> -
-
- -
- - - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- - updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) - } - onChange={value => - updateImmediate({ - ...expression, - leftAngle: value, - ...(linked.rotation ? { rightAngle: -value } : {}), - }) - } - /> - - updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) - } - onChange={value => - updateImmediate({ - ...expression, - rightAngle: value, - ...(linked.rotation ? { leftAngle: -value } : {}), - }) - } - /> -
-
-
- - - - updateImmediate({ ...expression, perspective: value })} - /> -
- {t('Afficher le maillage')} - -
-
-
- - )} + {!bodyEditing && } )} {!editorPageOpen && mode === 'avatars' && } @@ -1270,6 +1293,266 @@ export function StudioInspector({ controller }: { controller: StudioController } )} + {!sequenceEditing && !editing && !bodyEditing && mode === 'photo' && ( +
+ { + const sections = nextSections as typeof photoPanelSections + const currentSections = new Set(photoPanelSections) + const openedSection = sections.find(section => !currentSections.has(section)) + setPhotoPanelSections(sections) + if (openedSection) setPhotoTool(openedSection) + }} + > + + + + + + {t('Pose')} + {t('Orientation, regard, couleurs et perspective.')} + + + + + + + + + + + + + + {t('Cadrage')} + {t('Position, zoom et coins du cadre photo.')} + + + + +
+
+ updateSnapshotComposition({ x })} + /> + updateSnapshotComposition({ y })} + /> + updateSnapshotComposition({ scale: zoom / 100 })} + /> + updateSnapshotComposition({ cornerRadius })} + /> +
+ +
+
+
+
+ + + +
+ {expressions.map((preset, index) => ( + transitionToExpression(preset, index)} + runtimeError={expressionSemanticKeyError(preset)} + /> + ))} +
+
+ + + + + {t('Style')} + + + + {snapshotBackground !== 'transparent' && ( +
+ + {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( + + )} +
+ )} +
+ + + +
+ {t('Format d’export')} + {t('Choisis le type de fichier généré par le mode photo.')} +
+ +
+ + +
+ {t('Définition')} + {t('Dimensions du fichier exporté.')} +
+ +
+
+ + +
+ )} + {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( - - -
- {t('Aperçu du mode photo')} - {activeAvatar.name} - - {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()} - + + - - - - - - {t('Style')} - - - {snapshotBackground !== 'transparent' && ( -
- - {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( - +
+ {t('Composer dans le live preview')} +

+ {t( + 'Choisis une expression, ajuste la pose et cadre l’avatar dans un espace dédié.' )} -

- )} - - - - -
- {t('Format d’export')} - {t('Choisis le type de fichier généré par le mode photo.')} -
- -
- - -
- {t('Définition')} - {t('Dimensions du fichier exporté.')} -
- -
+

+
+
@@ -1727,7 +1913,7 @@ export function StudioInspector({ controller }: { controller: StudioController } )} - {activeSequence && !editorPageOpen && ( + {activeSequence && !editorPageOpen && mode !== 'photo' && ( )} - {!editorPageOpen && ( + {!editorPageOpen && mode !== 'photo' && (
+ )}
- {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.' + )}
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 383d6a4..9cd2429 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -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('png') + const [snapshotComposition, setSnapshotComposition] = useState(() => ({ + ...defaultSnapshotComposition, + cornerRadius: 18, + })) + const [photoTool, setPhotoTool] = useState('frame') + const [photoPanelSections, setPhotoPanelSections] = useState([]) 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, diff --git a/src/i18n/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts index b4b34a5..bf0ac11 100644 --- a/src/i18n/__tests__/i18n-test.ts +++ b/src/i18n/__tests__/i18n-test.ts @@ -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', () => { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index b9da123..22ab162 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -319,10 +319,42 @@ const english: Record = { '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 = { '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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index eef7535..e715f9e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -280,10 +280,43 @@ export const chinese: Record = { '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 = { 'Dégradé linéaire': '线性渐变', 'Dégradé radial': '径向渐变', 'Style d’arrière-plan': '背景样式', + Aléatoire: '随机', Couleur: '颜色', Départ: '起始颜色', Arrivée: '结束颜色',