优化视频提炼

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
+18 -2
View File
@@ -413,10 +413,23 @@ export const api = {
);
},
// 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。
extractVideoDigest(formData: FormData) {
extractVideoDigest(formData: FormData, signal?: AbortSignal) {
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{ method: "POST", body: formData }
{ method: "POST", body: formData, signal }
);
},
extractVideoDigestFromTask(taskId: string, modelConfigId?: string, signal?: AbortSignal) {
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{
method: "POST",
body: JSON.stringify({
reuse_task_id: taskId,
...(modelConfigId ? { model_config_id: modelConfigId } : {}),
}),
signal,
}
);
},
listVideoDigests() {
@@ -425,6 +438,9 @@ export const api = {
getVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`);
},
cancelVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`, { method: "DELETE" });
},
saveVideoDigest(id: string, prompt: string) {
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
method: "PATCH",
+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 || ""}
+1 -1
View File
@@ -682,7 +682,7 @@ export type FreeVideoTask = {
export type VideoDigestJob = {
id: string;
task_id: string;
status: "processing" | "succeeded" | "failed" | string;
status: "processing" | "succeeded" | "failed" | "cancelled" | string;
text: string;
prompt: string;
chars: number;
+32 -30
View File
@@ -419,6 +419,19 @@
padding-top: 22px;
}
.vr-page .remix-generating-content .remix-cancel-analyze {
margin-top: 6px;
min-width: 112px;
height: 40px;
color: var(--klein);
border-color: rgba(0, 47, 167, 0.22);
background: rgba(0, 47, 167, 0.06);
}
.vr-page .remix-generating-content .remix-cancel-analyze:hover:not(:disabled) {
background: rgba(0, 47, 167, 0.1);
}
.vr-page .video-flow-panel h2,
.vr-page .video-result-panel h2 {
margin: 0;
@@ -676,24 +689,6 @@
line-height: 1.55;
}
.vr-page .remix-generating-bar {
width: 100%;
height: 4px;
overflow: hidden;
margin-top: 6px;
border-radius: 999px;
background: rgba(0, 47, 167, 0.1);
}
.vr-page .remix-generating-bar span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--klein);
animation: remix-progress-slide 1.2s ease-in-out infinite;
}
@keyframes remix-frame-scan {
0% { transform: translateX(-130%); }
100% { transform: translateX(130%); }
@@ -704,11 +699,6 @@
50% { transform: scale(1.07); }
}
@keyframes remix-progress-slide {
0% { transform: translateX(-115%); }
100% { transform: translateX(260%); }
}
.vr-page .remix-progress-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -857,6 +847,16 @@
padding: 0;
overflow: hidden;
cursor: default;
background: #0f1728;
border-color: rgba(0, 47, 167, 0.28);
box-shadow: none;
}
.vr-page .remix-page .video-upload-field.has-preview:hover,
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
background: #0f1728;
border-color: rgba(0, 47, 167, 0.28);
box-shadow: none;
}
.vr-page .remix-page .video-upload-field.has-preview video {
@@ -891,11 +891,6 @@
opacity: 0.45;
}
.vr-page .remix-page .video-upload-field.has-preview:hover,
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
background: #0f1728;
}
.vr-page .remix-page .video-upload-field strong {
color: #1d2940;
font-size: 15px;
@@ -961,8 +956,7 @@
animation: none;
}
.vr-page .remix-generating-frame::after,
.vr-page .remix-generating-badge,
.vr-page .remix-generating-bar span {
.vr-page .remix-generating-badge {
animation: none;
}
}
@@ -1211,6 +1205,14 @@
font-weight: 600;
}
.vr-page .remix-history-prompt-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
}
.vr-page .remix-history-prompt textarea {
width: 100%;
min-height: 360px;