feat: reduce home to single generation composer
This commit is contained in:
790
web/app/page.tsx
790
web/app/page.tsx
@@ -4,18 +4,12 @@ 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"
|
||||
@@ -26,75 +20,55 @@ import {
|
||||
createCreativeImageJob,
|
||||
deleteGeneratedImage,
|
||||
deleteGeneratedVideo,
|
||||
generateCreativeCopy,
|
||||
generateImage,
|
||||
generateStoryboardVideo,
|
||||
getJob,
|
||||
listJobs,
|
||||
type CreativeCopyVariant,
|
||||
uploadReferenceFrame,
|
||||
type GeneratedImage,
|
||||
type GeneratedVideo,
|
||||
type Job,
|
||||
type JobSummary,
|
||||
} from "@/lib/api"
|
||||
|
||||
type CreationMode = "video" | "image" | "copy"
|
||||
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
|
||||
}
|
||||
|
||||
type InspirationCard = {
|
||||
title: string
|
||||
mode: CreationMode
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const OUTPUT_MODES: ModeConfig[] = [
|
||||
const MODES: ModeConfig[] = [
|
||||
{
|
||||
id: "video",
|
||||
label: "视频",
|
||||
id: "text-video",
|
||||
label: "文生视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "Seedance 2.0 全能参考,视频创意无限可能",
|
||||
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
label: "图片",
|
||||
id: "text-image",
|
||||
label: "文生图",
|
||||
icon: ImageIcon,
|
||||
placeholder: "生成一张 9:16 信息流营销图,SKG 颈部按摩仪佩戴清楚,真实办公室午休场景。",
|
||||
placeholder: "写清楚画面、主体、构图、光线和比例。例如:9:16 信息流营销图,真实办公室场景,SKG 颈部按摩仪佩戴清楚。",
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
label: "图文",
|
||||
icon: FileText,
|
||||
placeholder: "写一组 SKG 颈部按摩仪营销图文方案,包含 hook、脚本、caption 和生成提示词。",
|
||||
},
|
||||
]
|
||||
|
||||
const PROMPT_PRESETS: InspirationCard[] = [
|
||||
{
|
||||
title: "办公室午休",
|
||||
mode: "video",
|
||||
prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。",
|
||||
id: "first-frame-video",
|
||||
label: "首帧生视频",
|
||||
icon: Upload,
|
||||
needsFirstFrame: true,
|
||||
placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。",
|
||||
},
|
||||
{
|
||||
title: "下班回家放松",
|
||||
mode: "video",
|
||||
prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。",
|
||||
},
|
||||
{
|
||||
title: "白底产品功能图",
|
||||
mode: "image",
|
||||
prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。",
|
||||
},
|
||||
{
|
||||
title: "前三秒 Hook",
|
||||
mode: "copy",
|
||||
prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。",
|
||||
id: "first-last-frame-video",
|
||||
label: "首尾帧生视频",
|
||||
icon: Sparkles,
|
||||
needsFirstFrame: true,
|
||||
needsLastFrame: true,
|
||||
placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -119,79 +93,49 @@ 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
|
||||
function isVideoMode(mode: CreationMode) {
|
||||
return mode !== "text-image"
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [mode, setMode] = useState<CreationMode>("video")
|
||||
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(15)
|
||||
const [referenceFile, setReferenceFile] = useState<File | null>(null)
|
||||
const [referencePreview, setReferencePreview] = 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 [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 firstInputRef = useRef<HTMLInputElement>(null)
|
||||
const lastInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0]
|
||||
const images = useMemo(() => allGeneratedImages(job), [job])
|
||||
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 currentReference = referencePreview || sourceFrameSrc(job)
|
||||
const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
|
||||
const firstCopy = copyVariants[0]
|
||||
|
||||
const refreshJobs = useCallback(async () => {
|
||||
try {
|
||||
setRecentJobs(await listJobs(14))
|
||||
} catch {
|
||||
setRecentJobs([])
|
||||
}
|
||||
}, [])
|
||||
const submitting = busy === mode || busy === "job"
|
||||
|
||||
useEffect(() => {
|
||||
refreshJobs()
|
||||
}, [refreshJobs, job?.id, images.length, job?.generated_videos?.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!referenceFile) {
|
||||
setReferencePreview("")
|
||||
if (!firstFrameFile) {
|
||||
setFirstFramePreview("")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(referenceFile)
|
||||
setReferencePreview(url)
|
||||
const url = URL.createObjectURL(firstFrameFile)
|
||||
setFirstFramePreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [referenceFile])
|
||||
}, [firstFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastFrameFile) {
|
||||
setLastFramePreview("")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(lastFrameFile)
|
||||
setLastFramePreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [lastFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
@@ -205,60 +149,68 @@ export default function Home() {
|
||||
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)
|
||||
const resetResult = () => {
|
||||
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 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 promptWithContext = () => (
|
||||
`${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`
|
||||
)
|
||||
const setUploadFile = (slot: UploadSlot, file: File | null) => {
|
||||
if (slot === "first") setFirstFrameFile(file)
|
||||
if (slot === "last") setLastFrameFile(file)
|
||||
resetResult()
|
||||
}
|
||||
|
||||
const validatePrompt = () => {
|
||||
const validate = () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写一句生成要求")
|
||||
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 (!validatePrompt()) return
|
||||
setBusy("image")
|
||||
if (!validate()) return
|
||||
setBusy("text-image")
|
||||
setError("")
|
||||
try {
|
||||
const target = await ensureJob()
|
||||
const target = await prepareJob()
|
||||
const updated = await generateImage(target.id, 0, {
|
||||
prompt: promptWithContext(),
|
||||
mode: canUseReference ? "edit" : "text",
|
||||
prompt: promptWithGuardrails(),
|
||||
mode: "text",
|
||||
})
|
||||
setJob(updated)
|
||||
toast.success("图片已生成")
|
||||
@@ -272,16 +224,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const runVideo = async () => {
|
||||
if (!validatePrompt()) return
|
||||
setBusy("video")
|
||||
if (!validate()) return
|
||||
setBusy(mode)
|
||||
setError("")
|
||||
try {
|
||||
const target = await ensureJob()
|
||||
const target = await prepareJob()
|
||||
const lastFrame = [...target.frames].sort((a, b) => b.index - a.index)[0]
|
||||
const updated = await generateStoryboardVideo(target.id, 0, {
|
||||
prompt: promptWithContext(),
|
||||
prompt: promptWithGuardrails(),
|
||||
duration: seconds,
|
||||
count: 1,
|
||||
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||
first_image: activeMode.needsFirstFrame ? { kind: "keyframe", frame_idx: 0 } : null,
|
||||
last_image: activeMode.needsLastFrame && lastFrame ? { kind: "keyframe", frame_idx: lastFrame.index } : null,
|
||||
size: "720x1280",
|
||||
})
|
||||
setJob(updated)
|
||||
@@ -295,33 +249,8 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
if (mode === "text-image") return runImage()
|
||||
return runVideo()
|
||||
}
|
||||
|
||||
@@ -345,349 +274,218 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
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 grid-cols-[42px_minmax(0,1fr)] md:grid-cols-[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 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>
|
||||
<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
|
||||
{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>
|
||||
<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>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
<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={() => 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"
|
||||
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>
|
||||
<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)}
|
||||
{(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)}
|
||||
/>
|
||||
) : 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>
|
||||
)}
|
||||
</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 items-center gap-2 text-xs text-white/38">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user