完善视频提炼和视频复刻完善提交测试
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
RectangleVertical,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ScanLine,
|
||||
ScanSearch,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} 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";
|
||||
|
||||
@@ -167,6 +169,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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);
|
||||
@@ -236,21 +240,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await loadHistory();
|
||||
const listed = await loadHistory();
|
||||
if (cancelled) return;
|
||||
// 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、
|
||||
// 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。
|
||||
const inflight = listed?.inflight || null;
|
||||
const stored = readJobId();
|
||||
if (!stored) return;
|
||||
const restoreId = (inflight ? jobIdOf(inflight) : "") || stored;
|
||||
if (!restoreId) {
|
||||
forgetJob();
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const job = await api.getVideoDigest(stored);
|
||||
const job = inflight && jobIdOf(inflight) === restoreId
|
||||
? inflight
|
||||
: await api.getVideoDigest(restoreId);
|
||||
if (cancelled) return;
|
||||
if (job.status === "processing") {
|
||||
if (!job.video_url && !job.cover_url) {
|
||||
forgetJob();
|
||||
void api.cancelVideoDigest(stored).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
// 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的
|
||||
// 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询,
|
||||
// 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。
|
||||
applyJobMeta(job);
|
||||
setJobId(stored);
|
||||
setJobId(restoreId);
|
||||
rememberJob(restoreId);
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
if (job.status === "succeeded") {
|
||||
@@ -260,7 +274,9 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
/* 过期 / 已取消 / 不存在:当没任务 */
|
||||
}
|
||||
forgetJob();
|
||||
})();
|
||||
})().finally(() => {
|
||||
if (!cancelled) setRestoring(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -322,6 +338,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
};
|
||||
}, [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)) {
|
||||
@@ -391,6 +412,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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);
|
||||
@@ -460,6 +494,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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(" ");
|
||||
@@ -496,32 +531,54 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<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 · 最长 60 秒</span>
|
||||
<span>MP4 / MOV · 最长 3 分钟 · ≤200MB</span>
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} poster={coverUrl || undefined} 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
|
||||
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">
|
||||
<label
|
||||
className={`video-upload-field${videoDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...videoDrop.dropProps}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
@@ -534,7 +591,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>点击上传参考视频</strong>
|
||||
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
|
||||
<small>上传后自动识别镜头结构与内容节奏</small>
|
||||
</span>
|
||||
</label>
|
||||
@@ -544,7 +601,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
disabled={analyzing || !canAnalyze}
|
||||
disabled={restoring || analyzing || !canAnalyze}
|
||||
onClick={() => void analyze()}
|
||||
>
|
||||
<ScanSearch />
|
||||
@@ -554,6 +611,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</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>
|
||||
@@ -568,6 +632,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</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 />
|
||||
取消
|
||||
@@ -616,6 +681,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<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 />
|
||||
保存提示词
|
||||
@@ -629,12 +698,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user