feat: 视频流程树动态节点、全自动流水线、图片/视频点击放大预览
- 一键策划后自动连续执行完整流程(策划→图片→视频),无需手动点继续 - 节点数量跟随 API 返回的分镜数动态生成,策划前只显示 1 个占位节点 - 分镜图片和视频可点击弹出全屏预览浮层 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
CloseOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
FolderAddOutlined,
|
FolderAddOutlined,
|
||||||
@@ -111,6 +112,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||||
|
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const renderAbortRef = useRef({ current: false });
|
const renderAbortRef = useRef({ current: false });
|
||||||
const setView = useAppStore((s) => s.setView);
|
const setView = useAppStore((s) => s.setView);
|
||||||
@@ -145,24 +147,17 @@ export default function EcommerceVideoWorkspace({
|
|||||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||||
|
|
||||||
// ── Auto-advance: skip manual "next step" clicks ─────────
|
// ── Auto-advance: automatically run the full pipeline ─────────
|
||||||
const autoAdvanceTriggeredRef = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoAdvanceTriggeredRef.current) return;
|
|
||||||
const delay = 600;
|
const delay = 600;
|
||||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||||
autoAdvanceTriggeredRef.current = true;
|
|
||||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||||
autoAdvanceTriggeredRef.current = true;
|
|
||||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (stage === "idle" || stage === "cancelled") {
|
|
||||||
autoAdvanceTriggeredRef.current = false;
|
|
||||||
}
|
|
||||||
}, [stage, scenes, planResult]);
|
}, [stage, scenes, planResult]);
|
||||||
|
|
||||||
// ── Keep-alive: resume polling for running tasks ──────────
|
// ── Keep-alive: resume polling for running tasks ──────────
|
||||||
@@ -638,11 +633,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
{scenes.length > 0 ? scenes.map((s) => (
|
{scenes.length > 0 ? scenes.map((s) => (
|
||||||
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||||||
)) : (
|
)) : (
|
||||||
<>
|
|
||||||
<div className="ecom-video-tree__branch-tap" />
|
<div className="ecom-video-tree__branch-tap" />
|
||||||
<div className="ecom-video-tree__branch-tap" />
|
|
||||||
<div className="ecom-video-tree__branch-tap" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,7 +663,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}>
|
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
||||||
{imgReady ? (
|
{imgReady ? (
|
||||||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||||
) : (
|
) : (
|
||||||
@@ -688,7 +679,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}>
|
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
||||||
{vidReady ? (
|
{vidReady ? (
|
||||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||||
) : (
|
) : (
|
||||||
@@ -709,12 +700,11 @@ export default function EcommerceVideoWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}) : (
|
}) : (
|
||||||
[1, 2, 3].map((n) => (
|
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||||||
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
|
|
||||||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||||
<div className="ecom-video-tree-node__inner">
|
<div className="ecom-video-tree-node__inner">
|
||||||
<span className="ecom-video-tree-node__title">分镜文本{n}</span>
|
<span className="ecom-video-tree-node__title">分镜策划</span>
|
||||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
|
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
@@ -724,7 +714,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<div className="ecom-video-tree-node__placeholder">
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="ecom-video-tree-node__tag">分镜图{n}</span>
|
<span className="ecom-video-tree-node__tag">分镜图</span>
|
||||||
</article>
|
</article>
|
||||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
@@ -733,10 +723,9 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<div className="ecom-video-tree-node__placeholder">
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="ecom-video-tree-node__tag">分镜视频{n}</span>
|
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -753,6 +742,19 @@ export default function EcommerceVideoWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{previewMedia ? (
|
||||||
|
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
||||||
|
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
{previewMedia.type === "image" ? (
|
||||||
|
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
||||||
|
) : (
|
||||||
|
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1101,3 +1101,47 @@
|
|||||||
70% { opacity: 0.5; }
|
70% { opacity: 0.5; }
|
||||||
100% { opacity: 0; transform: translateX(100%); }
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Preview lightbox overlay ────────────────────── */
|
||||||
|
.ecom-video-preview-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
cursor: zoom-out;
|
||||||
|
animation: ecom-preview-fade-in 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-preview-overlay__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-preview-overlay img,
|
||||||
|
.ecom-video-preview-overlay video {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-preview-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user