"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowUp, Clapperboard, ExternalLink, Image as ImageIcon, Loader2, Plus, Sparkles, Upload, X, type LucideIcon, } from "lucide-react" import { Toaster, toast } from "sonner" import { MediaAssetTile } from "@/components/media-asset-tile" import { apiAssetUrl, createCreativeImageJob, deleteGeneratedImage, deleteGeneratedVideo, generateImage, generateStoryboardVideo, getRuntimeHealth, getJob, uploadReferenceFrame, type GeneratedImage, type GeneratedVideo, type Job, type RuntimeModelOption, type RuntimeSizeOption, } from "@/lib/api" type CreationMode = "text-video" | "text-image" | "first-frame-video" | "first-last-frame-video" type BusyTask = CreationMode | "job" | null type UploadSlot = "first" | "last" type ModeConfig = { id: CreationMode label: string icon: LucideIcon placeholder: string needsFirstFrame?: boolean needsLastFrame?: boolean } const MODES: ModeConfig[] = [ { id: "text-video", label: "文生视频", icon: Clapperboard, placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。", }, { id: "text-image", label: "文生图", icon: ImageIcon, placeholder: "写清楚画面、主体、构图、光线和比例。例如:9:16 信息流营销图,真实办公室场景,SKG 颈部按摩仪佩戴清楚。", }, { id: "first-frame-video", label: "首帧生视频", icon: Upload, needsFirstFrame: true, placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。", }, { id: "first-last-frame-video", label: "首尾帧生视频", icon: Sparkles, needsFirstFrame: true, needsLastFrame: true, placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。", }, ] function cx(...items: Array) { return items.filter(Boolean).join(" ") } function allGeneratedImages(job: Job | null): GeneratedImage[] { if (!job) return [] return job.frames.flatMap((frame) => frame.generated_images ?? []).sort((a, b) => b.created_at - a.created_at) } function latestGeneratedImage(job: Job | null): GeneratedImage | null { return allGeneratedImages(job)[0] ?? null } function latestGeneratedVideo(job: Job | null): GeneratedVideo | null { return [...(job?.generated_videos ?? [])].sort((a, b) => b.created_at - a.created_at)[0] ?? null } function videoSrc(job: Job, video: GeneratedVideo) { return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`) } function videoStatusText(video: GeneratedVideo) { if (video.status === "queued") { return video.queue_message || (video.queue_position && video.queue_position > 1 ? `排队中 · 前方 ${video.queue_position - 1} 个任务` : "排队中 · 即将开始") } if (video.status === "in_progress") { return video.queue_message ? `${video.queue_message} · ${Math.round(video.progress)}%` : `生成中 · ${Math.round(video.progress)}%` } if (video.status === "completed") return `${Math.round(video.duration)}s · 可播放` return "失败 · 可重试" } function isVideoMode(mode: CreationMode) { return mode !== "text-image" } const DEFAULT_IMAGE_SIZE_OPTIONS: RuntimeSizeOption[] = [ { id: "1024x1536", label: "竖图 2:3", value: "1024x1536" }, { id: "1024x1024", label: "方图 1:1", value: "1024x1024" }, { id: "1536x1024", label: "横图 3:2", value: "1536x1024" }, { id: "auto", label: "自动", value: "auto" }, ] const DEFAULT_VIDEO_SIZE_OPTIONS: RuntimeSizeOption[] = [ { id: "720x1280", label: "竖屏 9:16", value: "720x1280" }, { id: "1280x720", label: "横屏 16:9", value: "1280x720" }, { id: "1024x1024", label: "方形 1:1", value: "1024x1024" }, ] export default function Home() { const [mode, setMode] = useState("text-video") const [prompt, setPrompt] = useState("") const [seconds, setSeconds] = useState(12) const [firstFrameFile, setFirstFrameFile] = useState(null) const [lastFrameFile, setLastFrameFile] = useState(null) const [firstFramePreview, setFirstFramePreview] = useState("") const [lastFramePreview, setLastFramePreview] = useState("") const [imageModel, setImageModel] = useState("auto") const [videoModel, setVideoModel] = useState("seedance") const [imageSize, setImageSize] = useState("1024x1536") const [videoSize, setVideoSize] = useState("720x1280") const [videoDurationOptions, setVideoDurationOptions] = useState([5, 8, 10, 12, 15]) const [imageOptions, setImageOptions] = useState([ { id: "auto", label: "自动", model: "gpt-image-2", available: true }, ]) const [videoOptions, setVideoOptions] = useState([ { id: "seedance", label: "Seedance", model: "seedance", available: true }, ]) const [imageSizeOptions, setImageSizeOptions] = useState(DEFAULT_IMAGE_SIZE_OPTIONS) const [videoSizeOptions, setVideoSizeOptions] = useState(DEFAULT_VIDEO_SIZE_OPTIONS) const [job, setJob] = useState(null) const [busy, setBusy] = useState(null) const [error, setError] = useState("") const firstInputRef = useRef(null) const lastInputRef = useRef(null) const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0] const latestImage = latestGeneratedImage(job) const latestVideo = latestGeneratedVideo(job) const runningVideo = (job?.generated_videos ?? []).some((item) => item.status === "queued" || item.status === "in_progress") const submitting = busy === mode || busy === "job" useEffect(() => { getRuntimeHealth() .then((health) => { const models = health.models const nextImageOptions = models?.image_options?.length ? models.image_options : [ { id: "auto", label: "自动", model: models?.image || "gpt-image-2", available: true }, { id: models?.image || "gpt-image-2", label: "GPT Image 2", model: models?.image || "gpt-image-2", available: true }, ] const nextVideoOptions = models?.video_options?.length ? models.video_options : [{ id: models?.video || "seedance", label: "Seedance", model: models?.video || "seedance", available: !!models?.video_configured }] const nextImageSizeOptions = models?.image_size_options?.length ? models.image_size_options : DEFAULT_IMAGE_SIZE_OPTIONS const nextVideoSizeOptions = models?.video_size_options?.length ? models.video_size_options : DEFAULT_VIDEO_SIZE_OPTIONS const nextDurationOptions = models?.video_duration_options?.length ? models.video_duration_options : [5, 8, 10, 12, 15] setImageOptions(nextImageOptions) setVideoOptions(nextVideoOptions) setImageSizeOptions(nextImageSizeOptions) setVideoSizeOptions(nextVideoSizeOptions) setVideoDurationOptions(nextDurationOptions) if (!nextImageOptions.some((item) => item.id === imageModel)) setImageModel(nextImageOptions[0]?.id || "auto") if (!nextVideoOptions.some((item) => item.id === videoModel)) setVideoModel(nextVideoOptions[0]?.id || "seedance") if (!nextImageSizeOptions.some((item) => item.value === imageSize)) setImageSize(nextImageSizeOptions[0]?.value || "1024x1536") if (!nextVideoSizeOptions.some((item) => item.value === videoSize)) setVideoSize(nextVideoSizeOptions[0]?.value || "720x1280") if (!nextDurationOptions.includes(seconds)) setSeconds(nextDurationOptions.includes(12) ? 12 : (nextDurationOptions[0] ?? 5)) }) .catch(() => { setImageOptions([{ id: "auto", label: "自动", model: "gpt-image-2", available: true }]) setVideoOptions([{ id: "seedance", label: "Seedance", model: "seedance", available: true }]) setImageSizeOptions(DEFAULT_IMAGE_SIZE_OPTIONS) setVideoSizeOptions(DEFAULT_VIDEO_SIZE_OPTIONS) setVideoDurationOptions([5, 8, 10, 12, 15]) }) }, []) useEffect(() => { if (!firstFrameFile) { setFirstFramePreview("") return } const url = URL.createObjectURL(firstFrameFile) setFirstFramePreview(url) 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 () => { try { setJob(await getJob(job.id)) } catch { window.clearInterval(timer) } }, 2600) return () => window.clearInterval(timer) }, [job, runningVideo]) const resetResult = () => { setJob(null) setError("") } const onModeChange = (nextMode: CreationMode) => { setMode(nextMode) resetResult() if (nextMode === "text-video" || nextMode === "text-image") { 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() } const validate = () => { if (!prompt.trim()) { toast.error("先写提示词") return false } if (activeMode.needsFirstFrame && !firstFrameFile) { toast.error("先上传首帧") return false } if (activeMode.needsLastFrame && !lastFrameFile) { toast.error("先上传尾帧") return false } return true } const promptWithGuardrails = () => ( `${prompt.trim()}\n\nKeep the product shape stable when a product appears. Use a clean vertical marketing composition unless the prompt says otherwise.` ) const prepareJob = useCallback(async () => { setBusy("job") let created = await createCreativeImageJob(firstFrameFile) if (mode === "first-last-frame-video" && lastFrameFile) { created = await uploadReferenceFrame(created.id, lastFrameFile) } setJob(created) return created }, [firstFrameFile, lastFrameFile, mode]) const runImage = async () => { if (!validate()) return setBusy("text-image") setError("") try { const target = await prepareJob() const updated = await generateImage(target.id, 0, { prompt: promptWithGuardrails(), mode: "text", model: imageModel, size: imageSize, }) setJob(updated) toast.success("图片已生成") } catch (e) { const message = e instanceof Error ? e.message : "生图失败" setError(message) toast.error(message) } finally { setBusy(null) } } const runVideo = async () => { if (!validate()) return setBusy(mode) 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, size: videoSize, model: videoModel, }) setJob(updated) toast.success("已加入生成队列") } catch (e) { const message = e instanceof Error ? e.message : "生视频失败" setError(message) toast.error(message) } finally { setBusy(null) } } const runPrimary = () => { if (mode === "text-image") return runImage() return runVideo() } const deleteImage = async (image: GeneratedImage) => { if (!job) return try { setJob(await deleteGeneratedImage(job.id, 0, image.id)) toast.success("图片已删除") } catch (e) { toast.error(e instanceof Error ? e.message : "删除失败") } } const deleteVideo = async (video: GeneratedVideo) => { if (!job) return try { setJob(await deleteGeneratedVideo(job.id, video.id)) toast.success("视频已删除") } catch (e) { toast.error(e instanceof Error ? e.message : "删除失败") } } return (
SKG 生成
{job ? ( 详情 ) : null}

想生成什么?

选一种方式,写提示词,直接生成。

{MODES.map((item) => { const Icon = item.icon const selected = item.id === mode return ( ) })}
{(activeMode.needsFirstFrame || activeMode.needsLastFrame) ? (
firstInputRef.current?.click()} onClear={() => setUploadFile("first", null)} /> {activeMode.needsLastFrame ? ( lastInputRef.current?.click()} onClear={() => setUploadFile("last", null)} /> ) : null}
) : null} setUploadFile("first", event.target.files?.[0] ?? null)} /> setUploadFile("last", event.target.files?.[0] ?? null)} />