优化视频提炼
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user