545 lines
20 KiB
TypeScript
545 lines
20 KiB
TypeScript
"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,
|
||
} 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<string | false | null | undefined>) {
|
||
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 isVideoMode(mode: CreationMode) {
|
||
return mode !== "text-image"
|
||
}
|
||
|
||
export default function Home() {
|
||
const [mode, setMode] = useState<CreationMode>("text-video")
|
||
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 [imageOptions, setImageOptions] = useState<RuntimeModelOption[]>([
|
||
{ id: "auto", label: "自动", model: "gpt-image-2", available: true },
|
||
])
|
||
const [videoOptions, setVideoOptions] = useState<RuntimeModelOption[]>([
|
||
{ id: "seedance", label: "Seedance", model: "seedance", available: true },
|
||
])
|
||
const [job, setJob] = useState<Job | null>(null)
|
||
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)
|
||
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 }]
|
||
setImageOptions(nextImageOptions)
|
||
setVideoOptions(nextVideoOptions)
|
||
if (!nextImageOptions.some((item) => item.id === imageModel)) setImageModel(nextImageOptions[0]?.id || "auto")
|
||
if (!nextVideoOptions.some((item) => item.id === videoModel)) setVideoModel(nextVideoOptions[0]?.id || "seedance")
|
||
})
|
||
.catch(() => {
|
||
setImageOptions([{ id: "auto", label: "自动", model: "gpt-image-2", available: true }])
|
||
setVideoOptions([{ id: "seedance", label: "Seedance", model: "seedance", available: true }])
|
||
})
|
||
}, [])
|
||
|
||
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,
|
||
})
|
||
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: "720x1280",
|
||
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 (
|
||
<main className="min-h-screen bg-[#090a0f] text-white">
|
||
<Toaster richColors position="top-center" />
|
||
<div className="flex min-h-screen flex-col">
|
||
<header className="flex h-14 shrink-0 items-center justify-between px-4 text-xs text-white/42 sm:px-6">
|
||
<div className="inline-flex items-center gap-2">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-cyan-400/12 text-cyan-200">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
</span>
|
||
SKG 生成
|
||
</div>
|
||
{job ? (
|
||
<a href={`/detail/?job=${job.id}`} className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-white/52 transition hover:bg-white/8 hover:text-white">
|
||
详情 <ExternalLink className="h-3.5 w-3.5" />
|
||
</a>
|
||
) : null}
|
||
</header>
|
||
|
||
<section className="flex flex-1 items-center justify-center px-4 pb-16 pt-6">
|
||
<div className="grid w-full max-w-[760px] gap-5">
|
||
<div className="grid justify-items-center gap-2 text-center">
|
||
<h1 className="text-xl font-semibold tracking-normal text-white/92">想生成什么?</h1>
|
||
<p className="text-sm text-white/34">选一种方式,写提示词,直接生成。</p>
|
||
</div>
|
||
|
||
<section className="rounded-[18px] border border-white/8 bg-[#1c1f28] p-3 shadow-[0_28px_90px_rgba(0,0,0,0.32)]">
|
||
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{MODES.map((item) => {
|
||
const Icon = item.icon
|
||
const selected = item.id === mode
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onModeChange(item.id)}
|
||
className={cx(
|
||
"inline-flex h-10 items-center justify-center gap-2 rounded-xl border px-2 text-sm font-semibold transition",
|
||
selected ? "border-cyan-300/28 bg-cyan-300/12 text-cyan-100" : "border-white/7 bg-black/14 text-white/48 hover:border-white/14 hover:text-white",
|
||
)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
{item.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{(activeMode.needsFirstFrame || activeMode.needsLastFrame) ? (
|
||
<div className="mb-3 grid gap-2 sm:grid-cols-2">
|
||
<FrameUpload
|
||
label="首帧"
|
||
preview={firstFramePreview}
|
||
required={!!activeMode.needsFirstFrame}
|
||
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}
|
||
|
||
<input
|
||
ref={firstInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
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) => {
|
||
setPrompt(event.target.value)
|
||
setError("")
|
||
}}
|
||
placeholder={activeMode.placeholder}
|
||
className="min-h-36 w-full resize-none rounded-xl border border-white/7 bg-black/18 px-4 py-3 text-[15px] leading-7 text-white outline-none placeholder:text-white/24 focus:border-cyan-200/28"
|
||
/>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-2 text-xs text-white/38">
|
||
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
|
||
模型
|
||
<select
|
||
value={isVideoMode(mode) ? videoModel : imageModel}
|
||
onChange={(event) => {
|
||
if (isVideoMode(mode)) setVideoModel(event.target.value)
|
||
else setImageModel(event.target.value)
|
||
}}
|
||
className="max-w-36 bg-transparent text-white/76 outline-none"
|
||
>
|
||
{(isVideoMode(mode) ? videoOptions : imageOptions).map((item) => (
|
||
<option key={item.id} value={item.id} disabled={item.available === false}>
|
||
{item.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{isVideoMode(mode) ? (
|
||
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
|
||
时长
|
||
<select
|
||
value={seconds}
|
||
onChange={(event) => setSeconds(Number(event.target.value))}
|
||
className="bg-transparent text-white/76 outline-none"
|
||
>
|
||
{[5, 8, 12, 15, 20, 30].map((value) => <option key={value} value={value}>{value}s</option>)}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
<span>{activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}</span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={runPrimary}
|
||
disabled={!!busy}
|
||
className="inline-flex h-10 min-w-28 items-center justify-center gap-2 rounded-full bg-cyan-200 px-5 text-sm font-semibold text-black transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
||
生成
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{error ? (
|
||
<div className="rounded-2xl border border-rose-300/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{error}</div>
|
||
) : null}
|
||
|
||
{(latestImage || latestVideo) && (
|
||
<section className="rounded-[18px] border border-white/8 bg-[#161922] p-3">
|
||
<div className="mb-3 flex items-center justify-between gap-2">
|
||
<h2 className="text-sm font-semibold text-white/82">生成结果</h2>
|
||
{job ? <a href={`/detail/?job=${job.id}`} className="text-xs font-semibold text-cyan-200/82 hover:text-cyan-100">打开详情页</a> : null}
|
||
</div>
|
||
{latestVideo && job ? (
|
||
<MediaAssetTile
|
||
kind="video"
|
||
src={latestVideo.status === "completed" ? videoSrc(job, latestVideo) : undefined}
|
||
poster={apiAssetUrl(latestVideo.poster_url)}
|
||
objectFit="cover"
|
||
previewObjectFit="contain"
|
||
className="aspect-video w-full rounded-xl"
|
||
label={latestVideo.model}
|
||
meta={`${latestVideo.status} · ${Math.round(latestVideo.progress)}%`}
|
||
busy={latestVideo.status === "queued" || latestVideo.status === "in_progress"}
|
||
onDelete={() => deleteVideo(latestVideo)}
|
||
/>
|
||
) : latestImage ? (
|
||
<MediaAssetTile
|
||
src={apiAssetUrl(latestImage.url)}
|
||
alt="generated image"
|
||
objectFit="cover"
|
||
previewObjectFit="contain"
|
||
className="aspect-video w-full rounded-xl"
|
||
label={latestImage.model}
|
||
meta={latestImage.mode}
|
||
onDelete={() => deleteImage(latestImage)}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function FrameUpload({
|
||
label,
|
||
preview,
|
||
required,
|
||
onPick,
|
||
onClear,
|
||
}: {
|
||
label: string
|
||
preview: string
|
||
required: boolean
|
||
onPick: () => void
|
||
onClear: () => void
|
||
}) {
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={onPick}
|
||
className="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-dashed border-white/12 bg-black/16 text-sm font-semibold text-white/44 transition hover:border-cyan-200/30 hover:text-white"
|
||
>
|
||
{preview ? (
|
||
<MediaAssetTile
|
||
src={preview}
|
||
alt={label}
|
||
objectFit="cover"
|
||
previewObjectFit="contain"
|
||
className="h-full w-full rounded-xl"
|
||
/>
|
||
) : (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Plus className="h-4 w-4" />
|
||
上传{label}{required ? "" : "(可选)"}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{preview ? (
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
className="absolute right-2 top-2 rounded-lg bg-black/60 p-1 text-white/70 transition hover:bg-black hover:text-white"
|
||
aria-label={`移除${label}`}
|
||
title={`移除${label}`}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|