完成极速成片
This commit is contained in:
@@ -356,7 +356,7 @@ export const api = {
|
||||
},
|
||||
// 本项目在途的基础资产出图任务(刷新后据此重建「生成中」占位卡 loading)
|
||||
pendingAssets(id: string) {
|
||||
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; status: string }> }>(
|
||||
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; triview_of?: string; status: string }> }>(
|
||||
`/api/projects/${id}/pending-assets/`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -920,7 +920,22 @@
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.as-gen[data-asset-kind="person"] .as-gen-preview.ready {
|
||||
background-image: none;
|
||||
background-position: center top;
|
||||
}
|
||||
.as-gen-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
.as-gen-preview.generating { display: grid; place-items: center; }
|
||||
.as-gen-preview.ready.generating { display: block; }
|
||||
.as-gen-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1601,6 +1616,7 @@
|
||||
|
||||
/* ── 流程步骤4 · 基础资产卡生成中 loading + 转圈 ── */
|
||||
.asset-card-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-base); z-index: 2; }
|
||||
.asset-card-loading.veil { background: color-mix(in srgb, var(--surface) 62%, transparent); }
|
||||
.asset-card-loading .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .04em; }
|
||||
.asset-spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; }
|
||||
.asset-spinner.sm { width: 13px; height: 13px; border-width: 2px; display: inline-block; vertical-align: -2px; }
|
||||
@@ -1729,6 +1745,9 @@
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.asset-detail-lead .placeholder.ad-lead-img.has-mock-media {
|
||||
background-position: center top;
|
||||
}
|
||||
@supports not (aspect-ratio: 9 / 16) {
|
||||
.asset-detail-lead .placeholder.ad-lead-img { height: min(420px, 48vh); }
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
.quick-create-page .quick-progress-node.done { color: var(--quick-blue); }
|
||||
.quick-create-page .quick-progress-node.done .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 5px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }
|
||||
.quick-create-page .quick-progress-node.active .quick-progress-dot { box-shadow: 0 0 0 6px rgba(17,18,22,.09); }
|
||||
.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }
|
||||
.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 6px rgba(0,47,167,.11); animation: quick-progress-pulse 1.45s ease-out infinite; }
|
||||
.quick-create-page .quick-progress-node.active .quick-progress-dot::before { content: ""; position: absolute; inset: -7px; border-radius: 50%; background: conic-gradient(transparent 0 46%,rgba(0,47,167,.18) 56%,var(--quick-blue) 100%); -webkit-mask: radial-gradient(circle,transparent 0 68%,#000 71%); mask: radial-gradient(circle,transparent 0 68%,#000 71%); animation: quick-progress-orbit 1.2s linear infinite; }
|
||||
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
@@ -91,6 +93,8 @@
|
||||
.quick-create-page .quick-download-action { border-radius: 8px; }
|
||||
.quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; }
|
||||
@keyframes quick-spinner-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes quick-progress-pulse { 50% { box-shadow: 0 0 0 11px rgba(0,47,167,.04); } }
|
||||
@keyframes quick-progress-orbit { to { transform: rotate(360deg); } }
|
||||
.quick-create-page .quick-history { margin-top: 28px; }
|
||||
.quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
@@ -98,9 +102,12 @@
|
||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
|
||||
.quick-create-page .quick-history-thumb:disabled { cursor: default; }
|
||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-history-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); pointer-events: none; }
|
||||
.quick-create-page .quick-history-play svg { width: 22px; height: 22px; }
|
||||
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
|
||||
.quick-create-page .quick-history-copy { min-width: 0; }
|
||||
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
|
||||
|
||||
@@ -821,26 +821,46 @@ export function PipelinePage(props: {
|
||||
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
|
||||
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
|
||||
// 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading
|
||||
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean }>>([]);
|
||||
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean; triview_of?: string }>>([]);
|
||||
const pendingHas = (kind: string, label: string) =>
|
||||
pendingGen.some((p) => p.kind === kind && !p.is_triview && (p.label || "") === (label || ""));
|
||||
const pendingTriOf = (assetId?: string | null) =>
|
||||
Boolean(assetId && pendingGen.some((p) => p.is_triview && p.triview_of === assetId));
|
||||
const pendingTriFor = (kind: "person" | "scene", entity: AssetEntity) =>
|
||||
pendingTriOf(entity.group.adopted_asset)
|
||||
|| pendingGen.some((p) => p.is_triview && p.kind === kind && (p.label || "") === (entity.label || entity.name || ""));
|
||||
// 立绘出图后 seed 卡会换成 ent 卡,busyKey 必须两边都记,否则三视图还在跑、卡片已经换身份,转圈会丢。
|
||||
const entityGenKeys = (kind: "person" | "scene", ...names: Array<string | undefined | null>) => {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const name of names) {
|
||||
const tag = (name || "").trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
keys.push(`ent:${kind}:${tag}`, `seed:${kind}:${tag}`);
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
// 在途出图集合的稳定指纹:集合变化(开工/出片完成)才变 → 给审核轮询当「重启信号」,
|
||||
// 让真人出片后自动送审的盾能被盯到变绿(否则审核轮询可能在送审前就停了)。仅集合变才变,不会每轮抖动。
|
||||
const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}`).join("|"), [pendingGen]);
|
||||
const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}:${p.triview_of || ""}`).join("|"), [pendingGen]);
|
||||
const isBusy = (k: string) => genBusy.has(k);
|
||||
const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; });
|
||||
const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; });
|
||||
type GenResult = { id?: string; adopted_asset?: string | null } | null;
|
||||
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise<GenResult> {
|
||||
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
|
||||
addBusy(busyKey);
|
||||
const triKey = kind === "person" ? `${busyKey}:tri` : "";
|
||||
if (triKey) addBusy(triKey);
|
||||
const aliasKeys = kind === "product"
|
||||
? [busyKey]
|
||||
: [...new Set([busyKey, ...entityGenKeys(kind, label)])];
|
||||
if (aliasKeys.some((k) => genBusy.has(k))) return null; // 同一按钮防连点(不同按钮可并发)
|
||||
aliasKeys.forEach(addBusy);
|
||||
const triKeys = kind === "person" ? aliasKeys.map((k) => `${k}:tri`) : [];
|
||||
triKeys.forEach(addBusy);
|
||||
try {
|
||||
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult;
|
||||
} finally {
|
||||
delBusy(busyKey);
|
||||
if (triKey) delBusy(triKey);
|
||||
aliasKeys.forEach(delBusy);
|
||||
triKeys.forEach(delBusy);
|
||||
}
|
||||
}
|
||||
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
|
||||
@@ -2831,7 +2851,7 @@ export function PipelinePage(props: {
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
prevPendingIdsRef.current = list.map((pending) => pending.id);
|
||||
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
|
||||
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of })));
|
||||
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
|
||||
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; }
|
||||
} catch {
|
||||
@@ -3615,9 +3635,13 @@ export function PipelinePage(props: {
|
||||
const mainUrl = groupMainUrl(grp);
|
||||
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
||||
const entBK = `ent:${kind}:${entity.key}`;
|
||||
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name));
|
||||
const genKeys = entityGenKeys(kind, entity.key, entity.name, entity.label);
|
||||
const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name));
|
||||
const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity));
|
||||
const busy = portraitBusy || triBusy;
|
||||
const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…";
|
||||
const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
|
||||
const previewState = busy ? " generating" : mainUrl ? " ready" : " pending";
|
||||
const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`;
|
||||
return (
|
||||
<div className="as-gen" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||
{grp.id && (delArmed === grp.id ? (
|
||||
@@ -3632,14 +3656,15 @@ export function PipelinePage(props: {
|
||||
))}
|
||||
<div
|
||||
className={`as-gen-preview${previewState}`}
|
||||
style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||
style={mainUrl && kind !== "person" ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="点击查看详情"
|
||||
onClick={() => openAssetDetail(kind, entity)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}
|
||||
>
|
||||
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span>生成中…</span></span> : null}
|
||||
{kind === "person" && mainUrl ? <img className="as-gen-photo" src={mainUrl} alt="" /> : null}
|
||||
{busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span>{loadingText}</span></span> : null}
|
||||
</div>
|
||||
<div className="as-gen-meta">
|
||||
<h4 onClick={() => openAssetDetail(kind, entity)}>{entity.name}</h4>
|
||||
@@ -3666,7 +3691,7 @@ export function PipelinePage(props: {
|
||||
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
|
||||
</button>
|
||||
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>
|
||||
<Sparkles /><span>{busy ? "生成中…" : "AI 生成"}</span>
|
||||
<Sparkles /><span>{busy ? "生成中…" : mainUrl ? "重新生成" : "AI 生成"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4602,9 +4627,10 @@ export function PipelinePage(props: {
|
||||
return false;
|
||||
})();
|
||||
const pBK = `addet-portrait:${entity.key}`;
|
||||
const genKeysPortrait = entityGenKeys(adDetail.kind, entity.key, entity.name, entity.label);
|
||||
// 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈
|
||||
const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
|
||||
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
|
||||
const busyPortrait = isBusy(pBK) || genKeysPortrait.some((k) => isBusy(k)) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
|
||||
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`) || genKeysPortrait.some((k) => isBusy(`${k}:tri`)) || pendingTriOf(viewPortraitAsset) || pendingTriFor(adDetail.kind, entity);
|
||||
async function regenPortrait() {
|
||||
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
||||
// 重跑立绘 → 追加新立绘并自动接力该版三视图。
|
||||
@@ -4674,15 +4700,16 @@ export function PipelinePage(props: {
|
||||
{!triUrl && (busyTri
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
|
||||
: <span className="ph-frame">正 / 侧 / 背 · 三视图</span>)}
|
||||
{triUrl && busyTri && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
</div>
|
||||
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
|
||||
</div>
|
||||
</div>
|
||||
{!assetAlreadyHasTri && !busyTri && (
|
||||
{(!assetAlreadyHasTri || busyTri) && (
|
||||
<div className="asset-detail-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||
<span>{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"}</span>
|
||||
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
||||
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>{busyTri ? "生成中…" : assetAlreadyHasTri ? "重新生成三视图" : "AI 生成三视图"}</button>
|
||||
</div>
|
||||
)}
|
||||
{triVersions.length > 0 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user