优化视频提炼
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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 首尾帧)。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 || ""}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user