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

784 lines
33 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 {
ArrowRight,
BadgeCheck,
Clapperboard,
Copy,
ExternalLink,
FileText,
Image as ImageIcon,
Layers3,
Loader2,
PenLine,
Play,
RefreshCw,
ShieldCheck,
Sparkles,
Upload,
Wand2,
X,
type LucideIcon,
} from "lucide-react"
import { Toaster, toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
import {
apiAssetUrl,
createCreativeImageJob,
deleteGeneratedImage,
deleteGeneratedVideo,
generateCreativeCopy,
generateImage,
generateStoryboardVideo,
getJob,
listJobs,
type CreativeCopyVariant,
type GeneratedImage,
type GeneratedVideo,
type Job,
type JobSummary,
} from "@/lib/api"
type CreationMode = "text-image" | "image-image" | "text-video" | "image-video"
type BusyTask = "image" | "video" | "copy" | "job" | null
type ModeConfig = {
id: CreationMode
label: string
short: string
output: "image" | "video"
icon: LucideIcon
needsReference: boolean
tone: string
active: string
placeholder: string
}
const CREATION_MODES: ModeConfig[] = [
{
id: "text-image",
label: "文生图",
short: "文字到图片",
output: "image",
icon: Sparkles,
needsReference: false,
tone: "border-emerald-200 bg-emerald-50 text-emerald-900",
active: "border-emerald-500 bg-emerald-600 text-white",
placeholder: "一张 9:16 信息流营销图SKG 颈部按摩仪佩戴清晰,真实办公室午休场景,画面干净,有高级感。",
},
{
id: "image-image",
label: "图生图",
short: "参考图改图",
output: "image",
icon: ImageIcon,
needsReference: true,
tone: "border-cyan-200 bg-cyan-50 text-cyan-900",
active: "border-cyan-500 bg-cyan-600 text-white",
placeholder: "保留参考图里的姿态和产品佩戴关系,换成更明亮的生活方式广告画面,产品外形不能变。",
},
{
id: "text-video",
label: "文生视频",
short: "文字到短片",
output: "video",
icon: Clapperboard,
needsReference: false,
tone: "border-orange-200 bg-orange-50 text-orange-900",
active: "border-orange-500 bg-orange-600 text-white",
placeholder: "20 秒竖屏短视频:久坐办公的人摘下耳机,戴上 SKG 颈部按摩仪,镜头缓慢推进,强调日常放松。",
},
{
id: "image-video",
label: "图生视频",
short: "参考图动起来",
output: "video",
icon: Play,
needsReference: true,
tone: "border-rose-200 bg-rose-50 text-rose-900",
active: "border-rose-500 bg-rose-600 text-white",
placeholder: "把参考图变成 12 秒竖屏视频,人物自然佩戴产品,轻微转头,产品结构保持稳定,光线真实。",
},
]
const PROMPT_PRESETS = [
"真实办公室午休场景,产品佩戴清楚,镜头干净,适合信息流首帧。",
"年轻上班族下班回家放松,先展示疲惫状态,再自然戴上 SKG 产品。",
"白底产品功能图,高级电商质感,突出外形、佩戴方式和日常使用。",
"TikTok 竖屏短视频,前三秒强 hook产品不要变形动作自然可信。",
]
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 videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
function jobTitle(item: Job | JobSummary | null) {
if (!item) return "未选择任务"
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
return raw || item.id
}
function sourceFrameSrc(job: Job | null) {
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
}
function statusLabel(status?: string) {
if (!status) return "就绪"
const map: Record<string, string> = {
created: "已创建",
downloading: "下载中",
downloaded: "已下载",
splitting: "拆轨中",
frames_extracted: "可创作",
transcribing: "识别中",
transcribed: "已解析",
failed: "失败",
}
return map[status] ?? status
}
export default function Home() {
const [mode, setMode] = useState<CreationMode>("text-video")
const [prompt, setPrompt] = useState("")
const [product, setProduct] = useState("SKG 颈部按摩仪")
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
const [platform, setPlatform] = useState("TikTok / Reels")
const [tone, setTone] = useState("真实、直接、有购买理由")
const [seconds, setSeconds] = useState(12)
const [referenceFile, setReferenceFile] = useState<File | null>(null)
const [referencePreview, setReferencePreview] = useState("")
const [job, setJob] = useState<Job | null>(null)
const [busy, setBusy] = useState<BusyTask>(null)
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
const [error, setError] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const activeMode = CREATION_MODES.find((item) => item.id === mode) ?? CREATION_MODES[0]
const ActiveIcon = activeMode.icon
const images = useMemo(() => allGeneratedImages(job), [job])
const latestImage = latestGeneratedImage(job)
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job])
const runningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress")
const currentReference = referencePreview || sourceFrameSrc(job)
const currentOutputCount = images.length + generatedVideos.length
const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
const refreshJobs = useCallback(async () => {
try {
setRecentJobs(await listJobs(12))
} catch {
setRecentJobs([])
}
}, [])
useEffect(() => {
refreshJobs()
}, [refreshJobs, job?.id, currentOutputCount])
useEffect(() => {
if (!referenceFile) {
setReferencePreview("")
return
}
const url = URL.createObjectURL(referenceFile)
setReferencePreview(url)
return () => URL.revokeObjectURL(url)
}, [referenceFile])
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 ensureJob = useCallback(async () => {
if (job) return job
setBusy("job")
const created = await createCreativeImageJob(referenceFile)
setJob(created)
await refreshJobs()
return created
}, [job, referenceFile, refreshJobs])
const onFileChange = (file: File | null) => {
setReferenceFile(file)
setJob(null)
setCopyVariants([])
setError("")
}
const loadJob = async (id: string) => {
setBusy("job")
setError("")
try {
const loaded = await getJob(id)
setJob(loaded)
setReferenceFile(null)
setCopyVariants([])
} catch (e) {
const message = e instanceof Error ? e.message : "读取任务失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const validatePrompt = () => {
if (!prompt.trim()) {
toast.error(activeMode.output === "image" ? "先写图片要求" : "先写视频要求")
return false
}
if (activeMode.needsReference && !canUseReference) {
toast.error("这个入口需要先上传参考图或选择已有任务")
return false
}
return true
}
const runImage = async () => {
if (!validatePrompt()) return
setBusy("image")
setError("")
try {
const target = await ensureJob()
const updated = await generateImage(target.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}.`,
mode: activeMode.needsReference ? "edit" : "text",
})
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 (!validatePrompt()) return
setBusy("video")
setError("")
try {
const target = await ensureJob()
const updated = await generateStoryboardVideo(target.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
duration: seconds,
count: 1,
first_image: { kind: "keyframe", frame_idx: 0 },
size: "720x1280",
})
setJob(updated)
toast.success("视频已提交")
} catch (e) {
const message = e instanceof Error ? e.message : "生视频失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runCopy = async () => {
const goal = prompt.trim() || `${product} ${audience} ${platform}`
setBusy("copy")
setError("")
try {
const result = await generateCreativeCopy({
goal,
product,
audience,
platform,
tone,
seconds,
})
setCopyVariants(result.variants)
toast.success("图文方案已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "写文案失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runPrimary = () => {
if (activeMode.output === "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 : "删除失败")
}
}
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("已复制")
} catch {
toast.error("复制失败")
}
}
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
setMode(nextMode)
setPrompt(nextMode === "text-image" || nextMode === "image-image" ? variant.image_prompt_en : variant.video_prompt_en)
}
return (
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
<Toaster richColors position="top-center" />
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#16231f] text-white">
<Layers3 className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-normal">SKG </h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[#66746e]">
<span className="inline-flex items-center gap-1 rounded border border-[#cbd6d0] bg-white px-2 py-1">
<ShieldCheck className="h-3.5 w-3.5 text-[#0f766e]" />
</span>
<span className="inline-flex items-center gap-1 rounded border border-[#cbd6d0] bg-white px-2 py-1">
<BadgeCheck className="h-3.5 w-3.5 text-[#ea580c]" />
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<a
href="/agent/"
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-[#42524c] transition hover:border-[#9aa9a2] hover:text-[#17201d]"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
{job ? (
<a
href={`/detail/?job=${job.id}`}
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#16231f] px-3 text-white transition hover:bg-[#25342f]"
>
<ArrowRight className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</header>
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<span className="rounded bg-[#f2f5ef] px-2 py-1 text-[11px] text-[#66746e]">4 </span>
</div>
<div className="grid gap-2">
{CREATION_MODES.map((item) => {
const Icon = item.icon
const selected = item.id === mode
return (
<button
key={item.id}
type="button"
onClick={() => setMode(item.id)}
className={cx(
"flex min-h-14 items-center justify-between rounded-md border px-3 text-left transition focus:outline-none focus:ring-2 focus:ring-[#0f766e]/25",
selected ? item.active : `${item.tone} hover:border-[#94a39b]`,
)}
>
<span className="flex min-w-0 items-center gap-2">
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-semibold">{item.label}</span>
<span className={cx("block truncate text-[11px]", selected ? "text-white/72" : "text-current/62")}>{item.short}</span>
</span>
</span>
{selected ? <ArrowRight className="h-4 w-4 shrink-0" /> : null}
</button>
)
})}
</div>
</section>
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
{referenceFile ? (
<button
type="button"
onClick={() => onFileChange(null)}
className="inline-flex items-center gap-1 text-xs text-[#be3f18] hover:text-[#8f2f14]"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
/>
{currentReference ? (
<MediaAssetTile
src={currentReference}
alt="reference"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={referenceFile?.name || jobTitle(job)}
meta={referenceFile ? "local" : "job"}
onDelete={referenceFile ? () => onFileChange(null) : undefined}
/>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex aspect-[4/5] w-full flex-col items-center justify-center rounded-md border border-dashed border-[#b8c6bf] bg-[#f7f9f5] text-[#66746e] transition hover:border-[#0f766e] hover:text-[#0f766e] focus:outline-none focus:ring-2 focus:ring-[#0f766e]/25"
>
<Upload className="mb-2 h-5 w-5" />
<span className="text-sm"></span>
</button>
)}
</section>
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<button
type="button"
onClick={refreshJobs}
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
aria-label="刷新任务"
title="刷新任务"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
{recentJobs.length ? recentJobs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => loadJob(item.id)}
className={cx(
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
)}
>
<MediaAssetTile
src={apiAssetUrl(item.thumbnail)}
alt=""
objectFit="cover"
className="aspect-square rounded"
disablePreview={!item.thumbnail}
/>
<span className="min-w-0">
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
<span className="block text-[11px] text-[#66746e]">{statusLabel(item.status)} · {item.frame_count} · {item.video_count} </span>
</span>
</button>
)) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
)}
</div>
</section>
</aside>
<section className="flex min-h-[720px] flex-col rounded-lg border border-[#d8dfd4] bg-white">
<div className="flex items-center justify-between gap-3 border-b border-[#e1e6dd] p-4">
<div className="flex items-center gap-3">
<div className={cx("flex h-11 w-11 items-center justify-center rounded-md border", activeMode.tone)}>
<ActiveIcon className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">{activeMode.label}</h2>
<p className="text-sm text-[#66746e]">{activeMode.short}</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={runCopy}
disabled={!!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] px-4 text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <PenLine className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
className="inline-flex h-10 min-w-[132px] items-center justify-center gap-2 rounded-md bg-[#ea5b2d] px-5 text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === activeMode.output || busy === "job" ? <Loader2 className="h-4 w-4 animate-spin" /> : activeMode.output === "image" ? <Wand2 className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{activeMode.output === "image" ? "生成图片" : "生成视频"}
</button>
</div>
</div>
<div className="grid flex-1 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={platform}
onChange={(event) => setPlatform(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{["TikTok / Reels", "Amazon", "独立站", "小红书", "抖音"].map((value) => <option key={value}>{value}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
</div>
<label className="grid min-h-0 gap-2">
<span className="text-xs font-medium text-[#52635d]"></span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={activeMode.placeholder}
className="min-h-[280px] resize-none rounded-lg border border-[#d8dfd4] bg-[#f7f9f5] p-4 text-base leading-7 outline-none transition placeholder:text-[#8b9993] focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<div className="grid gap-3">
<div className="flex flex-wrap gap-2">
{PROMPT_PRESETS.map((item) => (
<button
key={item}
type="button"
onClick={() => setPrompt(item)}
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2 text-xs text-[#42524c] transition hover:border-[#0f766e]/65 hover:bg-[#edf7f3]"
>
{item}
</button>
))}
</div>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
{activeMode.needsReference && !canUseReference ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"></div>
) : null}
{error ? (
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
</div>
</div>
</section>
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 max-w-[260px] truncate text-xs text-[#66746e]">{jobTitle(job)}</p>
</div>
{job ? <span className="rounded bg-[#f2f5ef] px-2 py-1 font-mono text-[11px] text-[#66746e]">{job.id}</span> : null}
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{images.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{generatedVideos.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{copyVariants.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
</div>
{job ? (
<a
href={`/detail/?job=${job.id}`}
className="mt-3 inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-white text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60"
>
<ExternalLink className="h-4 w-4" />
</a>
) : null}
</section>
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="grid gap-4">
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<ImageIcon className="h-3.5 w-3.5" />
</div>
{latestImage ? (
<MediaAssetTile
src={apiAssetUrl(latestImage.url)}
alt="generated image"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={latestImage.model}
meta={latestImage.mode}
onDelete={() => deleteImage(latestImage)}
/>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<Clapperboard className="h-3.5 w-3.5" />
</div>
{generatedVideos.length ? generatedVideos.slice(0, 4).map((video) => (
<div key={video.id} className="grid gap-1.5">
<MediaAssetTile
kind="video"
src={video.status === "completed" ? videoSrc(job!, video) : undefined}
poster={apiAssetUrl(video.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-md"
label={video.model}
meta={`${video.status} · ${Math.round(video.progress)}%`}
busy={video.status === "queued" || video.status === "in_progress"}
onDelete={() => deleteVideo(video)}
/>
<div className="h-1 overflow-hidden rounded-full bg-[#edf1ec]">
<div className="h-full rounded-full bg-[#0f766e]" style={{ width: `${Math.max(4, video.progress)}%` }} />
</div>
</div>
)) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div>
)}
</div>
{copyVariants.length ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<FileText className="h-3.5 w-3.5" />
</div>
{copyVariants.map((variant, index) => (
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
<button
type="button"
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => useVariant(variant, activeMode.needsReference ? "image-image" : "text-image")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-xs font-semibold text-white hover:bg-[#115e59]"
>
<ImageIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => useVariant(variant, activeMode.needsReference ? "image-video" : "text-video")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-xs font-semibold text-white hover:bg-[#d94f25]"
>
<Clapperboard className="h-3.5 w-3.5" />
</button>
</div>
</article>
))}
</div>
) : null}
</div>
</section>
</aside>
</section>
</div>
</main>
)
}