// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。 import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, BadgeCheck, Clock3, Copy, Download, FileText, FileVideo, FileVideo2, PanelsTopLeft, Play, RectangleVertical, Save, ScanLine, ScanSearch, TextCursorInput, } from "lucide-react"; import { api, ApiError } from "../api"; import { MediaLightbox } from "../components/overlays"; 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"; const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024; function shotCount(text: string, frames: number) { const marks = text.match(/【第\s*\d+\s*镜】/g); return marks?.length || frames || 0; } function isGemini31(model: ModelConfig) { const blob = `${model.name} ${model.display_name}`.toLowerCase(); return blob.includes("gemini-3.1") || blob.includes("gemini 3.1") || model.name === "gemini-3.1-pro-preview"; } function pickDigestModel(models: ModelConfig[]) { const active = models.filter((model) => model.status === "active" && isGemini31(model)); return ( active.find((model) => /官转/.test(model.display_name)) || active.find((model) => model.name === "gemini-3.1-pro-preview") || active[0] || null ); } function fileKind(file: File | null, name: string) { if (file?.type === "video/quicktime" || /\.mov$/i.test(file?.name || name)) return "MOV"; if (/\.webm$/i.test(file?.name || name)) return "WEBM"; return "MP4"; } function ratioLabel(width: number, height: number) { if (!width || !height) return "—"; const r = width / height; if (Math.abs(r - 9 / 16) < 0.08) return "9:16 竖屏"; if (Math.abs(r - 16 / 9) < 0.08) return "16:9 横屏"; if (Math.abs(r - 1) < 0.08) return "1:1"; return width > height ? "横屏" : "竖屏"; } function readVideoMeta(file: File): Promise<{ duration: number; width: number; height: number }> { return new Promise((resolve) => { const url = URL.createObjectURL(file); const video = document.createElement("video"); video.preload = "metadata"; video.onloadedmetadata = () => { const meta = { duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 }; URL.revokeObjectURL(url); resolve(meta); }; video.onerror = () => { URL.revokeObjectURL(url); resolve({ duration: 0, width: 0, height: 0 }); }; video.src = url; }); } function fileMetaCopy(kind: string, size: number, width: number, height: number) { if (width && height) return `${kind} · ${width} × ${height}`; if (size > 0) return `${kind} · ${(size / (1024 * 1024)).toFixed(1)} MB`; return kind; } function progressClass(step: ProgressStage, stage: ProgressStage) { const order: ProgressStage[] = ["upload", "analyze", "prompt"]; const active = order.indexOf(stage); const index = order.indexOf(step); if (index < active) return "remix-progress-step done"; if (index === active) return "remix-progress-step active"; return "remix-progress-step"; } function historySummary(item: VideoDigestHistory) { const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"]; 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) { return job.task_id || job.id || ""; } export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: { textModels?: ModelConfig[]; onNotify: (type: "success" | "error" | "info", text: string) => void; onBack: () => void; navigate: NavigateFn; }) { const [file, setFile] = useState(null); const [jobId, setJobId] = useState(""); const [submitting, setSubmitting] = useState(false); const [prompt, setPrompt] = useState(""); const [duration, setDuration] = useState(0); const [shots, setShots] = useState(0); const [ratio, setRatio] = useState(""); const [fileName, setFileName] = useState(""); const [fileSize, setFileSize] = useState(0); const [kind, setKind] = useState("MP4"); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const [taskId, setTaskId] = useState(""); const [hasResult, setHasResult] = useState(false); const [remoteVideoUrl, setRemoteVideoUrl] = useState(""); const [history, setHistory] = useState([]); const [openHistoryId, setOpenHistoryId] = useState(""); const [playing, setPlaying] = useState(null); const promptRef = useRef(null); 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)), [textModels] ); const activeModel = useMemo(() => pickDigestModel(digestModels), [digestModels]); const [priceMultiplier, setPriceMultiplier] = useState(1); useEffect(() => { void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined); }, []); const estimatedPoints = priceMultiplier === 1 ? VIDEO_DIGEST_POINTS : Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6)))); const loadHistory = async () => { 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 { /* 无痕模式忽略 */ } 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 (blobPreviewUrl) URL.revokeObjectURL(blobPreviewUrl); }, [blobPreviewUrl]); useEffect(() => { const el = promptRef.current; if (!el || !hasResult) return; el.style.height = "auto"; 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 || analyzing) return; if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) { onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式"); return; } if (next.size > VIDEO_DIGEST_MAX_BYTES) { onNotify("error", "视频不能超过 200MB,请压缩后再传"); return; } const meta = await readVideoMeta(next); setFile(next); setFileName(next.name); setFileSize(next.size); setKind(fileKind(next, next.name)); setWidth(meta.width); setHeight(meta.height); setRatio(ratioLabel(meta.width, meta.height)); if (meta.duration) setDuration(Math.round(meta.duration)); setHasResult(false); setTaskId(""); setRemoteVideoUrl(""); setPrompt(""); }; const analyze = async () => { 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 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 { setSubmitting(false); } }; const savePrompt = async () => { const text = prompt.trim(); if (!text) return; sessionStorage.setItem(REMIX_PROMPT_KEY, text); if (taskId) { try { const saved = await api.saveVideoDigest(taskId, text); setHistory((items) => items.map((item) => (item.id === saved.id ? saved : item))); } catch { onNotify("error", "提示词保存失败,请重试"); return; } } try { await navigator.clipboard.writeText(text); onNotify("success", "提示词已保存,可继续生成或直接粘贴"); } catch { onNotify("success", "提示词已保存,可继续生成视频"); } }; 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); navigate("freeCreate"); }; const copyHistoryPrompt = async (text: string) => { try { await navigator.clipboard.writeText(text); onNotify("success", "提示词已复制"); } catch { onNotify("error", "复制失败,请手动选择文本"); } }; const analyzeLabel = analyzing ? "正在拆解…" : `生成这个需要 ${estimatedPoints} 积分`; 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 (

提炼提示词

上传参考视频,生成可编辑的视频提示词

1 上传视频
2 智能拆解
3 生成提示词

上传参考视频

参考视频 MP4 / MOV · 最长 60 秒
{previewUrl ? (
) : ( )}
{analyzing ? "正在生成提示词" : "等待生成提示词"}

提示词内容