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 { Pause, Play, Square } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'
|
||||||
import { useId } from 'react'
|
import { useEffect, useEffectEvent, useId, useRef } from 'react'
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@ -11,6 +11,11 @@ import { useStudioLanguage } from '@/i18n'
|
|||||||
import { type PlaybackStatus } from '@/app/studio-utils'
|
import { type PlaybackStatus } from '@/app/studio-utils'
|
||||||
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
|
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
|
||||||
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||||
|
import {
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
snapshotCornerRadius,
|
||||||
|
type SnapshotComposition,
|
||||||
|
} from '@/features/export/snapshotComposition'
|
||||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
|
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
export function ControlSection({
|
export function ControlSection({
|
||||||
@ -44,6 +49,8 @@ export function SnapshotPreview({
|
|||||||
colorFrom,
|
colorFrom,
|
||||||
colorTo,
|
colorTo,
|
||||||
renderStyle,
|
renderStyle,
|
||||||
|
composition,
|
||||||
|
onCompositionChange,
|
||||||
}: {
|
}: {
|
||||||
scene: RenderedScene
|
scene: RenderedScene
|
||||||
colors: RenderedColors
|
colors: RenderedColors
|
||||||
@ -51,11 +58,29 @@ export function SnapshotPreview({
|
|||||||
colorFrom: string
|
colorFrom: string
|
||||||
colorTo: string
|
colorTo: string
|
||||||
renderStyle: AvatarRenderStyle
|
renderStyle: AvatarRenderStyle
|
||||||
|
composition: SnapshotComposition
|
||||||
|
onCompositionChange: (composition: SnapshotComposition) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
const id = useId().replace(/:/g, '')
|
const id = useId().replace(/:/g, '')
|
||||||
const clipId = `${id}-clip`
|
const clipId = `${id}-clip`
|
||||||
|
const frameClipId = `${id}-frame-clip`
|
||||||
const linearId = `${id}-linear`
|
const linearId = `${id}-linear`
|
||||||
const radialId = `${id}-radial`
|
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 =
|
const backgroundFill =
|
||||||
background === 'solid'
|
background === 'solid'
|
||||||
? colorFrom
|
? colorFrom
|
||||||
@ -71,21 +96,158 @@ export function SnapshotPreview({
|
|||||||
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
|
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
|
||||||
: undefined
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={frameRef}
|
||||||
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
|
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' ? (
|
{renderStyle.type === 'pixel' ? (
|
||||||
<LivePixelAvatarCanvas
|
<motion.div
|
||||||
scene={scene}
|
className="snapshot-pixel-transform"
|
||||||
colors={colors}
|
style={{ x: pixelPositionX, y: pixelPositionY, scale }}
|
||||||
style={renderStyle}
|
>
|
||||||
className="avatar-preview"
|
<LivePixelAvatarCanvas
|
||||||
/>
|
scene={scene}
|
||||||
|
colors={colors}
|
||||||
|
style={renderStyle}
|
||||||
|
className="avatar-preview"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
|
<clipPath id={frameClipId}>
|
||||||
|
<rect
|
||||||
|
x="-150"
|
||||||
|
y="-150"
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
rx={snapshotCornerRadius(composition.cornerRadius)}
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
<clipPath id={clipId}>
|
<clipPath id={clipId}>
|
||||||
<motion.path d={scene.headPath} />
|
<motion.path d={scene.headPath} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
@ -98,22 +260,30 @@ export function SnapshotPreview({
|
|||||||
<stop offset="1" stopColor={colorTo} />
|
<stop offset="1" stopColor={colorTo} />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{background !== 'transparent' && (
|
<g clipPath={`url(#${frameClipId})`}>
|
||||||
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
{background !== 'transparent' && (
|
||||||
)}
|
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
||||||
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
)}
|
||||||
{scene.backPaths.map((pathValue, index) => (
|
<g ref={compositionGroupRef}>
|
||||||
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||||
))}
|
{scene.backPaths.map((pathValue, index) => (
|
||||||
<motion.path d={scene.headPath} fill={colors.body} />
|
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
||||||
<g clipPath={`url(#${clipId})`}>
|
))}
|
||||||
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
<motion.path d={scene.headPath} fill={colors.body} />
|
||||||
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
|
<g clipPath={`url(#${clipId})`}>
|
||||||
|
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
||||||
|
<motion.path
|
||||||
|
d={scene.rightPath}
|
||||||
|
fill={colors.eyes}
|
||||||
|
opacity={scene.rightOpacity}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{scene.frontPaths.map((pathValue, index) => (
|
||||||
|
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
||||||
|
))}
|
||||||
|
</motion.g>
|
||||||
</g>
|
</g>
|
||||||
{scene.frontPaths.map((pathValue, index) => (
|
</g>
|
||||||
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
|
||||||
))}
|
|
||||||
</motion.g>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import { type BodyNode } from '@/features/avatar/body'
|
|||||||
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
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 ExportFormat = 'react' | 'javascript'
|
||||||
export type SnapshotFormat = 'svg' | 'png'
|
export type SnapshotFormat = 'svg' | 'png'
|
||||||
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
||||||
|
|||||||
@ -94,6 +94,109 @@ input {
|
|||||||
content: '';
|
content: '';
|
||||||
pointer-events: none;
|
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 {
|
.photo-flash {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@ -791,37 +894,67 @@ input {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.snapshot-preview-card {
|
.snapshot-composer-card {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.snapshot-preview-card > div:last-child {
|
.snapshot-composer-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.snapshot-preview-card small,
|
.snapshot-composer-meta small,
|
||||||
.snapshot-preview-card span,
|
.snapshot-composer-meta span,
|
||||||
.snapshot-background-field small {
|
.snapshot-background-field small {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.snapshot-preview-card strong {
|
.snapshot-composer-meta strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.snapshot-preview {
|
||||||
width: 112px;
|
position: relative;
|
||||||
height: 112px;
|
width: min(100%, 360px);
|
||||||
flex: none;
|
aspect-ratio: 1;
|
||||||
|
margin-inline: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border-radius: var(--snapshot-corner-radius, 0%);
|
||||||
border-radius: 12px;
|
outline: 1px solid rgb(0 0 0 / 10%);
|
||||||
|
outline-offset: -1px;
|
||||||
background: white;
|
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 {
|
.snapshot-preview.is-transparent {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
@ -842,15 +975,62 @@ input {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.snapshot-background-field {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.snapshot-background-field > [data-slot='select-trigger'] {
|
.snapshot-background-field > [data-slot='select-trigger'] {
|
||||||
width: 190px;
|
width: 170px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.snapshot-background-field > div:first-child {
|
.snapshot-background-field [data-slot='select-value'] {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
gap: 3px;
|
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 {
|
.snapshot-colors {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -861,6 +1041,144 @@ input {
|
|||||||
.snapshot-settings-separator {
|
.snapshot-settings-separator {
|
||||||
margin-block: 14px;
|
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,
|
.avatar-head,
|
||||||
.preview-head {
|
.preview-head {
|
||||||
fill: var(--avatar-body-color, #5b7fe5);
|
fill: var(--avatar-body-color, #5b7fe5);
|
||||||
@ -3284,6 +3602,12 @@ p {
|
|||||||
height: min(100%, 82vw, 620px);
|
height: min(100%, 82vw, 620px);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
.photo-live-frame {
|
||||||
|
width: auto;
|
||||||
|
max-width: 82vw;
|
||||||
|
height: min(100%, 82vw, 620px);
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
.workspace-footer,
|
.workspace-footer,
|
||||||
.state-playback-footer,
|
.state-playback-footer,
|
||||||
.mobile-mode-tabs {
|
.mobile-mode-tabs {
|
||||||
@ -3421,6 +3745,20 @@ p {
|
|||||||
height: min(100%, 78vw, 340px);
|
height: min(100%, 78vw, 340px);
|
||||||
max-height: 100%;
|
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 {
|
.photo-capture-bar {
|
||||||
bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px));
|
bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px));
|
||||||
left: 12px;
|
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('transform="translate(3 -2)"')
|
||||||
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
|
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
|
||||||
expect(svg).toContain('fill="#111316"')
|
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', () => {
|
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 { AvatarColors } from '../avatar/avatars'
|
||||||
import type { RenderedScene } from '../rendering/renderedScene'
|
import type { RenderedScene } from '../rendering/renderedScene'
|
||||||
|
import {
|
||||||
|
defaultSnapshotComposition,
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
snapshotCornerRadius,
|
||||||
|
type SnapshotComposition,
|
||||||
|
} from './snapshotComposition'
|
||||||
|
|
||||||
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
||||||
|
|
||||||
@ -8,6 +14,7 @@ export type SnapshotOptions = {
|
|||||||
colorFrom: string
|
colorFrom: string
|
||||||
colorTo: string
|
colorTo: string
|
||||||
size: number
|
size: number
|
||||||
|
composition?: SnapshotComposition
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapeXml = (value: string) =>
|
const escapeXml = (value: string) =>
|
||||||
@ -51,9 +58,18 @@ export const serializeAvatarSnapshot = (
|
|||||||
colors: AvatarColors,
|
colors: AvatarColors,
|
||||||
options: SnapshotOptions
|
options: SnapshotOptions
|
||||||
) => {
|
) => {
|
||||||
|
const composition = normalizeSnapshotComposition(
|
||||||
|
options.composition ?? defaultSnapshotComposition
|
||||||
|
)
|
||||||
const headPath = scene.headPath.get()
|
const headPath = scene.headPath.get()
|
||||||
const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean)
|
const backPaths = scene.backPaths.flatMap(item => {
|
||||||
const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean)
|
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 offsetX = scene.offsetX.get()
|
||||||
const offsetY = scene.offsetY.get()
|
const offsetY = scene.offsetY.get()
|
||||||
const body = [
|
const body = [
|
||||||
@ -65,9 +81,11 @@ export const serializeAvatarSnapshot = (
|
|||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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)}">
|
<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>
|
||||||
${backgroundMarkup(options)}
|
<g clip-path="url(#snapshot-frame-clip)">
|
||||||
<g transform="translate(${offsetX} ${offsetY})">${body}</g>
|
${backgroundMarkup(options)}
|
||||||
|
<g transform="translate(${composition.x} ${composition.y}) scale(${composition.scale})"><g transform="translate(${offsetX} ${offsetY})">${body}</g></g>
|
||||||
|
</g>
|
||||||
</svg>`
|
</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 { motion } from 'motion/react'
|
||||||
import { type CSSProperties, useState } from 'react'
|
import { type CSSProperties, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
||||||
|
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
|
||||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
@ -24,8 +26,10 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
highlight,
|
highlight,
|
||||||
linked,
|
linked,
|
||||||
mode,
|
mode,
|
||||||
|
openPhotoMode,
|
||||||
persistEditedEyeExpression,
|
persistEditedEyeExpression,
|
||||||
photoFlash,
|
photoFlash,
|
||||||
|
photoTool,
|
||||||
playbackStatus,
|
playbackStatus,
|
||||||
previewCanvasExpression,
|
previewCanvasExpression,
|
||||||
previewExpressionDraft,
|
previewExpressionDraft,
|
||||||
@ -38,8 +42,15 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
selectedBodyNodeId,
|
selectedBodyNodeId,
|
||||||
selectedEyeSide,
|
selectedEyeSide,
|
||||||
setEditing,
|
setEditing,
|
||||||
|
setPhotoPanelSections,
|
||||||
|
setPhotoTool,
|
||||||
setSelectedEyeSide,
|
setSelectedEyeSide,
|
||||||
|
setSnapshotComposition,
|
||||||
showWire,
|
showWire,
|
||||||
|
snapshotBackground,
|
||||||
|
snapshotColorFrom,
|
||||||
|
snapshotColorTo,
|
||||||
|
snapshotComposition,
|
||||||
surface,
|
surface,
|
||||||
t,
|
t,
|
||||||
takePicture,
|
takePicture,
|
||||||
@ -47,9 +58,58 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
updateHighlight,
|
updateHighlight,
|
||||||
updateImmediate,
|
updateImmediate,
|
||||||
} = controller
|
} = controller
|
||||||
|
const photoMode = mode === 'photo'
|
||||||
|
const activatePhotoTool = (tool: typeof photoTool) => {
|
||||||
|
setPhotoTool(tool)
|
||||||
|
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
|
||||||
|
}
|
||||||
|
const resetPhotoSetup = () => {
|
||||||
|
updateImmediate({ ...defaultExpression })
|
||||||
|
setSnapshotComposition(current => ({ ...current, x: 0, y: 0, scale: 1 }))
|
||||||
|
}
|
||||||
|
const avatarCanvas = (
|
||||||
|
<AvatarCanvas
|
||||||
|
expression={canvasExpression}
|
||||||
|
avatarEyes={activeAvatarEyes}
|
||||||
|
surface={surface}
|
||||||
|
scene={renderedScene}
|
||||||
|
colors={renderedColors}
|
||||||
|
renderStyle={activeAvatar.renderStyle}
|
||||||
|
rotationGizmo={renderedRotationGizmo}
|
||||||
|
showWire={showWire}
|
||||||
|
bodyEditing={bodyEditing}
|
||||||
|
selectedBodyNodeId={selectedBodyNodeId}
|
||||||
|
selectedBodyNode={selectedBodyNode}
|
||||||
|
selectedSide={selectedEyeSide}
|
||||||
|
linked={linked}
|
||||||
|
highlight={highlight}
|
||||||
|
onHighlightChange={updateHighlight}
|
||||||
|
onBodyNodeSelect={selectBodyNode}
|
||||||
|
onBodyNodePreview={previewSelectedBodyNode}
|
||||||
|
onBodyNodeChange={commitBodyNode}
|
||||||
|
onEyeSelect={setSelectedEyeSide}
|
||||||
|
onPreview={previewCanvasExpression}
|
||||||
|
onChange={editing ? previewExpressionDraft : updateImmediate}
|
||||||
|
onReset={next => {
|
||||||
|
if (editing) {
|
||||||
|
setEditing(current => (current ? { ...current, draft: next } : current))
|
||||||
|
}
|
||||||
|
transitionToExpression(next)
|
||||||
|
}}
|
||||||
|
onEyeChange={
|
||||||
|
editing ? previewExpressionDraft : bodyEditing ? persistEditedEyeExpression : undefined
|
||||||
|
}
|
||||||
|
playback={
|
||||||
|
activeSequenceLabel && playbackStatus !== 'stopped'
|
||||||
|
? { name: activeSequenceLabel, status: playbackStatus }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onManipulationStart={freezeLivePreviewForManipulation}
|
||||||
|
/>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<motion.section
|
<motion.section
|
||||||
className="stage-column"
|
className={`stage-column${photoMode ? ' photo-mode-active' : ''}`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--avatar-body-color': renderedColors.body,
|
'--avatar-body-color': renderedColors.body,
|
||||||
@ -63,44 +123,20 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
setLanguage={controller.setLanguage}
|
setLanguage={controller.setLanguage}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<AvatarCanvas
|
{photoMode ? (
|
||||||
expression={canvasExpression}
|
<PhotoStageFrame
|
||||||
avatarEyes={activeAvatarEyes}
|
background={snapshotBackground}
|
||||||
surface={surface}
|
colorFrom={snapshotColorFrom}
|
||||||
scene={renderedScene}
|
colorTo={snapshotColorTo}
|
||||||
colors={renderedColors}
|
composition={snapshotComposition}
|
||||||
renderStyle={activeAvatar.renderStyle}
|
tool={photoTool}
|
||||||
rotationGizmo={renderedRotationGizmo}
|
onCompositionChange={setSnapshotComposition}
|
||||||
showWire={showWire}
|
>
|
||||||
bodyEditing={bodyEditing}
|
{avatarCanvas}
|
||||||
selectedBodyNodeId={selectedBodyNodeId}
|
</PhotoStageFrame>
|
||||||
selectedBodyNode={selectedBodyNode}
|
) : (
|
||||||
selectedSide={selectedEyeSide}
|
avatarCanvas
|
||||||
linked={linked}
|
)}
|
||||||
highlight={highlight}
|
|
||||||
onHighlightChange={updateHighlight}
|
|
||||||
onBodyNodeSelect={selectBodyNode}
|
|
||||||
onBodyNodePreview={previewSelectedBodyNode}
|
|
||||||
onBodyNodeChange={commitBodyNode}
|
|
||||||
onEyeSelect={setSelectedEyeSide}
|
|
||||||
onPreview={previewCanvasExpression}
|
|
||||||
onChange={editing ? previewExpressionDraft : updateImmediate}
|
|
||||||
onReset={next => {
|
|
||||||
if (editing) {
|
|
||||||
setEditing(current => (current ? { ...current, draft: next } : current))
|
|
||||||
}
|
|
||||||
transitionToExpression(next)
|
|
||||||
}}
|
|
||||||
onEyeChange={
|
|
||||||
editing ? previewExpressionDraft : bodyEditing ? persistEditedEyeExpression : undefined
|
|
||||||
}
|
|
||||||
playback={
|
|
||||||
activeSequenceLabel && playbackStatus !== 'stopped'
|
|
||||||
? { name: activeSequenceLabel, status: playbackStatus }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onManipulationStart={freezeLivePreviewForManipulation}
|
|
||||||
/>
|
|
||||||
{photoFlash > 0 && (
|
{photoFlash > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="photo-flash"
|
className="photo-flash"
|
||||||
@ -112,15 +148,48 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TooltipProvider>
|
<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">
|
<div className="photo-capture-bar">
|
||||||
<Button
|
<Button
|
||||||
className="photo-capture-button"
|
className="photo-capture-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('Prendre une photo')}
|
aria-label={t(photoMode ? 'Prendre une photo' : 'Ouvrir le mode photo')}
|
||||||
onClick={takePicture}
|
onClick={() => (photoMode ? takePicture() : openPhotoMode())}
|
||||||
>
|
>
|
||||||
<Camera />
|
<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>
|
</Button>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
open={photoHelpOpen}
|
open={photoHelpOpen}
|
||||||
@ -145,7 +214,11 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
<Info />
|
<Info />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="photo-help-tooltip">
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type Highlight,
|
type Highlight,
|
||||||
type Mode,
|
type Mode,
|
||||||
|
type PhotoTool,
|
||||||
type PlaybackStatus,
|
type PlaybackStatus,
|
||||||
type Side,
|
type Side,
|
||||||
type SnapshotFormat,
|
type SnapshotFormat,
|
||||||
@ -94,6 +95,10 @@ import {
|
|||||||
snapshotFileName,
|
snapshotFileName,
|
||||||
type SnapshotBackground,
|
type SnapshotBackground,
|
||||||
} from '@/features/export/snapshotExporter'
|
} from '@/features/export/snapshotExporter'
|
||||||
|
import {
|
||||||
|
defaultSnapshotComposition,
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
} from '@/features/export/snapshotComposition'
|
||||||
import {
|
import {
|
||||||
resetBodyEditorView,
|
resetBodyEditorView,
|
||||||
resolveCanvasPreviewExpression,
|
resolveCanvasPreviewExpression,
|
||||||
@ -153,6 +158,12 @@ export function useStudioController() {
|
|||||||
const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF')
|
const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF')
|
||||||
const [snapshotSize, setSnapshotSize] = useState('1024')
|
const [snapshotSize, setSnapshotSize] = useState('1024')
|
||||||
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
|
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 [photoFlash, setPhotoFlash] = useState(0)
|
||||||
const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
|
const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
|
||||||
status: 'idle' | 'success' | 'error'
|
status: 'idle' | 'success' | 'error'
|
||||||
@ -474,6 +485,13 @@ export function useStudioController() {
|
|||||||
return renderedExpression
|
return renderedExpression
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPhotoMode = () => {
|
||||||
|
freezeLivePreviewForManipulation()
|
||||||
|
setPhotoTool('frame')
|
||||||
|
setPhotoPanelSections([])
|
||||||
|
setMode('photo')
|
||||||
|
}
|
||||||
|
|
||||||
const updateImmediate = (next: Expression, preservePlayback = false) => {
|
const updateImmediate = (next: Expression, preservePlayback = false) => {
|
||||||
if (!preservePlayback && statePlaying) pauseState()
|
if (!preservePlayback && statePlaying) pauseState()
|
||||||
stopTransition(true)
|
stopTransition(true)
|
||||||
@ -1652,6 +1670,7 @@ export function useStudioController() {
|
|||||||
colorFrom: snapshotColorFrom,
|
colorFrom: snapshotColorFrom,
|
||||||
colorTo: snapshotColorTo,
|
colorTo: snapshotColorTo,
|
||||||
size: Number(snapshotSize),
|
size: Number(snapshotSize),
|
||||||
|
composition: snapshotComposition,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1664,6 +1683,11 @@ export function useStudioController() {
|
|||||||
canvas.height = size
|
canvas.height = size
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
if (!context) return null
|
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') {
|
if (snapshotBackground === 'solid') {
|
||||||
context.fillStyle = snapshotColorFrom
|
context.fillStyle = snapshotColorFrom
|
||||||
context.fillRect(0, 0, size, size)
|
context.fillRect(0, 0, size, size)
|
||||||
@ -1716,7 +1740,13 @@ export function useStudioController() {
|
|||||||
renderStyle
|
renderStyle
|
||||||
)
|
)
|
||||||
context.imageSmoothingEnabled = false
|
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
|
return canvas
|
||||||
}
|
}
|
||||||
const downloadSnapshotSvg = () => {
|
const downloadSnapshotSvg = () => {
|
||||||
@ -1938,11 +1968,14 @@ export function useStudioController() {
|
|||||||
linked,
|
linked,
|
||||||
mode,
|
mode,
|
||||||
openExpressionEditor,
|
openExpressionEditor,
|
||||||
|
openPhotoMode,
|
||||||
openSequenceEditor,
|
openSequenceEditor,
|
||||||
pauseState,
|
pauseState,
|
||||||
pendingProjectImport,
|
pendingProjectImport,
|
||||||
persistEditedEyeExpression,
|
persistEditedEyeExpression,
|
||||||
photoFlash,
|
photoFlash,
|
||||||
|
photoPanelSections,
|
||||||
|
photoTool,
|
||||||
playbackStatus,
|
playbackStatus,
|
||||||
playbackVisual,
|
playbackVisual,
|
||||||
prepareStudioProjectImport,
|
prepareStudioProjectImport,
|
||||||
@ -1989,12 +2022,15 @@ export function useStudioController() {
|
|||||||
setLinked,
|
setLinked,
|
||||||
setMode,
|
setMode,
|
||||||
setPendingProjectImport,
|
setPendingProjectImport,
|
||||||
|
setPhotoPanelSections,
|
||||||
|
setPhotoTool,
|
||||||
setSelectedEyeSide,
|
setSelectedEyeSide,
|
||||||
setSelectedSequenceStepId,
|
setSelectedSequenceStepId,
|
||||||
setSequenceEditing,
|
setSequenceEditing,
|
||||||
setSnapshotBackground,
|
setSnapshotBackground,
|
||||||
setSnapshotColorFrom,
|
setSnapshotColorFrom,
|
||||||
setSnapshotColorTo,
|
setSnapshotColorTo,
|
||||||
|
setSnapshotComposition,
|
||||||
setSnapshotFormat,
|
setSnapshotFormat,
|
||||||
setSnapshotSize,
|
setSnapshotSize,
|
||||||
setSpringSpeed,
|
setSpringSpeed,
|
||||||
@ -2003,6 +2039,7 @@ export function useStudioController() {
|
|||||||
snapshotBackground,
|
snapshotBackground,
|
||||||
snapshotColorFrom,
|
snapshotColorFrom,
|
||||||
snapshotColorTo,
|
snapshotColorTo,
|
||||||
|
snapshotComposition,
|
||||||
snapshotFormat,
|
snapshotFormat,
|
||||||
snapshotSize,
|
snapshotSize,
|
||||||
springSpeed,
|
springSpeed,
|
||||||
|
|||||||
@ -29,6 +29,24 @@ describe('avatar studio translations', () => {
|
|||||||
expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG')
|
expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG')
|
||||||
expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照')
|
expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照')
|
||||||
expect(translateStudioText('Format d’export', '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', () => {
|
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',
|
'Prêt à exporter': 'Ready to export',
|
||||||
Snapshot: 'Snapshot',
|
Snapshot: 'Snapshot',
|
||||||
'Mode photo': 'Photo Mode',
|
'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.',
|
'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.',
|
'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.',
|
||||||
'Aperçu du Snapshot': 'Snapshot preview',
|
'Aperçu du Snapshot': 'Snapshot preview',
|
||||||
'Aperçu du mode photo': 'Photo Mode 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',
|
'Arrière-plan': 'Background',
|
||||||
'Choisis un fond transparent, uni ou en dégradé.':
|
'Choisis un fond transparent, uni ou en dégradé.':
|
||||||
'Choose a transparent, solid or gradient background.',
|
'Choose a transparent, solid or gradient background.',
|
||||||
@ -332,6 +364,7 @@ const english: Record<string, string> = {
|
|||||||
'Dégradé linéaire': 'Linear gradient',
|
'Dégradé linéaire': 'Linear gradient',
|
||||||
'Dégradé radial': 'Radial gradient',
|
'Dégradé radial': 'Radial gradient',
|
||||||
'Style d’arrière-plan': 'Background style',
|
'Style d’arrière-plan': 'Background style',
|
||||||
|
Aléatoire: 'Random',
|
||||||
Couleur: 'Color',
|
Couleur: 'Color',
|
||||||
Départ: 'Start',
|
Départ: 'Start',
|
||||||
Arrivée: 'End',
|
Arrivée: 'End',
|
||||||
|
|||||||
@ -280,10 +280,43 @@ export const chinese: Record<string, string> = {
|
|||||||
'Prêt à exporter': '可以导出',
|
'Prêt à exporter': '可以导出',
|
||||||
Snapshot: '快照',
|
Snapshot: '快照',
|
||||||
'Mode photo': '照片模式',
|
'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.': '捕获头像的静态图像。',
|
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
||||||
'Les options de capture seront configurées ici.': '快照选项将在此配置。',
|
'Les options de capture seront configurées ici.': '快照选项将在此配置。',
|
||||||
'Aperçu du Snapshot': '快照预览',
|
'Aperçu du Snapshot': '快照预览',
|
||||||
'Aperçu du mode photo': '照片模式预览',
|
'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': '背景',
|
'Arrière-plan': '背景',
|
||||||
'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。',
|
'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。',
|
||||||
Style: '样式',
|
Style: '样式',
|
||||||
@ -292,6 +325,7 @@ export const chinese: Record<string, string> = {
|
|||||||
'Dégradé linéaire': '线性渐变',
|
'Dégradé linéaire': '线性渐变',
|
||||||
'Dégradé radial': '径向渐变',
|
'Dégradé radial': '径向渐变',
|
||||||
'Style d’arrière-plan': '背景样式',
|
'Style d’arrière-plan': '背景样式',
|
||||||
|
Aléatoire: '随机',
|
||||||
Couleur: '颜色',
|
Couleur: '颜色',
|
||||||
Départ: '起始颜色',
|
Départ: '起始颜色',
|
||||||
Arrivée: '结束颜色',
|
Arrivée: '结束颜色',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user