feat(video): 视频卡交互反馈 + 「全部重跑」真重跑已完成的段

- 单卡「重跑」/ 详情弹窗「重跑本场」/「全部重跑」点下立刻进「提交中」态:按钮转圈、视频块叠
  spinner 遮罩,撑到该段真进 running(busy 接管),堵掉空窗连点 → 防双倍扣费、不再点完没反应
- 「全部重跑」改为把已完成(succeeded)的段也重跑(原来过滤掉 succeeded → 全成功项目点了等于
  没点、还弹假的「已提交」);只跳过在途(running/queued)避免重复提交
- 视频卡加「共 N 版 ›」小标识(N>1 才显示),点开详情弹窗看历史版本/切换/采用

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 16:57:37 +08:00
co-authored by Claude Opus 4.8
parent f9c52a171c
commit 38f4d95951
3 changed files with 56 additions and 8 deletions
+2 -1
View File
@@ -921,7 +921,8 @@ export function App() {
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
// 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交
const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.submitVideo(pipelineProject.id, {
video_segment_id: segment.id,
+6
View File
@@ -466,6 +466,12 @@
.video-card-head .pill { flex: 0 0 auto; }
.video-meta { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; }
.video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; }
/* 按钮内联小 spinner(重跑「提交中/生成中」转圈),配合文字;在 btn-primary 上转白色可见 */
.btn-spin { width: 12px; height: 12px; border-width: 2px; flex: 0 0 auto; margin-right: 5px; vertical-align: -1px; }
.btn-primary .btn-spin { border-color: color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor; }
/* 多版本入口小标识「共 N 版 ›」—— 不是按钮、是可点 mono 标识,hover 转主橙 */
.video-ver-badge { background: none; border: 0; padding: 0; cursor: pointer; font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; color: var(--black-alpha-48); transition: color var(--t-base); }
.video-ver-badge:hover { color: var(--heat); }
/* ============= STAGE 5 · 拼接编辑器 ============= */
.content--fh .stage[data-stage-pane="5"].active > .editor { flex: 1 1 0; min-height: 0; overflow-y: auto; height: auto; }
+48 -7
View File
@@ -483,8 +483,8 @@ export function PipelinePage(props: {
onRenameActor?: (assetId: string, name: string) => Promise<unknown>;
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
onSkipStoryboard: () => Promise<unknown>;
onSubmitVideo: (segmentId: string, prompt: string) => void;
onSubmitAllVideos: (prompt: string) => void;
onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>;
onSubmitAllVideos: (prompt: string) => void | Promise<unknown>;
onPollVideosQuiet: () => void | Promise<void>;
exportResult: ExportPoll | null;
onRefreshExport: () => void;
@@ -874,6 +874,38 @@ export function PipelinePage(props: {
setVdVerId(null);
setVdPrompt("");
}
// 重跑「提交中」乐观态:点下立刻禁用+转圈,撑到该段真的进 running(busy 接管),堵掉空窗连点 → 防双倍扣费
const [rerunPending, setRerunPending] = useState<Set<string>>(() => new Set());
const clearRerunPending = (segId: string) =>
setRerunPending((s) => { if (!s.has(segId)) return s; const n = new Set(s); n.delete(segId); return n; });
function submitVideoOptimistic(segId: string, prompt: string) {
setRerunPending((s) => new Set(s).add(segId));
Promise.resolve(onSubmitVideo(segId, prompt))
.then((res) => { if (res == null) clearRerunPending(segId); }) // 失败(action 返回 null)→ 立即解禁可重试;成功交给下方 effect
.catch(() => clearRerunPending(segId));
window.setTimeout(() => clearRerunPending(segId), 20000); // 兜底:异常下也别永久禁用
}
// 「全部重跑」乐观态:把将被提交的段(非在途)全部立刻标 pending → 每张卡马上转圈,不必等状态回传
function submitAllVideosOptimistic() {
const ids = segments.filter((s) => !["running", "queued"].includes(s.status)).map((s) => s.id);
if (ids.length === 0) return;
setRerunPending((s) => { const n = new Set(s); ids.forEach((id) => n.add(id)); return n; });
Promise.resolve(onSubmitAllVideos(videoPrompt))
.then((res) => { if (res == null) ids.forEach(clearRerunPending); }) // 失败 → 解禁;成功交给上面的 effect 逐个摘除
.catch(() => ids.forEach(clearRerunPending));
ids.forEach((id) => window.setTimeout(() => clearRerunPending(id), 20000));
}
// 段状态变化时,把已真正进入 running/queued 的段从 pending 摘掉(此后由 busy 驱动 UI)
const segStatusKey = segments.map((s) => `${s.id}:${s.status}`).join("|");
useEffect(() => {
setRerunPending((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
for (const seg of segments) if (next.has(seg.id) && ["running", "queued"].includes(seg.status)) next.delete(seg.id);
return next.size === prev.size ? prev : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segStatusKey]);
// Esc 关闭详情弹窗
useEffect(() => {
if (!vdSegId) return;
@@ -3085,7 +3117,7 @@ export function PipelinePage(props: {
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardGen(shots, () => onSubmitAllVideos(videoPrompt))}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardGen(shots, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
@@ -3104,13 +3136,17 @@ export function PipelinePage(props: {
const url = segUrl(seg);
const tone = statusPill(seg.status);
const busy = ["running", "queued"].includes(seg.status);
const pending = rerunPending.has(seg.id);
const showBusy = busy || pending;
const verCount = seg.versions?.length ?? 0;
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role="button" tabIndex={0} title="点击查看详情(历史版本/提示词/重跑)" onClick={() => openVideoDetail(seg.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}>
{url
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
: <span className="ph-frame"> {seg.sort_order + 1}</span>}
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
{url && !showBusy && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
{showBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
</div>
<div className="body">
<div className="video-card-head">
@@ -3122,7 +3158,11 @@ export function PipelinePage(props: {
</div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => guardGen(shots.filter((s) => s.sort_order === seg.sort_order), () => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardGen(shots.filter((s) => s.sort_order === seg.sort_order), () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
{verCount > 1 && (
<button className="video-ver-badge" type="button" data-vstop title="查看历史版本" onClick={() => openVideoDetail(seg.id)}> {verCount} </button>
)}
{/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */}
<span className="spacer"></span>
{url
@@ -3524,6 +3564,7 @@ export function PipelinePage(props: {
{/* ── Stage 4 · 视频详情弹窗:大预览 + 历史版本(可切/可采用) + 本场提示词 + 重跑 ── */}
{vdSeg && (() => {
const busy = ["running", "queued"].includes(vdSeg.status);
const showBusy = busy || rerunPending.has(vdSeg.id);
const hint = (shots[vdSeg.sort_order]?.visual_prompt || shots[vdSeg.sort_order]?.narration || "").trim();
const rerunPrompt = vdPrompt.trim() || `${videoPrompt}${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds}`;
return (
@@ -3589,8 +3630,8 @@ export function PipelinePage(props: {
<div className="vd-modal-actions">
{vdVer?.asset_url && <a className="btn btn-ghost" href={vdVer.asset_url} target="_blank" rel="noreferrer"></a>}
<button className="btn btn-ghost" type="button" onClick={() => setVdSegId(null)}></button>
<button className="btn btn-primary" type="button" disabled={loading || busy} onClick={() => guardGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), () => onSubmitVideo(vdSeg.id, rerunPrompt))}>
{busy ? "生成中…" : "↻ 重跑本场"}
<button className="btn btn-primary" type="button" disabled={loading || showBusy} onClick={() => guardGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), () => submitVideoOptimistic(vdSeg.id, rerunPrompt))}>
{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"}
</button>
</div>
</div>