diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index 6bbdc58..86a952a 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -340,6 +340,27 @@ class VideoDigestApiTests(TestCase): self.assertEqual(response.status_code, 202) self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123") + def test_forwards_reuse_task_id_without_file(self): + payload = { + "id": "00000000-0000-0000-0000-000000000003", + "task_id": "00000000-0000-0000-0000-000000000003", + "status": "processing", + "text": "", + "duration": 15.0, + "file_name": "参考视频.mp4", + "estimated_cost": "30", + } + with patch("apps.ai.video_digest.submit_team_digest", return_value=payload) as mocked: + response = self.client.post( + "/api/ai/video-digest/", + {"reuse_task_id": "00000000-0000-0000-0000-000000000099", "model_config_id": "abc-123"}, + format="json", + ) + self.assertEqual(response.status_code, 202) + self.assertEqual(mocked.call_args.kwargs["reuse_task_id"], "00000000-0000-0000-0000-000000000099") + self.assertIsNone(mocked.call_args.kwargs["upload"]) + self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123") + def test_provider_failure_returns_json_not_500(self): with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")): upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") @@ -387,6 +408,9 @@ class VideoDigestApiTests(TestCase): "shot_count": 5, "video_url": "https://cdn.example/coffee.mp4", "cover_url": "https://cdn.example/coffee.jpg", + "source_key": f"digest-source-{extra_key or 'ok'}.mp4", + "source_url": "https://cdn.example/coffee-source.mp4", + "suffix": ".mp4", }, response_payload={"digest": prompt, "prompt": prompt}, is_deleted=is_deleted, @@ -503,6 +527,31 @@ class VideoDigestApiTests(TestCase): missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/") self.assertEqual(missing.status_code, 404) + def test_cancel_inflight_job(self): + from apps.ai.models import AITask + + task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="cancel") + response = self.client.delete(f"/api/ai/video-digest/{task.id}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "cancelled") + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.CANCELLED) + + def test_stale_inflight_is_auto_cancelled_on_list(self): + from datetime import timedelta + + from django.utils import timezone + + from apps.ai.models import AITask + + task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="stale") + AITask.objects.filter(id=task.id).update(created_at=timezone.now() - timedelta(minutes=20)) + listed = self.client.get("/api/ai/video-digest/") + self.assertEqual(listed.status_code, 200) + self.assertIsNone(listed.data["inflight"]) + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.CANCELLED) + @override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) class VideoDigestBillingTests(TestCase): diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index 9dcdbf6..3b92f4b 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -19,6 +19,7 @@ import subprocess import tempfile import uuid from dataclasses import dataclass +from datetime import timedelta from functools import lru_cache from pathlib import Path @@ -760,6 +761,8 @@ def serialize_digest_job(task) -> dict: job_status = "processing" elif task.status == AITask.Status.SUCCEEDED: job_status = "succeeded" + elif task.status == AITask.Status.CANCELLED: + job_status = "cancelled" else: job_status = "failed" duration = float(req.get("duration_seconds") or 0) @@ -805,6 +808,81 @@ def get_team_digest_job(*, team, task_id) -> dict | None: return serialize_digest_job(task) +_STALE_DIGEST = timedelta(minutes=15) + + +def _task_reservation(task): + try: + return task.credit_reservation + except Exception: # noqa: BLE001 — 无预留行 + return None + + +def cancel_team_digest(*, team, task_id, reason: str = "用户取消") -> dict | None: + """取消进行中的提炼:停 UI 轮询、退预留积分。worker 跑到一半会看见 CANCELLED 不再落成功。""" + from django.db import transaction + from django.utils import timezone + + from apps.ai.models import AITask + from apps.billing.services.ledger import release_credit + + with transaction.atomic(): + task = ( + AITask.objects.select_for_update() + .filter( + id=task_id, + team=team, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + is_deleted=False, + purged_at__isnull=True, + ) + .first() + ) + if task is None: + return None + if task.status == AITask.Status.SUCCEEDED: + return serialize_digest_job(task) + if task.status in {AITask.Status.FAILED, AITask.Status.CANCELLED}: + return serialize_digest_job(task) + reservation = _task_reservation(task) + task.status = AITask.Status.CANCELLED + task.error_message = (reason or "用户取消")[:2000] + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + if reservation is not None: + try: + release_credit(reservation=reservation, reason=(reason or "用户取消")[:200]) + except Exception: # noqa: BLE001 + logger.warning("cancel digest release_credit failed for %s", task_id, exc_info=True) + return serialize_digest_job(task) + + +def expire_stale_team_digests(*, team) -> None: + """卡住超过 15 分钟的提炼自动取消并退费,避免进页永远「正在拆解」。""" + from django.utils import timezone + + from apps.ai.models import AITask + + stale = AITask.objects.filter( + team=team, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + is_deleted=False, + purged_at__isnull=True, + status__in={ + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, + }, + created_at__lt=timezone.now() - _STALE_DIGEST, + ) + for task in stale: + cancel_team_digest(team=team, task_id=task.id, reason="提炼超时已自动取消") + + def get_inflight_team_digest(*, team) -> dict | None: from apps.ai.models import AITask @@ -831,8 +909,11 @@ def get_inflight_team_digest(*, team) -> dict | None: return serialize_digest_job(task) -def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict: - """秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。""" +def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_config_id=None) -> dict: + """秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。 + + 取消后再点生成时,可带 reuse_task_id 复用 TOS 上已有的参考视频,不用重新上传。 + """ from django.db import transaction from apps.ai.models import AITask @@ -844,39 +925,46 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict: if model_config is None: raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") - path, suffix, size, duration = _materialize_upload(upload) - file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4" - width = height = 0 - source_key = source_url = cover_key = cover_url = "" - try: - width, height = probe_video_size(path) - cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration)) - source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix) - finally: - Path(path).unlink(missing_ok=True) - if not source_key: - raise VideoDigestError("参考视频上传失败,请重试") + if upload is not None: + path, suffix, size, duration = _materialize_upload(upload) + file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4" + width = height = 0 + source_key = source_url = cover_key = cover_url = "" + try: + width, height = probe_video_size(path) + cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration)) + source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix) + finally: + Path(path).unlink(missing_ok=True) + if not source_key: + raise VideoDigestError("参考视频上传失败,请重试") + request_payload = { + "model": model_config.name, + "endpoint": model_config.endpoint, + "feature": "video_remix", + "duration_seconds": round(duration, 2), + "file_name": file_name, + "title": title_from_filename(file_name), + "file_size": size, + "width": width, + "height": height, + "ratio": ratio_label(width, height), + "suffix": suffix, + "source_key": source_key, + "source_url": source_url, + "cover_key": cover_key, + "cover_url": cover_url, + } + else: + request_payload = _payload_from_existing_source( + team=team, + reuse_task_id=reuse_task_id, + model_config=model_config, + ) quote = quote_video_digest(team=team, model_config=model_config) - request_payload = { - "model": model_config.name, - "endpoint": model_config.endpoint, - "feature": "video_remix", - "duration_seconds": round(duration, 2), - "file_name": file_name, - "title": title_from_filename(file_name), - "file_size": size, - "width": width, - "height": height, - "ratio": ratio_label(width, height), - "suffix": suffix, - "source_key": source_key, - "source_url": source_url, - "cover_key": cover_key, - "cover_url": cover_url, - } if quote.meta.get("rate"): - request_payload["points_per_yuan_snapshot"] = quote.meta["rate"] + request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} try: with transaction.atomic(): @@ -904,6 +992,53 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict: return serialize_digest_job(task) +def _payload_from_existing_source(*, team, reuse_task_id, model_config) -> dict: + from django.core.exceptions import ValidationError + + from apps.ai.models import AITask + + task_id = str(reuse_task_id or "").strip() + if not task_id: + raise VideoDigestError("请先上传参考视频") + try: + previous = AITask.objects.filter( + id=task_id, + team=team, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + is_deleted=False, + purged_at__isnull=True, + ).first() + except (ValueError, ValidationError, TypeError): + raise VideoDigestError("请先上传参考视频") from None + if previous is None: + raise VideoDigestError("请先上传参考视频") + req = previous.request_payload or {} + source_key = str(req.get("source_key") or "").strip() + if not source_key: + raise VideoDigestError("参考视频已失效,请重新上传") + file_name = str(req.get("file_name") or "") or "参考视频.mp4" + return { + "model": model_config.name, + "endpoint": model_config.endpoint, + "feature": "video_remix", + "duration_seconds": float(req.get("duration_seconds") or 0), + "file_name": file_name, + "title": str(req.get("title") or "").strip() or title_from_filename(file_name), + "file_size": int(req.get("file_size") or 0), + "width": int(req.get("width") or 0), + "height": int(req.get("height") or 0), + "ratio": str(req.get("ratio") or ""), + "suffix": str(req.get("suffix") or ".mp4"), + "source_key": source_key, + "source_url": str(req.get("source_url") or ""), + "cover_key": str(req.get("cover_key") or ""), + "cover_url": str(req.get("cover_url") or ""), + "video_key": str(req.get("video_key") or ""), + "video_url": str(req.get("video_url") or ""), + } + + def run_team_digest_task(*, task_id: str) -> None: """Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。""" from django.db import transaction @@ -1078,6 +1213,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co raise VideoDigestError(str(exc)) from exc reservation = task.credit_reservation try: + task.refresh_from_db() + if task.status == AITask.Status.CANCELLED: + if source_path: + Path(source_path).unlink(missing_ok=True) + return serialize_digest_job(task) task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) @@ -1105,6 +1245,9 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co except Exception as exc: # noqa: BLE001 if source_path: Path(source_path).unlink(missing_ok=True) + task.refresh_from_db() + if task.status == AITask.Status.CANCELLED: + return serialize_digest_job(task) _fail_digest_task(task, reservation, str(exc)) raise @@ -1145,6 +1288,10 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co } with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status == AITask.Status.CANCELLED: + return serialize_digest_job(locked) + task = locked task.status = AITask.Status.SUCCEEDED task.request_payload = request_payload task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]} diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 1a98230..ac442e3 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -711,9 +711,10 @@ class VideoDigestView(APIView): parser_classes = [MultiPartParser, FormParser, JSONParser] def get(self, request): - from .video_digest import get_inflight_team_digest, list_team_digest_history + from .video_digest import expire_stale_team_digests, get_inflight_team_digest, list_team_digest_history team = get_current_team(request.user) + expire_stale_team_digests(team=team) results = list_team_digest_history(team=team) return Response({ "results": results, @@ -723,7 +724,8 @@ class VideoDigestView(APIView): def post(self, request): upload = request.FILES.get("file") or request.data.get("file") - if upload is None: + reuse_task_id = request.data.get("reuse_task_id") or None + if upload is None and not reuse_task_id: return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST) require_worker_task("apps.ai.tasks.run_video_digest_task") from .video_digest import VideoDigestError, submit_team_digest @@ -734,6 +736,7 @@ class VideoDigestView(APIView): team=team, user=request.user, upload=upload, + reuse_task_id=reuse_task_id, model_config_id=request.data.get("model_config_id") or None, ) except VideoDigestError as exc: @@ -745,12 +748,16 @@ class VideoDigestView(APIView): {"detail": public_error.fallback_message, "error": public_error.as_dict()}, status=status.HTTP_502_BAD_GATEWAY, ) - return Response({"name": getattr(upload, "name", "") or "参考视频", **result}, status=status.HTTP_202_ACCEPTED) + return Response( + {"name": getattr(upload, "name", "") or result.get("file_name") or "参考视频", **result}, + status=status.HTTP_202_ACCEPTED, + ) class VideoDigestDetailView(APIView): """GET /api/ai/video-digest// 轮询提炼任务。 PATCH /api/ai/video-digest// 保存编辑后的提示词到这条历史。 + DELETE /api/ai/video-digest// 取消进行中的提炼并退预留积分。 """ parser_classes = [JSONParser, FormParser] @@ -780,6 +787,15 @@ class VideoDigestDetailView(APIView): return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND) return Response(item) + def delete(self, request, task_id): + from .video_digest import cancel_team_digest + + team = get_current_team(request.user) + item = cancel_team_digest(team=team, task_id=task_id) + if item is None: + return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response(item) + class FreeVideoView(APIView): """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index bda9297..ffcef90 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -413,10 +413,23 @@ export const api = { ); }, // 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。 - extractVideoDigest(formData: FormData) { + extractVideoDigest(formData: FormData, signal?: AbortSignal) { return request( "/api/ai/video-digest/", - { method: "POST", body: formData } + { method: "POST", body: formData, signal } + ); + }, + extractVideoDigestFromTask(taskId: string, modelConfigId?: string, signal?: AbortSignal) { + return request( + "/api/ai/video-digest/", + { + method: "POST", + body: JSON.stringify({ + reuse_task_id: taskId, + ...(modelConfigId ? { model_config_id: modelConfigId } : {}), + }), + signal, + } ); }, listVideoDigests() { @@ -425,6 +438,9 @@ export const api = { getVideoDigest(id: string) { return request(`/api/ai/video-digest/${id}/`); }, + cancelVideoDigest(id: string) { + return request(`/api/ai/video-digest/${id}/`, { method: "DELETE" }); + }, saveVideoDigest(id: string, prompt: string) { return request(`/api/ai/video-digest/${id}/`, { method: "PATCH", diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index 8c0c2f3..74f1f36 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -1,12 +1,12 @@ // 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。 import { useEffect, useMemo, useRef, useState } from "react"; import { + AlertCircle, ArrowLeft, ArrowRight, BadgeCheck, Clock3, Copy, - Download, FileText, FileVideo, FileVideo2, @@ -17,9 +17,10 @@ import { ScanLine, ScanSearch, TextCursorInput, + X, } from "lucide-react"; import { api, ApiError } from "../api"; -import { MediaLightbox } from "../components/overlays"; +import { ConfirmModal, MediaLightbox } from "../components/overlays"; import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types"; import type { NavigateFn } from "./route-config"; @@ -100,6 +101,11 @@ function progressClass(step: ProgressStage, stage: ProgressStage) { 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(" · "); @@ -141,6 +147,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: }) { const [file, setFile] = useState(null); const [jobId, setJobId] = useState(""); + const [watchId, setWatchId] = useState(""); const [submitting, setSubmitting] = useState(false); const [prompt, setPrompt] = useState(""); const [duration, setDuration] = useState(0); @@ -154,14 +161,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: 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([]); const [openHistoryId, setOpenHistoryId] = useState(""); const [playing, setPlaying] = useState(null); + const [confirmCancel, setConfirmCancel] = useState(false); const promptRef = useRef(null); const completedNoticeRef = useRef(""); + const abortRef = useRef(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)), @@ -195,7 +209,14 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: 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); + 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) => { @@ -215,15 +236,30 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: } let cancelled = false; void (async () => { - const data = await loadHistory(); + 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); + if (!stored) return; + try { + const job = await api.getVideoDigest(stored); + if (cancelled) return; + if (job.status === "processing") { + if (!job.video_url && !job.cover_url) { + forgetJob(); + void api.cancelVideoDigest(stored).catch(() => undefined); + return; + } + applyJobMeta(job); + setJobId(stored); + return; + } + if (job.status === "succeeded") { + applySucceededJob(job); + } + } catch { + /* 过期 / 已取消 / 不存在:当没任务 */ + } + forgetJob(); })(); return () => { cancelled = true; @@ -242,14 +278,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: }, [prompt, hasResult]); useEffect(() => { - if (!jobId) return; + if (!pollId) return; let cancelled = false; let timer = 0; const poll = async () => { try { - const job = await api.getVideoDigest(jobId); + const job = await api.getVideoDigest(pollId); if (cancelled) return; - applyJobMeta(job); if (job.status === "processing") { timer = window.setTimeout(poll, 2500); return; @@ -261,15 +296,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: 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; } @@ -281,7 +320,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: cancelled = true; window.clearTimeout(timer); }; - }, [jobId, onNotify]); + }, [pollId, onNotify]); const pickFile = async (next: File | null) => { if (!next || analyzing) return; @@ -305,19 +344,36 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: setHasResult(false); setTaskId(""); setRemoteVideoUrl(""); + setCoverUrl(""); + setSourceTaskId(""); setPrompt(""); }; const analyze = async () => { - if (!file || analyzing) return; + 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 { - const fd = new FormData(); - fd.append("file", file); - if (activeModel?.id) fd.append("model_config_id", activeModel.id); - const job = await api.extractVideoDigest(fd); + 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") { @@ -331,14 +387,35 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: return; } rememberJob(id); + setWatchId(""); setJobId(id); } catch (error) { + if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) 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; @@ -360,25 +437,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: } }; - 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); + const continueGenerate = (text = prompt) => { + const next = text.trim(); + if (!next) return; + sessionStorage.setItem(REMIX_PROMPT_KEY, next); navigate("freeCreate"); }; @@ -443,7 +505,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {previewUrl ? (
-
@@ -546,10 +616,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:

提示词内容

- @@ -633,14 +699,24 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: