完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
+99 -36
View File
@@ -13,6 +13,7 @@ import {
PanelsTopLeft,
Play,
RectangleVertical,
RefreshCw,
Save,
ScanLine,
ScanSearch,
@@ -21,6 +22,7 @@ import {
} from "lucide-react";
import { api, ApiError } from "../api";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
import type { NavigateFn } from "./route-config";
@@ -167,6 +169,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
// 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度
const [restoring, setRestoring] = useState(true);
const promptRef = useRef<HTMLTextAreaElement>(null);
const completedNoticeRef = useRef("");
const abortRef = useRef<AbortController | null>(null);
@@ -236,21 +240,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}
let cancelled = false;
void (async () => {
await loadHistory();
const listed = await loadHistory();
if (cancelled) return;
// 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、
// 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。
const inflight = listed?.inflight || null;
const stored = readJobId();
if (!stored) return;
const restoreId = (inflight ? jobIdOf(inflight) : "") || stored;
if (!restoreId) {
forgetJob();
setRestoring(false);
return;
}
try {
const job = await api.getVideoDigest(stored);
const job = inflight && jobIdOf(inflight) === restoreId
? inflight
: await api.getVideoDigest(restoreId);
if (cancelled) return;
if (job.status === "processing") {
if (!job.video_url && !job.cover_url) {
forgetJob();
void api.cancelVideoDigest(stored).catch(() => undefined);
return;
}
// 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的
// 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询,
// 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。
applyJobMeta(job);
setJobId(stored);
setJobId(restoreId);
rememberJob(restoreId);
setRestoring(false);
return;
}
if (job.status === "succeeded") {
@@ -260,7 +274,9 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
/* 过期 / 已取消 / 不存在:当没任务 */
}
forgetJob();
})();
})().finally(() => {
if (!cancelled) setRestoring(false);
});
return () => {
cancelled = true;
};
@@ -322,6 +338,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
};
}, [pollId, onNotify]);
const videoDrop = useFileDrop(
(files) => { void pickFile(files[0] || null); },
{ disabled: analyzing }
);
const pickFile = async (next: File | null) => {
if (!next || analyzing) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
@@ -391,6 +412,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
setJobId(id);
} catch (error) {
if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return;
if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有提炼在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: VideoDigestJob } | undefined)?.inflight;
if (running) {
const id = jobIdOf(running);
applyJobMeta(running);
rememberJob(id);
setWatchId("");
setJobId(id);
}
onNotify("info", error.message || "已有一个视频正在提炼中");
return;
}
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
setSubmitting(false);
@@ -460,6 +494,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
const panelClass = [
"video-result-panel remix-information-panel",
restoring ? "is-restoring" : "",
hasResult ? "has-result" : "",
analyzing ? "is-analyzing" : "",
].filter(Boolean).join(" ");
@@ -496,32 +531,54 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div>
<div className="video-flow-grid remix-flow-grid">
<section className="video-flow-panel video-remix-upload-panel">
<section
className={`video-flow-panel video-remix-upload-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<h2>上传参考视频</h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>参考视频</strong>
<span>MP4 / MOV · 最长 60 秒</span>
<span>MP4 / MOV · 最长 3 分钟 · ≤200MB</span>
</div>
{previewUrl ? (
<div className="video-upload-field has-file has-preview">
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,video/webm"
hidden
disabled={analyzing}
onChange={(event) => {
void pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
更换视频
</label>
<div
className={`video-upload-field has-file has-preview${videoDrop.dragging ? " is-dragover" : ""}`}
{...videoDrop.dropProps}
>
<div className="video-upload-preview">
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
<div className="video-upload-preview-meta">
<strong>{fileName || file?.name || "参考视频"}</strong>
<small>
{analyzing
? "正在提炼分镜稿…"
: [duration ? `${duration} 秒` : "", ratio, fileMetaCopy(kind, fileSize, width, height)]
.filter(Boolean).join(" · ")}
</small>
</div>
<label className={`video-upload-change${analyzing ? " is-disabled" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,video/webm"
hidden
disabled={analyzing}
onChange={(event) => {
void pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<RefreshCw />
更换视频
</label>
</div>
</div>
) : (
<label className="video-upload-field">
<label
className={`video-upload-field${videoDrop.dragging ? " is-dragover" : ""}`}
{...videoDrop.dropProps}
>
<input
type="file"
accept="video/mp4,video/quicktime,video/webm"
@@ -534,7 +591,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
/>
<span>
<FileVideo />
<strong>点击上传参考视频</strong>
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
<small>上传后自动识别镜头结构与内容节奏</small>
</span>
</label>
@@ -544,7 +601,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<button
type="button"
className="primary-action"
disabled={analyzing || !canAnalyze}
disabled={restoring || analyzing || !canAnalyze}
onClick={() => void analyze()}
>
<ScanSearch />
@@ -554,6 +611,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</section>
<aside className={panelClass} aria-live="polite">
<div className="remix-restoring-state" role="status" aria-live="polite">
<div className="remix-restoring-content">
<span className="remix-restoring-spinner" aria-hidden="true" />
<strong>正在读取任务状态</strong>
<span>确认有没有正在进行的提炼,稍候</span>
</div>
</div>
<div className="video-result-placeholder">
<div>
<span className="remix-placeholder-icon"><ScanLine /></span>
@@ -568,6 +632,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div>
<strong>正在提炼提示词</strong>
<span>离开页面也不会中断,稍后回来即可查看结果</span>
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
<button type="button" className="secondary-action remix-cancel-analyze" onClick={() => setConfirmCancel(true)}>
<X />
取消
@@ -616,6 +681,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div><h2>提示词内容</h2></div>
</div>
<div className="remix-prompt-head-actions">
<button type="button" className="primary-action remix-prompt-head-btn" onClick={() => continueGenerate()}>
<span>生成视频</span>
<ArrowRight />
</button>
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
<Save />
保存提示词
@@ -629,12 +698,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="primary-action" onClick={() => continueGenerate()}>
<span>生成视频</span>
<ArrowRight />
</button>
</div>
</div>
</section>