完成极速成片

This commit is contained in:
Azmat@qq.com
2026-08-25 18:46:20 +08:00
parent df6784b90c
commit 2f70d3e8a0
15 changed files with 495 additions and 146 deletions
+85 -62
View File
@@ -4,7 +4,6 @@ import {
ArrowLeft,
Boxes,
Clapperboard,
Download,
ImagePlus,
LayoutPanelTop,
RefreshCw,
@@ -15,10 +14,10 @@ import {
Upload,
WandSparkles,
X,
Columns2,
ArrowUpRight,
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal } from "../components/overlays";
import type { ModelConfig } from "../types";
import {
DEFAULT_BILLING_RATES,
@@ -70,14 +69,20 @@ function historyTitle(item: QuickCreateJob) {
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
}
function jobIsComplete(item: QuickCreateJob) {
return item.status === "succeeded" || Boolean(item.result?.video_url) || Boolean(item.result?.video_segments?.some((clip) => clip.video_url));
}
function historyBadge(item: QuickCreateJob) {
if (item.status === "cancelled") return "已取消";
if (item.status === "succeeded" || item.result?.video_url || item.result?.video_segments?.some((clip) => clip.video_url)) {
return "已完成";
}
if (jobIsComplete(item)) return "已完成";
return "未完成";
}
function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
}
function savedJobId() {
try {
return localStorage.getItem(QUICK_JOB_KEY) || "";
@@ -107,12 +112,12 @@ export function QuickCreatePage({
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
const [preview, setPreview] = useState("");
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [jobId, setJobId] = useState(savedJobId);
const [job, setJob] = useState<QuickCreateJob | null>(null);
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [serviceUnavailable, setServiceUnavailable] = useState(false);
const [unavailableMessage, setUnavailableMessage] = useState("");
const [history, setHistory] = useState<QuickCreateJob[]>([]);
@@ -131,10 +136,13 @@ export function QuickCreatePage({
const [videoModelId, setVideoModelId] = useState("");
const [billingRates, setBillingRates] = useState<BillingRates>(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 imageInputRef = useRef<HTMLInputElement>(null);
const productPrefillDoneRef = useRef(false);
useEffect(() => {
notifyRef.current = onNotify;
@@ -159,11 +167,12 @@ export function QuickCreatePage({
}, []);
useEffect(() => {
if (!initialProductId || jobId) return;
if (!initialProductId || jobId || productPrefillDoneRef.current) return;
let cancelled = false;
void api.product(initialProductId)
.then((product) => {
if (cancelled) return;
if (cancelled || productPrefillDoneRef.current) return;
productPrefillDoneRef.current = true;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
setSavedImages((current) => {
@@ -204,10 +213,6 @@ export function QuickCreatePage({
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [images]);
useEffect(() => {
setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || "");
}, [imagePreviews, savedImages]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
@@ -216,7 +221,31 @@ export function QuickCreatePage({
try {
const next = await api.quickCreateStatus(jobId);
if (cancelled) return;
setJob(next);
if (next.status === "succeeded") {
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
projectCreatedRef.current?.();
}
productPrefillDoneRef.current = true;
setHistory((current) => [next, ...current.filter((item) => item.id !== next.id)]);
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
watchedGeneratingRef.current = false;
setName("");
setImages([]);
setSavedImages([]);
setSourceProductId("");
setJob(null);
setJobId("");
setCancelling(false);
setConfirmCancel(false);
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
/* ignore */
}
return;
}
if (next.product_name) setName((current) => current || next.product_name);
if (next.product_images?.length) {
setSavedImages(next.product_images.filter((image) => image.asset_id));
@@ -229,16 +258,20 @@ export function QuickCreatePage({
setTotalDuration(next.settings.total_duration);
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
}
if (next.status === "succeeded") {
if (completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
projectCreatedRef.current?.();
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
if (next.status === "queued" || next.status === "running") watchedGeneratingRef.current = true;
setJob(next);
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);
}
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
watchedGeneratingRef.current = false;
return;
}
if (next.status === "failed" || next.status === "cancelled") return;
timer = window.setTimeout(poll, 2500);
} catch (error) {
if (cancelled) return;
@@ -312,6 +345,7 @@ export function QuickCreatePage({
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
try {
const next = await api.retryQuickCreate(job.id);
setJob(next);
@@ -343,6 +377,7 @@ export function QuickCreatePage({
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
try {
const form = new FormData();
form.append("name", name.trim());
@@ -395,9 +430,12 @@ export function QuickCreatePage({
setJob(null);
setJobId("");
setCancelling(false);
setConfirmCancel(false);
setServiceUnavailable(false);
setUnavailableMessage("");
completedNoticeRef.current = "";
terminalNoticeRef.current = "";
watchedGeneratingRef.current = false;
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
@@ -407,6 +445,7 @@ export function QuickCreatePage({
async function cancelGeneration() {
if (cancelling) return;
setConfirmCancel(false);
cancelRequestedRef.current = true;
if (!jobId) {
setSubmitting(false);
@@ -433,20 +472,17 @@ export function QuickCreatePage({
}
}
function requestCancelGeneration() {
if (!cancelling) setConfirmCancel(true);
}
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled";
const isFailed = !isGenerating && (job?.status === "failed" || isCancelled || serviceUnavailable);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, 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 sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
@@ -458,7 +494,6 @@ export function QuickCreatePage({
const shellClass = [
"quick-create-shell",
isGenerating ? "is-generating" : "",
isComplete ? "is-complete" : "",
isFailed ? "is-failed" : "",
].filter(Boolean).join(" ");
@@ -544,7 +579,7 @@ export function QuickCreatePage({
<div className="quick-form-footer">
{isGenerating ? (
<button type="button" className="quick-cancel-button" onClick={() => void cancelGeneration()} disabled={cancelling}>
<button type="button" className="quick-cancel-button" onClick={requestCancelGeneration} disabled={cancelling}>
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
@@ -573,43 +608,16 @@ export function QuickCreatePage({
})}
</div>
<div className="quick-generating-actions">
<button type="button" className="secondary-action" onClick={() => void cancelGeneration()} disabled={cancelling}>
<button type="button" className="secondary-action" onClick={requestCancelGeneration} disabled={cancelling}>
<X />{cancelling ? "正在取消…" : "取消生成"}
</button>
</div>
</div>
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || preview, `第${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || preview ? <img src={clip.poster_url || preview} alt={`第${index + 1}场视频首帧`} /> : clip.video_url ? <video src={clip.video_url} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>第{index + 1}场视频</strong><small>{clip.duration_seconds || 15}秒</small></span>
</button>
))}
</div>
<div className="quick-result-head"><div><h2>{videoClips.length || 1} 个视频已生成</h2><p>{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p></div><span className="quick-result-badge">质量检查通过</span></div>
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
{videoClips.length > 1 ? (
<button type="button" className="secondary-action" onClick={() => job && navigate("pipeline", { projectId: job.project_id })}><Columns2 />合并视频</button>
) : null}
{result?.video_url ? <a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download />下载视频</a> : <button type="button" className="primary-action" disabled><Download />下载视频</button>}
</div>
</div>
<div className="quick-state quick-state-failed">
<span className="quick-failed-icon"><AlertCircle /></span>
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的故事板不会重做。"}</p>
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : job?.phase === "script" ? "本次生成未完成" : "成片尚未完成"}</h2>
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。"}</p>
<div className="quick-failed-actions">
{canRetry && !serviceUnavailable ? (
<button type="button" className="primary-action" onClick={() => void retryGeneration()}><RefreshCw />重试</button>
@@ -636,19 +644,22 @@ export function QuickCreatePage({
<div className="quick-history-list">
{history.map((item) => {
const poster = item.result?.poster_url || "";
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
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 (
<article key={item.id} className="quick-history-card">
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
aria-label={`播放${historyTitle(item)}`}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty"><Play /></span>}
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
<div className="quick-history-copy">
@@ -668,6 +679,18 @@ export function QuickCreatePage({
)}
</section>
<ConfirmModal
open={confirmCancel}
title="确认取消生成?"
subtitle="当前任务将停止"
detail="取消后,本次极速成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
dismissable={!cancelling}
onCancel={() => { if (!cancelling) setConfirmCancel(false); }}
onConfirm={() => void cancelGeneration()}
/>
{playing ? (
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>