Files
20260512-skg-tk/web/app/page.tsx

608 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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"
}
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<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 [imageSize, setImageSize] = useState("1024x1536")
const [videoSize, setVideoSize] = useState("720x1280")
const [videoDurationOptions, setVideoDurationOptions] = useState<number[]>([5, 8, 10, 12, 15])
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 [imageSizeOptions, setImageSizeOptions] = useState<RuntimeSizeOption[]>(DEFAULT_IMAGE_SIZE_OPTIONS)
const [videoSizeOptions, setVideoSizeOptions] = useState<RuntimeSizeOption[]>(DEFAULT_VIDEO_SIZE_OPTIONS)
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 }]
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 (
<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={videoSize}
onChange={(event) => setVideoSize(event.target.value)}
className="max-w-32 bg-transparent text-white/76 outline-none"
>
{videoSizeOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<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"
>
{videoDurationOptions.map((value) => <option key={value} value={value}>{value}s</option>)}
</select>
</label>
</>
) : (
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
<select
value={imageSize}
onChange={(event) => setImageSize(event.target.value)}
className="max-w-32 bg-transparent text-white/76 outline-none"
>
{imageSizeOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
)}
<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>
)
}