优化视频提炼

This commit is contained in:
Azmat@qq.com
2026-08-27 15:21:48 +08:00
parent 51620bf25b
commit 3fbc2f2dab
7 changed files with 438 additions and 122 deletions
+49
View File
@@ -340,6 +340,27 @@ class VideoDigestApiTests(TestCase):
self.assertEqual(response.status_code, 202) self.assertEqual(response.status_code, 202)
self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123") 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): def test_provider_failure_returns_json_not_500(self):
with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")): with patch("apps.ai.video_digest.submit_team_digest", side_effect=RuntimeError("upstream 402")):
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
@@ -387,6 +408,9 @@ class VideoDigestApiTests(TestCase):
"shot_count": 5, "shot_count": 5,
"video_url": "https://cdn.example/coffee.mp4", "video_url": "https://cdn.example/coffee.mp4",
"cover_url": "https://cdn.example/coffee.jpg", "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}, response_payload={"digest": prompt, "prompt": prompt},
is_deleted=is_deleted, is_deleted=is_deleted,
@@ -503,6 +527,31 @@ class VideoDigestApiTests(TestCase):
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/") missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
self.assertEqual(missing.status_code, 404) 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"}}) @override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
class VideoDigestBillingTests(TestCase): class VideoDigestBillingTests(TestCase):
+152 -5
View File
@@ -19,6 +19,7 @@ import subprocess
import tempfile import tempfile
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@@ -760,6 +761,8 @@ def serialize_digest_job(task) -> dict:
job_status = "processing" job_status = "processing"
elif task.status == AITask.Status.SUCCEEDED: elif task.status == AITask.Status.SUCCEEDED:
job_status = "succeeded" job_status = "succeeded"
elif task.status == AITask.Status.CANCELLED:
job_status = "cancelled"
else: else:
job_status = "failed" job_status = "failed"
duration = float(req.get("duration_seconds") or 0) 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) 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: def get_inflight_team_digest(*, team) -> dict | None:
from apps.ai.models import AITask from apps.ai.models import AITask
@@ -831,8 +909,11 @@ def get_inflight_team_digest(*, team) -> dict | None:
return serialize_digest_job(task) return serialize_digest_job(task)
def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict: def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_config_id=None) -> dict:
"""秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。""" """秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。
取消后再点生成时,可带 reuse_task_id 复用 TOS 上已有的参考视频,不用重新上传。
"""
from django.db import transaction from django.db import transaction
from apps.ai.models import AITask from apps.ai.models import AITask
@@ -844,6 +925,7 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
if model_config is None: if model_config is None:
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
if upload is not None:
path, suffix, size, duration = _materialize_upload(upload) path, suffix, size, duration = _materialize_upload(upload)
file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4" file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
width = height = 0 width = height = 0
@@ -856,8 +938,6 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
Path(path).unlink(missing_ok=True) Path(path).unlink(missing_ok=True)
if not source_key: if not source_key:
raise VideoDigestError("参考视频上传失败,请重试") raise VideoDigestError("参考视频上传失败,请重试")
quote = quote_video_digest(team=team, model_config=model_config)
request_payload = { request_payload = {
"model": model_config.name, "model": model_config.name,
"endpoint": model_config.endpoint, "endpoint": model_config.endpoint,
@@ -875,8 +955,16 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
"cover_key": cover_key, "cover_key": cover_key,
"cover_url": cover_url, "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)
if quote.meta.get("rate"): 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: try:
with transaction.atomic(): with transaction.atomic():
@@ -904,6 +992,53 @@ def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict:
return serialize_digest_job(task) 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: def run_team_digest_task(*, task_id: str) -> None:
"""Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。""" """Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。"""
from django.db import transaction 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 raise VideoDigestError(str(exc)) from exc
reservation = task.credit_reservation reservation = task.credit_reservation
try: 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.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now() task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"]) 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 except Exception as exc: # noqa: BLE001
if source_path: if source_path:
Path(source_path).unlink(missing_ok=True) 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)) _fail_digest_task(task, reservation, str(exc))
raise raise
@@ -1145,6 +1288,10 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
} }
with transaction.atomic(): 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.status = AITask.Status.SUCCEEDED
task.request_payload = request_payload task.request_payload = request_payload
task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]} task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]}
+19 -3
View File
@@ -711,9 +711,10 @@ class VideoDigestView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser] parser_classes = [MultiPartParser, FormParser, JSONParser]
def get(self, request): 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) team = get_current_team(request.user)
expire_stale_team_digests(team=team)
results = list_team_digest_history(team=team) results = list_team_digest_history(team=team)
return Response({ return Response({
"results": results, "results": results,
@@ -723,7 +724,8 @@ class VideoDigestView(APIView):
def post(self, request): def post(self, request):
upload = request.FILES.get("file") or request.data.get("file") 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) return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
require_worker_task("apps.ai.tasks.run_video_digest_task") require_worker_task("apps.ai.tasks.run_video_digest_task")
from .video_digest import VideoDigestError, submit_team_digest from .video_digest import VideoDigestError, submit_team_digest
@@ -734,6 +736,7 @@ class VideoDigestView(APIView):
team=team, team=team,
user=request.user, user=request.user,
upload=upload, upload=upload,
reuse_task_id=reuse_task_id,
model_config_id=request.data.get("model_config_id") or None, model_config_id=request.data.get("model_config_id") or None,
) )
except VideoDigestError as exc: except VideoDigestError as exc:
@@ -745,12 +748,16 @@ class VideoDigestView(APIView):
{"detail": public_error.fallback_message, "error": public_error.as_dict()}, {"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_502_BAD_GATEWAY, 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): class VideoDigestDetailView(APIView):
"""GET /api/ai/video-digest/<id>/ 轮询提炼任务。 """GET /api/ai/video-digest/<id>/ 轮询提炼任务。
PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。 PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。
DELETE /api/ai/video-digest/<id>/ 取消进行中的提炼并退预留积分。
""" """
parser_classes = [JSONParser, FormParser] parser_classes = [JSONParser, FormParser]
@@ -780,6 +787,15 @@ class VideoDigestDetailView(APIView):
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND) return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
return Response(item) 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): class FreeVideoView(APIView):
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
+18 -2
View File
@@ -413,10 +413,23 @@ export const api = {
); );
}, },
// 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。 // 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。
extractVideoDigest(formData: FormData) { extractVideoDigest(formData: FormData, signal?: AbortSignal) {
return request<VideoDigestJob>( return request<VideoDigestJob>(
"/api/ai/video-digest/", "/api/ai/video-digest/",
{ method: "POST", body: formData } { method: "POST", body: formData, signal }
);
},
extractVideoDigestFromTask(taskId: string, modelConfigId?: string, signal?: AbortSignal) {
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{
method: "POST",
body: JSON.stringify({
reuse_task_id: taskId,
...(modelConfigId ? { model_config_id: modelConfigId } : {}),
}),
signal,
}
); );
}, },
listVideoDigests() { listVideoDigests() {
@@ -425,6 +438,9 @@ export const api = {
getVideoDigest(id: string) { getVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`); return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`);
}, },
cancelVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`, { method: "DELETE" });
},
saveVideoDigest(id: string, prompt: string) { saveVideoDigest(id: string, prompt: string) {
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, { return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
method: "PATCH", method: "PATCH",
+129 -43
View File
@@ -1,12 +1,12 @@
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。 // 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertCircle,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
BadgeCheck, BadgeCheck,
Clock3, Clock3,
Copy, Copy,
Download,
FileText, FileText,
FileVideo, FileVideo,
FileVideo2, FileVideo2,
@@ -17,9 +17,10 @@ import {
ScanLine, ScanLine,
ScanSearch, ScanSearch,
TextCursorInput, TextCursorInput,
X,
} from "lucide-react"; } from "lucide-react";
import { api, ApiError } from "../api"; 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 { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
@@ -100,6 +101,11 @@ function progressClass(step: ProgressStage, stage: ProgressStage) {
return "remix-progress-step"; 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) { function historySummary(item: VideoDigestHistory) {
const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"]; const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"];
return bits.join(" · "); return bits.join(" · ");
@@ -141,6 +147,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}) { }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [jobId, setJobId] = useState(""); const [jobId, setJobId] = useState("");
const [watchId, setWatchId] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
@@ -154,14 +161,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [taskId, setTaskId] = useState(""); const [taskId, setTaskId] = useState("");
const [hasResult, setHasResult] = useState(false); const [hasResult, setHasResult] = useState(false);
const [remoteVideoUrl, setRemoteVideoUrl] = useState(""); const [remoteVideoUrl, setRemoteVideoUrl] = useState("");
const [coverUrl, setCoverUrl] = useState("");
const [sourceTaskId, setSourceTaskId] = useState("");
const [history, setHistory] = useState<VideoDigestHistory[]>([]); const [history, setHistory] = useState<VideoDigestHistory[]>([]);
const [openHistoryId, setOpenHistoryId] = useState(""); const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null); const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
const promptRef = useRef<HTMLTextAreaElement>(null); const promptRef = useRef<HTMLTextAreaElement>(null);
const completedNoticeRef = useRef(""); const completedNoticeRef = useRef("");
const abortRef = useRef<AbortController | null>(null);
const userCancelledRef = useRef(false);
const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]); const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
const previewUrl = blobPreviewUrl || remoteVideoUrl; const previewUrl = blobPreviewUrl || remoteVideoUrl;
const analyzing = submitting || Boolean(jobId); const analyzing = submitting || Boolean(jobId);
const canAnalyze = Boolean(file || remoteVideoUrl || sourceTaskId);
const pollId = jobId || watchId;
const digestModels = useMemo( const digestModels = useMemo(
() => textModels.filter((model) => model.status === "active" && isGemini31(model)), () => 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.ratio) setRatio(job.ratio);
if (job.width) setWidth(job.width); if (job.width) setWidth(job.width);
if (job.height) setHeight(job.height); 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) => { const applySucceededJob = (job: VideoDigestJob) => {
@@ -215,15 +236,30 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
} }
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const data = await loadHistory(); await loadHistory();
if (cancelled) return; if (cancelled) return;
const stored = readJobId(); const stored = readJobId();
const inflightId = data?.inflight ? jobIdOf(data.inflight) : ""; if (!stored) return;
const next = stored || inflightId; try {
if (!next) return; const job = await api.getVideoDigest(stored);
rememberJob(next); if (cancelled) return;
if (data?.inflight && jobIdOf(data.inflight) === next) applyJobMeta(data.inflight); if (job.status === "processing") {
setJobId(next); 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 () => { return () => {
cancelled = true; cancelled = true;
@@ -242,14 +278,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}, [prompt, hasResult]); }, [prompt, hasResult]);
useEffect(() => { useEffect(() => {
if (!jobId) return; if (!pollId) return;
let cancelled = false; let cancelled = false;
let timer = 0; let timer = 0;
const poll = async () => { const poll = async () => {
try { try {
const job = await api.getVideoDigest(jobId); const job = await api.getVideoDigest(pollId);
if (cancelled) return; if (cancelled) return;
applyJobMeta(job);
if (job.status === "processing") { if (job.status === "processing") {
timer = window.setTimeout(poll, 2500); timer = window.setTimeout(poll, 2500);
return; return;
@@ -261,15 +296,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onNotify("success", "视频拆解完成,已生成提示词"); onNotify("success", "视频拆解完成,已生成提示词");
} }
void loadHistory(); void loadHistory();
} else if (job.status === "cancelled" || userCancelledRef.current) {
if (!userCancelledRef.current) onNotify("info", job.error_message || "拆解已取消");
} else { } else {
onNotify("error", job.error_message || "视频拆解失败,请重试"); onNotify("error", job.error_message || "视频拆解失败,请重试");
} }
setJobId(""); setJobId("");
setWatchId("");
forgetJob(); forgetJob();
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
if (error instanceof ApiError && error.status === 404) { if (error instanceof ApiError && error.status === 404) {
setJobId(""); setJobId("");
setWatchId("");
forgetJob(); forgetJob();
return; return;
} }
@@ -281,7 +320,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
cancelled = true; cancelled = true;
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [jobId, onNotify]); }, [pollId, onNotify]);
const pickFile = async (next: File | null) => { const pickFile = async (next: File | null) => {
if (!next || analyzing) return; if (!next || analyzing) return;
@@ -305,19 +344,36 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
setHasResult(false); setHasResult(false);
setTaskId(""); setTaskId("");
setRemoteVideoUrl(""); setRemoteVideoUrl("");
setCoverUrl("");
setSourceTaskId("");
setPrompt(""); setPrompt("");
}; };
const analyze = async () => { 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); setSubmitting(true);
setHasResult(false); setHasResult(false);
setPrompt(""); setPrompt("");
try { try {
let job;
if (file) {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
if (activeModel?.id) fd.append("model_config_id", activeModel.id); if (activeModel?.id) fd.append("model_config_id", activeModel.id);
const job = await api.extractVideoDigest(fd); 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); const id = jobIdOf(job);
applyJobMeta(job); applyJobMeta(job);
if (job.status === "succeeded") { if (job.status === "succeeded") {
@@ -331,14 +387,35 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
return; return;
} }
rememberJob(id); rememberJob(id);
setWatchId("");
setJobId(id); setJobId(id);
} catch (error) { } catch (error) {
if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return;
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally { } finally {
setSubmitting(false); 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 savePrompt = async () => {
const text = prompt.trim(); const text = prompt.trim();
if (!text) return; if (!text) return;
@@ -360,25 +437,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
} }
}; };
const downloadVideo = () => { const continueGenerate = (text = prompt) => {
if (!remoteVideoUrl) { const next = text.trim();
onNotify("info", "这条没有保存原片,重新上传拆一次就能下载"); if (!next) return;
return; sessionStorage.setItem(REMIX_PROMPT_KEY, next);
}
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"); navigate("freeCreate");
}; };
@@ -443,7 +505,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div> </div>
{previewUrl ? ( {previewUrl ? (
<div className="video-upload-field has-file has-preview"> <div className="video-upload-field has-file has-preview">
<video src={previewUrl} controls playsInline preload="metadata" /> <video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}> <label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
<input <input
type="file" type="file"
@@ -479,7 +541,12 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
)} )}
</div> </div>
<div className="video-flow-actions remix-analyze-actions"> <div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}> <button
type="button"
className="primary-action"
disabled={analyzing || !canAnalyze}
onClick={() => void analyze()}
>
<ScanSearch /> <ScanSearch />
<span>{analyzeLabel}</span> <span>{analyzeLabel}</span>
</button> </button>
@@ -501,7 +568,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</div> </div>
<strong></strong> <strong></strong>
<span></span> <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> </div>
<div className="video-analysis-result"> <div className="video-analysis-result">
@@ -546,10 +616,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div><h2></h2></div> <div><h2></h2></div>
</div> </div>
<div className="remix-prompt-head-actions"> <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()}> <button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
<Save /> <Save />
@@ -564,7 +630,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
/> />
<div className="video-flow-actions remix-prompt-actions"> <div className="video-flow-actions remix-prompt-actions">
<button type="button" className="primary-action" onClick={continueGenerate}> <button type="button" className="primary-action" onClick={() => continueGenerate()}>
<span></span> <span></span>
<ArrowRight /> <ArrowRight />
</button> </button>
@@ -633,6 +699,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div className="remix-history-prompt" id={promptId} hidden={!open}> <div className="remix-history-prompt" id={promptId} hidden={!open}>
<div className="remix-history-prompt-head"> <div className="remix-history-prompt-head">
<strong></strong> <strong></strong>
<div className="remix-history-prompt-actions">
<button
type="button"
className="remix-history-copy-button"
onClick={() => continueGenerate(item.prompt)}
>
<ArrowRight />
</button>
<button <button
type="button" type="button"
className="remix-history-copy-button" className="remix-history-copy-button"
@@ -642,6 +717,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</button> </button>
</div> </div>
</div>
<textarea readOnly value={item.prompt} /> <textarea readOnly value={item.prompt} />
</div> </div>
</article> </article>
@@ -652,6 +728,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
</section> </section>
</section> </section>
</div> </div>
<ConfirmModal
open={confirmCancel}
title="确认取消拆解?"
subtitle="当前任务将停止"
detail="取消后本次提炼会停止,已预扣的积分会退回。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
onCancel={() => setConfirmCancel(false)}
onConfirm={() => void cancelAnalyze()}
/>
<MediaLightbox <MediaLightbox
open={Boolean(playing?.video_url)} open={Boolean(playing?.video_url)}
src={playing?.video_url || ""} src={playing?.video_url || ""}
+1 -1
View File
@@ -682,7 +682,7 @@ export type FreeVideoTask = {
export type VideoDigestJob = { export type VideoDigestJob = {
id: string; id: string;
task_id: string; task_id: string;
status: "processing" | "succeeded" | "failed" | string; status: "processing" | "succeeded" | "failed" | "cancelled" | string;
text: string; text: string;
prompt: string; prompt: string;
chars: number; chars: number;
+32 -30
View File
@@ -419,6 +419,19 @@
padding-top: 22px; padding-top: 22px;
} }
.vr-page .remix-generating-content .remix-cancel-analyze {
margin-top: 6px;
min-width: 112px;
height: 40px;
color: var(--klein);
border-color: rgba(0, 47, 167, 0.22);
background: rgba(0, 47, 167, 0.06);
}
.vr-page .remix-generating-content .remix-cancel-analyze:hover:not(:disabled) {
background: rgba(0, 47, 167, 0.1);
}
.vr-page .video-flow-panel h2, .vr-page .video-flow-panel h2,
.vr-page .video-result-panel h2 { .vr-page .video-result-panel h2 {
margin: 0; margin: 0;
@@ -676,24 +689,6 @@
line-height: 1.55; line-height: 1.55;
} }
.vr-page .remix-generating-bar {
width: 100%;
height: 4px;
overflow: hidden;
margin-top: 6px;
border-radius: 999px;
background: rgba(0, 47, 167, 0.1);
}
.vr-page .remix-generating-bar span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--klein);
animation: remix-progress-slide 1.2s ease-in-out infinite;
}
@keyframes remix-frame-scan { @keyframes remix-frame-scan {
0% { transform: translateX(-130%); } 0% { transform: translateX(-130%); }
100% { transform: translateX(130%); } 100% { transform: translateX(130%); }
@@ -704,11 +699,6 @@
50% { transform: scale(1.07); } 50% { transform: scale(1.07); }
} }
@keyframes remix-progress-slide {
0% { transform: translateX(-115%); }
100% { transform: translateX(260%); }
}
.vr-page .remix-progress-strip { .vr-page .remix-progress-strip {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -857,6 +847,16 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
cursor: default; cursor: default;
background: #0f1728;
border-color: rgba(0, 47, 167, 0.28);
box-shadow: none;
}
.vr-page .remix-page .video-upload-field.has-preview:hover,
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
background: #0f1728;
border-color: rgba(0, 47, 167, 0.28);
box-shadow: none;
} }
.vr-page .remix-page .video-upload-field.has-preview video { .vr-page .remix-page .video-upload-field.has-preview video {
@@ -891,11 +891,6 @@
opacity: 0.45; opacity: 0.45;
} }
.vr-page .remix-page .video-upload-field.has-preview:hover,
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
background: #0f1728;
}
.vr-page .remix-page .video-upload-field strong { .vr-page .remix-page .video-upload-field strong {
color: #1d2940; color: #1d2940;
font-size: 15px; font-size: 15px;
@@ -961,8 +956,7 @@
animation: none; animation: none;
} }
.vr-page .remix-generating-frame::after, .vr-page .remix-generating-frame::after,
.vr-page .remix-generating-badge, .vr-page .remix-generating-badge {
.vr-page .remix-generating-bar span {
animation: none; animation: none;
} }
} }
@@ -1211,6 +1205,14 @@
font-weight: 600; font-weight: 600;
} }
.vr-page .remix-history-prompt-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
}
.vr-page .remix-history-prompt textarea { .vr-page .remix-history-prompt textarea {
width: 100%; width: 100%;
min-height: 360px; min-height: 360px;