极速成片页面和提炼提示词

This commit is contained in:
Azmat@qq.com
2026-08-24 15:52:05 +08:00
parent 2f5b7304b0
commit 00fc454db7
29 changed files with 1424 additions and 483 deletions
+219 -173
View File
@@ -1,23 +1,37 @@
// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。
// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } from "lucide-react";
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
BadgeCheck,
Clock3,
FileVideo,
FileVideo2,
PanelsTopLeft,
RectangleVertical,
Save,
ScanLine,
ScanSearch,
TextCursorInput,
} from "lucide-react";
import { api } from "../api";
import { publicModelDisplayName } from "../model-display";
import type { ModelConfig } from "../types";
import type { NavigateFn } from "./route-config";
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
const REMIX_DRAFT_KEY = "vr-digest-draft";
const VIDEO_DIGEST_POINTS = 30;
type RemixDraft = {
prompt: string;
duration: number;
shots: number;
rhythm: string;
ratio: string;
fileName: string;
fileSize: number;
modelId: string;
fileKind: string;
width: number;
height: number;
};
function loadDraft(): RemixDraft | null {
@@ -43,24 +57,11 @@ function saveDraft(draft: RemixDraft) {
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
function formatSize(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function shotCount(text: string, frames: number) {
const marks = text.match(/【第\s*\d+\s*镜】/g);
return marks?.length || frames || 0;
}
function rhythmCopy(text: string) {
if (/快节奏|紧凑/.test(text)) return "快节奏种草";
if (/慢节奏|舒缓/.test(text)) return "慢节奏展示";
const form = text.match(/形式[::]\s*([^\n]+)/);
if (form?.[1]) return form[1].trim().slice(0, 12);
return "按参考片节奏";
}
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";
@@ -76,6 +77,45 @@ function pickDigestModel(models: ModelConfig[]) {
);
}
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;
}
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
textModels?: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
@@ -88,31 +128,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [prompt, setPrompt] = useState(draft?.prompt || "");
const [duration, setDuration] = useState(draft?.duration || 0);
const [shots, setShots] = useState(draft?.shots || 0);
const [rhythm, setRhythm] = useState(draft?.rhythm || "");
const [ratio, setRatio] = useState(draft?.ratio || "");
const [fileName, setFileName] = useState(draft?.fileName || "");
const [fileSize, setFileSize] = useState(draft?.fileSize || 0);
const [modelId, setModelId] = useState(draft?.modelId || "");
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const done = prompt.length > 0;
const [kind, setKind] = useState(draft?.fileKind || "MP4");
const [width, setWidth] = useState(draft?.width || 0);
const [height, setHeight] = useState(draft?.height || 0);
const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim()));
const promptRef = useRef<HTMLTextAreaElement>(null);
const digestModels = useMemo(
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
[textModels]
);
const defaultModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
const activeModelId = modelId || defaultModel?.id || "";
const activeModel = digestModels.find((model) => model.id === activeModelId) || defaultModel;
const activeModelName = publicModelDisplayName(activeModel, "Gemini 3.1 Pro 官转");
const estimatedPoints = Math.round(Number(activeModel?.unit_price || 0));
const activeModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
const [priceMultiplier, setPriceMultiplier] = useState(1);
useEffect(() => {
if (!modelMenuOpen) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".vr-model-pick")) setModelMenuOpen(false);
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [modelMenuOpen]);
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))));
useEffect(() => {
if (!prompt.trim()) return;
@@ -120,14 +156,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
prompt,
duration,
shots,
rhythm,
ratio,
fileName: file?.name || fileName,
fileSize: file?.size || fileSize,
modelId: activeModelId,
fileKind: kind,
width,
height,
});
}, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]);
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
const pickFile = (next: File | null) => {
useEffect(() => {
const el = promptRef.current;
if (!el || !hasResult) return;
el.style.height = "auto";
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
}, [prompt, hasResult]);
const pickFile = async (next: File | null) => {
if (!next) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
@@ -137,9 +182,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
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);
};
const analyze = async () => {
@@ -148,16 +200,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
try {
const fd = new FormData();
fd.append("file", file);
if (activeModelId) fd.append("model_config_id", activeModelId);
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);
setDuration(digest.duration || duration);
setShots(shotCount(text, digest.frames));
setRhythm(rhythmCopy(text));
setFileName(file.name);
setFileSize(file.size);
onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`);
setHasResult(true);
onNotify("success", "视频拆解完成,已生成提示词");
} catch (error) {
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
@@ -184,140 +236,134 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
navigate("freeCreate");
};
const analyzeLabel = busy
? "正在拆解…"
: `生成这个需要 ${estimatedPoints} 积分`;
return (
<div className="vr-page">
<div className="vr-inner">
<header className="page-head">
<div className="vr-title-row">
<button type="button" className="btn btn-ghost vr-back" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div>
<h1>视频复刻</h1>
<div className="sub">
上传参考视频,拆解镜头、动作、运镜和节奏,生成可编辑提示词
<span className="mono">[ /remix ]</span>
</div>
</div>
</div>
</header>
<div className="vr-grid">
<section className="vr-panel">
<div className="section-h">
<h2>上传参考视频</h2>
<span className="more">// 视频提炼</span>
</div>
<p className="vr-lead">建议使用主体清晰、镜头完整的电商短视频。拆解稿会落在右侧,先逐镜改完再去生成。</p>
<div className="vr-step">
<div className="vr-step-h">
<strong>参考视频</strong>
<span>MP4 / MOV · 最长 3 分钟 · 200MB</span>
</div>
<label className={`vr-upload${file ? " has-file" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,.mp4,.mov,.m4v,.webm"
hidden
onChange={(event) => {
pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo />
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
<small>
{file
? `${formatSize(file.size)} · 视频已就绪,可开始拆解`
: fileName
? `${formatSize(fileSize)} · 上次拆解还在,刷新不会丢。要重拆请再选一次文件`
: "上传后抽帧拆解镜头结构与节奏"}
</small>
</span>
</label>
</div>
<div className="vr-actions">
<div className="vr-meta">
{digestModels.length > 0 ? (
<div className={`chip-wrap vr-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择视频提炼模型" onClick={() => setModelMenuOpen((open) => !open)}>
{activeModelName}
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div className="chip-menu">
{digestModels.map((model) => (
<div
key={model.id}
className={`mi${model.id === activeModelId ? " selected" : ""}`}
role="menuitemradio"
aria-checked={model.id === activeModelId}
tabIndex={0}
onClick={() => { setModelId(model.id); setModelMenuOpen(false); }}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setModelId(model.id);
setModelMenuOpen(false);
}
}}
>
{publicModelDisplayName(model)}
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</div>
))}
</div>
</div>
) : null}
<span>{estimatedPoints > 0 ? `视频解析预计消耗 ${estimatedPoints} 积分` : "视频解析按一次文本模型计费"}</span>
</div>
<button type="button" className="btn btn-primary" disabled={!file || busy} onClick={() => void analyze()}>
{busy ? <span className="spinner" aria-hidden="true" /> : <ScanSearch />}
<span>{busy ? "正在拆解…" : done ? "重新拆解" : "开始拆解"}</span>
<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="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>
<label className={`video-upload-field${file ? " has-file" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime"
hidden
onChange={(event) => {
void pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo />
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
<small>
{file
? "视频已就绪,可开始拆解"
: fileName
? "上次拆解还在,刷新不会丢。要重拆请再选一次文件"
: "上传后自动识别镜头结构与内容节奏"}
</small>
</span>
</label>
</div>
<div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
</div>
</section>
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<div className="video-result-placeholder">
<div>
<span className="remix-placeholder-icon"><ScanLine /></span>
<strong>等待视频解析</strong>
</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>等待生成提示词</strong></div>
</div>
<div className="remix-prompt-result">
<div className="remix-prompt-head">
<div className="remix-prompt-title">
<div><h2>提示词内容</h2></div>
</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="secondary-action" onClick={() => void savePrompt()}>
<Save />
保存提示词
</button>
<button type="button" className="primary-action" onClick={continueGenerate}>
<span>生成视频</span>
<ArrowRight />
</button>
</div>
</div>
</section>
<aside className={`vr-result${done ? " has-result" : ""}`}>
{!done ? (
<div className="vr-placeholder">
<div>
<ScanLine />
<strong>{busy ? "正在抽帧拆解这条视频" : "等待视频解析"}</strong>
<span>{busy ? "大约需要半分钟。拆解稿会落在这里,请先逐镜核对再去生成。" : "完成后将在这里展示镜头摘要和复刻提示词"}</span>
</div>
</div>
) : (
<div className="vr-analysis">
<div className="section-h">
<h2>视频拆解完成</h2>
<span className="more">[ {shots} SHOTS ]</span>
</div>
<p className="vr-lead">已识别 {shots} 个镜头。拆解必然有错,先改「拆解存疑」再继续生成。</p>
<div className="vr-summary">
<span>视频时长<strong>{duration} 秒</strong></span>
<span>镜头数量<strong>{shots} 镜</strong></span>
<span>内容节奏<strong>{rhythm}</strong></span>
</div>
<textarea
className="textarea vr-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="vr-actions">
<button type="button" className="btn" onClick={() => void savePrompt()}>
<Save />
保存提示词
</button>
<button type="button" className="btn btn-primary" onClick={continueGenerate}>
<span>继续生成视频</span>
<ArrowRight />
</button>
</div>
</div>
)}
</aside>
</div>
</section>
</div>
</div>
);