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:
co-authored by
Claude Opus 4.8
parent
f9c52a171c
commit
38f4d95951
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user