优化视频提炼

This commit is contained in:
Azmat@qq.com
2026-08-27 15:21:48 +08:00
parent 51620bf25b
commit 3fbc2f2dab
7 changed files with 438 additions and 122 deletions
+140 -54
View File
@@ -1,12 +1,12 @@
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
BadgeCheck,
Clock3,
Copy,
Download,
FileText,
FileVideo,
FileVideo2,
@@ -17,9 +17,10 @@ import {
ScanLine,
ScanSearch,
TextCursorInput,
X,
} from "lucide-react";
import { api, ApiError } from "../api";
import { MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
import type { NavigateFn } from "./route-config";
@@ -100,6 +101,11 @@ function progressClass(step: ProgressStage, stage: ProgressStage) {
return "remix-progress-step";
}
function sameMediaUrl(a: string, b: string) {
if (!a || !b) return false;
return a.split("?")[0] === b.split("?")[0];
}
function historySummary(item: VideoDigestHistory) {
const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"];
return bits.join(" · ");
@@ -141,6 +147,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}) {
const [file, setFile] = useState<File | null>(null);
const [jobId, setJobId] = useState("");
const [watchId, setWatchId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [prompt, setPrompt] = useState("");
const [duration, setDuration] = useState(0);
@@ -154,14 +161,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [taskId, setTaskId] = useState("");
const [hasResult, setHasResult] = useState(false);
const [remoteVideoUrl, setRemoteVideoUrl] = useState("");
const [coverUrl, setCoverUrl] = useState("");
const [sourceTaskId, setSourceTaskId] = useState("");
const [history, setHistory] = useState<VideoDigestHistory[]>([]);
const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
const promptRef = useRef<HTMLTextAreaElement>(null);
const completedNoticeRef = useRef("");
const abortRef = useRef<AbortController | null>(null);
const userCancelledRef = useRef(false);
const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
const previewUrl = blobPreviewUrl || remoteVideoUrl;
const analyzing = submitting || Boolean(jobId);
const canAnalyze = Boolean(file || remoteVideoUrl || sourceTaskId);
const pollId = jobId || watchId;
const digestModels = useMemo(
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
@@ -195,7 +209,14 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
if (job.ratio) setRatio(job.ratio);
if (job.width) setWidth(job.width);
if (job.height) setHeight(job.height);
if (job.video_url) setRemoteVideoUrl(job.video_url);
if (job.video_url) {
setRemoteVideoUrl((current) => (current && sameMediaUrl(current, job.video_url) ? current : job.video_url));
}
if (job.cover_url) {
setCoverUrl((current) => (current && sameMediaUrl(current, job.cover_url) ? current : job.cover_url));
}
const id = jobIdOf(job);
if (id) setSourceTaskId(id);
};
const applySucceededJob = (job: VideoDigestJob) => {
@@ -215,15 +236,30 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}
let cancelled = false;
void (async () => {
const data = await loadHistory();
await loadHistory();
if (cancelled) return;
const stored = readJobId();
const inflightId = data?.inflight ? jobIdOf(data.inflight) : "";
const next = stored || inflightId;
if (!next) return;
rememberJob(next);
if (data?.inflight && jobIdOf(data.inflight) === next) applyJobMeta(data.inflight);
setJobId(next);
if (!stored) return;
try {
const job = await api.getVideoDigest(stored);
if (cancelled) return;
if (job.status === "processing") {
if (!job.video_url && !job.cover_url) {
forgetJob();
void api.cancelVideoDigest(stored).catch(() => undefined);
return;
}
applyJobMeta(job);
setJobId(stored);
return;
}
if (job.status === "succeeded") {
applySucceededJob(job);
}
} catch {
/* 过期 / 已取消 / 不存在:当没任务 */
}
forgetJob();
})();
return () => {
cancelled = true;
@@ -242,14 +278,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}, [prompt, hasResult]);
useEffect(() => {
if (!jobId) return;
if (!pollId) return;
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const job = await api.getVideoDigest(jobId);
const job = await api.getVideoDigest(pollId);
if (cancelled) return;
applyJobMeta(job);
if (job.status === "processing") {
timer = window.setTimeout(poll, 2500);
return;
@@ -261,15 +296,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onNotify("success", "视频拆解完成,已生成提示词");
}
void loadHistory();
} else if (job.status === "cancelled" || userCancelledRef.current) {
if (!userCancelledRef.current) onNotify("info", job.error_message || "拆解已取消");
} else {
onNotify("error", job.error_message || "视频拆解失败,请重试");
}
setJobId("");
setWatchId("");
forgetJob();
} catch (error) {
if (cancelled) return;
if (error instanceof ApiError && error.status === 404) {
setJobId("");
setWatchId("");
forgetJob();
return;
}
@@ -281,7 +320,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
cancelled = true;
window.clearTimeout(timer);
};
}, [jobId, onNotify]);
}, [pollId, onNotify]);
const pickFile = async (next: File | null) => {
if (!next || analyzing) return;
@@ -305,19 +344,36 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
setHasResult(false);
setTaskId("");
setRemoteVideoUrl("");
setCoverUrl("");
setSourceTaskId("");
setPrompt("");
};
const analyze = async () => {
if (!file || analyzing) return;
if (analyzing || (!file && !sourceTaskId)) return;
userCancelledRef.current = false;
abortRef.current?.abort();
const previous = watchId || (!jobId ? readJobId() : "");
if (previous && previous !== sourceTaskId) {
setWatchId("");
void api.cancelVideoDigest(previous).catch(() => undefined);
}
const controller = new AbortController();
abortRef.current = controller;
setSubmitting(true);
setHasResult(false);
setPrompt("");
try {
const fd = new FormData();
fd.append("file", file);
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
const job = await api.extractVideoDigest(fd);
let job;
if (file) {
const fd = new FormData();
fd.append("file", file);
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
job = await api.extractVideoDigest(fd, controller.signal);
} else {
job = await api.extractVideoDigestFromTask(sourceTaskId, activeModel?.id, controller.signal);
}
if (userCancelledRef.current) return;
const id = jobIdOf(job);
applyJobMeta(job);
if (job.status === "succeeded") {
@@ -331,14 +387,35 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
return;
}
rememberJob(id);
setWatchId("");
setJobId(id);
} catch (error) {
if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return;
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
setSubmitting(false);
}
};
const cancelAnalyze = async () => {
userCancelledRef.current = true;
abortRef.current?.abort();
const id = jobId || watchId || readJobId();
setConfirmCancel(false);
setSubmitting(false);
setJobId("");
setWatchId("");
forgetJob();
if (id) {
try {
await api.cancelVideoDigest(id);
} catch {
/* 已经停掉界面即可 */
}
}
onNotify("info", "已取消拆解");
};
const savePrompt = async () => {
const text = prompt.trim();
if (!text) return;
@@ -360,25 +437,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}
};
const downloadVideo = () => {
if (!remoteVideoUrl) {
onNotify("info", "这条没有保存原片,重新上传拆一次就能下载");
return;
}
const link = document.createElement("a");
link.href = remoteVideoUrl;
link.download = `${(fileName || "参考视频").replace(/\.[^.]+$/, "") || "参考视频"}.mp4`;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
onNotify("success", "已开始下载参考视频");
};
const continueGenerate = () => {
const text = prompt.trim();
if (!text) return;
sessionStorage.setItem(REMIX_PROMPT_KEY, text);
const continueGenerate = (text = prompt) => {
const next = text.trim();
if (!next) return;
sessionStorage.setItem(REMIX_PROMPT_KEY, next);
navigate("freeCreate");
};
@@ -443,7 +505,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div>
{previewUrl ? (
<div className="video-upload-field has-file has-preview">
<video src={previewUrl} controls playsInline preload="metadata" />
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
<input
type="file"
@@ -479,7 +541,12 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
)}
</div>
<div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}>
<button
type="button"
className="primary-action"
disabled={analyzing || !canAnalyze}
onClick={() => void analyze()}
>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
@@ -501,7 +568,10 @@ 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 />
</button>
</div>
</div>
<div className="video-analysis-result">
@@ -546,10 +616,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div><h2></h2></div>
</div>
<div className="remix-prompt-head-actions">
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={downloadVideo} disabled={!remoteVideoUrl}>
<Download />
</button>
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
<Save />
@@ -564,7 +630,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="primary-action" onClick={continueGenerate}>
<button type="button" className="primary-action" onClick={() => continueGenerate()}>
<span></span>
<ArrowRight />
</button>
@@ -633,14 +699,24 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div className="remix-history-prompt" id={promptId} hidden={!open}>
<div className="remix-history-prompt-head">
<strong></strong>
<button
type="button"
className="remix-history-copy-button"
onClick={() => void copyHistoryPrompt(item.prompt)}
>
<Copy />
</button>
<div className="remix-history-prompt-actions">
<button
type="button"
className="remix-history-copy-button"
onClick={() => continueGenerate(item.prompt)}
>
<ArrowRight />
</button>
<button
type="button"
className="remix-history-copy-button"
onClick={() => void copyHistoryPrompt(item.prompt)}
>
<Copy />
</button>
</div>
</div>
<textarea readOnly value={item.prompt} />
</div>
@@ -652,6 +728,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</section>
</section>
</div>
<ConfirmModal
open={confirmCancel}
title="确认取消拆解?"
subtitle="当前任务将停止"
detail="取消后本次提炼会停止,已预扣的积分会退回。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
onCancel={() => setConfirmCancel(false)}
onConfirm={() => void cancelAnalyze()}
/>
<MediaLightbox
open={Boolean(playing?.video_url)}
src={playing?.video_url || ""}