Files
yingqing/core/frontend/src/routes/video-remix.tsx
T

665 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 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<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;
onBack: () => void;
navigate: NavigateFn;
}) {
const [file, setFile] = useState<File | null>(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<VideoDigestHistory[]>([]);
const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const promptRef = useRef<HTMLTextAreaElement>(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 (
<div className="vr-page">
<div className="vr-inner">
<section className="video-tool-page remix-page">
<header className="page-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div className="page-heading">
<h1>提炼提示词</h1>
<p>上传参考视频,生成可编辑的视频提示词</p>
</div>
</div>
</header>
<div className="remix-progress-strip" aria-label="提示词提炼进度">
<div className={progressClass("upload", stage)} data-remix-progress="upload">
<span className="remix-progress-dot">1</span>
<span>上传视频</span>
</div>
<div className={progressClass("analyze", stage)} data-remix-progress="analyze">
<span className="remix-progress-dot">2</span>
<span>智能拆解</span>
</div>
<div className={progressClass("prompt", stage)} data-remix-progress="prompt">
<span className="remix-progress-dot">3</span>
<span>生成提示词</span>
</div>
</div>
<div className="video-flow-grid remix-flow-grid">
<section className="video-flow-panel video-remix-upload-panel">
<h2>上传参考视频</h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>参考视频</strong>
<span>MP4 / MOV · 最长 60 </span>
</div>
{previewUrl ? (
<div className="video-upload-field has-file has-preview">
<video src={previewUrl} controls playsInline preload="metadata" />
<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 = "";
}}
/>
更换视频
</label>
</div>
) : (
<label className="video-upload-field">
<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 = "";
}}
/>
<span>
<FileVideo />
<strong>点击上传参考视频</strong>
<small>上传后自动识别镜头结构与内容节奏</small>
</span>
</label>
)}
</div>
<div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
</div>
</section>
<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>
<div>
<span className="remix-eyebrow">Analysis complete</span>
<h2>参考视频信息</h2>
</div>
</div>
<div className="remix-info-list">
<div className="remix-info-item">
<span><Clock3 />视频时长</span>
<strong>{duration ? `${duration} 秒` : "—"}</strong>
</div>
<div className="remix-info-item">
<span><RectangleVertical />画面比例</span>
<strong>{ratio || "—"}</strong>
</div>
<div className="remix-info-item">
<span><PanelsTopLeft />镜头数量</span>
<strong>{shots ? `${shots} 个镜头` : "—"}</strong>
</div>
<div className="remix-info-item remix-info-file">
<span><FileVideo2 />已解析文件</span>
<strong>{fileName || "参考视频.mp4"}</strong>
<small>{fileMetaCopy(kind, fileSize, width, height)}</small>
</div>
</div>
</div>
</aside>
</div>
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<div className="remix-prompt-placeholder">
<span><TextCursorInput /></span>
<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}
className="analysis-prompt"
aria-label="复刻提示词"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="primary-action" onClick={continueGenerate}>
<span>生成视频</span>
<ArrowRight />
</button>
</div>
</div>
</section>
<section className="remix-history-section" aria-labelledby="remixHistoryTitle">
<div className="remix-history-head">
<h2 id="remixHistoryTitle">提取过的项目</h2>
<span className="remix-history-count">{history.length} 个项目</span>
</div>
{history.length === 0 ? (
<div className="remix-history-empty">还没有提取过的项目</div>
) : (
<div className="remix-history-list">
{history.map((item) => {
const open = openHistoryId === item.id;
const promptId = `remix-history-prompt-${item.id}`;
return (
<article className="remix-history-card" key={item.id}>
<div
className="remix-history-cover is-playable"
onClick={() => {
if (item.video_url) {
setPlaying(item);
return;
}
onNotify("info", "这条没有保存原片,重新上传拆一次就能播放");
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.currentTarget.click();
}}
role="button"
tabIndex={0}
aria-label={`播放 ${item.title}`}
>
{item.cover_url ? (
<img src={item.cover_url} alt={`${item.title}封面`} />
) : null}
<span className="remix-history-play" aria-hidden="true"><Play /></span>
<span className="remix-history-duration">{item.duration_label || "00:00"}</span>
</div>
<div className="remix-history-content">
<div className="remix-history-copy">
<span className="remix-history-status">{item.status || "已完成"}</span>
<h3>{item.title}</h3>
<p>{historySummary(item)}</p>
</div>
<div className="remix-history-actions">
<time dateTime={item.created_date}>{item.created_date}</time>
<button
type="button"
className="remix-history-open"
aria-expanded={open}
aria-controls={promptId}
onClick={() => setOpenHistoryId(open ? "" : item.id)}
>
<FileText />
<span>{open ? "收起提示词" : "查看提示词"}</span>
</button>
</div>
</div>
<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>
<textarea readOnly value={item.prompt} />
</div>
</article>
);
})}
</div>
)}
</section>
</section>
</div>
<MediaLightbox
open={Boolean(playing?.video_url)}
src={playing?.video_url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</div>
);
}