"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowRight, ArrowUp, Clapperboard, Copy, ExternalLink, FileText, Folder, Image as ImageIcon, Layers3, Loader2, Menu, Plus, RefreshCw, Search, 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 short: string icon: LucideIcon accent: string active: string placeholder: string } type InspirationCard = { title: string tag: string mode: CreationMode prompt: string tone: string } const OUTPUT_MODES: ModeConfig[] = [ { id: "video", label: "视频", short: "竖屏短片 / 产品动态", icon: Clapperboard, accent: "text-cyan-200", active: "border-cyan-300/60 bg-cyan-300/12 text-cyan-50", placeholder: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物戴上 SKG 颈部按摩仪,镜头干净,突出日常放松和产品佩戴清楚。", }, { id: "image", label: "图片", short: "营销图 / 首帧 / 产品场景", icon: ImageIcon, accent: "text-emerald-200", active: "border-emerald-300/60 bg-emerald-300/12 text-emerald-50", placeholder: "生成一张 9:16 信息流营销图,SKG 颈部按摩仪佩戴清楚,真实办公室午休场景,画面干净,有高级感。", }, { id: "copy", label: "图文", short: "标题 / 脚本 / caption", icon: FileText, accent: "text-orange-200", active: "border-orange-300/65 bg-orange-300/12 text-orange-50", placeholder: "写一组 SKG 颈部按摩仪的营销图文方案,面向久坐办公人群,语气真实直接,有前三秒 hook 和可用于生图/生视频的提示词。", }, ] const PROMPT_PRESETS: InspirationCard[] = [ { title: "办公室午休", tag: "短视频", mode: "video", prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。", tone: "from-cyan-500/22 via-slate-900 to-slate-950", }, { title: "下班回家放松", tag: "短视频", mode: "video", prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。", tone: "from-orange-500/22 via-slate-900 to-slate-950", }, { title: "白底功能图", tag: "图片", mode: "image", prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。", tone: "from-emerald-500/22 via-slate-900 to-slate-950", }, { title: "前三秒 Hook", tag: "图文", mode: "copy", prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。", tone: "from-fuchsia-500/22 via-slate-900 to-slate-950", }, { title: "真实生活方式", tag: "图片", mode: "image", prompt: "生成一张真实生活方式营销图,人物在家中沙发放松,佩戴 SKG 颈部按摩仪,光线自然,产品清晰可见。", tone: "from-blue-500/20 via-slate-900 to-slate-950", }, ] 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 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 = { created: "已创建", downloading: "下载中", downloaded: "已下载", splitting: "拆轨中", frames_extracted: "可创作", transcribing: "识别中", transcribed: "已解析", failed: "失败", } return map[status] ?? status } export default function Home() { const [mode, setMode] = useState("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(null) const [referencePreview, setReferencePreview] = useState("") const [job, setJob] = useState(null) const [busy, setBusy] = useState(null) const [copyVariants, setCopyVariants] = useState([]) const [recentJobs, setRecentJobs] = useState([]) const [showSettings, setShowSettings] = useState(false) const [error, setError] = useState("") const fileInputRef = useRef(null) const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0] const ActiveIcon = activeMode.icon 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 (
SKG 营销内容工作台

开启你的 SKG 创作模式 即刻生成!

上传素材是图生图 / 图生视频,不上传就是文生图 / 文生视频。

{currentReference ? ( onFileChange(null) : undefined} /> ) : ( )}
onFileChange(event.target.files?.[0] ?? null)} />