import { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, ArrowLeft, Boxes, Clapperboard, ChevronRight, ImagePlus, RefreshCw, ScrollText, SlidersHorizontal, Sparkles, Play, Upload, WandSparkles, X, Columns2, Download, ArrowUpRight, } from "lucide-react"; import { api, ApiError, type QuickCreateJob } from "../api"; import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { useFileDrop } from "../components/use-file-drop"; import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock"; import type { ModelConfig } from "../types"; import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel, type BillingRates, } from "../components/free-create/constants"; import type { NavigateFn } from "./route-config"; import { PC_CAT_MORE, PC_CAT_MORE_LABEL, PC_CAT_OPTIONS, PC_CAT_PRIMARY, } from "../product-business"; const PROGRESS_STEPS = [ { label: "脚本", icon: ScrollText }, { label: "资产", icon: Boxes }, { label: "视频", icon: Clapperboard }, ]; const QUICK_RATIOS = [ { value: "9:16", label: "9:16 竖屏" }, { value: "16:9", label: "16:9 横屏" }, { value: "1:1", label: "1:1 方形" }, { value: "3:4", label: "3:4 竖版" }, { value: "4:3", label: "4:3 横版" }, { value: "21:9", label: "21:9 超宽" }, ]; const QUICK_RESOLUTIONS = [ { value: "480p", label: "480p 流畅" }, { value: "720p", label: "720p 高清" }, { value: "1080p", label: "1080p 超清" }, { value: "4k", label: "4K 超清" }, ]; const QUICK_DURATIONS = [15, 30, 45, 60]; function modelResolutions(config: ModelConfig | undefined) { const capabilities = (config?.metadata?.capabilities || {}) as Record; const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : []; const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : []; return (nested.length ? nested : legacy).map(String); } function formatClock(seconds: number) { const total = Math.max(0, Math.round(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const rest = total % 60; return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; } function historyTitle(item: QuickCreateJob) { return stripQuickCreateSuffix(item.title || item.product_name || "一键成片"); } function jobIsComplete(item: QuickCreateJob) { return item.status === "succeeded" || Boolean(item.result?.video_url) || Boolean(item.result?.video_segments?.some((clip) => clip.video_url)); } function isReviewFailure(text?: string) { return /审核|敏感内容|moderation|safety_violation|sensitivecontent/i.test(text || ""); } function historyBadge(item: QuickCreateJob) { if (item.status === "cancelled") return "已取消"; if (jobIsComplete(item)) return "已完成"; return "未完成"; } function historyCover(item: QuickCreateJob) { return item.product_images?.[0]?.url || ""; } function historyVideoUrl(item: QuickCreateJob) { return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || ""; } function savedJobId() { return readQuickCreateJobId(); } export function QuickCreatePage({ onBack, backLabel = "返回视频创作", initialProductId, navigate, onNotify, onProjectCreated, onQuickCreateStatus, modelConfigs, }: { onBack: () => void; backLabel?: string; initialProductId?: string; navigate: NavigateFn; onNotify?: (type: "success" | "error" | "info", text: string) => void; onProjectCreated?: () => void; onQuickCreateStatus?: (projectId: string, status: string) => void; modelConfigs: ModelConfig[]; }) { const [name, setName] = useState(""); const [category, setCategory] = useState(""); const [catMoreOpen, setCatMoreOpen] = useState(false); const [images, setImages] = useState([]); const [savedImages, setSavedImages] = useState>([]); const [sourceProductId, setSourceProductId] = useState(""); const [imagePreviews, setImagePreviews] = useState([]); const [jobId, setJobId] = useState(savedJobId); const [job, setJob] = useState(null); const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); // 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器, // 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。 const [restoring, setRestoring] = useState(true); const [serviceUnavailable, setServiceUnavailable] = useState(false); const [unavailableMessage, setUnavailableMessage] = useState(""); const [pollEpoch, setPollEpoch] = useState(0); const [history, setHistory] = useState([]); const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null); useBodyScrollLock(Boolean(playing)); const videoConfigs = useMemo( () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), [modelConfigs], ); const preferredModel = useMemo( () => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0], [videoConfigs], ); const [aspectRatio, setAspectRatio] = useState("9:16"); const [resolution, setResolution] = useState("720p"); const [totalDuration, setTotalDuration] = useState(15); const [videoModelId, setVideoModelId] = useState(""); const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES); const completedNoticeRef = useRef(""); const terminalNoticeRef = useRef(""); const watchedGeneratingRef = useRef(false); const cancelRequestedRef = useRef(false); const notifyRef = useRef(onNotify); const projectCreatedRef = useRef(onProjectCreated); const quickCreateStatusRef = useRef(onQuickCreateStatus); const imageInputRef = useRef(null); const productPrefillDoneRef = useRef(false); useEffect(() => { notifyRef.current = onNotify; projectCreatedRef.current = onProjectCreated; quickCreateStatusRef.current = onQuickCreateStatus; }, [onNotify, onProjectCreated, onQuickCreateStatus]); useEffect(() => { if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id); }, [preferredModel, videoModelId]); useEffect(() => { void api.billingConfig() .then((config) => setBillingRates({ margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate, multiplier: Number(config.team_price_multiplier) || 1, })) .catch(() => undefined); void api.quickCreateHistory() .then((payload) => { setHistory((payload.results || []).filter(jobIsComplete)); const running = payload.inflight || null; if (running) { setJob(running); setJobId(running.id); rememberQuickCreateJob(running.id); } else if (!savedJobId()) { forgetQuickCreateJob(); } }) .catch(() => undefined) .finally(() => setRestoring(false)); }, []); useEffect(() => { if (!initialProductId || jobId || productPrefillDoneRef.current) return; let cancelled = false; void api.product(initialProductId) .then((product) => { if (cancelled || productPrefillDoneRef.current) return; productPrefillDoneRef.current = true; setName((current) => current || product.title || ""); setSourceProductId((current) => current || product.id); if (product.category && PC_CAT_OPTIONS.includes(product.category)) { setCategory((current) => current || product.category); setCatMoreOpen(PC_CAT_MORE.includes(product.category)); } setSavedImages((current) => { if (current.length) return current; return (product.images || []) .filter((image) => image.asset) .slice(0, 9) .map((image) => ({ asset_id: image.asset, url: image.preview_url || "" })); }); }) .catch(() => undefined); return () => { cancelled = true; }; }, [initialProductId, jobId]); const selectedVideoModel = useMemo( () => videoConfigs.find((config) => config.id === videoModelId) || preferredModel, [preferredModel, videoConfigs, videoModelId], ); const supportedResolutions = useMemo( () => modelResolutions(selectedVideoModel), [selectedVideoModel], ); useEffect(() => { if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return; setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]); }, [resolution, supportedResolutions]); useEffect(() => { if (!images.length) { setImagePreviews([]); return; } const urls = images.map((image) => URL.createObjectURL(image)); setImagePreviews(urls); return () => urls.forEach((url) => URL.revokeObjectURL(url)); }, [images]); useEffect(() => { if (!jobId) return; let cancelled = false; let timer = 0; const poll = async () => { try { const next = await api.quickCreateStatus(jobId); if (cancelled) return; if (next.status === "queued" || next.status === "running") { watchedGeneratingRef.current = true; } if (next.status !== "succeeded") { applyJobProduct(next); } if (next.settings) { setAspectRatio(next.settings.aspect_ratio); setResolution(next.settings.resolution); setTotalDuration(next.settings.total_duration); if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id); } setJob(next); if (next.status === "succeeded") { if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) { completedNoticeRef.current = next.id; notifyRef.current?.("success", "一键成片已生成"); projectCreatedRef.current?.(); } if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status); clearDraft(); void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined); watchedGeneratingRef.current = false; return; } if (next.status === "failed" || next.status === "cancelled") { if (terminalNoticeRef.current !== next.id) { terminalNoticeRef.current = next.id; const message = next.status === "cancelled" ? "一键成片已取消" : next.error_message || "一键成片未完成,已完成的步骤会保留,可重试继续"; notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message); } if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status); void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined); watchedGeneratingRef.current = false; return; } timer = window.setTimeout(poll, 2500); } catch (error) { if (cancelled) return; // 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID; // 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。 if (error instanceof ApiError && error.status === 404) { setJob(null); setJobId(""); clearDraft(); forgetQuickCreateJob(); notifyRef.current?.("info", "已清除其他账号的一键成片记录"); return; } notifyRef.current?.("error", error instanceof Error ? error.message : "读取一键成片进度失败"); timer = window.setTimeout(poll, 8000); } }; void poll(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [jobId, pollEpoch]); useEffect(() => { if (!playing) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setPlaying(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [playing]); function playClip(url?: string, title?: string) { if (!url) { onNotify?.("info", "视频还不能播放,请稍后再试"); return; } setPlaying({ url, title: title || "预览视频" }); } function selectImages(files: FileList | File[] | null) { const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/")); setImages((current) => { const room = Math.max(0, 9 - savedImages.length - current.length); const accepted = selected.slice(0, room); if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张"); return [...current, ...accepted]; }); } function removeImage(index: number) { if (index < savedImages.length) { setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index)); return; } setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length)); } function clearImages() { setSavedImages([]); setImages([]); } async function retryGeneration() { if (job?.id && job.status === "failed") { setSubmitting(true); setServiceUnavailable(false); setUnavailableMessage(""); cancelRequestedRef.current = false; terminalNoticeRef.current = ""; watchedGeneratingRef.current = true; setJob((current) => ( current ? { ...current, status: "running", error_message: "", message: "正在从上次进度继续生成…" } : current )); try { const next = await api.retryQuickCreate(job.id); setJob(next); setJobId(next.id); setPollEpoch((value) => value + 1); rememberQuickCreateJob(next.id); if (next.status === "queued" || next.status === "running") { onNotify?.("success", "已从上次进度继续生成"); } else if (next.status === "succeeded") { onNotify?.("success", "一键成片已生成"); } else { onNotify?.("error", next.error_message || "继续生成失败,已完成的步骤会保留"); } onProjectCreated?.(); } catch (error) { onNotify?.("error", error instanceof Error ? error.message : "继续生成失败"); } finally { setSubmitting(false); } return; } await startGeneration(); } async function startGeneration() { if (restoring) { onNotify?.("info", "正在读取任务状态,请稍候"); return; } if (!name.trim() || (!images.length && !savedImages.length)) { onNotify?.("info", "请先填写商品名称并上传商品图片"); return; } if (!category) { onNotify?.("info", "请选择商品品类,脚本会按品类来写"); return; } setSubmitting(true); setJob(null); setServiceUnavailable(false); setUnavailableMessage(""); cancelRequestedRef.current = false; terminalNoticeRef.current = ""; try { const form = new FormData(); form.append("name", name.trim()); form.append("category", category); form.append("aspect_ratio", aspectRatio); form.append("resolution", resolution); form.append("total_duration", String(totalDuration)); if (videoModelId) form.append("video_model_config_id", videoModelId); if (sourceProductId && savedImages.length) { form.append("source_product_id", sourceProductId); savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id)); } images.forEach((image) => form.append("images", image)); const created = await api.startQuickCreate(form); if (cancelRequestedRef.current) { try { await api.cancelQuickCreate(created.id); } catch { /* 启动刚成功但用户已点取消时,尽量停掉后台任务 */ } resetResult(); notifyRef.current?.("info", "已取消本次生成"); return; } setJob(created); setJobId(created.id); setImages([]); setSavedImages(created.product_images || []); setSourceProductId(created.product_id || ""); rememberQuickCreateJob(created.id); onNotify?.("success", "一键成片任务已启动"); onProjectCreated?.(); } catch (error) { if (error instanceof ApiError && error.status === 409) { // 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。 const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight; if (running) { setJob(running); setJobId(running.id); rememberQuickCreateJob(running.id); } onNotify?.("info", error.message || "已有一个一键成片正在进行中"); } else if (error instanceof ApiError && error.status === 503) { setServiceUnavailable(true); setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试"); onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试"); } else { onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败"); } } finally { setSubmitting(false); } } function applyJobProduct(next: QuickCreateJob) { if (next.product_name) setName((current) => current || next.product_name); if (next.product_images?.length) { setSavedImages(next.product_images.filter((image) => image.asset_id)); if (next.product_id) setSourceProductId(next.product_id); setImages([]); } } function clearDraft() { setName(""); setCategory(""); setCatMoreOpen(false); setImages([]); setSavedImages([]); setSourceProductId(""); productPrefillDoneRef.current = true; } function resetResult() { setJob(null); setJobId(""); setCancelling(false); setConfirmCancel(false); setServiceUnavailable(false); setUnavailableMessage(""); completedNoticeRef.current = ""; terminalNoticeRef.current = ""; watchedGeneratingRef.current = false; clearDraft(); forgetQuickCreateJob(); } function openProfessional(projectId?: string, status?: string) { if (!projectId) return; const released = status === "queued" || status === "running" || !status ? "failed" : status; quickCreateStatusRef.current?.(projectId, released); navigate("pipeline", { projectId, forcePipeline: true, quickCreateStatus: released }); } async function cancelGeneration() { if (cancelling) return; setConfirmCancel(false); cancelRequestedRef.current = true; if (!jobId) { setSubmitting(false); resetResult(); notifyRef.current?.("info", "已取消本次生成"); return; } setCancelling(true); try { const next = await api.cancelQuickCreate(jobId); setJob(next); if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status); notifyRef.current?.("info", "已取消本次生成"); } catch (error) { if (error instanceof ApiError && error.status === 404) { resetResult(); notifyRef.current?.("info", "已退出本次生成"); return; } // 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。 resetResult(); notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成"); } finally { setCancelling(false); } } function requestCancelGeneration() { if (!cancelling) setConfirmCancel(true); } const isGenerating = submitting || job?.status === "queued" || job?.status === "running"; const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating }); const isComplete = job?.status === "succeeded"; const isCancelled = job?.status === "cancelled"; const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable); const reviewBlocked = isReviewFailure(job?.error_message); const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews]; const imageCount = savedImages.length + images.length; const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked; const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked; const activePhase = submitting ? 0 : Math.max(0, Math.min(PROGRESS_STEPS.length, job?.phase_index ?? 0)); const result = job?.result; const videoClips = result?.video_segments?.length ? result.video_segments : result?.video_url ? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }] : []; const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean)); const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : ""); const sceneCount = Math.max(1, Math.round(totalDuration / 15)); const videoEstimate = estimateCost( selectedVideoModel, { ratio: aspectRatio, resolution, duration: totalDuration, refs: [] }, billingRates, ); // 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算。 // 故事板下线后每场少一次 image-2 出图,预估相应下调。 const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200; const shellClass = [ "quick-create-shell", restoring ? "is-restoring" : "", isGenerating ? "is-generating" : "", isComplete ? "is-complete" : "", isFailed ? "is-failed" : "", ].filter(Boolean).join(" "); return (

一键成片

告诉我们这是什么商品

商品品类必选 · 写脚本时会用到
{PC_CAT_PRIMARY.map((option) => ( ))} {catMoreOpen ? PC_CAT_MORE.map((option) => ( )) : null}
商品图片必填 · 最多9张
{ selectImages(event.target.files); event.currentTarget.value = ""; }} /> {imageCount ? (
{Array.from({ length: 9 }, (_, index) => { const image = displayUrls[index]; return
{index < imageCount ? <>{image ? {`商品图片 : null} : null}
; })}
) : }
{isGenerating ? ( ) : ( )}

核心参数可选,其余自动完成

正在为商品生成视频

{job?.message || "正在生成视频并进行质量检查…"}

{PROGRESS_STEPS.map((step, index) => { const Icon = step.icon; const done = index < activePhase; const active = isGenerating && index === activePhase && activePhase < PROGRESS_STEPS.length; return
{step.label}
; })}
{videoClips.map((clip, index) => { const clipUrl = clip.video_url || result?.video_url || ""; return ( ); })}

{videoClips.length || 1} 个视频已生成

{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}

质量检查通过
1 ? "" : " is-single"}`}> {videoClips.length > 1 ? ( mergedUrl ? ( ) : ( ) ) : null} {result?.video_url ? ( 下载视频 ) : ( )}

{serviceUnavailable ? "一键成片暂不可用" : isCancelled ? "已取消本次生成" : reviewBlocked ? "图片未通过审核" : job?.phase === "script" ? "本次生成未完成" : "成片尚未完成"}

{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : reviewBlocked ? (job?.error_message || "商品图或生成画面未通过内容审核。请更换商品图,或进入专业模式调整后再生成。") : (job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。")}

{reviewBlocked && job?.project_id ? ( ) : null} {canRetry && !serviceUnavailable && !reviewBlocked ? ( ) : null} {!reviewBlocked && job?.project_id ? ( ) : null}

过往一键成片项目

{history.length}个项目
{history.length ? (
{history.map((item) => { const cover = historyCover(item); const videoUrl = historyVideoUrl(item); const duration = item.result?.duration_seconds || item.settings?.total_duration || 15; const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15)); const badge = historyBadge(item); const canPlay = Boolean(videoUrl); return (
{badge}

{historyTitle(item)}

一键成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}

); })}
) : (

还没有完成的一键成片,生成成功后会出现在这里。

)}
} dismissable={!cancelling} onCancel={() => { if (!cancelling) setConfirmCancel(false); }} onConfirm={() => void cancelGeneration()} /> {playing ? (
setPlaying(null)} role="presentation">
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
{playing.title}
) : null}
); }