665 lines
24 KiB
TypeScript
665 lines
24 KiB
TypeScript
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 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>
|
||
);
|
||
}
|