fix: make canvas the root generation experience

This commit is contained in:
2026-05-25 17:57:23 +08:00
parent fb9dc17b42
commit e767d2b388
13 changed files with 139 additions and 190 deletions

View File

@@ -8,7 +8,6 @@ import {
Image as ImageIcon,
Loader2,
Plus,
Sparkles,
Upload,
X,
type LucideIcon,
@@ -24,7 +23,6 @@ import {
generateStoryboardVideo,
getRuntimeHealth,
getJob,
uploadReferenceFrame,
type GeneratedImage,
type GeneratedVideo,
type Job,
@@ -32,26 +30,19 @@ import {
type RuntimeSizeOption,
} from "@/lib/api"
type CreationMode = "text-video" | "text-image" | "first-frame-video" | "first-last-frame-video"
type CreationMode = "text-image" | "text-video" | "image-video"
type BusyTask = CreationMode | "job" | null
type UploadSlot = "first" | "last"
type UploadSlot = "first"
type ModeConfig = {
id: CreationMode
label: string
icon: LucideIcon
placeholder: string
needsFirstFrame?: boolean
needsLastFrame?: boolean
needsImage?: boolean
}
const MODES: ModeConfig[] = [
{
id: "text-video",
label: "文生视频",
icon: Clapperboard,
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
},
{
id: "text-image",
label: "文生图",
@@ -59,19 +50,17 @@ const MODES: ModeConfig[] = [
placeholder: "写清楚画面、主体、构图、光线和比例。例如9:16 信息流营销图真实办公室场景SKG 颈部按摩仪佩戴清楚。",
},
{
id: "first-frame-video",
label: "首帧生视频",
icon: Upload,
needsFirstFrame: true,
placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。",
id: "text-video",
label: "生视频",
icon: Clapperboard,
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
},
{
id: "first-last-frame-video",
label: "首尾帧生视频",
icon: Sparkles,
needsFirstFrame: true,
needsLastFrame: true,
placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。",
id: "image-video",
label: "生视频",
icon: Upload,
needsImage: true,
placeholder: "上传图片后,写它要怎么动、镜头怎么运动、产品细节怎么保持、视频节奏和时长。",
},
]
@@ -129,9 +118,7 @@ export default function Home() {
const [prompt, setPrompt] = useState("")
const [seconds, setSeconds] = useState(12)
const [firstFrameFile, setFirstFrameFile] = useState<File | null>(null)
const [lastFrameFile, setLastFrameFile] = useState<File | null>(null)
const [firstFramePreview, setFirstFramePreview] = useState("")
const [lastFramePreview, setLastFramePreview] = useState("")
const [imageModel, setImageModel] = useState("auto")
const [videoModel, setVideoModel] = useState("seedance")
const [imageSize, setImageSize] = useState("1024x1536")
@@ -149,7 +136,6 @@ export default function Home() {
const [busy, setBusy] = useState<BusyTask>(null)
const [error, setError] = useState("")
const firstInputRef = useRef<HTMLInputElement>(null)
const lastInputRef = useRef<HTMLInputElement>(null)
const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0]
const latestImage = latestGeneratedImage(job)
@@ -203,16 +189,6 @@ export default function Home() {
return () => URL.revokeObjectURL(url)
}, [firstFrameFile])
useEffect(() => {
if (!lastFrameFile) {
setLastFramePreview("")
return
}
const url = URL.createObjectURL(lastFrameFile)
setLastFramePreview(url)
return () => URL.revokeObjectURL(url)
}, [lastFrameFile])
useEffect(() => {
if (!job || !runningVideo) return
const timer = window.setInterval(async () => {
@@ -233,18 +209,13 @@ export default function Home() {
const onModeChange = (nextMode: CreationMode) => {
setMode(nextMode)
resetResult()
if (nextMode === "text-video" || nextMode === "text-image") {
if (nextMode !== "image-video") {
setFirstFrameFile(null)
setLastFrameFile(null)
}
if (nextMode === "first-frame-video") {
setLastFrameFile(null)
}
}
const setUploadFile = (slot: UploadSlot, file: File | null) => {
if (slot === "first") setFirstFrameFile(file)
if (slot === "last") setLastFrameFile(file)
resetResult()
}
@@ -253,12 +224,8 @@ export default function Home() {
toast.error("先写提示词")
return false
}
if (activeMode.needsFirstFrame && !firstFrameFile) {
toast.error("先上传首帧")
return false
}
if (activeMode.needsLastFrame && !lastFrameFile) {
toast.error("先上传尾帧")
if (activeMode.needsImage && !firstFrameFile) {
toast.error("先上传图片")
return false
}
return true
@@ -270,13 +237,10 @@ export default function Home() {
const prepareJob = useCallback(async () => {
setBusy("job")
let created = await createCreativeImageJob(firstFrameFile)
if (mode === "first-last-frame-video" && lastFrameFile) {
created = await uploadReferenceFrame(created.id, lastFrameFile)
}
const created = await createCreativeImageJob(firstFrameFile)
setJob(created)
return created
}, [firstFrameFile, lastFrameFile, mode])
}, [firstFrameFile])
const runImage = async () => {
if (!validate()) return
@@ -307,13 +271,12 @@ export default function Home() {
setError("")
try {
const target = await prepareJob()
const lastFrame = [...target.frames].sort((a, b) => b.index - a.index)[0]
const updated = await generateStoryboardVideo(target.id, 0, {
prompt: promptWithGuardrails(),
duration: seconds,
count: 1,
first_image: activeMode.needsFirstFrame ? { kind: "keyframe", frame_idx: 0 } : null,
last_image: activeMode.needsLastFrame && lastFrame ? { kind: "keyframe", frame_idx: lastFrame.index } : null,
first_image: activeMode.needsImage ? { kind: "keyframe", frame_idx: 0 } : null,
last_image: null,
size: videoSize,
model: videoModel,
})
@@ -397,24 +360,15 @@ export default function Home() {
})}
</div>
{(activeMode.needsFirstFrame || activeMode.needsLastFrame) ? (
<div className="mb-3 grid gap-2 sm:grid-cols-2">
{activeMode.needsImage ? (
<div className="mb-3 grid gap-2">
<FrameUpload
label="首帧"
label="图片"
preview={firstFramePreview}
required={!!activeMode.needsFirstFrame}
required
onPick={() => firstInputRef.current?.click()}
onClear={() => setUploadFile("first", null)}
/>
{activeMode.needsLastFrame ? (
<FrameUpload
label="尾帧"
preview={lastFramePreview}
required
onPick={() => lastInputRef.current?.click()}
onClear={() => setUploadFile("last", null)}
/>
) : null}
</div>
) : null}
@@ -425,14 +379,6 @@ export default function Home() {
className="hidden"
onChange={(event) => setUploadFile("first", event.target.files?.[0] ?? null)}
/>
<input
ref={lastInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => setUploadFile("last", event.target.files?.[0] ?? null)}
/>
<textarea
value={prompt}
onChange={(event) => {
@@ -505,12 +451,12 @@ export default function Home() {
</select>
</label>
)}
<span>{activeMode.needsFirstFrame ? "图片作为参考" : "只根据文字生成"}</span>
<span>{activeMode.needsImage ? "图片作为视频参考" : "只根据文字生成"}</span>
</div>
<div className="flex items-center gap-2">
<a
href="/canvas/"
href="/"
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-white/10 bg-white/6 px-4 text-sm font-semibold text-white/72 transition hover:border-cyan-200/24 hover:text-cyan-100"
>
<ExternalLink className="h-4 w-4" />

View File

@@ -55,8 +55,8 @@ const props = defineProps({
// Image role options | 图片角色选项
const imageRoleOptions = [
{ label: '首帧', key: 'first_frame_image' },
{ label: '尾帧', key: 'last_frame_image' },
{ label: '图片', key: 'first_frame_image' },
{ label: '结束图', key: 'last_frame_image' },
{ label: '参考图', key: 'input_reference' }
]
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
// Current role label | 当前角色标签
const currentRoleLabel = computed(() => {
const option = imageRoleOptions.find(o => o.key === currentRole.value)
return option?.label || '首帧'
return option?.label || '图片'
})
// Calculate bezier path | 计算贝塞尔路径
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
// Handle role selection | 处理角色选择
const handleRoleSelect = (role) => {
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
// Keep endpoint image roles unique when advanced users edit edge roles.
if (role === 'first_frame_image' || role === 'last_frame_image') {
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
const sameTargetEdges = edges.value.filter(edge =>

View File

@@ -932,7 +932,7 @@ const handleVideoGen = () => {
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
})
// Connect text node to config node | 连接文本节点到配置节点

View File

@@ -82,16 +82,8 @@
提示词 {{ connectedPrompt ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ imagesByRole.referenceImages.length > 0 ? `${imagesByRole.referenceImages.length}` : '○' }}
:class="connectedImages.length > 0 ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
图片 {{ connectedImages.length > 0 ? `${connectedImages.length}` : '○' }}
</span>
</div>
@@ -195,7 +187,7 @@ const connectedImages = computed(() => {
edgeId: edge.id,
url: sourceNode.data.url,
base64: sourceNode.data.base64,
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
role: edge.data?.imageRole || 'first_frame_image' // Default reference image | 默认参考图
})
}
}
@@ -385,12 +377,12 @@ const handleGenerate = async () => {
params.prompt = prompt
}
// Add first frame image | 添加首帧图片
// Add primary reference image | 添加主参考图
if (first_frame_image) {
params.first_frame_image = first_frame_image
}
// Add last frame image | 添加尾帧图片
// Add optional ending reference image | 添加可选结束参考图
if (last_frame_image) {
params.last_frame_image = last_frame_image
}

View File

@@ -17,7 +17,7 @@ const routes = [
]
const router = createRouter({
history: createWebHistory('/canvas/'),
history: createWebHistory('/'),
routes
})

View File

@@ -164,22 +164,15 @@
v-if="needsFirstFrame"
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
>
{{ firstFrameFile ? `首帧 · ${firstFrameFile.name}` : '上传首帧' }}
{{ firstFrameFile ? `图片 · ${firstFrameFile.name}` : '上传图片' }}
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('first', event)" />
</label>
<label
v-if="needsLastFrame"
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
>
{{ lastFrameFile ? `尾帧 · ${lastFrameFile.name}` : '上传尾帧' }}
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('last', event)" />
</label>
<button
v-if="firstFrameFile || lastFrameFile"
v-if="firstFrameFile"
@click="clearFrameFiles"
class="px-2 py-1.5 text-xs rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]"
>
清空
清空图片
</button>
</div>
<textarea
@@ -194,10 +187,7 @@
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<span v-if="firstFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<img :src="firstFramePreview" alt="首帧" class="h-full w-full object-cover" />
</span>
<span v-if="lastFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<img :src="lastFramePreview" alt="尾帧" class="h-full w-full object-cover" />
<img :src="firstFramePreview" alt="参考图片" class="h-full w-full object-cover" />
</span>
</div>
<div class="flex items-center gap-3">
@@ -370,22 +360,17 @@ const showGrid = ref(true)
const isProcessing = ref(false)
const creationModes = [
{ id: 'text-video', label: '文生视频' },
{ id: 'text-image', label: '文生图' },
{ id: 'first-frame-video', label: '首帧生视频' },
{ id: 'first-last-frame-video', label: '首尾帧生视频' }
{ id: 'text-video', label: '生视频' },
{ id: 'image-video', label: '生视频' }
]
const creationMode = ref('text-video')
const creationMode = ref('text-image')
const firstFrameFile = ref(null)
const lastFrameFile = ref(null)
const firstFramePreview = ref('')
const lastFramePreview = ref('')
const needsFirstFrame = computed(() => creationMode.value === 'first-frame-video' || creationMode.value === 'first-last-frame-video')
const needsLastFrame = computed(() => creationMode.value === 'first-last-frame-video')
const needsFirstFrame = computed(() => creationMode.value === 'image-video')
const canSubmit = computed(() => {
if (!chatInput.value.trim()) return false
if (needsFirstFrame.value && !firstFrameFile.value) return false
if (needsLastFrame.value && !lastFrameFile.value) return false
return true
})
@@ -443,18 +428,14 @@ const nodeTypeOptions = [
// Input placeholder | 输入占位符
const inputPlaceholder = computed(() => {
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
if (creationMode.value === 'first-frame-video') return '上传首帧后,写人物动作、镜头运动、产品细节保持和视频节奏'
if (creationMode.value === 'first-last-frame-video') return '上传首帧和尾帧后,写中间如何过渡、动作节奏和产品细节保持'
if (creationMode.value === 'image-video') return '上传图片后,写人物动作、镜头运动、产品细节保持和视频节奏'
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
})
const setCreationMode = (mode) => {
creationMode.value = mode
if (mode === 'text-video' || mode === 'text-image') {
if (mode !== 'image-video') {
clearFrameFiles()
} else if (mode === 'first-frame-video') {
lastFrameFile.value = null
lastFramePreview.value = ''
}
}
@@ -473,20 +454,13 @@ const handleFrameFile = (slot, event) => {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
firstFrameFile.value = file
firstFramePreview.value = url
} else {
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
lastFrameFile.value = file
lastFramePreview.value = url
}
}
const clearFrameFiles = () => {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
firstFrameFile.value = null
lastFrameFile.value = null
firstFramePreview.value = ''
lastFramePreview.value = ''
}
// Add new node | 添加新节点
@@ -575,7 +549,7 @@ const onConnect = (params) => {
addEdge({
...params,
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
})
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
// Use promptOrder edge type | 使用提示词顺序边类型
@@ -773,25 +747,15 @@ const sendMessage = async () => {
const firstId = addNode('image', { x: baseX, y: baseY + 160 }, {
url: dataUrl,
base64: dataUrl,
label: '首帧'
label: '参考图'
})
imageNodeIds.push({ id: firstId, role: 'first_frame_image' })
promptY = baseY - 140
updateNode(textNodeId, { zIndex: 5 })
}
if (needsLastFrame.value && lastFrameFile.value) {
const dataUrl = await fileToDataUrl(lastFrameFile.value)
const lastId = addNode('image', { x: baseX, y: baseY + 440 }, {
url: dataUrl,
base64: dataUrl,
label: '尾帧'
})
imageNodeIds.push({ id: lastId, role: 'last_frame_image' })
}
const videoConfigNodeId = addNode('videoConfig', { x: videoX, y: promptY }, {
label: creationMode.value === 'text-video' ? '文生视频' : '生视频',
label: creationMode.value === 'text-video' ? '文生视频' : '生视频',
autoExecute: true
})

View File

@@ -4,7 +4,7 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
base: '/canvas/',
base: '/',
plugins: [vue()],
resolve: {
alias: {

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "pnpm build:canvas && next build",
"build:canvas": "cd canvas-app && pnpm build && node ../scripts/sync-canvas-dist.mjs",
"build": "pnpm build:canvas && next build && node scripts/sync-canvas-root.mjs",
"build:canvas": "cd canvas-app && pnpm build",
"build:next": "next build",
"dev": "next dev -p 4290",
"dev:canvas": "cd canvas-app && pnpm dev --host 0.0.0.0 --port 4292",

View File

@@ -0,0 +1,28 @@
import { cp, mkdir, readdir, rm } from "node:fs/promises"
import { existsSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const here = dirname(fileURLToPath(import.meta.url))
const webRoot = resolve(here, "..")
const source = resolve(webRoot, "canvas-app", "dist")
const target = resolve(webRoot, "out")
if (!existsSync(source)) {
throw new Error(`Canvas build output missing: ${source}`)
}
if (!existsSync(target)) {
throw new Error(`Next export output missing: ${target}`)
}
await mkdir(target, { recursive: true })
for (const entry of await readdir(source)) {
const from = resolve(source, entry)
const to = resolve(target, entry)
await rm(to, { recursive: true, force: true })
await cp(from, to, { recursive: true, force: true })
}
await rm(resolve(target, "canvas"), { recursive: true, force: true })