From 51620bf25b58ed329a8f188232cb7ba4a65c835d Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Thu, 27 Aug 2026 11:54:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=A7=86=E9=A2=91=E5=A4=8D?= =?UTF-8?q?=E5=88=BB=E5=92=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/airshelf/celery.py | 11 + core/backend/apps/adminpanel/tests.py | 14 +- core/backend/apps/adminpanel/views.py | 19 +- core/backend/apps/ai/free_video.py | 32 +- core/backend/apps/ai/media_probe.py | 9 + core/backend/apps/ai/script_agent.py | 8 +- core/backend/apps/ai/tasks.py | 34 + core/backend/apps/ai/test_video_digest.py | 54 +- core/backend/apps/ai/test_video_replace.py | 236 +++++++ core/backend/apps/ai/urls.py | 4 + core/backend/apps/ai/video_digest.py | 312 ++++++++- core/backend/apps/ai/video_replace.py | 308 +++++++++ core/backend/apps/ai/views.py | 123 +++- core/backend/apps/assets/free_library.py | 4 +- core/backend/apps/assets/review.py | 80 ++- core/backend/apps/assets/tests.py | 37 ++ core/backend/apps/common/celery_health.py | 2 +- .../apps/projects/services/quick_create.py | 19 +- .../apps/projects/test_quick_create.py | 52 +- core/backend/apps/projects/views.py | 29 +- core/frontend/src/App.tsx | 44 +- core/frontend/src/admin-page.css | 6 +- core/frontend/src/api.ts | 50 +- core/frontend/src/components/app-shell.tsx | 4 +- .../src/components/free-create/constants.ts | 6 +- core/frontend/src/components/overlays.tsx | 13 +- core/frontend/src/design-restraint.css | 58 +- core/frontend/src/product-create-page.css | 2 +- core/frontend/src/quick-create-lock.ts | 9 + core/frontend/src/quick-create-page.css | 14 +- core/frontend/src/routes/admin/admin-app.tsx | 18 +- .../src/routes/admin/admin-reviews.tsx | 83 +-- core/frontend/src/routes/dashboard.tsx | 8 +- core/frontend/src/routes/free-create.tsx | 3 +- core/frontend/src/routes/models.tsx | 2 +- core/frontend/src/routes/pipeline.tsx | 82 +-- core/frontend/src/routes/products.tsx | 10 +- core/frontend/src/routes/projects.tsx | 10 +- core/frontend/src/routes/quick-create.tsx | 43 +- core/frontend/src/routes/route-config.ts | 6 +- core/frontend/src/routes/video-remix.tsx | 231 +++++-- core/frontend/src/routes/video-replace.tsx | 608 +++++++++++++----- core/frontend/src/styles.css | 6 +- core/frontend/src/types.ts | 29 +- core/frontend/src/video-remix-page.css | 170 ++++- core/frontend/src/video-replace-page.css | 336 +++++++--- 46 files changed, 2575 insertions(+), 663 deletions(-) create mode 100644 core/backend/apps/ai/test_video_replace.py create mode 100644 core/backend/apps/ai/video_replace.py diff --git a/core/backend/airshelf/celery.py b/core/backend/airshelf/celery.py index 8d2aa3f..094e393 100644 --- a/core/backend/airshelf/celery.py +++ b/core/backend/airshelf/celery.py @@ -9,3 +9,14 @@ app = Celery("airshelf") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() + +from celery.signals import worker_ready # noqa: E402 + + +@worker_ready.connect +def _kick_asset_review_drain(**kwargs): + """Worker 起来就自动转人脸审核,不用人去后台点送审。""" + from apps.ai.tasks import drain_asset_reviews_task + + drain_asset_reviews_task.apply_async(countdown=8) + diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 3ed2788..a86eb9e 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -328,11 +328,23 @@ class AdminAssetReviewTests(TestCase): mock_submit.assert_not_called() def test_poll_only_processing(self): - with patch("apps.adminpanel.views.poll_asset_review", return_value="active") as mock_poll: + with patch("apps.assets.review.submit_asset_for_review", return_value=False), patch( + "apps.assets.review.poll_asset_review", return_value="active" + ) as mock_poll: r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json") self.assertEqual(r.status_code, 200) self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing mock_poll.assert_called_once() + self.assertEqual(r.data.get("submitted", 0), 0) + + def test_poll_auto_submits_unsubmitted(self): + with patch("apps.assets.review.submit_asset_for_review", return_value=True) as mock_submit, patch( + "apps.assets.review.poll_asset_review", return_value="processing" + ): + r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json") + self.assertEqual(r.status_code, 200) + self.assertGreaterEqual(r.data["submitted"], 1) + mock_submit.assert_called() class AdminTaskMonitorTests(TestCase): diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 123c74b..1d3f62a 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -406,15 +406,20 @@ def admin_asset_reviews_submit(request): @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_asset_reviews_poll(request): - """轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。""" + """自动送审未送审资产 + 轮询审核中状态。给 asset_ids 则只轮询这些,否则全平台兜底。""" + from apps.assets.review import drain_platform_reviews + ids = request.data.get("asset_ids") - qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False) if ids: - qs = qs.filter(id__in=ids) - statuses = {} - for asset in qs: - statuses[str(asset.id)] = poll_asset_review(asset) - return Response({"polled": len(statuses), "statuses": statuses}) + qs = Asset.objects.filter( + category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False, id__in=ids + ) + statuses = {} + for asset in qs: + statuses[str(asset.id)] = poll_asset_review(asset) + return Response({"polled": len(statuses), "submitted": 0, "statuses": statuses}) + result = drain_platform_reviews() + return Response(result) def _refresh_inflight_task(task: AITask, *, operator) -> AITask: diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 6d6aacd..47cdfe6 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -31,6 +31,7 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit, from .models import AITask, ModelConfig from .providers.volcano import VolcanoArkProvider from .generation_errors import classify_generation_error, public_error_for_task +from .media_probe import REF_DURATION_MAX from .video_errors import parse_provider_error from .video_pricing import get_resolution @@ -319,7 +320,7 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试") if audio_n > 0 and image_n + video_n == 0: raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频") - if video_duration_total > 15: + if video_duration_total > REF_DURATION_MAX: raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试") # @label 替换:按 label 长度降序,防子串吞噬 @@ -409,6 +410,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: """提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。 创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。""" prompt = str(params.get("prompt") or "").strip() + feature = str(params.get("feature") or "free_video").strip() or "free_video" mode = str(params.get("mode") or "universal") model_name = str(params.get("model") or HIGH_RES_MODEL) aspect_ratio = str(params.get("aspect_ratio") or "16:9") @@ -486,7 +488,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: reserve_amount = video_reserve_amount(quote.points) request_payload = { - "feature": "free_video", + "feature": feature, "mode": mode, "model": model_name, "endpoint": model_config.endpoint, @@ -505,6 +507,13 @@ def submit_free_video(*, team, user, params: dict) -> AITask: "references": built["snapshots"], "model_routing_v1": True, } + extra = params.get("extra_payload") + if isinstance(extra, dict): + protected = set(request_payload) + for key, value in extra.items(): + if key in protected or value in (None, ""): + continue + request_payload[key] = value # 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套 with transaction.atomic(): @@ -546,7 +555,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: generate_audio=generate_audio, seed=seed if seed != -1 else None, search_mode=search_mode, - request_summary={"feature": "free_video", "mode": mode}, + request_summary={"feature": feature, "mode": mode}, ) response, provider_task_id = routed.value # execute_model_call 以原子 F 表达式累计实际尝试的平台成本;刷新内存对象, @@ -625,7 +634,7 @@ def _notify_failure(task: AITask, *, raw: str, hint: str) -> None: task=task, project=None, recipient=task.created_by, - stage_label="自由创作视频", + stage_label="视频复刻" if (task.request_payload or {}).get("feature") == "video_replace" else "自由创作视频", raw=raw, hint=hint, ) @@ -643,19 +652,27 @@ def _store_free_video_media(*, task: AITask, media: str) -> Asset: asset_id = uuid.uuid4() object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4" stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) - prompt = (task.request_payload or {}).get("prompt") or "" + payload = task.request_payload or {} + prompt = payload.get("prompt") or "" + feature = payload.get("feature") or "free_video" + subject = str(payload.get("subject_name") or "").strip() + if feature == "video_replace": + mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻" + asset_name = f"{subject}{mode_label}" if subject else mode_label + else: + asset_name = prompt[:255] or "自由创作视频" asset = Asset.objects.create( id=asset_id, team=task.team, created_by=task.created_by, # Asset.name 是通用资产字段,受 255 字符上限约束;完整显示标题由资产接口从 # 关联任务的 request_payload.prompt 派生,避免再出现 50 字符业务截断。 - name=(prompt[:255] or "自由创作视频"), + name=asset_name[:255], asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.FREE_CREATE, origin_task=task, - metadata={"feature": "free_video"}, + metadata={"feature": feature}, ) AssetFile.objects.create( asset=asset, @@ -892,6 +909,7 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa "status": task.status, "mode": payload.get("mode") or "universal", "model": payload.get("model") or "", + "feature": payload.get("feature") or "free_video", "prompt": payload.get("prompt") or "", "aspect_ratio": payload.get("aspect_ratio") or "16:9", "resolution": payload.get("resolution") or "720p", diff --git a/core/backend/apps/ai/media_probe.py b/core/backend/apps/ai/media_probe.py index 8c640e4..7351bad 100644 --- a/core/backend/apps/ai/media_probe.py +++ b/core/backend/apps/ai/media_probe.py @@ -8,6 +8,15 @@ import tempfile from pathlib import Path +# 参考视频/音频对外仍写「2-15 秒」。15 秒成片常被探测成 15.01–15.04(帧率取整 / AAC 对齐),给半秒容差。 +REF_DURATION_MIN = 1.95 +REF_DURATION_MAX = 15.5 + + +def duration_in_ref_range(seconds: float) -> bool: + return REF_DURATION_MIN <= seconds <= REF_DURATION_MAX + + def probe_duration(file_path: str) -> float | None: """ffprobe 取媒体时长(秒)。失败返回 None。""" try: diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 29a1d44..7f0dafd 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -1571,7 +1571,10 @@ def stream_script_agent( target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。""" from apps.ai.services import create_ai_task, stream_routed_text_request - fmt, structure = coerce_combo(presentation_format, video_structure) + # 极速成片与专业创作都只产出口播。历史项目、模板或请求里的短剧/Vlog + # 仅作兼容读取,绝不能重新进入实际生成链路。 + del presentation_format + fmt, structure = coerce_combo(DEFAULT_PRESENTATION_FORMAT, video_structure) yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"}) skill_loaded = bool(load_ecommerce_skill(fmt, structure)) @@ -1898,7 +1901,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)") total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜 # 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架 - fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure")) + # 单镜重写也必须遵守当前统一的口播规则,不能被旧稿的表现形式带回短剧/Vlog。 + fmt, structure = combo_keys(DEFAULT_PRESENTATION_FORMAT, base_draft.get("video_structure")) product_image_urls = _script_product_reference_urls(project, model_config) messages = build_agent_messages( diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index ae7ba27..1400099 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -31,6 +31,16 @@ def extract_entities_task(self, task_id: str) -> str: return task_id +@app.task(bind=True, max_retries=0) +def run_video_digest_task(self, task_id: str) -> str: + """视频提炼页的慢活(抽帧 + Gemini,可达 1–2 分钟)在 worker 内跑,离开页面也不中断。 + 幂等且失败自退费(见 run_team_digest_task),故 max_retries=0,不向上抛重试。""" + from apps.ai.video_digest import run_team_digest_task + + run_team_digest_task(task_id=task_id) + return task_id + + @app.task(bind=True, max_retries=0) def generate_base_asset_task(self, task_id: str) -> str: """基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。 @@ -87,3 +97,27 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30) return task_id + +@app.task(bind=True, max_retries=0) +def drain_asset_reviews_task(self) -> str: + """全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。 + 自重排(无 celery beat);eager 下不自转,避免单测死循环。""" + from django.conf import settings as dj_settings + from django.core.cache import cache + + from apps.assets.review import drain_platform_reviews + + if cache.add("asset-review-drain-lock", "1", timeout=50): + try: + drain_platform_reviews() + except Exception: # noqa: BLE001 — 单轮失败不终结循环 + import logging + + logging.getLogger(__name__).warning("drain_asset_reviews_task failed", exc_info=True) + finally: + cache.delete("asset-review-drain-lock") + if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False): + return "ok" + drain_asset_reviews_task.apply_async(countdown=45) + return "ok" + diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index 445adf3..6bbdc58 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -283,6 +283,7 @@ def _digest_with_duration(clip, duration: float): return frames_from_upload(clip) +@override_settings(CELERY_TASK_ALWAYS_EAGER=True) class VideoDigestApiTests(TestCase): """视频提炼页独立入口:不绑项目,缺文件直接 400。""" @@ -300,42 +301,47 @@ class VideoDigestApiTests(TestCase): def test_returns_digest_without_project(self): payload = { - "text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3, - "chars": 120, - "frames": 4, - "duration": 15.0, + "id": "00000000-0000-0000-0000-000000000001", "task_id": "00000000-0000-0000-0000-000000000001", + "status": "processing", + "text": "", + "prompt": "", + "chars": 0, + "duration": 15.0, + "shots": 0, + "file_name": "ref.mp4", "estimated_cost": "30", } - with patch("apps.ai.video_digest.digest_team_video", return_value=payload): + with patch("apps.ai.video_digest.submit_team_digest", return_value=payload): upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 202) self.assertEqual(response.data["name"], "ref.mp4") - self.assertIn("【第 1 镜】", response.data["text"]) + self.assertEqual(response.data["status"], "processing") + self.assertEqual(response.data["task_id"], payload["task_id"]) self.assertEqual(response.data["duration"], 15.0) def test_forwards_model_config_id(self): payload = { - "text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3, - "chars": 120, - "frames": 4, - "duration": 15.0, + "id": "00000000-0000-0000-0000-000000000002", "task_id": "00000000-0000-0000-0000-000000000002", + "status": "processing", + "text": "", + "duration": 15.0, "estimated_cost": "30", } - with patch("apps.ai.video_digest.digest_team_video", return_value=payload) as mocked: + with patch("apps.ai.video_digest.submit_team_digest", return_value=payload) as mocked: upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") response = self.client.post( "/api/ai/video-digest/", {"file": upload, "model_config_id": "abc-123"}, format="multipart", ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 202) 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.digest_team_video", 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") response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart") self.assertEqual(response.status_code, 502) @@ -392,6 +398,7 @@ class VideoDigestApiTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["results"], []) self.assertEqual(response.data["total"], 0) + self.assertIsNone(response.data["inflight"]) def test_history_lists_succeeded_team_digests(self): from apps.ai.models import AITask @@ -477,6 +484,25 @@ class VideoDigestApiTests(TestCase): ) self.assertEqual(missing.status_code, 404) + def test_polls_inflight_job_and_exposes_it_on_list(self): + from apps.ai.models import AITask + + task = self._seed_digest_task(status=AITask.Status.RESERVED, extra_key="inflight") + listed = self.client.get("/api/ai/video-digest/") + self.assertEqual(listed.status_code, 200) + self.assertEqual(listed.data["inflight"]["id"], str(task.id)) + self.assertEqual(listed.data["inflight"]["status"], "processing") + + polled = self.client.get(f"/api/ai/video-digest/{task.id}/") + self.assertEqual(polled.status_code, 200) + self.assertEqual(polled.data["status"], "processing") + self.assertEqual(polled.data["task_id"], str(task.id)) + self.assertEqual(polled.data["file_name"], "参考视频.mp4") + + def test_poll_missing_job_returns_404(self): + missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/") + self.assertEqual(missing.status_code, 404) + @override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) class VideoDigestBillingTests(TestCase): diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py new file mode 100644 index 0000000..efb8158 --- /dev/null +++ b/core/backend/apps/ai/test_video_replace.py @@ -0,0 +1,236 @@ +"""视频复刻:专用提交/历史隔离/提示词后端写死。 + +运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test +""" +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Team, TeamMember, User +from apps.ai.free_video import submit_free_video +from apps.ai.models import AITask, ModelConfig +from apps.ai.test_free_video import STANDARD, _ark_create_response +from apps.ai.video_replace import PRODUCT_PROMPT, submit_video_replace +from apps.assets.models import Asset, AssetFile, Model +from apps.billing.models import CreditAccount +from apps.products.models import Product, ProductImage + + +def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png"): + asset = Asset.objects.create( + team=team, + created_by=user, + name=name, + asset_type=kind, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.UPLOAD, + ) + AssetFile.objects.create( + asset=asset, + object_key="k/1", + bucket="b", + content_type="video/mp4" if kind == Asset.Type.VIDEO else "image/png", + size_bytes=1, + preview_url=preview, + duration_ms=duration_ms, + is_primary=True, + ) + return asset + + +class SubmitVideoReplaceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="vrep", password="p") + self.team = Team.objects.create(name="VR", owner=self.user) + CreditAccount.objects.create(team=self.team, balance="10000.0000") + self.provider = MagicMock() + self.provider.create_video_task.return_value = _ark_create_response() + patch("apps.ai.services.build_provider", return_value=self.provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() + patch("apps.assets.assets_client.is_enabled", return_value=False).start() + self.addCleanup(patch.stopall) + self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4") + self.image = _asset(self.team, self.user, name="精华.png", preview="http://tos/product.png") + self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华", cover_asset=self.image) + ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True) + + def _submit(self, **over): + params = { + "replace_mode": "product", + "video_asset_id": str(self.video.id), + "product_id": str(self.product.id), + "model": STANDARD, + "aspect_ratio": "9:16", + "resolution": "480p", + "duration": 4, + } + params.update(over) + return submit_video_replace(team=self.team, user=self.user, params=params) + + def test_product_library_uses_backend_prompt_and_feature(self): + task = self._submit() + self.assertEqual(task.status, AITask.Status.SUBMITTED) + payload = task.request_payload + self.assertEqual(payload["feature"], "video_replace") + self.assertEqual(payload["replace_mode"], "product") + self.assertEqual(payload["subject_name"], "净颜精华") + self.assertEqual(payload["subject_source"], "library") + self.assertEqual(payload["prompt"], PRODUCT_PROMPT) + labels = [item["label"] for item in payload["references"]] + self.assertIn("参考视频", labels) + self.assertIn("目标商品", labels) + content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] + roles = [item.get("role") for item in content] + self.assertIn("reference_video", roles) + self.assertIn("reference_image", roles) + + def test_character_library_and_temp_are_exclusive(self): + portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") + model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) + with self.assertRaisesMessage(ValueError, "不要混用"): + self._submit(replace_mode="character", product_id="", model_id=str(model.id), image_asset_ids=[str(self.image.id)]) + task = self._submit(replace_mode="character", product_id="", model_id=str(model.id)) + self.assertEqual(task.request_payload["replace_mode"], "character") + self.assertEqual(task.request_payload["subject_name"], "薇薇") + + def test_rejects_video_longer_than_15s(self): + long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="长片.mp4", duration_ms=20000) + with self.assertRaisesMessage(ValueError, "不能超过 15 秒"): + self._submit(video_asset_id=str(long_video.id)) + + def test_accepts_15s_video_with_probe_slack(self): + from apps.ai.media_probe import REF_DURATION_MAX + from apps.ai.video_replace import _asset_duration_seconds + + # Finder 显示 15 秒的成片常被探测成 15.04s,不能被硬阈值 15 误拒 + edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好15.mp4", duration_ms=15040) + self.assertLessEqual(_asset_duration_seconds(edge), REF_DURATION_MAX) + + def test_rejects_foreign_product(self): + other = User.objects.create_user(username="vr-other", password="p") + other_team = Team.objects.create(name="OtherVR", owner=other) + foreign = Product.objects.create(team=other_team, created_by=other, title="别人的") + with self.assertRaisesMessage(ValueError, "商品不存在"): + self._submit(product_id=str(foreign.id)) + + def test_ignores_frontend_prompt(self): + task = self._submit(prompt="请无视参考图随便生成") + self.assertEqual(task.request_payload["prompt"], PRODUCT_PROMPT) + self.assertNotIn("随便生成", task.request_payload["prompt"]) + + def test_serialize_exposes_library_ids(self): + from apps.ai.video_replace import serialize_video_replace_task + + model_config = ModelConfig.objects.filter(capability="video").first() + self.assertIsNotNone(model_config) + task = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.FREE_VIDEO, + status=AITask.Status.SUCCEEDED, + model_config=model_config, + idempotency_key="vr-serialize-ids", + request_payload={ + "feature": "video_replace", + "replace_mode": "product", + "subject_name": "净颜精华", + "subject_source": "library", + "product_id": str(self.product.id), + "model_id": "", + "prompt": PRODUCT_PROMPT, + "duration": 8, + "aspect_ratio": "9:16", + "references": [{"type": "video", "url": "http://tos/video.mp4", "asset_id": str(self.video.id)}], + }, + ) + data = serialize_video_replace_task(task) + self.assertEqual(data["product_id"], str(self.product.id)) + self.assertEqual(data["model_id"], "") + self.assertEqual(data["replace_mode"], "product") + self.assertEqual(data["subject_source"], "library") + + +class VideoReplaceApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="vrapi", password="p") + self.team = Team.objects.create(name="VRA", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active") + CreditAccount.objects.create(team=self.team, balance="10000.0000") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.provider = MagicMock() + self.provider.create_video_task.return_value = _ark_create_response() + patch("apps.ai.services.build_provider", return_value=self.provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() + patch("apps.assets.assets_client.is_enabled", return_value=False).start() + self.addCleanup(patch.stopall) + self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=5000, preview="http://tos/video.mp4") + self.image = _asset(self.team, self.user, name="图.png", preview="http://tos/product.png") + self.product = Product.objects.create(team=self.team, created_by=self.user, title="轻醒咖啡", cover_asset=self.image) + ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True) + + def test_submit_list_isolated_from_free_video(self): + free = submit_free_video( + team=self.team, + user=self.user, + params={ + "prompt": "一只猫", + "mode": "universal", + "model": STANDARD, + "aspect_ratio": "16:9", + "resolution": "480p", + "duration": 4, + "references": [], + }, + ) + resp = self.client.post( + "/api/ai/video-replace/", + { + "replace_mode": "product", + "video_asset_id": str(self.video.id), + "product_id": str(self.product.id), + "model": STANDARD, + "aspect_ratio": "9:16", + "resolution": "480p", + "duration": 4, + }, + format="json", + ) + self.assertEqual(resp.status_code, 202) + replace_id = resp.json()["task"]["id"] + self.assertEqual(resp.json()["task"]["replace_mode"], "product") + self.assertEqual(resp.json()["task"]["feature"], "video_replace") + + free_list = self.client.get("/api/ai/free-video/").json() + free_ids = {item["id"] for item in free_list["results"]} + self.assertIn(str(free.id), free_ids) + self.assertNotIn(replace_id, free_ids) + + replace_list = self.client.get("/api/ai/video-replace/").json() + replace_ids = {item["id"] for item in replace_list["results"]} + self.assertIn(replace_id, replace_ids) + self.assertNotIn(str(free.id), replace_ids) + + def test_legacy_prompt_prefix_shows_in_replace_history(self): + task = submit_free_video( + team=self.team, + user=self.user, + params={ + "prompt": "[视频复刻] 商品:旧稿。保留镜头。", + "mode": "universal", + "model": STANDARD, + "aspect_ratio": "9:16", + "resolution": "480p", + "duration": 4, + "references": [], + }, + ) + listing = self.client.get("/api/ai/video-replace/").json() + self.assertEqual(listing["results"][0]["id"], str(task.id)) + self.assertEqual(listing["results"][0]["replace_mode"], "product") + self.assertEqual(self.client.get("/api/ai/free-video/").json()["total"], 0) + + def test_validation_error_returns_400(self): + resp = self.client.post("/api/ai/video-replace/", {"replace_mode": "product"}, format="json") + self.assertEqual(resp.status_code, 400) diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index 8605b0e..9c4eb5d 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -16,6 +16,8 @@ from .views import ( GenerateImageView, ImageConversationViewSet, ModelConfigViewSet, + VideoReplacePollView, + VideoReplaceView, ) router = DefaultRouter() @@ -35,4 +37,6 @@ urlpatterns = [ path("free-video//restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"), path("free-video//purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"), path("free-video//", FreeVideoDetailView.as_view(), name="ai-free-video-detail"), + path("video-replace/", VideoReplaceView.as_view(), name="ai-video-replace"), + path("video-replace//poll/", VideoReplacePollView.as_view(), name="ai-video-replace-poll"), ] + router.urls diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index d5fbc08..9dcdbf6 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -700,7 +700,274 @@ def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None: return serialize_digest_history(task) -def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict: +class _FileFromPath: + def __init__(self, path: str, name: str): + self.name = name + self.size = Path(path).stat().st_size + self._path = path + + def chunks(self, chunk_size=1024 * 1024): + with Path(self._path).open("rb") as handle: + while True: + data = handle.read(chunk_size) + if not data: + break + yield data + + +def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]: + ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}" + mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4") + key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}" + from apps.assets.storage import TosStorage + + storage = TosStorage() + with Path(path).open("rb") as fileobj: + stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime) + return stored.object_key, storage.public_url(object_key=stored.object_key) + + +def _download_source_to_temp(object_key: str, suffix: str) -> str: + from apps.assets.storage import TosStorage + + ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}" + storage = TosStorage() + body = storage.client.get_object(Bucket=storage.bucket, Key=object_key)["Body"].read() + tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False) + try: + tmp.write(body) + tmp.flush() + finally: + tmp.close() + return tmp.name + + +def serialize_digest_job(task) -> dict: + from apps.ai.generation_errors import public_error_for_task + from apps.ai.models import AITask + + req = task.request_payload or {} + resp = task.response_payload or {} + prompt = str(resp.get("prompt") or resp.get("digest") or "").strip() + inflight = task.status in { + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, + } + if inflight: + job_status = "processing" + elif task.status == AITask.Status.SUCCEEDED: + job_status = "succeeded" + else: + job_status = "failed" + duration = float(req.get("duration_seconds") or 0) + file_name = str(req.get("file_name") or "") or "参考视频.mp4" + public_error = public_error_for_task(task, operation="video_digest") if job_status == "failed" else None + video_url = _media_url_from_payload(req, "video_url", "video_key", signed=True) or _media_url_from_payload( + req, "source_url", "source_key", signed=True + ) + return { + "id": str(task.id), + "task_id": str(task.id), + "status": job_status, + "text": prompt, + "prompt": prompt, + "chars": len(prompt), + "duration": round(duration, 1) if duration else 0, + "shots": int(req.get("shot_count") or 0) or shot_count(prompt), + "file_name": file_name, + "title": str(req.get("title") or "").strip() or title_from_filename(file_name), + "ratio": str(req.get("ratio") or ""), + "width": int(req.get("width") or 0), + "height": int(req.get("height") or 0), + "cover_url": _media_url_from_payload(req, "cover_url", "cover_key"), + "video_url": video_url, + "estimated_cost": str(task.estimated_cost), + "error_message": (public_error.fallback_message if public_error else "") or str(task.error_message or ""), + } + + +def get_team_digest_job(*, team, task_id) -> dict | None: + from apps.ai.models import AITask + + task = 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() + if task is None: + return None + return serialize_digest_job(task) + + +def get_inflight_team_digest(*, team) -> dict | None: + from apps.ai.models import AITask + + task = ( + 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, + }, + ) + .order_by("-created_at") + .first() + ) + if task is None: + return None + return serialize_digest_job(task) + + +def submit_team_digest(*, team, user, upload, model_config_id=None) -> dict: + """秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。""" + from django.db import transaction + + from apps.ai.models import AITask + from apps.ai.tasks import run_video_digest_task + from apps.billing.pricing import quote_video_digest + from apps.billing.services.ledger import reserve_credit + + model_config = resolve_digest_model_config(preferred_id=model_config_id) + 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("参考视频上传失败,请重试") + + 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"] + + try: + with transaction.atomic(): + task = AITask.objects.create( + team=team, + created_by=user, + project=None, + task_type=AITask.Type.VIDEO_DIGEST, + status=AITask.Status.CREATED, + model_config=model_config, + idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}", + request_payload=request_payload, + estimated_cost=quote.points, + base_cost=quote.base_cost_yuan, + ) + reserve_credit(team=team, user=user, task=task, amount=quote.points) + task.status = AITask.Status.RESERVED + task.save(update_fields=["status", "updated_at"]) + except ValueError as exc: + if "insufficient credit" in str(exc).lower(): + raise VideoDigestError("团队余额不足,请充值后重试") from exc + raise VideoDigestError(str(exc)) from exc + + run_video_digest_task.delay(str(task.id)) + return serialize_digest_job(task) + + +def run_team_digest_task(*, task_id: str) -> None: + """Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。""" + from django.db import transaction + from django.utils import timezone + + from apps.ai.models import AITask + + task = AITask.objects.select_related("team", "created_by", "model_config", "credit_reservation").filter( + id=task_id, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + ).first() + if task is None: + return + if task.status == AITask.Status.SUCCEEDED: + return + if task.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}: + return + + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status == AITask.Status.SUCCEEDED: + return + if locked.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}: + return + locked.status = AITask.Status.SUBMITTED + locked.submitted_at = locked.submitted_at or timezone.now() + locked.save(update_fields=["status", "submitted_at", "updated_at"]) + task = locked + + req = task.request_payload or {} + source_key = str(req.get("source_key") or "") + suffix = str(req.get("suffix") or ".mp4") + file_name = str(req.get("file_name") or "参考视频.mp4") + if not source_key: + reservation = getattr(task, "credit_reservation", None) + _fail_digest_task(task, reservation, "参考视频丢失,请重新上传") + return + + local_path = "" + try: + local_path = _download_source_to_temp(source_key, suffix) + upload = _FileFromPath(local_path, file_name) + _digest_video( + team=task.team, + user=task.created_by, + upload=upload, + project=None, + product_hint="", + model_config_id=str(task.model_config_id) if task.model_config_id else None, + existing_task=task, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("async video digest failed for %s", task.id) + task.refresh_from_db() + if task.status not in {AITask.Status.SUCCEEDED, AITask.Status.FAILED}: + reservation = getattr(task, "credit_reservation", None) + _fail_digest_task(task, reservation, str(exc)) + finally: + if local_path: + Path(local_path).unlink(missing_ok=True) + + +def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None, existing_task=None) -> dict: """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" from django.db import transaction from django.utils import timezone @@ -720,7 +987,11 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co height = int(extras.get("height") or 0) file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0) - model_config = resolve_digest_model_config(preferred_id=model_config_id) + model_config = ( + existing_task.model_config + if existing_task is not None and existing_task.model_config_id + else resolve_digest_model_config(preferred_id=model_config_id) + ) if model_config is None: if source_path: Path(source_path).unlink(missing_ok=True) @@ -765,7 +1036,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co if quote.meta.get("rate"): request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} - if project is not None: + if existing_task is not None: + task = existing_task + existing_req = dict(task.request_payload or {}) + request_payload = {**existing_req, **request_payload} + task.request_payload = request_payload + task.save(update_fields=["request_payload", "updated_at"]) + elif project is not None: task = create_ai_task( project=project, user=user, @@ -831,18 +1108,25 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co _fail_digest_task(task, reservation, str(exc)) raise - cover_key, cover_url, video_key, video_url = "", "", "", "" + existing_req = dict(task.request_payload or {}) + cover_key = str(existing_req.get("cover_key") or "") + cover_url = str(existing_req.get("cover_url") or "") + video_key = str(existing_req.get("video_key") or "") + video_url = str(existing_req.get("video_url") or "") if project is None: + if not cover_key: + try: + cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"") + except Exception: # noqa: BLE001 — 封面失败不挡拆解结果 + logger.warning("video digest cover upload failed", exc_info=True) try: - cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"") - except Exception: # noqa: BLE001 — 封面失败不挡拆解结果 - logger.warning("video digest cover upload failed", exc_info=True) - try: - video_key, video_url = _store_digest_video( + stored_key, stored_url = _store_digest_video( team=team, path=source_path, suffix=str(extras.get("suffix") or ".mp4"), ) + if stored_key: + video_key, video_url = stored_key, stored_url except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播 logger.warning("video digest source upload failed", exc_info=True) if source_path: @@ -851,13 +1135,13 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co shots = shot_count(digest) request_payload = { - **(task.request_payload or {}), + **existing_req, **request_payload, "shot_count": shots, - "cover_key": cover_key, - "cover_url": cover_url, - "video_key": video_key, - "video_url": video_url, + "cover_key": cover_key or existing_req.get("cover_key") or "", + "cover_url": cover_url or existing_req.get("cover_url") or "", + "video_key": video_key or existing_req.get("video_key") or "", + "video_url": video_url or existing_req.get("video_url") or "", } with transaction.atomic(): diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py new file mode 100644 index 0000000..2e5c284 --- /dev/null +++ b/core/backend/apps/ai/video_replace.py @@ -0,0 +1,308 @@ +"""视频复刻:参考视频 + 商品图/人物图 → Seedance 换商品或换角色。 + +不新建任务类型、不接检测/抠图。提交仍走 submit_free_video,只把 +feature=video_replace 和 replace_mode 写进 payload,提示词由后端写死。 +""" +from __future__ import annotations + +import uuid + +from django.db.models import Q + +from apps.assets.models import Asset, Model +from apps.products.models import Product + +from .free_video import HIGH_RES_MODEL, serialize_free_video_task, submit_free_video +from .media_probe import REF_DURATION_MAX + +FEATURE = "video_replace" +REPLACE_MODES = {"product", "character"} +MAX_IMAGES = 9 +LEGACY_PROMPT_PREFIX = "[视频复刻]" + +PRODUCT_PROMPT = ( + "使用@参考视频作为镜头、节奏与口播氛围基准," + "将画面中需要替换的原商品完整替换为@目标商品的外观。" + "保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围," + "商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。" +) +CHARACTER_PROMPT = ( + "使用@参考视频作为镜头、节奏与口播氛围基准," + "将画面中需要替换的原人物完整替换为@目标角色。" + "保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围," + "角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。" +) + + +def is_video_replace_task(task) -> bool: + payload = task.request_payload or {} + if payload.get("feature") == FEATURE: + return True + return str(payload.get("prompt") or "").startswith(LEGACY_PROMPT_PREFIX) + + +def video_replace_q() -> Q: + return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX) + + +def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) -> dict: + data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets) + payload = task.request_payload or {} + replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") or "") + data.update({ + "feature": FEATURE, + "replace_mode": replace_mode, + "subject_name": payload.get("subject_name") or "", + "subject_source": payload.get("subject_source") or "", + "product_id": payload.get("product_id") or "", + "model_id": payload.get("model_id") or "", + }) + return data + + +def submit_video_replace(*, team, user, params: dict): + """校验素材 → 套提示词 → 复用 free_video 提交。失败抛 ValueError。""" + replace_mode = str(params.get("replace_mode") or "").strip() + if replace_mode not in REPLACE_MODES: + raise ValueError("请选择替换商品或替换角色") + + product_id = _optional_uuid(params.get("product_id"), "商品") + model_id = _optional_uuid(params.get("model_id"), "角色") + image_ids = _uuid_list(params.get("image_asset_ids"), "参考图") + has_product = product_id is not None + has_model = model_id is not None + has_temp = bool(image_ids) + + if replace_mode == "product": + if has_model: + raise ValueError("商品复刻请选择商品,不要同时选择角色") + if has_product and has_temp: + raise ValueError("请从商品库选择,或临时上传商品图,不要混用") + if not has_product and not has_temp: + raise ValueError("请选择商品或上传商品参考图") + else: + if has_product: + raise ValueError("角色复刻请选择角色,不要同时选择商品") + if has_model and has_temp: + raise ValueError("请从人物库选择,或临时上传角色图,不要混用") + if not has_model and not has_temp: + raise ValueError("请选择角色或上传角色参考图") + + video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频") + video_seconds = _asset_duration_seconds(video) + if video_seconds > REF_DURATION_MAX: + raise ValueError("参考视频不能超过 15 秒,请剪短后重试") + + if has_product: + subject_name, image_refs, subject_source = _product_library_refs(team, product_id) + elif has_model: + subject_name, image_refs, subject_source = _character_library_refs(team, model_id) + else: + noun = "商品" if replace_mode == "product" else "角色" + subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun) + + prompt = PRODUCT_PROMPT if replace_mode == "product" else CHARACTER_PROMPT + duration = _output_duration(params.get("duration"), video_seconds) + references = [ + _owned_ref(video, kind="video", role="reference_video", label="参考视频"), + *image_refs, + ] + return submit_free_video( + team=team, + user=user, + params={ + "prompt": prompt, + "mode": "universal", + "model": str(params.get("model") or HIGH_RES_MODEL), + "aspect_ratio": str(params.get("aspect_ratio") or "9:16"), + "resolution": str(params.get("resolution") or "720p"), + "duration": duration, + "seed": params.get("seed", -1), + "generate_audio": True, + "references": references, + "feature": FEATURE, + "extra_payload": { + "replace_mode": replace_mode, + "subject_name": subject_name, + "subject_source": subject_source, + "product_id": str(product_id) if product_id else "", + "model_id": str(model_id) if model_id else "", + }, + }, + ) + + +def _legacy_replace_mode(prompt: str) -> str: + return "character" if prompt.startswith("[视频复刻·角色]") else "product" + + +def _optional_uuid(value, label: str): + text = str(value or "").strip() + if not text: + return None + try: + return uuid.UUID(text) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label}无效") from exc + + +def _uuid_list(value, label: str) -> list: + if value in (None, ""): + return [] + if not isinstance(value, (list, tuple)): + raise ValueError(f"{label}格式无效") + if len(value) > MAX_IMAGES: + raise ValueError(f"{label}最多 {MAX_IMAGES} 张") + seen = set() + out = [] + for item in value: + parsed = _optional_uuid(item, label) + if parsed is None or parsed in seen: + continue + seen.add(parsed) + out.append(parsed) + return out + + +def _team_asset(team, asset_id, *, kind: str, label: str) -> Asset: + parsed = _optional_uuid(asset_id, label) + if parsed is None: + raise ValueError(f"请先上传{label}") + asset = Asset.objects.filter(id=parsed, team=team, is_deleted=False, purged_at__isnull=True).first() + if asset is None: + raise ValueError(f"{label}不存在或已被删除") + if asset.asset_type != kind: + raise ValueError(f"{label}类型不正确") + return asset + + +def _asset_duration_seconds(asset: Asset) -> float: + primary = asset.files.filter(is_primary=True).first() or asset.files.first() + if primary is None or not primary.duration_ms: + return 0.0 + return primary.duration_ms / 1000.0 + + +def _output_duration(requested, video_seconds: float) -> int: + try: + value = int(requested) if requested not in (None, "") else 0 + except (TypeError, ValueError): + value = 0 + if value: + return min(15, max(4, value)) + if video_seconds: + return min(15, max(4, int(round(video_seconds)))) + return 15 + + +def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict: + from .services import _asset_preview_url + + url = _asset_preview_url(asset) + if not url: + raise ValueError(f"「{label}」没有可用文件") + ref = { + "url": url, + "type": kind, + "role": role, + "label": label, + "source": "upload", + "asset_id": str(asset.id), + } + seconds = _asset_duration_seconds(asset) + if seconds: + ref["duration"] = seconds + return ref + + +def _library_image_ref(asset: Asset, *, team, label: str) -> dict: + from .services import _asset_preview_url, _seedance_ref_url + + if asset.team_id == team.id and not asset.is_deleted: + return { + "url": _asset_preview_url(asset) or "", + "type": "image", + "role": "reference_image", + "label": label, + "source": "asset", + "asset_id": str(asset.id), + } + raw = _asset_preview_url(asset) + url = _seedance_ref_url(raw, asset.review_status, asset.review_remote_id) + if not url: + raise ValueError(f"「{label}」没有可用文件") + return { + "url": url, + "type": "image", + "role": "reference_image", + "label": label, + "source": "upload", + "asset_id": str(asset.id), + } + + +def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]: + product = ( + Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE) + .select_related("cover_asset") + .prefetch_related("images__asset") + .first() + ) + if product is None: + raise ValueError("商品不存在或已被删除") + assets = [] + seen = set() + for image in product.images.all(): + asset = image.asset + if asset is None or asset.id in seen or asset.is_deleted: + continue + seen.add(asset.id) + assets.append(asset) + if len(assets) >= MAX_IMAGES: + break + if not assets and product.cover_asset_id and not product.cover_asset.is_deleted: + assets.append(product.cover_asset) + if not assets: + raise ValueError("这个商品还没有可用图片") + refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)] + return product.title, refs, "library" + + +def _character_library_refs(team, model_id: uuid.UUID) -> tuple[str, list, str]: + model = ( + Model.objects.filter(Q(team=team) | Q(is_official=True), id=model_id, is_deleted=False, purged_at__isnull=True) + .select_related("portrait_asset", "triview_asset") + .first() + ) + if model is None: + raise ValueError("角色不存在或已被删除") + assets = [] + seen = set() + for asset in (model.portrait_asset, model.triview_asset): + if asset is None or asset.id in seen or asset.is_deleted: + continue + seen.add(asset.id) + assets.append(asset) + if len(assets) >= MAX_IMAGES: + break + if not assets: + raise ValueError("这个角色还没有可用图片") + labels = ["目标角色", "目标角色三视图"] + refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"目标角色{index + 1}") for index, asset in enumerate(assets)] + return model.name, refs, "library" + + +def _temporary_image_refs(team, image_ids: list, *, noun: str) -> tuple[str, list, str]: + refs = [] + for index, asset_id in enumerate(image_ids): + asset = _team_asset(team, asset_id, kind=Asset.Type.IMAGE, label=f"{noun}参考图") + label = "目标商品" if noun == "商品" else "目标角色" + if index > 0: + label = f"{label}{index + 1}" + refs.append(_owned_ref(asset, kind="image", role="reference_image", label=label)) + fallback = "临时商品素材" if noun == "商品" else "临时角色素材" + name = Asset.objects.filter(id=image_ids[0]).values_list("name", flat=True).first() or fallback + subject = name.rsplit(".", 1)[0] if name else fallback + if len(refs) > 1: + subject = f"{subject}({len(refs)}张参考图)" + return subject, refs, "temporary" diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index c99d8f2..1a98230 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -15,7 +15,7 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.assets.models import Asset from apps.assets.serializers import AssetFileSerializer, AssetSerializer from apps.common.api import TeamScopedViewSetMixin, get_current_team -from apps.common.celery_health import require_worker +from apps.common.celery_health import require_worker, require_worker_task from apps.products.models import Product from .generation_errors import classify_generation_error, public_error_for_task @@ -666,11 +666,13 @@ def _free_video_task_queryset(team): ) -def _free_video_list_queryset(team): +def _free_video_list_queryset(team, *, include_replace=False): """正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。""" + from .video_replace import video_replace_q + video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO) active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True) - return ( + qs = ( _free_video_task_queryset(team) .annotate( _has_video_asset=Exists(video_assets), @@ -682,6 +684,9 @@ def _free_video_list_queryset(team): _has_active_video_asset=False, ) ) + if include_replace: + return qs.filter(video_replace_q()) + return qs.exclude(video_replace_q()) def _free_video_trash_queryset(team): @@ -699,28 +704,33 @@ def _set_free_video_generated_assets_deleted(task, deleted): class VideoDigestView(APIView): """视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。 - POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。 + POST /api/ai/video-digest/ multipart file → 秒回任务,worker 抽帧 + Gemini。失败退还。 GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。 """ parser_classes = [MultiPartParser, FormParser, JSONParser] def get(self, request): - from .video_digest import list_team_digest_history + from .video_digest import get_inflight_team_digest, list_team_digest_history team = get_current_team(request.user) results = list_team_digest_history(team=team) - return Response({"results": results, "total": len(results)}) + return Response({ + "results": results, + "total": len(results), + "inflight": get_inflight_team_digest(team=team), + }) def post(self, request): - from .video_digest import VideoDigestError, digest_team_video - upload = request.FILES.get("file") or request.data.get("file") if upload is None: 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 + team = get_current_team(request.user) try: - result = digest_team_video( + result = submit_team_digest( team=team, user=request.user, upload=upload, @@ -735,14 +745,25 @@ 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}) + return Response({"name": getattr(upload, "name", "") or "参考视频", **result}, status=status.HTTP_202_ACCEPTED) class VideoDigestDetailView(APIView): - """PATCH /api/ai/video-digest// 保存编辑后的提示词到这条历史。""" + """GET /api/ai/video-digest// 轮询提炼任务。 + PATCH /api/ai/video-digest// 保存编辑后的提示词到这条历史。 + """ parser_classes = [JSONParser, FormParser] + def get(self, request, task_id): + from .video_digest import get_team_digest_job + + team = get_current_team(request.user) + item = get_team_digest_job(team=team, task_id=task_id) + if item is None: + return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response(item) + def patch(self, request, task_id): from .video_digest import VideoDigestError, save_digest_prompt @@ -817,6 +838,82 @@ class FreeVideoView(APIView): ) +class VideoReplaceView(APIView): + """视频复刻:参考视频 + 商品图/人物图,走 Seedance 换商品或换角色。 + + POST /api/ai/video-replace/ 提交(提示词后端写死) + GET /api/ai/video-replace/ 本页历史(不含自由创作) + """ + + def post(self, request): + require_worker() + from .video_replace import serialize_video_replace_task, submit_video_replace + + team = get_current_team(request.user) + try: + task = submit_video_replace(team=team, user=request.user, params=request.data or {}) + except ValueError as exc: + message = str(exc) + internal_kind = ( + "user_credit_insufficient" if "余额不足" in message + else "model_unavailable" if "模型未配置" in message + else "provider_rate_limited" if "任务进行中" in message + else "invalid_input" + ) + public_error = classify_generation_error( + exc, operation="video_generate", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) + task = _free_video_task_queryset(team).get(id=task.id) + return Response({"task": serialize_video_replace_task(task)}, status=status.HTTP_202_ACCEPTED) + + def get(self, request): + from .video_replace import serialize_video_replace_task + + team = get_current_team(request.user) + try: + offset = max(0, int(request.query_params.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + try: + page_size = min(50, max(1, int(request.query_params.get("page_size") or 20))) + except (TypeError, ValueError): + page_size = 20 + qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at") + total = qs.count() + tasks = list(qs[offset : offset + page_size]) + return Response( + { + "results": [serialize_video_replace_task(t) for t in tasks], + "total": total, + "has_more": offset + page_size < total, + } + ) + + +class VideoReplacePollView(APIView): + """POST /api/ai/video-replace//poll/ —— 与自由创作共用 finalize,只认复刻任务。""" + + def post(self, request, task_id): + from .free_video import finalize_free_video + from .video_replace import is_video_replace_task, serialize_video_replace_task + + team = get_current_team(request.user) + task = _free_video_task_queryset(team).filter(id=task_id).first() + if task is None or not is_video_replace_task(task): + return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND) + if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + try: + task = finalize_free_video(task=task) + except Exception: # noqa: BLE001 — 单次轮询失败不终结任务 + logger.warning("video replace poll failed for %s", task_id, exc_info=True) + task = _free_video_task_queryset(team).get(id=task.id) + return Response({"task": serialize_video_replace_task(task)}) + + class FreeVideoPollView(APIView): """POST /api/ai/free-video//poll/ —— web 进程内单次轮询+终态化(幂等)。 前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。""" @@ -963,7 +1060,7 @@ class FreeVideoUploadView(APIView): from apps.assets.models import Asset, AssetFile from apps.assets.storage import TosStorage - from .media_probe import extract_video_poster, probe_duration + from .media_probe import duration_in_ref_range, extract_video_poster, probe_duration upload = request.FILES.get("file") if upload is None: @@ -1015,7 +1112,7 @@ class FreeVideoUploadView(APIView): duration = probe_duration(str(tmp_path)) if duration is None: return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) - if not (2 <= duration <= 15): + if not duration_in_ref_range(duration): label = "视频" if kind == "video" else "音频" return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST) if kind == "video": diff --git a/core/backend/apps/assets/free_library.py b/core/backend/apps/assets/free_library.py index 9fc2a0d..fbe5297 100644 --- a/core/backend/apps/assets/free_library.py +++ b/core/backend/apps/assets/free_library.py @@ -123,7 +123,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: file_id = uuid.uuid4() if kind != "image": - from apps.ai.media_probe import extract_video_poster, probe_duration + from apps.ai.media_probe import duration_in_ref_range, extract_video_poster, probe_duration with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp: tmp_path = Path(tmp) / f"in{suffix}" @@ -131,7 +131,7 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: duration = probe_duration(str(tmp_path)) if duration is None: return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) - if not (2 <= duration <= 15): + if not duration_in_ref_range(duration): label = "视频" if kind == "video" else "音频" return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST) if kind == "video": diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index b6b8640..bd4b413 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -116,6 +116,73 @@ def reference_review_state(asset: Asset) -> str: return "unsubmitted" +def _unsubmitted_review_qs(*, team=None): + qs = Asset.objects.filter( + category__in=Asset.REVIEW_CATEGORIES, + review_status="", + is_deleted=False, + ) + if team is not None: + qs = qs.filter(team=team) + return qs + + +def _processing_review_qs(*, team=None): + qs = Asset.objects.filter( + category__in=Asset.REVIEW_CATEGORIES, + review_status="processing", + is_deleted=False, + ) + if team is not None: + qs = qs.filter(team=team) + return qs + + +def submit_unsubmitted_reviews(*, team=None, limit: int = 25) -> int: + """把未送审的人脸资产自动丢给火山,不用人去后台点「批量送审」。""" + submitted = 0 + cap = max(1, min(int(limit), 50)) + for asset in _unsubmitted_review_qs(team=team).order_by("created_at")[:cap]: + if submit_asset_for_review(asset): + submitted += 1 + return submitted + + +def poll_processing_reviews(*, team=None, limit: int = 80) -> dict[str, str]: + cap = max(1, min(int(limit), 200)) + out: dict[str, str] = {} + for asset in _processing_review_qs(team=team).order_by("updated_at")[:cap]: + out[str(asset.id)] = poll_asset_review(asset) + return out + + +def drain_platform_reviews(*, submit_limit: int = 25, poll_limit: int = 80) -> dict: + """全平台:未送审自动送 + 审核中自动拉状态。worker 兜底循环,人不在后台页也会跑。""" + submitted = submit_unsubmitted_reviews(limit=submit_limit) + statuses = poll_processing_reviews(limit=poll_limit) + return {"submitted": submitted, "polled": len(statuses), "statuses": statuses} + + +def ensure_review_drain_loop() -> None: + """第一次有人轮询审核时,顺手把 worker 兜底循环拉起来(没有 beat 也能自转)。""" + import sys + + from django.conf import settings as dj_settings + from django.core.cache import cache + + if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False) or "test" in sys.argv: + return + if not cache.add("asset-review-drain-kick", "1", timeout=300): + return + try: + from apps.ai.tasks import drain_asset_reviews_task + + drain_asset_reviews_task.delay() + except Exception: # noqa: BLE001 + cache.delete("asset-review-drain-kick") + logger.warning("kick asset review drain loop failed", exc_info=True) + + def poll_asset_review(asset: Asset) -> str: """查单个真人资产审核状态并更新 review_status。返回最新状态。 只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。""" @@ -144,11 +211,8 @@ def poll_asset_review(asset: Asset) -> str: def poll_team_reviews(team) -> dict: - """轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。""" - out: dict[str, str] = {} - pending = Asset.objects.filter( - team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False - ) - for asset in pending: - out[str(asset.id)] = poll_asset_review(asset) - return out + """先把本团队未送审的人脸资产送出去,再轮询审核中状态。 + 前端基础资产趴定时打这里:用户不用去后台点送审,也不用等管理员。""" + ensure_review_drain_loop() + submit_unsubmitted_reviews(team=team, limit=20) + return poll_processing_reviews(team=team, limit=80) diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 60a85b5..321fd83 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -890,3 +890,40 @@ class ReviewScopeTests(TestCase): with patch("apps.assets.review.assets_client.is_enabled", return_value=False): out = review.poll_team_reviews(team) self.assertEqual(set(out.keys()), set(keep.values())) + + def test_poll_team_reviews_auto_submits_unsubmitted(self): + from apps.assets import review + + _, team = _mk_team("usub", "TeamUSub") + empty = Asset.objects.create( + team=team, name="待审角色", asset_type="image", source="ai_generated", + category=Asset.Category.PERSON, review_status="", + ) + with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch( + "apps.assets.review.ensure_review_drain_loop" + ): + review.poll_team_reviews(team) + sub.assert_called() + self.assertEqual(sub.call_args.args[0].id, empty.id) + + def test_drain_platform_reviews_submits_then_polls(self): + from apps.assets import review + + _, team = _mk_team("drain", "TeamDrain") + Asset.objects.create( + team=team, name="未送", asset_type="image", source="ai_generated", + category=Asset.Category.PERSON, review_status="", + ) + proc = Asset.objects.create( + team=team, name="审中", asset_type="image", source="ai_generated", + category=Asset.Category.PERSON, review_status="processing", + ) + with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch( + "apps.assets.review.poll_asset_review", return_value="active" + ) as polled: + out = review.drain_platform_reviews() + self.assertEqual(out["submitted"], 1) + self.assertEqual(out["polled"], 1) + self.assertEqual(out["statuses"][str(proc.id)], "active") + sub.assert_called_once() + polled.assert_called_once() diff --git a/core/backend/apps/common/celery_health.py b/core/backend/apps/common/celery_health.py index 5e2b256..782d994 100644 --- a/core/backend/apps/common/celery_health.py +++ b/core/backend/apps/common/celery_health.py @@ -142,5 +142,5 @@ def require_worker_task(task_name: str) -> None: _registered_task_cache[task_name] = (available, now + ttl) if available is False: exc = WorkerUnavailable() - exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。" + exc.detail = "一键成片后台任务尚未加载,请重启 Celery worker 后再试。" raise exc diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index 0cf868c..6cc3bf0 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -170,14 +170,14 @@ def _public_error(raw: str) -> str: text = (raw or "").strip() lower = text.lower() if QUICK_SCRIPT_MODEL_NAME in lower: - return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置" + return "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置" if "insufficient credit" in lower or "额度不足" in text: - return "可用积分不足,极速成片已暂停" + return "可用积分不足,一键成片已暂停" if "no active" in lower or "not configured" in lower or "没有可用" in text: return "当前缺少可用的生成模型,请联系管理员配置" if _looks_like_review_error(text): return REVIEW_FAIL_MESSAGE - return "极速成片暂未完成,请稍后重试或进入专业模式查看" + return "一键成片暂未完成,请稍后重试或进入专业模式查看" def _safe_error(exc: Exception) -> str: @@ -301,7 +301,7 @@ def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str job.refresh_from_db(fields=["status", "metadata", "phase"]) if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}: return - public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500] + public_message = (message or "一键成片暂未完成,请稍后重试").strip()[:500] metadata = dict(job.metadata or {}) if internal_error: metadata["internal_error"] = internal_error[:2000] @@ -365,7 +365,7 @@ def _consume_script_agent(job: QuickCreateJob) -> None: settings = _quick_settings(project) user = job.created_by or project.created_by if user is None: - raise ValueError("极速成片任务缺少创建人") + raise ValueError("一键成片任务缺少创建人") model_config = get_quick_script_model() if model_config is None: raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured") @@ -376,7 +376,8 @@ def _consume_script_agent(job: QuickCreateJob) -> None: getattr(product, "category", "") or "", getattr(product, "title", "") or "", ) - fmt = wizard.get("presentation_format") or rec["format"] + # 极速成片与专业创作一致,脚本表现形式统一为口播。 + fmt = "oral" structure = wizard.get("video_structure") or rec["structure"] persona = wizard.get("persona") or rec["persona"] format_label = PRESENTATION_FORMATS.get(fmt, fmt) @@ -486,7 +487,7 @@ def _advance_product(job: QuickCreateJob) -> int: ProductSellingPoint.objects.create( product=product, title=product.title, - detail="由极速成片根据商品名称自动填写", + detail="由一键成片根据商品名称自动填写", sort_order=0, ) _save_job( @@ -695,7 +696,7 @@ def _start_base_assets(job: QuickCreateJob) -> None: project = job.project user = job.created_by or project.created_by if user is None: - raise ValueError("极速成片任务缺少创建人") + raise ValueError("一键成片任务缺少创建人") with transaction.atomic(): job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id) metadata = dict(job.metadata or {}) @@ -939,7 +940,7 @@ def _start_videos(job: QuickCreateJob) -> None: submit_video_segment( video_segment=segment, user=job.created_by or job.project.created_by, - prompt="极速成片自动生成,严格遵循本镜故事板与脚本。", + prompt="一键成片自动生成,严格遵循本镜故事板与脚本。", model_config_id=settings["video_model_config_id"] or None, aspect_ratio=settings["aspect_ratio"], resolution=settings["resolution"], diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 8dc10d0..360a52c 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -307,14 +307,14 @@ class QuickCreateApiTests(TestCase): def test_retry_api_resumes_failed_job(self): product = Product.objects.create(team=self.team, created_by=self.user, title="续跑商品") - project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 极速成片") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 一键成片") job = QuickCreateJob.objects.create( team=self.team, created_by=self.user, project=project, status=QuickCreateJob.Status.FAILED, phase=QuickCreateJob.Phase.PRODUCTION, - message="极速成片暂未完成,请稍后重试或进入专业模式查看", + message="一键成片暂未完成,请稍后重试或进入专业模式查看", ) with patch("apps.projects.tasks.advance_quick_create_task.apply_async") as enqueue: response = self.client.post(f"/api/projects/quick-create-retry/{job.id}/") @@ -325,7 +325,7 @@ class QuickCreateApiTests(TestCase): def test_history_lists_completed_jobs_only(self): product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品") - project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 一键成片") QuickCreateJob.objects.create( team=self.team, created_by=self.user, @@ -334,17 +334,17 @@ class QuickCreateApiTests(TestCase): phase=QuickCreateJob.Phase.COMPLETE, ) failed_product = Product.objects.create(team=self.team, created_by=self.user, title="失败商品") - failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 极速成片") + failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 一键成片") QuickCreateJob.objects.create( team=self.team, created_by=self.user, project=failed_project, status=QuickCreateJob.Status.FAILED, phase=QuickCreateJob.Phase.PRODUCTION, - message="极速成片暂未完成,请稍后重试或进入专业模式查看", + message="一键成片暂未完成,请稍后重试或进入专业模式查看", ) running_product = Product.objects.create(team=self.team, created_by=self.user, title="进行中商品") - running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 极速成片") + running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 一键成片") QuickCreateJob.objects.create( team=self.team, created_by=self.user, @@ -369,13 +369,13 @@ class QuickCreateApiTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["count"], 1) titles = [item["title"] for item in response.data["results"]] - self.assertIn("历史商品 · 极速成片", titles) - self.assertNotIn("失败商品 · 极速成片", titles) - self.assertNotIn("进行中商品 · 极速成片", titles) + self.assertIn("历史商品 · 一键成片", titles) + self.assertNotIn("失败商品 · 一键成片", titles) + self.assertNotIn("进行中商品 · 一键成片", titles) def test_history_hides_deleted_projects(self): product = Product.objects.create(team=self.team, created_by=self.user, title="还在的商品") - alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 极速成片") + alive = Project.objects.create(team=self.team, created_by=self.user, product=product, name="还在的商品 · 一键成片") QuickCreateJob.objects.create( team=self.team, created_by=self.user, @@ -388,7 +388,7 @@ class QuickCreateApiTests(TestCase): team=self.team, created_by=self.user, product=deleted_product, - name="已删商品 · 极速成片", + name="已删商品 · 一键成片", is_deleted=True, ) QuickCreateJob.objects.create( @@ -403,8 +403,8 @@ class QuickCreateApiTests(TestCase): self.assertEqual(response.status_code, 200) titles = [item["title"] for item in response.data["results"]] self.assertEqual(response.data["count"], 1) - self.assertIn("还在的商品 · 极速成片", titles) - self.assertNotIn("已删商品 · 极速成片", titles) + self.assertIn("还在的商品 · 一键成片", titles) + self.assertNotIn("已删商品 · 一键成片", titles) def test_list_serializer_flags_quick_create_projects(self): product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品") @@ -412,7 +412,7 @@ class QuickCreateApiTests(TestCase): team=self.team, created_by=self.user, product=product, - name="列表商品 · 极速成片", + name="列表商品 · 一键成片", metadata={"quick_create": True}, ) normal = Project.objects.create( @@ -436,7 +436,7 @@ class QuickCreateApiTests(TestCase): team=self.team, created_by=self.user, product=product, - name="锁单商品 · 极速成片", + name="锁单商品 · 一键成片", metadata={"quick_create": True}, ) job = QuickCreateJob.objects.create( @@ -448,7 +448,7 @@ class QuickCreateApiTests(TestCase): blocked = self.client.patch(f"/api/projects/{project.id}/", {"name": "不该改"}, format="json") self.assertEqual(blocked.status_code, 409) - self.assertIn("极速成片", str(blocked.data)) + self.assertIn("一键成片", str(blocked.data)) allowed = self.client.get(f"/api/projects/{project.id}/") self.assertEqual(allowed.status_code, 200) @@ -473,7 +473,7 @@ class QuickCreateCoordinatorTests(TestCase): team=self.team, created_by=self.user, product=self.product, - name="测试精华 · 极速成片", + name="测试精华 · 一键成片", status=Project.Status.SCRIPTING, current_stage=ProjectStage.Stage.SCRIPT, ) @@ -554,7 +554,7 @@ class QuickCreateCoordinatorTests(TestCase): QuickCreateJob.objects.filter(id=self.job.id).update( status=QuickCreateJob.Status.QUEUED, phase=QuickCreateJob.Phase.PRODUCT, - message="等待开始极速成片", + message="等待开始一键成片", updated_at=timezone.now() - timedelta(seconds=30), ) self.job.refresh_from_db() @@ -748,7 +748,7 @@ class QuickCreateCoordinatorTests(TestCase): self.job.metadata = {"storyboard_started": True} self.job.save(update_fields=["status", "phase", "metadata", "updated_at"]) - fail_quick_create(self.job, "极速成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket") + fail_quick_create(self.job, "一键成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket") self.job.refresh_from_db() self.project.refresh_from_db() self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED) @@ -772,7 +772,7 @@ class QuickCreateCoordinatorTests(TestCase): def test_recover_clears_false_failed_project_before_video_starts(self): self.project.status = Project.Status.FAILED self.project.current_stage = ProjectStage.Stage.VIDEO - self.project.failure_reason = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.project.failure_reason = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.project.save(update_fields=["status", "current_stage", "failure_reason", "updated_at"]) self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.PRODUCTION @@ -805,7 +805,7 @@ class QuickCreateCoordinatorTests(TestCase): def test_resume_failed_production_job_keeps_progress(self, enqueue): self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.PRODUCTION - self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.job.metadata = {"storyboard_started": True, "transient_retries": 8, "video_fail_retries": 2} self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"]) @@ -859,7 +859,7 @@ class QuickCreateCoordinatorTests(TestCase): def test_failed_job_serializer_rewrites_hidden_moderation_error(self): self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.PRODUCTION - self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.job.metadata = {"internal_error": "400 moderation_blocked safety_violations=[sexual]"} self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"]) data = QuickCreateJobSerializer(self.job).data @@ -882,7 +882,7 @@ class QuickCreateCoordinatorTests(TestCase): segment.save(update_fields=["adopted_version", "status", "updated_at"]) self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.PRODUCTION - self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.job.save(update_fields=["status", "phase", "error_message", "updated_at"]) recover_quick_create(self.job) @@ -894,7 +894,7 @@ class QuickCreateCoordinatorTests(TestCase): """专业模式完成后,极速任务的旧失败状态必须自动被回收。""" self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.PRODUCTION - self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.job.save(update_fields=["status", "phase", "error_message", "updated_at"]) self.project.status = Project.Status.COMPLETED self.project.current_stage = ProjectStage.Stage.VIDEO @@ -1143,7 +1143,7 @@ class QuickCreateCoordinatorTests(TestCase): base_ids = self._ready_asset_tasks() self.job.status = QuickCreateJob.Status.FAILED self.job.phase = QuickCreateJob.Phase.ASSETS - self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看" self.job.metadata = { "base_asset_task_ids": base_ids, "assets_started": True, @@ -1194,7 +1194,7 @@ class QuickCreateCoordinatorTests(TestCase): asset = Asset.objects.create( team=self.team, created_by=self.user, - name="极速成片", + name="一键成片", asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP, diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 187e3de..041d9dd 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -98,7 +98,7 @@ logger = logging.getLogger(__name__) class QuickCreateInProgress(APIException): status_code = status.HTTP_409_CONFLICT - default_detail = "该项目正在极速成片中,请到极速成片页查看进度" + default_detail = "该项目正在一键成片中,请到一键成片页查看进度" default_code = "quick_create_running" @@ -553,7 +553,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): ] if text_model is None: return Response( - {"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"}, + {"detail": "一键成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) if missing: @@ -653,7 +653,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): team=team, created_by=request.user, product=product, - name=f"{name} · 极速成片", + name=f"{name} · 一键成片", status=Project.Status.SCRIPTING, current_stage=ProjectStage.Stage.SCRIPT, metadata={ @@ -665,7 +665,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): "video_model_config_id": str(video_model.id), "video_model_name": video_model.name, "video_model_label": video_model.display_name, - "presentation_format": rec["format"], + "presentation_format": "oral", "video_structure": rec["structure"], "persona": rec["persona"], }, @@ -676,7 +676,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): ProductSellingPoint.objects.create( product=product, title=name, - detail="由极速成片根据商品名称自动填写", + detail="由一键成片根据商品名称自动填写", sort_order=0, ) job = QuickCreateJob.objects.create( @@ -686,7 +686,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): status=QuickCreateJob.Status.QUEUED, phase=QuickCreateJob.Phase.PRODUCT, progress=0, - message="等待开始极速成片", + message="等待开始一键成片", ) try: @@ -722,7 +722,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): def quick_create_status(self, request, job_id=None): job = self._quick_job_queryset().filter(id=job_id).first() if job is None: - return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) from .services.quick_create import recover_quick_create try: @@ -736,7 +736,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): def quick_create_cancel(self, request, job_id=None): job = self._quick_job_queryset().filter(id=job_id).first() if job is None: - return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) if job.status == QuickCreateJob.Status.SUCCEEDED: return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST) from .services.quick_create import cancel_quick_create @@ -749,7 +749,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): def quick_create_retry(self, request, job_id=None): job = self._quick_job_queryset().filter(id=job_id).first() if job is None: - return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response({"detail": "一键成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) if job.status == QuickCreateJob.Status.SUCCEEDED: return Response({"detail": "任务已完成"}, status=status.HTTP_400_BAD_REQUEST) if job.status == QuickCreateJob.Status.CANCELLED: @@ -808,14 +808,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): "template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}), } ) - # 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。 - # 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。 + # 表现形式已统一为口播;旧模板里的短剧/Vlog 只兼容读取结构,不能覆盖当前规则。 raw_format = getattr(template, "presentation_format", "") or "" raw_structure = getattr(template, "video_structure", "") or "" if raw_format or raw_structure: - fmt, structure = coerce_template_combo(raw_format, raw_structure) - wizard["presentation_format"] = fmt + _fmt, structure = coerce_template_combo(raw_format, raw_structure) wizard["video_structure"] = structure + wizard["presentation_format"] = "oral" persona = coerce_persona(getattr(template, "persona", "") or "") if persona: wizard["persona"] = persona @@ -860,7 +859,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): """对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。 请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、persona、 base_version_id(改稿)、aspect_ratio、total_duration(15/30/45/60)、 - presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。 + video_structure(pain|contrast|review|scene)。表现形式固定为口播。 响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。""" project = self.get_object() mode = str(request.data.get("mode") or "auto") @@ -870,7 +869,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): # 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断 total_duration = coerce_total_duration(request.data.get("total_duration")) presentation_format, video_structure = combo_keys( - request.data.get("presentation_format"), + "oral", request.data.get("video_structure"), ) wizard = ((project.metadata or {}).get("wizard") if isinstance(project.metadata, dict) else None) or {} diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 57c46f8..cde75f6 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -424,7 +424,7 @@ export function App() { const locked = lockedQuickCreateProject(listed, detailed); if (!locked) return; rememberQuickCreateJob(locked.quick_create_job_id); - setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" }); + setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" }); navigate("quickCreate", { productId: locked.product, replace: true }); }, [authed, page, activeProjectId, projects, projectDetail]); @@ -554,12 +554,12 @@ export function App() { const locked = canForce ? null : lockedQuickCreateProject(listed, detailed); if (locked) { rememberQuickCreateJob(locked.quick_create_job_id); - setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" }); + setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" }); next = "quickCreate"; options = { ...options, productId: locked.product, replace: options.replace }; } } - // 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。 + // 图片创作 / 一键成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。 const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId); const projectId = options.projectId ?? activeProjectId; if (options.productId !== undefined) setActiveProductId(options.productId); @@ -951,7 +951,7 @@ export function App() { ); case "productCreateUpload": // 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开 - // 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情 + // 创建成功后由 ProductsPage 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗,不再自动跳详情 return ( navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} /> +
+ +
+ + navigate("account")}> + + 余额 {money(billing?.account.balance)} + + +
+
-
- -
- - navigate("account")}> - - 余额 {money(billing?.account.balance)} - - -
-
{notice && } diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 3de20be..6bd49f7 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -15,7 +15,7 @@ .admin-app .sidebar .nav-panel { display: flex; flex-direction: column; - height: calc(100vh - 116px); + height: calc(100vh - var(--topbar-height)); min-height: 0; padding: 10px 0 0; overflow: hidden; @@ -161,8 +161,8 @@ .admin-logout:hover { color: var(--st-text); background: var(--st-hover); } .admin-app .topbar { - height: 116px; - min-height: 116px; + height: var(--topbar-height); + min-height: var(--topbar-height); padding: 0 28px; background: #fff; border-bottom: 1px solid rgba(27, 32, 40, 0.08); diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index e5f9eef..bda9297 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -44,6 +44,7 @@ import type { User, UserPreference, VideoDigestHistory, + VideoDigestJob, VoiceoverInfo } from "./types"; import type { PresentationFormat, VideoStructure } from "./script-setup"; @@ -411,32 +412,18 @@ export const api = { { method: "POST", body: formData } ); }, - // 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。 + // 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。 extractVideoDigest(formData: FormData) { - return request<{ - name: string; - chars: number; - text: string; - frames: number; - duration: number; - input?: string; - estimated_cost?: string; - task_id?: string; - title?: string; - file_name?: string; - ratio?: string; - shots?: number; - cover_url?: string; - video_url?: string; - width?: number; - height?: number; - }>( + return request( "/api/ai/video-digest/", { method: "POST", body: formData } ); }, listVideoDigests() { - return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/"); + return request<{ results: VideoDigestHistory[]; total: number; inflight?: VideoDigestJob | null }>("/api/ai/video-digest/"); + }, + getVideoDigest(id: string) { + return request(`/api/ai/video-digest/${id}/`); }, saveVideoDigest(id: string, prompt: string) { return request(`/api/ai/video-digest/${id}/`, { @@ -915,6 +902,27 @@ export const api = { }) { return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) }); }, + submitVideoReplace(payload: { + replace_mode: "product" | "character"; + video_asset_id: string; + product_id?: string; + model_id?: string; + image_asset_ids?: string[]; + model?: string; + aspect_ratio?: string; + resolution?: string; + duration?: number; + }) { + return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) }); + }, + videoReplaceTasks(offset = 0, pageSize = 20) { + return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>( + `/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}` + ); + }, + pollVideoReplace(id: string) { + return request<{ task: FreeVideoTask }>(`/api/ai/video-replace/${id}/poll/`, { method: "POST" }); + }, freeVideoTasks(offset = 0, pageSize = 20) { return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>( `/api/ai/free-video/?offset=${offset}&page_size=${pageSize}` @@ -1107,7 +1115,7 @@ export const adminApi = { ); }, pollReviews(assetIds?: string[]) { - return request<{ polled: number; statuses: Record }>( + return request<{ polled: number; submitted?: number; statuses: Record }>( "/api/admin/asset-reviews/poll/", { method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) } ); diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 6b0577c..2213c8a 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -16,7 +16,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" }, { id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, - { id: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" }, + { id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" }, { id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" }, { id: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, @@ -26,7 +26,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" }, { id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" }, { id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" }, - { id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" }, + { id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" }, { id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" }, { id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" }, { id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" } diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts index 7b02eed..ed09720 100644 --- a/core/frontend/src/components/free-create/constants.ts +++ b/core/frontend/src/components/free-create/constants.ts @@ -33,6 +33,7 @@ export const MAX_IMAGES = 9; export const MAX_VIDEOS = 3; export const MAX_AUDIOS = 3; export const MAX_VIDEO_TOTAL_SECONDS = 15; +export const VIDEO_DURATION_SLACK = 0.5; // 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"]; @@ -145,8 +146,9 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise { URL.revokeObjectURL(url); const duration = el.duration; if (!isFinite(duration)) resolve({ ok: true, type: kind }); - else if (duration < 2 || duration > 15) resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` }); - else resolve({ ok: true, type: kind, duration: Math.round(duration * 10) / 10 }); + else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) { + resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` }); + } else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) }); }; el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); }; el.src = url; diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index 30fb4f4..b5d4419 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -2,6 +2,12 @@ import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react"; +// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。 +// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。 +export function OverlayPortal({ children }: { children: ReactNode }) { + return createPortal(children, document.body); +} + // 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。 // 多浮层叠开用计数,最后一个关闭才解锁。 let scrollLockCount = 0; @@ -64,7 +70,7 @@ export function MediaLightbox({ open, src, kind, name, close }: { return () => document.removeEventListener("keydown", onKey); }, [open, close]); if (!open || !src) return null; - // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + // 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏 return createPortal(
@@ -114,7 +120,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, close); if (!mounted) return null; - // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + // 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏 return createPortal(
event.stopPropagation()}> @@ -143,7 +149,7 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, onCancel); if (!mounted) return null; - // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 + // 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏 return createPortal(
event.stopPropagation()}> @@ -185,7 +191,6 @@ export function Drawer({ title, open, close, children, className }: { title: str useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, close); if (!mounted) return null; - // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 return createPortal( <>
diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index f8e9c85..daa412d 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -51,6 +51,10 @@ :root { /* 比影擎 164px 略宽:我们子项带数量徽章,164 会挤 */ --sidebar-width: 192px; + --topbar-height: 116px; + /* 壳层:顶栏/侧栏收窄键 < 页面浮层。浮层必须盖住搜索/余额/铃铛 */ + --z-topbar: 50; + --z-overlay: 200; /* ===== Backgrounds (冷灰 · YZ 影擎) ===== */ --background-base: #f7f8fa; @@ -223,6 +227,7 @@ img, svg, video { display: block; max-width: 100%; } .app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: var(--topbar-height) minmax(0, 1fr); min-height: 100vh; transition: grid-template-columns var(--t-base); } @@ -232,6 +237,7 @@ body.sidebar-collapsed .app { grid-template-columns: 96px minmax(0, 1fr); } aside.sidebar { --klein: #002fa7; --st-black: #101012; + grid-row: 1 / -1; padding: 0; border-right: 1px solid rgba(27, 32, 40, 0.08); background: rgba(28, 34, 43, 0.035); @@ -252,8 +258,8 @@ aside.sidebar { justify-content: center; margin: 0; padding: 0; - height: 116px; - min-height: 116px; + height: var(--topbar-height); + min-height: var(--topbar-height); box-sizing: border-box; overflow: hidden; background: #fff; @@ -357,7 +363,7 @@ aside.sidebar { font-weight: 500; /* 中文标签用 sans 字体,不用 mono + uppercase */ } -nav { display: flex; flex-direction: column; gap: 2px; } +aside.sidebar nav { display: flex; flex-direction: column; gap: 2px; } nav a { display: flex; align-items: center; gap: 11px; padding: 9px 12px; @@ -414,7 +420,7 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); } } aside.sidebar .nav-panel { position: relative; - height: calc(100vh - 116px); + height: calc(100vh - var(--topbar-height)); min-height: 0; padding-top: 14px; padding-bottom: 82px; @@ -710,7 +716,7 @@ aside.sidebar .nav-panel > nav { } body.sidebar-collapsed aside.sidebar { padding: 0; } -body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: 116px; min-height: 116px; } +body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: var(--topbar-height); min-height: var(--topbar-height); } body.sidebar-collapsed .brand-clip { width: 34px; height: 34px; @@ -760,13 +766,22 @@ body.sidebar-collapsed .user .em, body.sidebar-collapsed .user::after { display: none; } /* ─── Main + grid background ─── */ -main { position: relative; background: #fff; min-width: 0; overflow-x: hidden; } +.app > main { + grid-column: 2; + grid-row: 2; + position: relative; + background: #fff; + min-width: 0; + min-height: 0; + overflow-x: clip; + overflow-y: visible; +} .app:has(.pipeline-page) { height: 100vh; overflow: hidden; } -main:has(.pipeline-page) { - height: 100vh; +.app > main:has(.pipeline-page) { + min-height: 0; overflow: hidden; display: flex; flex-direction: column; @@ -774,6 +789,7 @@ main:has(.pipeline-page) { .grid-bg { position: absolute; inset: 0; + z-index: 0; pointer-events: none; background-image: linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px), @@ -828,13 +844,17 @@ main:has(.pipeline-page) { /* ─── Topbar ─── */ .topbar { + grid-column: 2; + grid-row: 1; display: flex; align-items: center; gap: 16px; padding: 0 clamp(36px, 3.2vw, 68px); border-bottom: 1px solid rgba(27, 32, 40, 0.08); background: #fff; - position: sticky; top: 0; z-index: 50; - height: 116px; - min-height: 116px; + box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035); + position: sticky; top: 0; z-index: var(--z-topbar); + align-self: start; + height: var(--topbar-height); + min-height: var(--topbar-height); min-width: 0; box-sizing: border-box; flex-wrap: nowrap; @@ -1013,11 +1033,13 @@ main:has(.pipeline-page) { /* ─── Content ─── */ .content { - /* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px */ + /* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px + 禁止设 z-index:会形成层叠上下文,页面里的 fixed 抽屉/弹窗再高也盖不住顶栏。 + 网格底 .grid-bg 在前、本节点 position:relative,DOM 顺序即可压住网格。 */ padding: 24px 28px 60px; position: relative; - z-index: 1; - min-height: calc(100vh - 116px); + z-index: auto; + min-height: calc(100vh - var(--topbar-height)); animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1); } .content:has(.pipeline-page) { @@ -1029,8 +1051,8 @@ main:has(.pipeline-page) { animation: none; } @keyframes yz-page-enter { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { opacity: 0; } + to { opacity: 1; } } .content > .corner-mark { display: none; } @@ -2637,7 +2659,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); } position: fixed; inset: 0; background: rgba(21, 20, 15, .32); display: block; - z-index: 90; + z-index: var(--z-overlay); opacity: 0; visibility: hidden; pointer-events: none; @@ -2649,7 +2671,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); } width: 540px; max-width: 100vw; background: var(--surface); border-left: 1px solid var(--border-faint); - z-index: 95; + z-index: calc(var(--z-overlay) + 5); transform: translateX(100%); transition: transform .25s cubic-bezier(.32, .72, 0, 1); display: flex; flex-direction: column; diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index d09a9b0..d435fff 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -281,7 +281,7 @@ --pcd-line: rgba(34, 42, 54, 0.12); position: fixed; inset: 0; - z-index: 80; + z-index: var(--z-overlay); visibility: hidden; opacity: 0; pointer-events: none; diff --git a/core/frontend/src/quick-create-lock.ts b/core/frontend/src/quick-create-lock.ts index 7973b7f..e2566a8 100644 --- a/core/frontend/src/quick-create-lock.ts +++ b/core/frontend/src/quick-create-lock.ts @@ -1,4 +1,13 @@ const QUICK_JOB_KEY = "airshelf:quick-create-job"; +const QUICK_CREATE_SUFFIX = / · (?:极速成片|一键成片)$/; + +export function hasQuickCreateSuffix(name?: string) { + return QUICK_CREATE_SUFFIX.test(name || ""); +} + +export function stripQuickCreateSuffix(name: string) { + return name.replace(QUICK_CREATE_SUFFIX, ""); +} export function isQuickCreateBusy(project?: { quick_create_status?: string } | null) { return project?.quick_create_status === "queued" || project?.quick_create_status === "running"; diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index 9bb0582..b69a579 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -137,13 +137,13 @@ .quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); } .quick-create-page .quick-history-open svg { width: 15px; height: 15px; } .quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; } -.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); } -.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; } -.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; } -.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; } -.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; } -.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; } -.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; } +.quick-player-bg { position: fixed; inset: 0; z-index: var(--z-overlay); display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); } +.quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; } +.quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; } +.quick-player-bar strong { font-size: 13px; font-weight: 600; } +.quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; } +.quick-player-bar svg { width: 16px; height: 16px; } +.quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; } @media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } } @media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } } @media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } } diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index cb3da67..7ba603f 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -162,17 +162,17 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
+
+
+ + {active.slug ? {active.label} : null} +
+
+ 超管模式 +
+
-
-
- - {active.slug ? {active.label} : null} -
-
- 超管模式 -
-
{toast && } diff --git a/core/frontend/src/routes/admin/admin-reviews.tsx b/core/frontend/src/routes/admin/admin-reviews.tsx index 95b6675..e708ada 100644 --- a/core/frontend/src/routes/admin/admin-reviews.tsx +++ b/core/frontend/src/routes/admin/admin-reviews.tsx @@ -32,48 +32,54 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [tab, setTab] = useState(""); - const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); - const load = useCallback(async () => { - setLoading(true); - setSelected(new Set()); + const load = useCallback(async ({ silent = false } = {}) => { + if (!silent) setLoading(true); try { const res = await adminApi.assetReviews({ review_status: tab || undefined, page, page_size: PAGE_SIZE }); if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; } setAssets(res.results); setCount(res.count); } catch { - notify("error", "加载审核队列失败"); + if (!silent) notify("error", "加载审核队列失败"); } finally { - setLoading(false); + if (!silent) setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, page]); useEffect(() => { void load(); }, [load]); - function toggleOne(id: string) { - setSelected((s) => { - const next = new Set(s); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - } - function toggleAll() { - setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id)))); - } + useEffect(() => { + let alive = true; + const tick = async () => { + if (document.hidden) return; + try { + await adminApi.pollReviews(); + if (alive) await load({ silent: true }); + } catch { + /* 自动送审失败不挡列表 */ + } + }; + void tick(); + const timer = window.setInterval(tick, 12000); + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [load]); - async function submit(ids: string[]) { - if (busy || ids.length === 0) return; + async function retry(id: string) { + if (busy) return; setBusy(true); try { - const res = await adminApi.submitReviews(ids); - notify("success", `已提交 ${res.submitted} 个资产送审`); - await load(); + await adminApi.submitReviews([id]); + notify("success", "已重新送审"); + await load({ silent: true }); } catch { - notify("error", "送审失败"); + notify("error", "重试失败"); } finally { setBusy(false); } @@ -84,8 +90,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { setBusy(true); try { const res = await adminApi.pollReviews(); - notify("success", res.polled > 0 ? `已刷新 ${res.polled} 个审核中资产` : "暂无审核中的资产"); - await load(); + const submitted = Number(res.submitted || 0); + const polled = Number(res.polled || 0); + if (submitted || polled) { + notify("success", [ + submitted ? `自动送审 ${submitted} 个` : "", + polled ? `刷新 ${polled} 个审核中` : "", + ].filter(Boolean).join(" · ")); + } else { + notify("success", "暂无待处理审核"); + } + await load({ silent: true }); } catch { notify("error", "刷新失败"); } finally { @@ -98,7 +113,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {

资产审核

-
{count} 个真人资产 · 火山人像素材库绿盾 / 红标
+
{count} 个真人资产 · 未送审会自动提交火山审核,无需手动点送审