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

816 lines
31 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 {
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<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 [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<VideoDigestHistory[]>([]);
const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
// 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度
const [restoring, setRestoring] = useState(true);
const promptRef = useRef<HTMLTextAreaElement>(null);
const completedNoticeRef = useRef("");
const abortRef = useRef<AbortController | null>(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 (
<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>
</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${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<h2>上传参考视频</h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>参考视频</strong>
<span>MP4 / MOV · 最长 30 · 200MB</span>
</div>
{previewUrl ? (
<div
className={`video-upload-field has-file has-preview${videoDrop.dragging ? " is-dragover" : ""}`}
{...videoDrop.dropProps}
>
<div className="video-upload-preview">
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
<div className="video-upload-preview-meta">
<strong>{fileName || file?.name || "参考视频"}</strong>
<small>
{analyzing
? "正在提炼分镜稿…"
: [duration ? `${duration} 秒` : "", ratio, fileMetaCopy(kind, fileSize, width, height)]
.filter(Boolean).join(" · ")}
</small>
</div>
<label className={`video-upload-change${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 = "";
}}
/>
<RefreshCw />
更换视频
</label>
</div>
</div>
) : (
<label
className={`video-upload-field${videoDrop.dragging ? " is-dragover" : ""}`}
{...videoDrop.dropProps}
>
<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>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
<small>上传后自动识别镜头结构与内容节奏</small>
</span>
</label>
)}
</div>
<div className="video-flow-actions remix-analyze-actions">
<button
type="button"
className="primary-action"
disabled={restoring || analyzing || !canAnalyze}
onClick={() => void analyze()}
>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
</div>
</section>
<aside className={panelClass} aria-live="polite">
<div className="remix-restoring-state" role="status" aria-live="polite">
<div className="remix-restoring-content">
<span className="remix-restoring-spinner" aria-hidden="true" />
<strong>正在读取任务状态</strong>
<span>确认有没有正在进行的提炼,稍候</span>
</div>
</div>
<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>
<button type="button" className="secondary-action remix-cancel-analyze" onClick={() => setConfirmCancel(true)}>
<X />
取消
</button>
</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="primary-action remix-prompt-head-btn" onClick={() => continueGenerate()}>
<span>生成视频</span>
<ArrowRight />
</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>
</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>
<div className="remix-history-prompt-actions">
<button
type="button"
className="remix-history-copy-button"
onClick={() => continueGenerate(item.prompt)}
>
<ArrowRight />
去生成视频
</button>
<button
type="button"
className="remix-history-copy-button"
onClick={() => void copyHistoryPrompt(item.prompt)}
>
<Copy />
复制提示词
</button>
</div>
</div>
<textarea readOnly value={item.prompt} />
</div>
</article>
);
})}
</div>
)}
</section>
</section>
</div>
<ConfirmModal
open={confirmCancel}
title="确认取消拆解?"
subtitle="当前任务将停止"
detail="取消后本次提炼会停止,已预扣的积分会退回。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
onCancel={() => setConfirmCancel(false)}
onConfirm={() => void cancelAnalyze()}
/>
<MediaLightbox
open={Boolean(playing?.video_url)}
src={playing?.video_url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</div>
);
}