完成极速成品和脚本优化
This commit is contained in:
@@ -5,9 +5,12 @@ import {
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
RectangleVertical,
|
||||
Save,
|
||||
ScanLine,
|
||||
@@ -15,45 +18,15 @@ import {
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { ModelConfig } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { ModelConfig, VideoDigestHistory } 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;
|
||||
ratio: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileKind: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function loadDraft(): RemixDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMIX_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const draft = JSON.parse(raw) as RemixDraft;
|
||||
if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null;
|
||||
return draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: RemixDraft) {
|
||||
try {
|
||||
localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft));
|
||||
localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt);
|
||||
} catch {
|
||||
/* 隐私模式写满时静默,内存态仍可用 */
|
||||
}
|
||||
}
|
||||
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||
|
||||
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
@@ -116,6 +89,20 @@ function fileMetaCopy(kind: string, size: number, width: number, height: number)
|
||||
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(" · ");
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -124,18 +111,22 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft] = useState(loadDraft);
|
||||
const [prompt, setPrompt] = useState(draft?.prompt || "");
|
||||
const [duration, setDuration] = useState(draft?.duration || 0);
|
||||
const [shots, setShots] = useState(draft?.shots || 0);
|
||||
const [ratio, setRatio] = useState(draft?.ratio || "");
|
||||
const [fileName, setFileName] = useState(draft?.fileName || "");
|
||||
const [fileSize, setFileSize] = useState(draft?.fileSize || 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 [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 [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 digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
@@ -150,20 +141,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
? 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 || []);
|
||||
} catch {
|
||||
/* 历史失败不挡当前拆解 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt.trim()) return;
|
||||
saveDraft({
|
||||
prompt,
|
||||
duration,
|
||||
shots,
|
||||
ratio,
|
||||
fileName: file?.name || fileName,
|
||||
fileSize: file?.size || fileSize,
|
||||
fileKind: kind,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
|
||||
try {
|
||||
localStorage.removeItem(REMIX_DRAFT_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
void loadHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
@@ -192,6 +190,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
setRatio(ratioLabel(meta.width, meta.height));
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
setTaskId("");
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
@@ -205,11 +204,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(shotCount(text, digest.frames));
|
||||
setFileName(file.name);
|
||||
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();
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
@@ -221,6 +225,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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", "提示词已保存,可继续生成或直接粘贴");
|
||||
@@ -236,10 +249,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate("freeCreate");
|
||||
};
|
||||
|
||||
const copyHistoryPrompt = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onNotify("success", "提示词已复制");
|
||||
} catch {
|
||||
onNotify("error", "复制失败,请手动选择文本");
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
<div className="vr-inner">
|
||||
@@ -256,6 +280,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</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>
|
||||
@@ -264,28 +303,40 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<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>
|
||||
{file && previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} controls playsInline preload="metadata" />
|
||||
<label className="remix-replace-video">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
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
|
||||
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 || busy} onClick={() => void analyze()}>
|
||||
@@ -363,8 +414,94 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user