// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。 import { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, ArrowLeft, ArrowRight, BadgeCheck, Clock3, Copy, FileText, FileVideo, FileVideo2, PanelsTopLeft, Play, RectangleVertical, RefreshCw, Save, ScanLine, ScanSearch, TextCursorInput, X, } from "lucide-react"; import { api, ApiError } from "../api"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { useFileDrop } from "../components/use-file-drop"; 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"; /** 未挂牌时的回落价(与后端 VIDEO_DIGEST_POINTS 一致)。 */ const VIDEO_DIGEST_POINTS_FALLBACK = 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 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(" · "); } 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 [watchId, setWatchId] = 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 [coverUrl, setCoverUrl] = useState(""); const [sourceTaskId, setSourceTaskId] = useState(""); const [history, setHistory] = useState([]); const [openHistoryId, setOpenHistoryId] = useState(""); const [playing, setPlaying] = useState(null); const [confirmCancel, setConfirmCancel] = useState(false); // 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度 const [restoring, setRestoring] = useState(true); const promptRef = useRef(null); const completedNoticeRef = useRef(""); const abortRef = useRef(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)), [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); }, []); // 与后端一致:只认挂牌 points_per_call,不吃 unit_price;未挂牌回落 30 const hangpaiCall = (() => { const raw = (activeModel?.metadata?.points_pricing as { points_per_call?: unknown } | undefined)?.points_per_call; if (raw == null) return null; const n = Number(raw); return Number.isFinite(n) && n > 0 ? n : null; })(); const listedPoints = hangpaiCall ?? VIDEO_DIGEST_POINTS_FALLBACK; const estimatedPoints = priceMultiplier === 1 ? listedPoints : Math.max(1, Math.round(Number((listedPoints * 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((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) => { 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 listed = await loadHistory(); if (cancelled) return; // 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、 // 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。 const inflight = listed?.inflight || null; const stored = readJobId(); const restoreId = (inflight ? jobIdOf(inflight) : "") || stored; if (!restoreId) { forgetJob(); setRestoring(false); return; } try { const job = inflight && jobIdOf(inflight) === restoreId ? inflight : await api.getVideoDigest(restoreId); if (cancelled) return; if (job.status === "processing") { // 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的 // 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询, // 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。 applyJobMeta(job); setJobId(restoreId); rememberJob(restoreId); setRestoring(false); return; } if (job.status === "succeeded") { applySucceededJob(job); } } catch { /* 过期 / 已取消 / 不存在:当没任务 */ } forgetJob(); })().finally(() => { if (!cancelled) setRestoring(false); }); return () => { cancelled = true; }; }, []); useEffect(() => () => { if (blobPreviewUrl) URL.revokeObjectURL(blobPreviewUrl); }, [blobPreviewUrl]); useEffect(() => { if (!pollId) return; let cancelled = false; let timer = 0; const poll = async () => { try { const job = await api.getVideoDigest(pollId); if (cancelled) return; 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 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; } timer = window.setTimeout(poll, 8000); } }; void poll(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [pollId, onNotify]); const videoDrop = useFileDrop( (files) => { void pickFile(files[0] || null); }, { disabled: analyzing } ); 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(""); setCoverUrl(""); setSourceTaskId(""); setPrompt(""); }; const analyze = async () => { 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 { 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") { applySucceededJob(job); onNotify("success", "视频拆解完成,已生成提示词"); void loadHistory(); return; } if (!id) { onNotify("error", "视频拆解失败,请重试"); return; } rememberJob(id); setWatchId(""); setJobId(id); } catch (error) { if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return; if (error instanceof ApiError && error.status === 409) { // 后端单飞闸:已有提炼在跑。直接接上它,别让用户对着报错干瞪眼。 const running = (error.payload as { inflight?: VideoDigestJob } | undefined)?.inflight; if (running) { const id = jobIdOf(running); applyJobMeta(running); rememberJob(id); setWatchId(""); setJobId(id); } onNotify("info", error.message || "已有一个视频正在提炼中"); 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; 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 continueGenerate = (text = prompt) => { const next = text.trim(); if (!next) return; sessionStorage.setItem(REMIX_PROMPT_KEY, next); 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", restoring ? "is-restoring" : "", hasResult ? "has-result" : "", analyzing ? "is-analyzing" : "", ].filter(Boolean).join(" "); return (

提炼提示词

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

上传参考视频

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

提示词内容