From c0ed44b10c3633448e2e3f18463e14f9d912f324 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 12:21:14 +0800 Subject: [PATCH 1/5] feat(quick-start): add kinetic copy cycle Quick Start needs reusable subtitle transitions across entry and generation states. Add a page-local copy cycle with ordered exit and entrance motion. Respect reduced-motion preferences while preventing overlapping text. --- .../pages/quick-start/kinetic-copy-cycle.css | 176 ++++++++++++++++++ .../pages/quick-start/kinetic-copy-cycle.tsx | 162 ++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 frontend/src/pages/quick-start/kinetic-copy-cycle.css create mode 100644 frontend/src/pages/quick-start/kinetic-copy-cycle.tsx diff --git a/frontend/src/pages/quick-start/kinetic-copy-cycle.css b/frontend/src/pages/quick-start/kinetic-copy-cycle.css new file mode 100644 index 00000000..bd5f574c --- /dev/null +++ b/frontend/src/pages/quick-start/kinetic-copy-cycle.css @@ -0,0 +1,176 @@ +.kinetic-copy-cycle { + display: grid; + place-items: center; +} + +.quick-start-agent-copy { + place-items: start; + text-align: left; +} + +.quick-start-agent-copy .kinetic-copy-line-inner-prefixed { + justify-content: flex-start; +} + +.kinetic-copy-line { + display: block; + overflow: hidden; + min-height: 1.55em; +} + +.kinetic-copy-line-inner { + display: block; + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + animation: kinetic-copy-line-enter 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation-delay: calc(210ms + var(--kinetic-copy-line-index) * 90ms); + backface-visibility: hidden; + will-change: opacity, transform, letter-spacing; +} + +.kinetic-copy-line-inner-prefixed { + display: flex; + align-items: center; + justify-content: center; +} + +.kinetic-copy-cycle-resting .kinetic-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: none; +} + +.kinetic-copy-cycle-exiting .kinetic-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: kinetic-copy-line-exit 400ms cubic-bezier(0.55, 0, 1, 0.45) forwards; + animation-delay: calc(var(--kinetic-copy-line-reverse-index) * 55ms); +} + +.kinetic-copy-prefix { + display: inline-block; +} + +.kinetic-copy-character { + display: inline-block; +} + +.kinetic-copy-cycle-entering[data-copy-motion-mode='characters'] .kinetic-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: none; +} + +.kinetic-copy-cycle-entering[data-copy-motion-mode='characters'] .kinetic-copy-prefix { + opacity: 0; + transform: translate3d(0, 0.38em, 0); + filter: blur(4px); + animation: kinetic-copy-character-enter 520ms cubic-bezier(0.16, 1, 0.3, 1) 90ms forwards; +} + +.kinetic-copy-cycle-entering[data-copy-motion-mode='characters'] .kinetic-copy-character { + opacity: 0; + transform: translate3d(0, 0.42em, 0); + filter: blur(4px); + animation: kinetic-copy-character-enter 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation-delay: calc(100ms + min(var(--kinetic-copy-character-index) * 24ms, 340ms)); + will-change: opacity, transform, filter; +} + +.kinetic-copy-cycle-exiting[data-copy-motion-mode='characters'] .kinetic-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, -0.16em, 0); + animation: none; + transition: transform 420ms cubic-bezier(0.55, 0, 1, 0.45); +} + +.kinetic-copy-cycle-exiting[data-copy-motion-mode='characters'] .kinetic-copy-prefix { + opacity: 1; + animation: kinetic-copy-character-exit 300ms cubic-bezier(0.55, 0, 1, 0.45) 140ms forwards; +} + +.kinetic-copy-cycle-exiting[data-copy-motion-mode='characters'] .kinetic-copy-character { + opacity: 1; + transform: translate3d(0, 0, 0); + filter: blur(0); + animation: kinetic-copy-character-exit 300ms cubic-bezier(0.55, 0, 1, 0.45) forwards; + animation-delay: calc(min(var(--kinetic-copy-character-reverse-index) * 10ms, 140ms)); + will-change: opacity, transform, filter; +} + +@keyframes kinetic-copy-line-enter { + 0% { + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + } + 100% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } +} + +@keyframes kinetic-copy-line-exit { + 0% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } + 46% { + opacity: 1; + } + 100% { + opacity: 0; + letter-spacing: 0.045em; + transform: translate3d(0, -118%, 0); + } +} + +@keyframes kinetic-copy-character-enter { + 0% { + opacity: 0; + transform: translate3d(0, 0.42em, 0); + filter: blur(4px); + } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); + filter: blur(0); + } +} + +@keyframes kinetic-copy-character-exit { + 0% { + opacity: 1; + transform: translate3d(0, 0, 0); + filter: blur(0); + } + 100% { + opacity: 0; + transform: translate3d(0, -0.42em, 0); + filter: blur(4px); + } +} + +@media (prefers-reduced-motion: reduce) { + .kinetic-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: none; + animation: none; + } + + .kinetic-copy-prefix, + .kinetic-copy-character { + opacity: 1; + transform: none; + filter: none; + animation: none; + } +} diff --git a/frontend/src/pages/quick-start/kinetic-copy-cycle.tsx b/frontend/src/pages/quick-start/kinetic-copy-cycle.tsx new file mode 100644 index 00000000..41eab7d4 --- /dev/null +++ b/frontend/src/pages/quick-start/kinetic-copy-cycle.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState, type CSSProperties, type ElementType } from 'react' + +import './kinetic-copy-cycle.css' + +export type KineticCopyMessage = + | readonly string[] + | { + lines: readonly string[] + prefix?: string + className?: string + prefixClassName?: string + } + +export interface KineticCopyCycleProps { + messages: readonly KineticCopyMessage[] + active?: boolean + as?: ElementType + ariaLabel?: string + className?: string + firstCycleMs?: number + cycleMs?: number + loopStartIndex?: number + motionMode?: 'line' | 'characters' +} + +type CopyPhase = 'entering' | 'resting' | 'exiting' + +const ENTER_DURATION_MS = 760 +const EXIT_DURATION_MS = 460 +const DEFAULT_CYCLE_MS = 4_200 + +function messageParts(message: KineticCopyMessage) { + return Array.isArray(message) + ? { lines: message as readonly string[] } + : (message as Exclude) +} + +/** Quick Start 的裁切字幕:完整退场后再换文案,避免重叠与跳字。 */ +export function KineticCopyCycle({ + messages, + active = true, + as: Tag = 'div', + ariaLabel, + className = '', + firstCycleMs = DEFAULT_CYCLE_MS, + cycleMs = DEFAULT_CYCLE_MS, + loopStartIndex = 0, + motionMode = 'line', +}: KineticCopyCycleProps) { + const [renderedMessages, setRenderedMessages] = useState(messages) + const [copyIndex, setCopyIndex] = useState(0) + const [phase, setPhase] = useState('entering') + + useEffect(() => { + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + if (messages === renderedMessages) return + + if (!active || reduceMotion) { + setRenderedMessages(messages) + setCopyIndex(0) + setPhase('resting') + return + } + + setPhase('exiting') + const replacementTimer = window.setTimeout(() => { + setRenderedMessages(messages) + setCopyIndex(0) + setPhase('entering') + }, EXIT_DURATION_MS) + + return () => window.clearTimeout(replacementTimer) + }, [active, messages, renderedMessages]) + + useEffect(() => { + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + if (messages !== renderedMessages) return + + setCopyIndex(0) + setPhase(active && !reduceMotion ? 'entering' : 'resting') + if (!active || reduceMotion || renderedMessages.length < 2) return + + let exitTimer: number | null = null + let swapTimer: number | null = null + let restTimer: number | null = window.setTimeout(() => setPhase('resting'), ENTER_DURATION_MS) + + const scheduleExit = (delay: number) => { + exitTimer = window.setTimeout(() => { + setPhase('exiting') + swapTimer = window.setTimeout(() => { + setCopyIndex((current) => + current + 1 < renderedMessages.length ? current + 1 : loopStartIndex, + ) + setPhase('entering') + restTimer = window.setTimeout(() => setPhase('resting'), ENTER_DURATION_MS) + scheduleExit(Math.max(0, cycleMs - EXIT_DURATION_MS)) + }, EXIT_DURATION_MS) + }, delay) + } + + scheduleExit(firstCycleMs) + + return () => { + if (exitTimer !== null) window.clearTimeout(exitTimer) + if (swapTimer !== null) window.clearTimeout(swapTimer) + if (restTimer !== null) window.clearTimeout(restTimer) + } + }, [active, cycleMs, firstCycleMs, loopStartIndex, messages, renderedMessages]) + + if (renderedMessages.length === 0) return null + + const message = messageParts(renderedMessages[copyIndex % renderedMessages.length]) + + return ( + + {message.lines.map((line, lineIndex) => ( + + + {lineIndex === 0 && message.prefix ? ( + + {message.prefix} + + ) : null} + {motionMode === 'characters' + ? Array.from(line).map((character, characterIndex, characters) => ( + + )) + : line} + + + ))} + + ) +} From fa6691e960f834f7e0565114edf4dc1204deb247 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 12:21:32 +0800 Subject: [PATCH 2/5] feat(quick-start): continue creation in one conversation The creation entry and run view currently feel like separate products. Reshape the run as one flowing transcript with a persistent composer and result-focused states. Reuse existing session calls while making completed destinations explicit. --- frontend/src/pages/quick-start/index.tsx | 1268 +++++++++++------ .../pages/quick-start/quick-start-motion.css | 194 +++ 2 files changed, 1002 insertions(+), 460 deletions(-) create mode 100644 frontend/src/pages/quick-start/quick-start-motion.css diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index a4517396..171e466d 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -4,25 +4,28 @@ import { useMemo, useRef, useState, + type CSSProperties, type ChangeEvent, type FormEvent, + type ReactNode, } from 'react' +import { ArrowUp, ImageSquare, X } from '@phosphor-icons/react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import { type ActionFirstFrameWorkflowNode, type CharacterTemplateWorkflowNode, type WorkflowRun, - type WorkflowNode, - type WorkflowNodeType, } from '@/entities' import { ExportButton, type ExportPackageModel } from '@/features/export-package' +import { KineticCopyCycle, type KineticCopyMessage } from './kinetic-copy-cycle' import { quickStartService, type QuickStartEntryService, type QuickStartFrame, type QuickStartSession, } from './service' +import './quick-start-motion.css' export type { CreateQuickStartServiceOptions, @@ -31,26 +34,79 @@ export type { QuickStartSession, } from './service' -const STEP_LABELS: Record = { - 'character-setup': '角色设定', - 'character-template': '角色图', - 'action-first-frame': '候选选择', - 'action-generation-method': '生成路线', - 'action-full-frame': '动作生成', - review: '审核', -} - -const EXAMPLES = [ +const STYLE_PROMPTS = [ + { + title: '16-bit 日式 RPG', + detail: '清晰轮廓 · 明亮配色', + prompt: '16-bit 日式 RPG 像素风,清晰轮廓,明亮配色', + }, { - label: '像素守夜人', - prompt: '一位提着风灯、披深色斗篷的像素守夜人', + title: '暗黑哥特像素', + detail: '低饱和 · 强烈明暗', + prompt: '暗黑哥特像素风,低饱和配色,强烈明暗对比', }, { - label: '轻装信使', - prompt: '轻装信使,侧视像素风,轮廓清晰,动作轻快', + title: '温暖手绘像素', + detail: '柔和色彩 · 纸张质感', + prompt: '温暖手绘像素风,柔和配色,细腻纸张质感', }, ] as const +const ROLE_IDEAS = [ + '银色卷发、戴星形单片眼镜的裁缝', + '长着鹿角、披苔藓斗篷的邮差', + '戴透明水母帽、穿蓝色雨衣的药剂师', + '蓬松白胡子、背黄铜工具箱的机械师', + '紫色短发、戴猫耳耳机的情报员', + '披白羽斗篷、戴月牙面具的占星师', + '红色双辫、穿宽大飞行夹克的小飞行员', + '黑色卷发、戴珊瑚项链的海洋祭司', +] as const + +const ROLE_IDEA_MESSAGES: readonly KineticCopyMessage[] = [ + { lines: ['想做一个什么角色?'], className: 'text-app-ink' }, + ...ROLE_IDEAS.map((idea) => ({ + prefix: '试试', + prefixClassName: + 'mr-3 font-mono text-[10px] font-bold tracking-[0.14em] text-app-faint sm:text-[11px]', + lines: [idea], + className: 'text-app-accent', + })), +] + +const ROLE_DEFAULT_MESSAGE: readonly KineticCopyMessage[] = [ + { lines: ['用文字塑造你的角色……'], className: 'text-app-ink' }, +] + +const TEMPLATE_GENERATION_MESSAGES: readonly KineticCopyMessage[] = [ + { lines: ['勾勒角色轮廓'] }, + { lines: ['给衣服配颜色'] }, + { lines: ['把发型画清楚'] }, + { lines: ['添上表情'] }, + { lines: ['处理一下光影'] }, + { lines: ['补齐画面细节'] }, +] + +const FIRST_FRAME_GENERATION_MESSAGES: readonly KineticCopyMessage[] = [ + { lines: ['摆好动作姿态'] }, + { lines: ['调整手脚位置'] }, + { lines: ['让重心自然一点'] }, + { lines: ['拉开姿态的区别'] }, + { lines: ['保持角色样子'] }, + { lines: ['补上动作细节'] }, +] + +const ACTION_GENERATION_MESSAGES: readonly KineticCopyMessage[] = [ + { lines: ['把动作连起来'] }, + { lines: ['补上中间的变化'] }, + { lines: ['理顺每一帧的节奏'] }, + { lines: ['检查手脚的衔接'] }, + { lines: ['让起落自然一点'] }, + { lines: ['调整动作幅度'] }, +] + +const ENTRY_HANDOFF_MS = 460 + function playtestPath(characterId: string, outfitId: string, actionId?: string): string { const path = `/playtest/${encodeURIComponent(characterId)}/${encodeURIComponent(outfitId)}` return actionId ? `${path}?${new URLSearchParams({ actionId })}` : path @@ -74,7 +130,6 @@ export function QuickStartPage({ service }: QuickStartPageProps) { const [createdSession, setCreatedSession] = useState(null) const characterId = searchParams.get('characterId') const outfitId = searchParams.get('outfitId') - return runId ? ( (null) const [submitting, setSubmitting] = useState(false) + const [entryTransition, setEntryTransition] = useState<'idle' | 'leaving'>('idle') const [error, setError] = useState(null) const fileInput = useRef(null) const submitAbortController = useRef(null) + const handoffTimer = useRef | null>(null) const unavailableReason = service.unavailableReason + const hasPrompt = Boolean(prompt.trim()) + const showStylePrompts = !hasPrompt && !templateFile + + const originalPromptShortcuts = [ + { + label: '像素守夜人', + prompt: '一位提着风灯、披深色斗篷的像素守夜人', + }, + { + label: '轻装信使', + prompt: '轻装信使,侧视像素风,轮廓清晰,动作轻快', + }, + ] as const useEffect( () => () => { submitAbortController.current?.abort() + if (handoffTimer.current) clearTimeout(handoffTimer.current) }, [], ) @@ -208,19 +279,26 @@ function QuickStartInput({ const abortController = new AbortController() submitAbortController.current = abortController setSubmitting(true) + setEntryTransition('leaving') setError(null) try { - const session = templateFile - ? await service.startWithUploadedTemplate( - templateFile, - normalizedPrompt, - abortController.signal, - ) - : await service.start(normalizedPrompt) + const sessionPromise = templateFile + ? service.startWithUploadedTemplate(templateFile, normalizedPrompt, abortController.signal) + : service.start(normalizedPrompt) + const handoffPromise = new Promise((resolve) => { + handoffTimer.current = setTimeout(() => { + handoffTimer.current = null + resolve() + }, ENTRY_HANDOFF_MS) + }) + const [session] = await Promise.all([sessionPromise, handoffPromise]) onSessionCreated(session) navigate(`/quick-start/${encodeURIComponent(session.runId)}`) } catch (cause) { if (!abortController.signal.aborted) { + if (handoffTimer.current) clearTimeout(handoffTimer.current) + handoffTimer.current = null + setEntryTransition('idle') setError(errorMessage(cause, '创建失败,请稍后重试')) } } finally { @@ -232,78 +310,87 @@ function QuickStartInput({ } return ( -
+
-
-
-
-

- QUICK START / CREATE CHARACTER -

-

- 用一句角色设定, -
- 开始一条可追踪的制作流程。 -

-
- - AI 快捷创作 - -
- -
- +
+
+ -
- -
-
- {EXAMPLES.slice(1).map((example) => ( +
+ {originalPromptShortcuts.map((shortcut) => ( ))}
+
+
void submit(event)} - className="grid gap-3 rounded-[1.4rem] border border-app-line-strong bg-app-surface-raised p-4 shadow-app-panel sm:grid-cols-[1fr_auto]" + className="grid items-center gap-1.5 rounded-xl border border-app-line-strong bg-app-surface-raised p-1.5 shadow-app-panel transition-shadow focus-within:border-app-accent focus-within:shadow-[0_18px_48px_rgb(29_37_31/14%)] sm:grid-cols-[1fr_auto_auto]" > -