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

694 lines
27 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,
Copy,
ExternalLink,
FileText,
Folder,
Image as ImageIcon,
Layers3,
Loader2,
Menu,
Plus,
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 = "video" | "image" | "copy"
type BusyTask = CreationMode | "job" | null
type ModeConfig = {
id: CreationMode
label: string
icon: LucideIcon
placeholder: string
}
type InspirationCard = {
title: string
mode: CreationMode
prompt: string
}
const OUTPUT_MODES: ModeConfig[] = [
{
id: "video",
label: "视频",
icon: Clapperboard,
placeholder: "Seedance 2.0 全能参考,视频创意无限可能",
},
{
id: "image",
label: "图片",
icon: ImageIcon,
placeholder: "生成一张 9:16 信息流营销图SKG 颈部按摩仪佩戴清楚,真实办公室午休场景。",
},
{
id: "copy",
label: "图文",
icon: FileText,
placeholder: "写一组 SKG 颈部按摩仪营销图文方案,包含 hook、脚本、caption 和生成提示词。",
},
]
const PROMPT_PRESETS: InspirationCard[] = [
{
title: "办公室午休",
mode: "video",
prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。",
},
{
title: "下班回家放松",
mode: "video",
prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。",
},
{
title: "白底产品功能图",
mode: "image",
prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。",
},
{
title: "前三秒 Hook",
mode: "copy",
prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。",
},
]
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 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>("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(15)
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 [showSettings, setShowSettings] = useState(false)
const [error, setError] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0]
const images = useMemo(() => allGeneratedImages(job), [job])
const latestImage = latestGeneratedImage(job)
const latestVideo = latestGeneratedVideo(job)
const runningVideo = (job?.generated_videos ?? []).some((item) => item.status === "queued" || item.status === "in_progress")
const currentReference = referencePreview || sourceFrameSrc(job)
const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
const firstCopy = copyVariants[0]
const refreshJobs = useCallback(async () => {
try {
setRecentJobs(await listJobs(14))
} catch {
setRecentJobs([])
}
}, [])
useEffect(() => {
refreshJobs()
}, [refreshJobs, job?.id, images.length, job?.generated_videos?.length])
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 promptWithContext = () => (
`${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`
)
const validatePrompt = () => {
if (!prompt.trim()) {
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: promptWithContext(),
mode: canUseReference ? "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: promptWithContext(),
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 (mode === "image") return runImage()
if (mode === "copy") return runCopy()
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 useInspiration = (item: InspirationCard) => {
setMode(item.mode)
setPrompt(item.prompt)
setError("")
}
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
setMode(nextMode)
setPrompt(nextMode === "image" ? variant.image_prompt_en : nextMode === "video" ? variant.video_prompt_en : variant.script_zh)
}
return (
<main className="min-h-screen bg-[#090a0f] text-white">
<Toaster richColors position="top-center" />
<div className="grid min-h-screen" style={{ gridTemplateColumns: "42px 132px minmax(0, 1fr)" }}>
<aside className="flex min-h-screen flex-col items-center border-r border-white/6 bg-[#090a0f] py-6">
<div className="mb-[230px] flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-400/12 text-cyan-200">
<Sparkles className="h-4 w-4" />
</div>
<nav className="grid gap-6 text-[10px] text-white/64">
<button type="button" className="group grid justify-items-center gap-1.5 transition hover:text-white">
<Sparkles className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
</button>
<button type="button" className="group grid justify-items-center gap-1.5 text-white transition">
<Wand2 className="h-4 w-4 text-cyan-200" />
</button>
<button type="button" onClick={refreshJobs} className="group grid justify-items-center gap-1.5 transition hover:text-white">
<Folder className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
</button>
</nav>
<div className="mt-auto grid justify-items-center gap-5 text-white/42">
<div className="h-5 w-5 rounded-full bg-gradient-to-br from-slate-500 to-slate-800 ring-1 ring-white/12" />
<a href="/agent/" className="text-[10px] transition hover:text-white" title="高级复刻">
<Layers3 className="mx-auto mb-1 h-3.5 w-3.5" />
Agent
</a>
<button type="button" className="rounded-lg p-2 transition hover:bg-white/8 hover:text-white" aria-label="菜单">
<Menu className="h-4 w-4" />
</button>
</div>
</aside>
<aside className="hidden min-h-screen border-r border-white/6 bg-[#15171e] px-2 py-5 md:block">
<div className="mb-4 flex items-center justify-between px-1 text-xs font-semibold text-white/86">
<span></span>
<button type="button" className="rounded-md p-1 text-white/36 transition hover:bg-white/8 hover:text-white" aria-label="收起侧栏">
<Menu className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-2">
<button
type="button"
onClick={() => {
setJob(null)
setPrompt("")
setCopyVariants([])
setReferenceFile(null)
setError("")
}}
className="flex h-8 items-center gap-2 rounded-md bg-white/8 px-2 text-left text-xs font-semibold text-white/86 transition hover:bg-white/12"
>
<Wand2 className="h-3.5 w-3.5 text-white/58" />
</button>
<button
type="button"
onClick={() => setPrompt(activeMode.placeholder)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-xs font-semibold text-white/58 transition hover:bg-white/8 hover:text-white"
>
<FileText className="h-3.5 w-3.5 text-white/40" />
</button>
{job ? (
<a
href={`/detail/?job=${job.id}`}
className="mt-2 flex min-h-9 items-center gap-2 rounded-md border border-cyan-200/16 bg-cyan-300/8 px-2 text-left text-xs font-semibold text-cyan-100 transition hover:border-cyan-200/30"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
<div className="mt-8 border-t border-white/6 pt-3">
<div className="mb-2 px-1 text-[11px] font-semibold text-white/34"></div>
<div className="grid gap-1.5">
{recentJobs.slice(0, 5).map((item) => (
<button
key={item.id}
type="button"
onClick={() => loadJob(item.id)}
className={cx(
"min-w-0 rounded-md px-2 py-2 text-left transition hover:bg-white/8",
job?.id === item.id ? "bg-white/8 text-white" : "text-white/42",
)}
>
<span className="block truncate text-[11px] font-semibold">{jobTitle(item)}</span>
<span className="mt-0.5 block truncate text-[10px] text-white/30">{statusLabel(item.status)} · {item.video_count} </span>
</button>
))}
</div>
</div>
</aside>
<section className="relative min-h-screen overflow-hidden bg-[#0b0c10]">
<div className="absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_50%_0%,rgba(17,211,239,0.08),transparent_58%)]" />
<div className="relative flex min-h-screen items-center justify-center px-5 py-16">
<section className="mb-20 grid w-full max-w-[520px] -translate-y-12 justify-items-center gap-5">
<h1 className="text-center text-lg font-semibold tracking-normal text-white/92"></h1>
<section className="relative w-full rounded-2xl border border-white/7 bg-[#1d1f27] p-3 shadow-[0_28px_80px_rgba(0,0,0,0.28)]">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute left-5 top-4 flex h-16 w-12 -rotate-6 items-center justify-center overflow-hidden rounded bg-[#2a2d37] text-white/34 shadow-lg transition hover:text-white"
aria-label="上传素材"
title="上传素材"
>
{currentReference ? (
<MediaAssetTile
src={currentReference}
alt="reference"
objectFit="cover"
previewObjectFit="contain"
className="h-full w-full rounded"
disablePreview={!currentReference}
/>
) : (
<Plus className="h-4 w-4" />
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
/>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={activeMode.placeholder}
className="min-h-9 w-full resize-none bg-transparent pl-16 pr-8 pt-0.5 text-sm leading-6 text-white outline-none placeholder:text-white/24"
/>
{referenceFile ? (
<button
type="button"
onClick={() => onFileChange(null)}
className="absolute right-4 top-4 rounded-lg p-1 text-white/34 transition hover:bg-white/8 hover:text-white"
aria-label="移除素材"
title="移除素材"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{OUTPUT_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(
"inline-flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-semibold transition",
selected ? "border-cyan-300/24 bg-cyan-300/10 text-cyan-200" : "border-white/7 bg-black/14 text-white/48 hover:border-white/14 hover:text-white",
)}
>
<Icon className="h-3.5 w-3.5" />
{item.label}
</button>
)
})}
<button
type="button"
onClick={() => setShowSettings((value) => !value)}
className="inline-flex h-8 items-center rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
>
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
>
<Upload className="h-3.5 w-3.5" />
{referenceFile ? "已上传" : "参考"}
</button>
</div>
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#424757] text-white/76 transition hover:bg-cyan-300 hover:text-black disabled:cursor-not-allowed disabled:opacity-60"
aria-label="开始生成"
title="开始生成"
>
{busy === mode || busy === "job" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
</button>
</div>
</section>
{showSettings ? (
<section className="grid w-full gap-3 rounded-2xl border border-white/7 bg-[#171922] p-3 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<select
value={platform}
onChange={(event) => setPlatform(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
>
{["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-white/42"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
<label className="grid gap-1.5 md:col-span-2">
<span className="text-xs font-medium text-white/42"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
</section>
) : null}
{error ? (
<div className="w-full rounded-2xl border border-rose-300/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{error}</div>
) : null}
<div className="mt-1 flex w-full flex-wrap justify-center gap-2">
{PROMPT_PRESETS.map((item) => (
<button
key={item.title}
type="button"
onClick={() => useInspiration(item)}
className="rounded-xl border border-white/7 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white/38 transition hover:border-cyan-200/22 hover:text-white"
>
{item.title}
</button>
))}
</div>
{(latestImage || latestVideo || firstCopy) && (
<section className="fixed bottom-6 right-6 z-20 w-[320px] rounded-2xl border border-white/8 bg-[#171922]/95 p-3 shadow-2xl backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-white/88"></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)}
/>
) : firstCopy ? (
<article className="rounded-xl border border-white/8 bg-white/[0.04] p-3">
<div className="flex items-start justify-between gap-2">
<h3 className="line-clamp-1 text-sm font-semibold text-white">{firstCopy.title || "营销方案"}</h3>
<button
type="button"
onClick={() => copyText([firstCopy.hook_zh, firstCopy.script_zh, firstCopy.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded-lg p-1 text-white/42 transition hover:bg-white/8 hover:text-white"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-white/58">{firstCopy.hook_zh}</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<button type="button" onClick={() => useVariant(firstCopy, "image")} className="rounded-lg bg-emerald-300/12 px-2 py-2 text-xs font-semibold text-emerald-100"></button>
<button type="button" onClick={() => useVariant(firstCopy, "video")} className="rounded-lg bg-cyan-300/12 px-2 py-2 text-xs font-semibold text-cyan-100"></button>
</div>
</article>
) : null}
</section>
)}
</section>
</div>
</section>
</div>
</main>
)
}