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 {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
@@ -111,6 +112,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = 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 renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
@@ -145,24 +147,17 @@ export default function EcommerceVideoWorkspace({
|
||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Auto-advance: skip manual "next step" clicks ─────────
|
||||
const autoAdvanceTriggeredRef = useRef(false);
|
||||
// ── Auto-advance: automatically run the full pipeline ─────────
|
||||
useEffect(() => {
|
||||
if (autoAdvanceTriggeredRef.current) return;
|
||||
const delay = 600;
|
||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "idle" || stage === "cancelled") {
|
||||
autoAdvanceTriggeredRef.current = false;
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
@@ -638,11 +633,7 @@ export default function EcommerceVideoWorkspace({
|
||||
{scenes.length > 0 ? scenes.map((s) => (
|
||||
<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>
|
||||
@@ -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>
|
||||
</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 ? (
|
||||
<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>
|
||||
</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 ? (
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
) : (
|
||||
@@ -709,34 +700,32 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
[1, 2, 3].map((n) => (
|
||||
<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">
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{n}</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
|
||||
</div>
|
||||
</article>
|
||||
<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>
|
||||
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜策划</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜图{n}</span>
|
||||
</article>
|
||||
<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>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{n}</span>
|
||||
</article>
|
||||
</article>
|
||||
<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>
|
||||
</div>
|
||||
))
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜图</span>
|
||||
</article>
|
||||
<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>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,6 +742,19 @@ export default function EcommerceVideoWorkspace({
|
||||
) : null}
|
||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1101,3 +1101,47 @@
|
||||
70% { opacity: 0.5; }
|
||||
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