完成视频复刻和优化
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
@@ -17,13 +18,14 @@ import {
|
||||
ScanSearch,
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { api, ApiError } from "../api";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { ModelConfig, VideoDigestHistory } from "../types";
|
||||
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||
const JOB_KEY = "airshelf:video-remix-job";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||
@@ -103,6 +105,34 @@ function historySummary(item: VideoDigestHistory) {
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function readJobId() {
|
||||
try {
|
||||
return localStorage.getItem(JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberJob(id: string) {
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, id);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
function forgetJob() {
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
function jobIdOf(job: Pick<VideoDigestJob, "id" | "task_id">) {
|
||||
return job.task_id || job.id || "";
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -110,7 +140,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate: NavigateFn;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [shots, setShots] = useState(0);
|
||||
@@ -122,11 +153,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const [height, setHeight] = useState(0);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [hasResult, setHasResult] = useState(false);
|
||||
const [remoteVideoUrl, setRemoteVideoUrl] = useState("");
|
||||
const [history, setHistory] = useState<VideoDigestHistory[]>([]);
|
||||
const [openHistoryId, setOpenHistoryId] = useState("");
|
||||
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
const completedNoticeRef = useRef("");
|
||||
const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
const previewUrl = blobPreviewUrl || remoteVideoUrl;
|
||||
const analyzing = submitting || Boolean(jobId);
|
||||
|
||||
const digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
@@ -145,23 +180,59 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
try {
|
||||
const data = await api.listVideoDigests();
|
||||
setHistory(data.results || []);
|
||||
return data;
|
||||
} catch {
|
||||
/* 历史失败不挡当前拆解 */
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyJobMeta = (job: VideoDigestJob) => {
|
||||
if (job.duration) setDuration(job.duration);
|
||||
if (job.file_name) {
|
||||
setFileName(job.file_name);
|
||||
setKind(fileKind(null, job.file_name));
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
const applySucceededJob = (job: VideoDigestJob) => {
|
||||
const text = (job.text || job.prompt || "").trim();
|
||||
applyJobMeta(job);
|
||||
setPrompt(text);
|
||||
setShots(job.shots || shotCount(text, 0));
|
||||
setTaskId(jobIdOf(job));
|
||||
setHasResult(Boolean(text));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.removeItem(REMIX_DRAFT_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
void loadHistory();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const data = 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);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
if (blobPreviewUrl) URL.revokeObjectURL(blobPreviewUrl);
|
||||
}, [blobPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
@@ -170,8 +241,50 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
|
||||
}, [prompt, hasResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const job = await api.getVideoDigest(jobId);
|
||||
if (cancelled) return;
|
||||
applyJobMeta(job);
|
||||
if (job.status === "processing") {
|
||||
timer = window.setTimeout(poll, 2500);
|
||||
return;
|
||||
}
|
||||
if (job.status === "succeeded") {
|
||||
applySucceededJob(job);
|
||||
if (completedNoticeRef.current !== jobIdOf(job)) {
|
||||
completedNoticeRef.current = jobIdOf(job);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
}
|
||||
void loadHistory();
|
||||
} else {
|
||||
onNotify("error", job.error_message || "视频拆解失败,请重试");
|
||||
}
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId, onNotify]);
|
||||
|
||||
const pickFile = async (next: File | null) => {
|
||||
if (!next) return;
|
||||
if (!next || analyzing) return;
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
|
||||
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||
return;
|
||||
@@ -191,33 +304,38 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
setTaskId("");
|
||||
setRemoteVideoUrl("");
|
||||
setPrompt("");
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
if (!file || busy) return;
|
||||
setBusy(true);
|
||||
if (!file || analyzing) return;
|
||||
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 digest = await api.extractVideoDigest(fd);
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(digest.shots || shotCount(text, digest.frames));
|
||||
setFileName(digest.file_name || file.name);
|
||||
setFileSize(file.size);
|
||||
if (digest.ratio) setRatio(digest.ratio);
|
||||
if (digest.width) setWidth(digest.width);
|
||||
if (digest.height) setHeight(digest.height);
|
||||
setTaskId(digest.task_id || "");
|
||||
setHasResult(true);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
void loadHistory();
|
||||
const job = await api.extractVideoDigest(fd);
|
||||
const id = jobIdOf(job);
|
||||
applyJobMeta(job);
|
||||
if (job.status === "succeeded") {
|
||||
applySucceededJob(job);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
void loadHistory();
|
||||
return;
|
||||
}
|
||||
if (!id) {
|
||||
onNotify("error", "视频拆解失败,请重试");
|
||||
return;
|
||||
}
|
||||
rememberJob(id);
|
||||
setJobId(id);
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,6 +360,21 @@ 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;
|
||||
@@ -258,11 +391,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
const analyzeLabel = analyzing
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
|
||||
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
|
||||
const panelClass = [
|
||||
"video-result-panel remix-information-panel",
|
||||
hasResult ? "has-result" : "",
|
||||
analyzing ? "is-analyzing" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
@@ -303,14 +441,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
{file && previewUrl ? (
|
||||
{previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} controls playsInline preload="metadata" />
|
||||
<label className="remix-replace-video">
|
||||
<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 = "";
|
||||
@@ -325,6 +464,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
disabled={analyzing}
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
@@ -339,20 +479,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
)}
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}>
|
||||
<ScanSearch />
|
||||
<span>{analyzeLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<aside className={panelClass} aria-live="polite">
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<span className="remix-placeholder-icon"><ScanLine /></span>
|
||||
<strong>等待视频解析</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-generating-state" role="status">
|
||||
<div className="remix-generating-content">
|
||||
<div className="remix-generating-visual">
|
||||
<span className="remix-generating-frame"><ScanSearch /></span>
|
||||
<span className="remix-generating-badge"><FileVideo2 /></span>
|
||||
</div>
|
||||
<strong>正在提炼提示词</strong>
|
||||
<span>离开页面也不会中断,稍后回来即可查看结果</span>
|
||||
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<div className="remix-info-heading">
|
||||
<span className="remix-status-icon"><BadgeCheck /></span>
|
||||
@@ -387,13 +538,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="remix-prompt-placeholder">
|
||||
<span><TextCursorInput /></span>
|
||||
<div><strong>等待生成提示词</strong></div>
|
||||
<div><strong>{analyzing ? "正在生成提示词" : "等待生成提示词"}</strong></div>
|
||||
</div>
|
||||
<div className="remix-prompt-result">
|
||||
<div className="remix-prompt-head">
|
||||
<div className="remix-prompt-title">
|
||||
<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 />
|
||||
保存提示词
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
@@ -403,10 +564,6 @@ 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="secondary-action" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={continueGenerate}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
|
||||
Reference in New Issue
Block a user