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:
parent
97127cf07a
commit
fc7445002a
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
22
src/features/export/__tests__/snapshot-composition-test.ts
Normal file
22
src/features/export/__tests__/snapshot-composition-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
|
||||
64
src/features/export/__tests__/snapshot-palette-test.ts
Normal file
64
src/features/export/__tests__/snapshot-palette-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
31
src/features/export/snapshotComposition.ts
Normal file
31
src/features/export/snapshotComposition.ts
Normal 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
|
||||
@ -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>`
|
||||
}
|
||||
|
||||
|
||||
148
src/features/export/snapshotPalette.ts
Normal file
148
src/features/export/snapshotPalette.ts
Normal 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)
|
||||
}
|
||||
177
src/features/studio/components/PhotoStageFrame.tsx
Normal file
177
src/features/studio/components/PhotoStageFrame.tsx
Normal 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
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '结束颜色',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user