From 849b14a0f613e467d15aa2c3cdc192ef0417944b Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 28 Aug 2026 10:15:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=A7=86=E9=A2=91=E6=8F=90?= =?UTF-8?q?=E7=82=BC=E5=92=8C=E8=A7=86=E9=A2=91=E5=A4=8D=E5=88=BB=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=8F=90=E4=BA=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/launch.json | 12 + core/backend/apps/ai/test_video_digest.py | 47 ++++ core/backend/apps/ai/test_video_replace.py | 151 +++++++++++- core/backend/apps/ai/video_digest.py | 105 +++++++- core/backend/apps/ai/video_replace.py | 90 ++++++- core/backend/apps/ai/views.py | 51 +++- core/backend/apps/products/serializers.py | 20 ++ core/backend/apps/products/tests.py | 40 +++ .../apps/projects/services/quick_create.py | 23 ++ .../apps/projects/test_quick_create.py | 49 ++++ core/backend/apps/projects/views.py | 19 ++ core/frontend/src/ai-tools-page.css | 7 + core/frontend/src/api.ts | 17 +- .../free-create/asset-library-modal.tsx | 18 +- .../src/components/free-create/constants.ts | 24 +- .../frontend/src/components/model-library.tsx | 10 +- core/frontend/src/components/use-file-drop.ts | 53 ++++ core/frontend/src/free-create-page.css | 7 + core/frontend/src/models-page.css | 13 + core/frontend/src/pipeline-page.css | 7 + core/frontend/src/product-detail-page.css | 8 + core/frontend/src/quick-create-page.css | 19 ++ core/frontend/src/routes/ai-tools.tsx | 12 +- core/frontend/src/routes/models.tsx | 22 +- core/frontend/src/routes/pipeline.tsx | 52 +++- core/frontend/src/routes/products.tsx | 9 +- core/frontend/src/routes/quick-create.tsx | 55 ++++- core/frontend/src/routes/settings.tsx | 15 +- core/frontend/src/routes/video-remix.tsx | 135 ++++++++--- core/frontend/src/routes/video-replace.tsx | 228 +++++++++++++++--- core/frontend/src/types.ts | 2 + core/frontend/src/video-remix-page.css | 171 +++++++++---- core/frontend/src/video-replace-page.css | 192 ++++++++++++--- 33 files changed, 1475 insertions(+), 208 deletions(-) create mode 100644 .claude/launch.json create mode 100644 core/frontend/src/components/use-file-drop.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8a80467 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "airshelf-frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "core/frontend", + "port": 5173 + } + ] +} diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index 86a952a..dea297d 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -523,6 +523,53 @@ class VideoDigestApiTests(TestCase): self.assertEqual(polled.data["task_id"], str(task.id)) self.assertEqual(polled.data["file_name"], "参考视频.mp4") + def test_inflight_without_preview_urls_is_still_restorable(self): + """封面/原片直链拿不到(TOS 慢、签名失败)不代表任务死了。 + + 前端曾拿「有没有预览图」当判活依据,退出再进来时把正在跑的任务直接取消掉, + 用户看到的就是「任务凭空消失」。这里锁住:没有任何预览 URL 也照样回 processing。 + """ + from apps.ai.models import AITask + + task = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="nopreview") + task.request_payload = { + **task.request_payload, + "video_url": "", "cover_url": "", "source_url": "", "source_key": "", "cover_key": "", + } + task.save(update_fields=["request_payload"]) + + listed = self.client.get("/api/ai/video-digest/") + self.assertEqual(listed.data["inflight"]["id"], str(task.id)) + self.assertEqual(listed.data["inflight"]["status"], "processing") + self.assertEqual(listed.data["inflight"]["video_url"], "") + self.assertEqual(listed.data["inflight"]["cover_url"], "") + + polled = self.client.get(f"/api/ai/video-digest/{task.id}/") + self.assertEqual(polled.data["status"], "processing") + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.SUBMITTED) # 只是查看,不该被动到 + + def test_second_submit_is_blocked_while_one_is_running(self): + """已有提炼在跑时再提交要 409,并把在跑的那条带回去让前端接上。""" + from apps.ai.models import AITask + + running = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="busy") + resp = self.client.post( + "/api/ai/video-digest/", + {"reuse_task_id": str(running.id)}, + format="multipart", + ) + self.assertEqual(resp.status_code, 409) + self.assertIn("完成或取消后", resp.json()["detail"]) + self.assertEqual(resp.json()["inflight"]["id"], str(running.id)) + + # 收掉之后放行(这里只验闸门,不验后面的真实提炼) + running.status = AITask.Status.CANCELLED + running.save(update_fields=["status"]) + from apps.ai.video_digest import get_inflight_team_digest + + self.assertIsNone(get_inflight_team_digest(team=self.team)) + 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) diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index ba5ed76..898d2d6 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -172,6 +172,12 @@ class SubmitVideoReplaceTests(TestCase): self.assertNotIn("reference_video", roles) # 参考视频不再发给火山 self.assertNotIn("video_url", [item.get("type") for item in content]) + def _retire(self, task): + """把任务收成终态,好让单飞闸放行下一次提交。""" + task.status = AITask.Status.CANCELLED + task.save(update_fields=["status", "updated_at"]) + return task + def _fake_source_file(self): """_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。""" tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) @@ -240,6 +246,38 @@ class SubmitVideoReplaceTests(TestCase): # 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」 self.assertNotIn("净颜精华", str(seen["messages"])) + def test_digest_call_is_recorded_for_admin_task_monitor(self): + """复刻里的提炼要在运营后台任务详情的尝试链里看得到,否则出问题无从查起。""" + from apps.ai.models import AIModelAttempt + + task = self._run_digest(self._submit()) + attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence")) + self.assertEqual(len(attempts), 1) + self.assertEqual(attempts[0].status, AIModelAttempt.Status.SUCCEEDED) + self.assertEqual(attempts[0].capability, "text") + self.assertEqual(attempts[0].request_summary.get("for"), "video_replace") + self.assertTrue(attempts[0].response_summary.get("shots")) + self.assertIsNotNone(attempts[0].finished_at) + + def test_failed_digest_attempts_are_recorded_too(self): + """废稿重试也要各留一条,才能看出是「模型抽风」而不是「没跑」。""" + from apps.ai import video_digest + from apps.ai.models import AIModelAttempt + + task = self._submit() + text_model = ModelConfig.objects.filter(capability="text", status="active").first() + with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \ + patch.object( + video_digest, "digest_input_from_upload", + return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}), + ), \ + patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \ + patch("apps.ai.services._collect_extract_text", side_effect=[("", {}), (DIGEST_SAMPLE, {})]): + run_replace_digest(task) + attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence")) + self.assertEqual([a.status for a in attempts], ["failed", "succeeded"]) + self.assertTrue(attempts[1].is_retry) + def test_digest_retries_once_before_giving_up(self): """Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。""" from apps.ai import video_digest @@ -278,6 +316,52 @@ class SubmitVideoReplaceTests(TestCase): self.assertFalse(self.provider.create_video_task.called) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) + def test_second_submit_is_blocked_while_one_is_running(self): + """刷新页面时前端拉状态有空档,用户容易以为「没任务」再点一次。 + 后端必须拦住,否则重复建任务、重复扣费。""" + from apps.ai.video_replace import VideoReplaceInProgress, get_inflight_video_replace + + first = self._submit() + with self.assertRaises(VideoReplaceInProgress) as ctx: + self._submit() + self.assertEqual(ctx.exception.task.id, first.id) # 409 要带上在跑的那条 + self.assertIn("完成或取消后", str(ctx.exception)) + self.assertEqual(get_inflight_video_replace(self.team).id, first.id) + + # 收掉之后放行 + self._retire(first) + self.assertIsNone(get_inflight_video_replace(self.team)) + self.assertEqual(self._submit().status, AITask.Status.CREATED) + + def test_replace_api_returns_409_with_inflight(self): + """前端拿这个 409 直接切到「进行中」,而不是弹个错就完了。""" + from rest_framework.test import APIClient + + from apps.accounts.models import TeamMember + + TeamMember.objects.get_or_create( + team=self.team, user=self.user, defaults={"role": "owner", "status": "active"} + ) + client = APIClient() + client.force_authenticate(self.user) + payload = { + "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, + } + first = client.post("/api/ai/video-replace/", payload, format="json") + self.assertEqual(first.status_code, 202) + again = client.post("/api/ai/video-replace/", payload, format="json") + self.assertEqual(again.status_code, 409) + self.assertEqual(again.json()["inflight"]["id"], first.json()["task"]["id"]) + + listed = client.get("/api/ai/video-replace/").json() + self.assertEqual(listed["inflight"]["id"], first.json()["task"]["id"]) + def test_missing_digest_model_is_rejected_at_submit(self): """提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。""" @@ -340,6 +424,26 @@ class SubmitVideoReplaceTests(TestCase): self.assertNotIn("三视图", prompt) self.assertIn("@目标商品", prompt) + def test_triview_only_product_still_fills_target_slot(self): + """商品只有三视图、没有实拍图时,三视图要顶上来当 @目标商品 —— + 否则提示词里的 @目标商品 找不到同名 label,火山收到的是一句没有指代的字面量。""" + bare = Product.objects.create(team=self.team, created_by=self.user, title="只有三视图") + tri = _asset(self.team, self.user, name="三视图.png", preview="http://tos/only-tri.png") + tri.metadata = {"product_id": str(bare.id), "view": "three_view"} + tri.save(update_fields=["metadata"]) + + task = self._submit(product_id=str(bare.id)) + labels = [ref["label"] for ref in task.request_payload["references"]] + self.assertEqual(labels, ["目标商品"]) + + task = self._run_digest(task) + self.assertEqual(task.status, AITask.Status.SUBMITTED) + prompt = task.request_payload["prompt"] + self.assertIn("@目标商品", prompt) + self.assertNotIn("@目标商品三视图", prompt) # 没有第二张图就别点名它 + api_prompt = task.request_payload.get("api_prompt") or "" + self.assertNotIn("@目标商品", api_prompt) # 已被换成火山认的「图片N」 + def test_triview_yields_budget_so_refs_never_exceed_nine(self): """三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。""" from apps.ai.video_replace import MAX_IMAGES @@ -369,10 +473,51 @@ class SubmitVideoReplaceTests(TestCase): 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) + def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self): + """商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒; + 角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。""" + portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") + model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) + mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=30000) + long_ok = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=60000) + too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=75000) + + # 商品复刻:30s / 60s 都放行。单飞闸只允许一条在跑,所以每次提交后收掉再试下一条。 + self.assertEqual(self._retire(self._submit(video_asset_id=str(mid.id))).status, AITask.Status.CANCELLED) + self.assertEqual(self._retire(self._submit(video_asset_id=str(long_ok.id))).status, AITask.Status.CANCELLED) + with self.assertRaisesMessage(ValueError, "不能超过 60 秒"): + self._submit(video_asset_id=str(too_long.id)) + + # 角色复刻:超过 15 秒仍然拒 with self.assertRaisesMessage(ValueError, "不能超过 15 秒"): - self._submit(video_asset_id=str(long_video.id)) + self._submit( + replace_mode="character", product_id="", model_id=str(model.id), + video_asset_id=str(mid.id), + ) + + def test_long_source_asks_model_to_condense(self): + """60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。""" + long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=58000) + # duration=None → 按参考视频推导成片时长(火山单次上限 15 秒),与线上一致 + task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=None)) + prompt = task.request_payload["prompt"] + self.assertIn("压缩改编", prompt) + self.assertIn("58 秒", prompt) + self.assertIn("15 秒", prompt) + + def test_short_source_skips_condense_note(self): + """参考视频和成片时长差不多时别啰嗦。""" + task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒 + self.assertNotIn("压缩改编", task.request_payload["prompt"]) + + def test_prompt_always_pins_character_consistency(self): + """上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致; + 没有人物时也要说清楚「别从商品图凭空套脸」。""" + prompt = self._run_digest(self._submit()).request_payload["prompt"] + self.assertIn("自始至终是同一个人", prompt) + self.assertIn("不要中途换脸", prompt) + self.assertIn("参考图里如果有人物", prompt) + self.assertIn("不要从商品图上凭空套一张脸", prompt) def test_accepts_15s_video_with_probe_slack(self): from apps.ai.media_probe import REF_DURATION_MAX diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index b037835..cf2200c 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -59,6 +59,14 @@ class VideoDigestError(ValueError): """用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。""" +class VideoDigestInProgress(VideoDigestError): + """本团队已有提炼在跑。视图转 409,并把在跑的那条带回去让前端接上。""" + + def __init__(self, job: dict): + super().__init__("已有一个视频正在提炼中,完成或取消后才能再提交") + self.job = job + + @dataclass(frozen=True) class VideoFrame: at_seconds: int @@ -923,6 +931,14 @@ def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_con from apps.billing.pricing import quote_video_digest from apps.billing.services.ledger import reserve_credit + # 单飞闸:一个团队同时只允许一条提炼在跑。 + # 刷新页面时前端要异步拉状态,这个空档用户很容易以为「没任务」再传一次 —— 前端加载态 + # 只是治标,真正兜底在这里。先跑过期回收,死任务不会把人永久锁住。 + expire_stale_team_digests(team=team) + running = get_inflight_team_digest(team=team) + if running: + raise VideoDigestInProgress(running) + model_config = resolve_digest_model_config(preferred_id=model_config_id) if model_config is None: raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") @@ -1359,6 +1375,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict] 复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻 task 上(AIModelAttempt),出问题查得到。 """ + import time + + from apps.ai.models import AIModelAttempt from apps.ai.services import _collect_extract_text, get_text_provider primary = asset.files.filter(is_primary=True).first() or asset.files.first() @@ -1408,7 +1427,12 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict] expected_frames = len(frames) or (24 if video else 0) digest = "" last_error: Exception | None = None - for attempt in range(1, DIGEST_MAX_ATTEMPTS + 1): + for attempt_no in range(1, DIGEST_MAX_ATTEMPTS + 1): + # 这一步不走 execute_model_call(它要求 task.model_config 就是本次主模型, + # 而复刻任务的主模型是 Seedance),所以尝试记录得自己落 —— 不落的话 + # 运营后台的任务详情看不到这次 Gemini 调用,出问题无从查起。 + record = _open_digest_attempt(task, model_config, attempt_no, duration, expected_frames, video) + started = time.monotonic() try: text, _payload = _collect_extract_text( provider, @@ -1418,12 +1442,19 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict] extra_body={"max_tokens": DIGEST_MAX_TOKENS}, ) digest = validate_digest_text(text, duration=duration, frame_count=expected_frames) + _close_digest_attempt( + record, AIModelAttempt.Status.SUCCEEDED, started, + summary={"chars": len(digest), "shots": shot_count(digest)}, + ) break except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次 last_error = exc + _close_digest_attempt( + record, AIModelAttempt.Status.FAILED, started, error=str(exc), + ) logger.warning( "replace digest attempt %s/%s failed for task %s: %s", - attempt, DIGEST_MAX_ATTEMPTS, task.id, exc, + attempt_no, DIGEST_MAX_ATTEMPTS, task.id, exc, ) if not digest: raise VideoDigestError(f"参考视频拆解失败:{last_error}") @@ -1439,3 +1470,73 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict] finally: if local_path: Path(local_path).unlink(missing_ok=True) + + +def _open_digest_attempt(task, model_config, attempt_no: int, duration: float, frames: int, video): + """给复刻任务补一条「提炼」尝试记录,让运营后台的尝试链能看到这次 Gemini 调用。 + + 审计失败绝不能拖垮拆解本身,所以整段吞异常、返回 None。 + """ + from django.db import transaction + from django.db.models import Max + from django.utils import timezone + + from apps.ai.models import AIModelAttempt, AITask + + try: + with transaction.atomic(): + AITask.objects.select_for_update().only("id").get(pk=task.pk) + sequence = ( + AIModelAttempt.objects.filter(task_id=task.pk).aggregate(m=Max("sequence"))["m"] or 0 + ) + 1 + provider = model_config.provider + return AIModelAttempt.objects.create( + task_id=task.pk, + sequence=sequence, + provider=provider, + model_config=model_config, + provider_name=provider.name, + provider_display_name=provider.display_name, + model_name=model_config.name, + model_display_name=model_config.display_name, + public_model_name=model_config.display_name or model_config.name, + capability="text", + operation="video_digest", + status=AIModelAttempt.Status.STARTED, + is_retry=attempt_no > 1, + started_at=timezone.now(), + request_summary={ + "for": "video_replace", + "duration_seconds": round(float(duration or 0), 2), + "frame_count": frames, + "input": "native_video" if video is not None else "frames", + }, + ) + except Exception: # noqa: BLE001 — 审计写失败不影响出片 + logger.warning("video replace digest attempt record failed", exc_info=True) + return None + + +def _close_digest_attempt(record, status, started_at: float, *, summary: dict | None = None, error: str = ""): + import time + + from django.utils import timezone + + if record is None: + return + try: + record.status = status + record.finished_at = timezone.now() + record.duration_ms = max(0, round((time.monotonic() - started_at) * 1000)) + if summary: + record.response_summary = summary + if error: + record.error_type = "processing_failed" + record.raw_error = error[:2000] + record.safe_error_summary = "参考视频拆解未通过校验" + record.save(update_fields=[ + "status", "finished_at", "duration_ms", "response_summary", + "error_type", "raw_error", "safe_error_summary", "updated_at", + ]) + except Exception: # noqa: BLE001 + logger.warning("video replace digest attempt close failed", exc_info=True) diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index df0750f..a54f176 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -43,10 +43,38 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]" # 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id), # 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。 TRIVIEW_LABEL = "目标商品三视图" +# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。 +# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒。 +PRODUCT_REF_DURATION_MAX = 60.5 REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试" REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试" REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试" + +class VideoReplaceInProgress(ValueError): + """本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。""" + + def __init__(self, task): + super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交") + self.task = task + + +def get_inflight_video_replace(team): + """本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。""" + _reap_stale_free_video_tasks(team=team) + return ( + AITask.objects.filter( + team=team, + task_type=AITask.Type.FREE_VIDEO, + status__in=IN_FLIGHT_STATUSES, + is_deleted=False, + purged_at__isnull=True, + ) + .filter(video_replace_q()) + .order_by("-created_at") + .first() + ) + # 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步: # 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成 # 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。 @@ -62,8 +90,22 @@ PRODUCT_DIGEST_TAIL = ( "不要改造型、不要改配色、不要凭空补细节。\n" "{triview}" "3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n" + " · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致," + "不要中途换脸、换人或换装。\n" + " · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准," + "五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演," + "不要从商品图上凭空套一张脸。\n" "4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n" "5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。" + "{condense}" +) + +# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编, +# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。 +PRODUCT_CONDENSE_NOTE = ( + "\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**," + "保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;" + "不要只拍开头几镜就停,也不要把画面加速成快放。" ) # 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。 PRODUCT_TRIVIEW_NOTE = ( @@ -107,7 +149,14 @@ def compact_digest_for_video(digest_text: str) -> str: return "\n".join(line for line in lines if line.strip()).strip() -def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str: +def build_product_replace_prompt( + digest_text: str, + subject_name: str, + *, + has_triview: bool = False, + source_seconds: float = 0, + output_seconds: int = 0, +) -> str: """分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。 @目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items @@ -119,9 +168,18 @@ def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_tri if not body: raise ValueError("参考视频拆解结果为空,请重试") subject = (subject_name or "").strip() or "目标商品" + source = int(round(float(source_seconds or 0))) + output = int(output_seconds or 0) + # 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。 + condense = ( + PRODUCT_CONDENSE_NOTE.format(source=source, output=output) + if source and output and source - output >= 3 + else "" + ) tail = PRODUCT_DIGEST_TAIL.format( subject=subject, triview=PRODUCT_TRIVIEW_NOTE if has_triview else "", + condense=condense, ) return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}") @@ -190,9 +248,20 @@ def submit_video_replace(*, team, user, params: dict): if not has_model and not has_temp: raise ValueError("请选择角色或上传角色参考图") + # 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档, + # 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收 + # 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。 + running = get_inflight_video_replace(team) + if running is not None: + raise VideoReplaceInProgress(running) + 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: + if replace_mode == "product": + if video_seconds > PRODUCT_REF_DURATION_MAX: + raise ValueError("参考视频不能超过 60 秒,请剪短后重试") + elif video_seconds > REF_DURATION_MAX: + # 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。 raise ValueError("参考视频不能超过 15 秒,请剪短后重试") if has_product: @@ -345,7 +414,11 @@ def run_replace_digest(task) -> None: (ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or []) ) prompt = build_product_replace_prompt( - digest, str(payload.get("subject_name") or ""), has_triview=has_triview + digest, + str(payload.get("subject_name") or ""), + has_triview=has_triview, + source_seconds=float(payload.get("digest_source_duration") or 0), + output_seconds=int(payload.get("duration") or 0), ) except (VideoDigestError, ValueError) as exc: _fail_reviewing_task(task, str(exc), error_code="processing_failed") @@ -776,10 +849,15 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]: assets.append(asset) if len(assets) >= image_budget: break - if not assets and product.cover_asset_id and not product.cover_asset.is_deleted: + if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted: assets.append(product.cover_asset) - if not assets and triview is None: - raise ValueError("这个商品还没有可用图片") + if not assets: + # 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。 + # 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。 + if triview is None: + raise ValueError("这个商品还没有可用图片") + assets = [triview] + triview = None refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)] if triview is not None: # 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。 diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 00621de..ea8116e 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -728,7 +728,7 @@ class VideoDigestView(APIView): if upload is None and not reuse_task_id: return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST) require_worker_task("apps.ai.tasks.run_video_digest_task") - from .video_digest import VideoDigestError, submit_team_digest + from .video_digest import VideoDigestError, VideoDigestInProgress, submit_team_digest team = get_current_team(request.user) try: @@ -739,6 +739,9 @@ class VideoDigestView(APIView): reuse_task_id=reuse_task_id, model_config_id=request.data.get("model_config_id") or None, ) + except VideoDigestInProgress as exc: + # 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了 + return Response({"detail": str(exc), "inflight": exc.job}, status=status.HTTP_409_CONFLICT) except VideoDigestError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案 @@ -866,11 +869,21 @@ class VideoReplaceView(APIView): # 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。 if str((request.data or {}).get("replace_mode") or "") == "product": require_worker_task("apps.ai.tasks.run_video_replace_digest_task") - from .video_replace import serialize_video_replace_task, submit_video_replace + from .video_replace import ( + VideoReplaceInProgress, + 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 VideoReplaceInProgress as exc: + # 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了 + return Response( + {"detail": str(exc), "inflight": serialize_video_replace_task(exc.task)}, + status=status.HTTP_409_CONFLICT, + ) except ValueError as exc: message = str(exc) internal_kind = ( @@ -906,11 +919,16 @@ class VideoReplaceView(APIView): qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at") total = qs.count() tasks = list(qs[offset : offset + page_size]) + from .video_replace import get_inflight_video_replace + + running = get_inflight_video_replace(team) if offset == 0 else None return Response( { "results": [serialize_video_replace_task(t) for t in tasks], "total": total, "has_more": offset + page_size < total, + # 首页才带:前端刷新后据此恢复「进行中」,不必自己在列表里翻状态 + "inflight": serialize_video_replace_task(running) if running is not None else None, } ) @@ -1059,6 +1077,13 @@ _FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"} _FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"} _FREE_REF_IMAGE_MAX = 30 * 1024 * 1024 _FREE_REF_VIDEO_MAX = 50 * 1024 * 1024 +# 视频复刻·商品:参考视频只喂给提炼模型(不发火山),没有 15 秒的硬限制, +# 按「提炼提示词」页那套放宽到 60 秒 / 200MB。自由创作和角色复刻仍走上面的 15 秒。 +# 拿这个 purpose 传上来的长视频,若被拿去自由创作/角色复刻,下游 build_content_items +# 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。 +_REPLACE_SOURCE_PURPOSE = "video_replace_product" +_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024 +_REPLACE_SOURCE_DURATION_MAX = 60.5 _FREE_REF_AUDIO_MAX = 15 * 1024 * 1024 @@ -1088,6 +1113,7 @@ class FreeVideoUploadView(APIView): team = get_current_team(request.user) content_type = (upload.content_type or "").lower() size = upload.size or 0 + long_source = str(request.data.get("purpose") or "").strip() == _REPLACE_SOURCE_PURPOSE if content_type in _FREE_REF_IMAGE_TYPES: kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type] @@ -1095,8 +1121,12 @@ class FreeVideoUploadView(APIView): return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST) elif content_type in _FREE_REF_VIDEO_TYPES: kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov" - if size > _FREE_REF_VIDEO_MAX: - return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST) + video_max = _REPLACE_SOURCE_VIDEO_MAX if long_source else _FREE_REF_VIDEO_MAX + if size > video_max: + return Response( + {"detail": f"视频大小不能超过 {video_max // 1024 // 1024}MB"}, + status=status.HTTP_400_BAD_REQUEST, + ) elif content_type in _FREE_REF_AUDIO_TYPES: kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav" if size > _FREE_REF_AUDIO_MAX: @@ -1132,9 +1162,16 @@ class FreeVideoUploadView(APIView): duration = probe_duration(str(tmp_path)) if duration is None: return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) - 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) + from .media_probe import REF_DURATION_MIN + + if long_source and kind == "video": + in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX + range_hint = "视频时长需在 2-60 秒之间" + else: + in_range = duration_in_ref_range(duration) + range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间" + if not in_range: + return Response({"detail": range_hint}, status=status.HTTP_400_BAD_REQUEST) if kind == "video": poster_bytes = extract_video_poster(str(tmp_path)) diff --git a/core/backend/apps/products/serializers.py b/core/backend/apps/products/serializers.py index 3599ed8..8a540b7 100644 --- a/core/backend/apps/products/serializers.py +++ b/core/backend/apps/products/serializers.py @@ -36,6 +36,7 @@ class ProductSerializer(serializers.ModelSerializer): images = ProductImageSerializer(many=True, required=False) selling_points = ProductSellingPointSerializer(many=True, required=False) cover_preview_url = serializers.SerializerMethodField() + triview_preview_url = serializers.SerializerMethodField() class Meta: model = Product @@ -51,6 +52,7 @@ class ProductSerializer(serializers.ModelSerializer): "status", "cover_asset", "cover_preview_url", + "triview_preview_url", "images", "selling_points", "created_at", @@ -61,6 +63,24 @@ class ProductSerializer(serializers.ModelSerializer): def get_cover_preview_url(self, obj) -> str: return _asset_preview_url(obj.cover_asset) + def get_triview_preview_url(self, obj) -> str: + """商品三视图(白底多角度单张 16:9)。商品库存的是 standalone Asset,不在 images 里, + 视频复刻要靠它锁商品各面,前端也要能直接告诉用户「这次带了三视图」。""" + from apps.assets.models import Asset + + asset = ( + Asset.objects.filter( + team_id=obj.team_id, + is_deleted=False, + purged_at__isnull=True, + metadata__product_id=str(obj.id), + metadata__view="three_view", + ) + .order_by("-created_at") + .first() + ) + return _asset_preview_url(asset) if asset else "" + def validate(self, attrs): # 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。 # 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。 diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 5b52c90..8e54eaf 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -242,3 +242,43 @@ class ProductBusinessTypeTests(TestCase): self.assertIn("火锅套餐", local) self.assertNotIn("面膜", local) + + +class ProductTriviewFieldTests(TestCase): + """商品三视图是 standalone Asset(metadata.view=three_view + product_id),不在 images 里。 + 视频复刻靠它锁商品各面,前端也要能直接告诉用户「这次带了三视图」。""" + + def setUp(self): + self.user = User.objects.create_user(username="ptri", password="p") + self.team = Team.objects.create(name="PT", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华") + + def _asset(self, name, preview, metadata=None): + asset = Asset.objects.create( + team=self.team, created_by=self.user, name=name, + asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, + category=Asset.Category.UPLOAD, metadata=metadata or {}, + ) + AssetFile.objects.create( + asset=asset, object_key="k/1", bucket="b", content_type="image/png", + size_bytes=1, preview_url=preview, is_primary=True, + ) + return asset + + def test_triview_url_exposed_when_present(self): + self._asset("三视图", "http://tos/tri.png", {"product_id": str(self.product.id), "view": "three_view"}) + data = self.client.get(f"/api/products/{self.product.id}/").json() + self.assertEqual(data["triview_preview_url"], "http://tos/tri.png") + + def test_blank_when_no_triview(self): + data = self.client.get(f"/api/products/{self.product.id}/").json() + self.assertEqual(data["triview_preview_url"], "") + + def test_other_products_triview_does_not_leak(self): + other = Product.objects.create(team=self.team, created_by=self.user, title="别的商品") + self._asset("三视图", "http://tos/other.png", {"product_id": str(other.id), "view": "three_view"}) + data = self.client.get(f"/api/products/{self.product.id}/").json() + self.assertEqual(data["triview_preview_url"], "") diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index 6cc3bf0..fd80fb7 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -259,6 +259,29 @@ def _should_mark_project_failed(job: QuickCreateJob) -> bool: return True +# 编排器活着时会不断推进并保存 job(updated_at 跟着动)。超过这个时长没动过, +# 基本就是 worker 挂了 —— 不当它在跑,否则一次抽风会把团队永久锁在"有任务进行中"。 +QUICK_CREATE_STALE_AFTER = timedelta(minutes=30) + + +def get_inflight_quick_create(team): + """本团队正在跑的一键成片。没有 / 已经僵死的返回 None。""" + from django.utils import timezone + + return ( + QuickCreateJob.objects.select_related("project") + .filter( + team=team, + status__in=(QuickCreateJob.Status.QUEUED, QuickCreateJob.Status.RUNNING), + project__is_deleted=False, + project__purged_at__isnull=True, + updated_at__gte=timezone.now() - QUICK_CREATE_STALE_AFTER, + ) + .order_by("-created_at") + .first() + ) + + def restore_false_failed_quick_creates(team) -> None: jobs = ( QuickCreateJob.objects.select_related("project") diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 360a52c..7bf0626 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -111,6 +111,55 @@ class QuickCreateApiTests(TestCase): {str(image.asset_id) for image in product.images.order_by("sort_order")}, ) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) + @patch("apps.projects.views.get_default_model") + @patch("apps.projects.tasks.advance_quick_create_task.apply_async") + @patch("apps.projects.views.require_worker_task") + @patch("apps.projects.views._store_uploaded_asset") + def test_second_submit_is_blocked_while_one_is_running(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model): + """刷新页面时前端拉状态有空档,用户容易以为「没任务」再提交一次 —— + 那会重复建商品、重复扣费。后端必须拦住,并把在跑的那条带回去。""" + store_asset.side_effect = self._uploaded_asset + video_model = SimpleNamespace( + id=uuid.uuid4(), + name="doubao-seedance-2-0-fast-260128", + display_name="Seedance 2.0 Fast", + metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}}, + ) + get_model.side_effect = [object(), video_model, object(), video_model] + + def _post(): + return self.client.post( + "/api/projects/quick-create/", + { + "name": "轻醒咖啡", + "images": [SimpleUploadedFile("front.png", b"png-one", content_type="image/png")], + "aspect_ratio": "9:16", + "resolution": "720p", + "total_duration": "15", + "category": "食品饮料", + }, + ) + + first = _post() + self.assertEqual(first.status_code, 202) + job_id = str(QuickCreateJob.objects.get(project__product__title="轻醒咖啡").id) + + again = _post() + self.assertEqual(again.status_code, 409) + self.assertIn("完成或取消后", again.json()["detail"]) + self.assertEqual(again.json()["inflight"]["id"], job_id) + + # 历史接口也要把在跑的那条带出来,前端刷新后才能恢复进度 + listed = self.client.get("/api/projects/quick-create-history/").json() + self.assertEqual(listed["inflight"]["id"], job_id) + + # 收成终态后放行 + QuickCreateJob.objects.filter(id=job_id).update(status=QuickCreateJob.Status.CANCELLED) + from apps.projects.services.quick_create import get_inflight_quick_create + + self.assertIsNone(get_inflight_quick_create(self.team)) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) @patch("apps.projects.views.get_default_model") @patch("apps.projects.tasks.advance_quick_create_task.apply_async") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 041d9dd..041e468 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -485,6 +485,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): def quick_create(self, request): """商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。""" require_worker_task("apps.projects.tasks.advance_quick_create_task") + # 单飞闸:一个团队同时只允许一条一键成片。刷新页面时前端拉状态有空档, + # 用户容易以为「没任务」再提交一次 —— 那会重复建商品、重复扣费。 + # get_inflight_quick_create 会跳过 30 分钟没动过的僵尸 job,不至于把人永久锁住。 + from .services.quick_create import get_inflight_quick_create + + running_job = get_inflight_quick_create(self.get_team()) + if running_job is not None: + return Response( + { + "detail": "已有一个一键成片正在进行中,完成或取消后才能再提交", + "inflight": QuickCreateJobSerializer(running_job).data, + }, + status=status.HTTP_409_CONFLICT, + ) name = str(request.data.get("name") or "").strip() uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]") source_product_id = str(request.data.get("source_product_id") or "").strip() @@ -775,9 +789,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): ) .order_by("-created_at") ) + from .services.quick_create import get_inflight_quick_create + + running = get_inflight_quick_create(self.get_team()) return Response({ "count": jobs.count(), "results": QuickCreateJobSerializer(jobs[:30], many=True).data, + # 进行中的那条:前端刷新/重进页面据此恢复进度,不再显示成空表单 + "inflight": QuickCreateJobSerializer(running).data if running is not None else None, }) @transaction.atomic diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 78c5521..b2c8163 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -3456,3 +3456,10 @@ .yz-image .result-panel { height: auto; min-height: 420px; } .yz-image .studio-layout { grid-template-columns: 1fr; height: auto; } } + +/* 参考图可直接拖进输入区 */ +.yz-image .image-composer.is-dragover { + outline: 1.5px dashed var(--heat); + outline-offset: 4px; + border-radius: var(--r-md, 8px); +} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index ffcef90..6c743f6 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -127,11 +127,14 @@ export function setRemember(username: string | null, remember: boolean) { export class ApiError extends Error { status: number; + /** 解析成功的响应体。409 这类「冲突」要把在跑的任务带回给调用方接上,只有 message 不够用。 */ + payload?: Record; - constructor(status: number, message: string) { + constructor(status: number, message: string, payload?: Record) { super(message); this.name = "ApiError"; this.status = status; + this.payload = payload; } } @@ -169,8 +172,10 @@ async function request(path: string, options: RequestInit = {}): Promise { const text = await response.text(); // DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上 let message = text || `${response.status} ${response.statusText}`; + let payload: Record | undefined; try { const data = JSON.parse(text) as Record; + payload = data; const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0]; if (typeof first === "string") message = first; else if (Array.isArray(first) && typeof first[0] === "string") message = first[0]; @@ -191,7 +196,7 @@ async function request(path: string, options: RequestInit = {}): Promise { if (response.status === 413) { message = "文件太大,服务器没接收。视频请压到 200MB 以内再传"; } - throw new ApiError(response.status, message); + throw new ApiError(response.status, message, payload); } if (response.status === 204) return undefined as T; return response.json() as Promise; @@ -379,7 +384,10 @@ export const api = { return request(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" }); }, quickCreateHistory() { - return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/"); + // inflight = 正在跑的那条:刷新/重进页面据此恢复进度,不再显示成空表单 + return request<{ count: number; results: QuickCreateJob[]; inflight?: QuickCreateJob | null }>( + "/api/projects/quick-create-history/" + ); }, // 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉 updateProject(id: string, payload: { name?: string; metadata?: Record }) { @@ -932,7 +940,8 @@ export const api = { 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 }>( + // inflight 只在首页返回:刷新/重进页面据此恢复「进行中」,不用自己在列表里翻状态 + return request<{ results: FreeVideoTask[]; total: number; has_more: boolean; inflight?: FreeVideoTask | null }>( `/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}` ); }, diff --git a/core/frontend/src/components/free-create/asset-library-modal.tsx b/core/frontend/src/components/free-create/asset-library-modal.tsx index abd20ba..c4f57f1 100644 --- a/core/frontend/src/components/free-create/asset-library-modal.tsx +++ b/core/frontend/src/components/free-create/asset-library-modal.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; import { api } from "../../api"; +import { useFileDrop } from "../use-file-drop"; import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types"; import { useBodyScrollLock } from "../overlays"; @@ -131,6 +132,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { } }; + // 素材库弹窗:整块 body 都能接住拖进来的文件。在组内传素材,在组外快速建图。 + const libDrop = useFileDrop( + (files) => { + const file = files[0]; + if (!file) return; + if (activeGroup) void uploadAsset(file); + else void quickUploadImage(file); + }, + { disabled: uploading } + ); + const quickUploadImage = async (file: File) => { setQuickUploading(true); try { @@ -278,7 +290,11 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { -
{ setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}> +
{ setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }} + > {!activeGroup ? ( <>
diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts index ed09720..57034ca 100644 --- a/core/frontend/src/components/free-create/constants.ts +++ b/core/frontend/src/components/free-create/constants.ts @@ -137,7 +137,7 @@ function probeImage(file: File): Promise { }); } -function probeMedia(file: File, kind: "video" | "audio"): Promise { +function probeMedia(file: File, kind: "video" | "audio", maxSeconds = MAX_VIDEO_TOTAL_SECONDS): Promise { return new Promise((resolve) => { const url = URL.createObjectURL(file); const el = document.createElement(kind); @@ -146,24 +146,34 @@ 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 - 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) }); + else if (duration < 2 - 0.05 || duration > maxSeconds + VIDEO_DURATION_SLACK) { + resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-${maxSeconds} 秒之间` }); + } else resolve({ ok: true, type: kind, duration: Math.min(maxSeconds, Math.round(duration * 10) / 10) }); }; el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); }; el.src = url; }); } -export async function checkRefFile(file: File): Promise { +/** 参考素材前置校验。 + * 默认按自由创作那套(15 秒 / 50MB)——参考视频要直传火山,是火山的硬限制。 + * 视频复刻·商品这类「视频只喂给提炼模型、不进火山」的入口,用 options 放宽。 */ +export async function checkRefFile( + file: File, + options?: { maxSeconds?: number; maxVideoBytes?: number } +): Promise { const type = (file.type || "").toLowerCase(); + const maxSeconds = options?.maxSeconds ?? MAX_VIDEO_TOTAL_SECONDS; + const maxVideoBytes = options?.maxVideoBytes ?? VIDEO_MAX_BYTES; if (IMAGE_TYPES.includes(type)) { if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" }; return probeImage(file); } if (VIDEO_TYPES.includes(type)) { - if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" }; - return probeMedia(file, "video"); + if (file.size > maxVideoBytes) { + return { ok: false, error: `视频大小不能超过 ${Math.round(maxVideoBytes / 1024 / 1024)}MB` }; + } + return probeMedia(file, "video", maxSeconds); } if (AUDIO_TYPES.includes(type)) { if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" }; diff --git a/core/frontend/src/components/model-library.tsx b/core/frontend/src/components/model-library.tsx index a4f8f21..a1b5a25 100644 --- a/core/frontend/src/components/model-library.tsx +++ b/core/frontend/src/components/model-library.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties } from "react"; import { api } from "../api"; +import { useFileDrop } from "./use-file-drop"; import type { Asset, ModelEntity } from "../types"; import { useBodyScrollLock, MediaLightbox } from "./overlays"; @@ -94,6 +95,11 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen } finally { setBusy(false); } } // 本地上传 → 直接作为选中立绘进右侧栏 + const candidateDrop = useFileDrop( + (files) => { void uploadCandidate(files[0]); }, + { disabled: busy, accept: (f) => f.type.startsWith("image/") } + ); + async function uploadCandidate(file?: File | null) { if (!file) return; setBusy(true); @@ -156,9 +162,9 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen ) : (
1 上传本地模特形象图 → 右侧命名并保存
-
fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}> +
fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}> -
{busy ? "上传中…" : "点击选择本地模特图片上传"}
+
{busy ? "上传中…" : candidateDrop.dragging ? "松开即可上传" : "点击或拖拽本地模特图片上传"}
JPG / PNG / WEBP
{ void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} /> diff --git a/core/frontend/src/components/use-file-drop.ts b/core/frontend/src/components/use-file-drop.ts new file mode 100644 index 0000000..5dcb27d --- /dev/null +++ b/core/frontend/src/components/use-file-drop.ts @@ -0,0 +1,53 @@ +import { useCallback, useRef, useState } from "react"; + +/** 全站上传区共用的拖拽投放。各页只管拿 dragging 加自己的高亮类,逻辑不再各写一份。 */ +export function useFileDrop( + onFiles: (files: File[]) => void, + options?: { disabled?: boolean; accept?: (file: File) => boolean } +) { + const [dragging, setDragging] = useState(false); + // 拖过子元素时浏览器会连发 dragenter/dragleave,只看 leave 会让高亮疯狂闪。用进出计数兜住。 + const depth = useRef(0); + const disabled = Boolean(options?.disabled); + const accept = options?.accept; + + const reset = useCallback(() => { + depth.current = 0; + setDragging(false); + }, []); + + const hasFiles = (event: React.DragEvent) => + Array.from(event.dataTransfer?.types || []).includes("Files"); + + const onDragEnter = useCallback((event: React.DragEvent) => { + if (disabled || !hasFiles(event)) return; + event.preventDefault(); + depth.current += 1; + setDragging(true); + }, [disabled]); + + const onDragOver = useCallback((event: React.DragEvent) => { + if (disabled || !hasFiles(event)) return; + // 不 preventDefault 浏览器会把文件当导航打开,drop 根本不触发 + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }, [disabled]); + + const onDragLeave = useCallback((event: React.DragEvent) => { + if (disabled) return; + event.preventDefault(); + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragging(false); + }, [disabled]); + + const onDrop = useCallback((event: React.DragEvent) => { + if (disabled) return; + event.preventDefault(); + reset(); + const files = Array.from(event.dataTransfer?.files || []); + const picked = accept ? files.filter(accept) : files; + if (picked.length) onFiles(picked); + }, [disabled, accept, onFiles, reset]); + + return { dragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } }; +} diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index a6cb4ab..6a8ae99 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -1052,3 +1052,10 @@ .fc-player-nav.prev { left: 6px; } .fc-player-nav.next { right: 6px; } } + +/* 素材库弹窗:整块 body 接住拖进来的文件 */ +.fc-lib-body.is-dragover { + outline: 1.5px dashed var(--heat); + outline-offset: -8px; + background: var(--heat-8); +} diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index cdab182..65f531f 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -338,3 +338,16 @@ .model-detail-b { grid-template-columns: 1fr; padding: 20px; } .model-detail-left { width: min(100%, 320px); margin: 0 auto; } } + +/* 拖图进列表即添加模特 */ +.models-page .ml-grid.is-dragover { + outline: 1.5px dashed var(--heat); + outline-offset: 8px; + border-radius: var(--r-md, 8px); +} + +/* 模特详情:拖图替换形象图 */ +.model-detail-portrait.is-dragover { + outline: 1.5px solid var(--heat); + outline-offset: 2px; +} diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index b575d32..ec27af3 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -2187,3 +2187,10 @@ .chat-model-menu.chip-menu .mi.selected .mi-check { color: var(--klein) !important; } + +/* 模特立绘上传区:拖拽投放高亮 */ +.actorlib-drop.is-dragover { + border-style: solid; + border-color: var(--heat); + background: var(--heat-8); +} diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index 4ff101d..1a5ddc6 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -939,3 +939,11 @@ .task-stats { grid-template-columns: repeat(2, 1fr); } } } + +/* 拖拽投放高亮:实线 + 主橙,和 hover 区分 */ +.img-upload.is-dragover { + border-style: solid; + border-color: var(--heat); + color: var(--heat); + background: var(--heat-8); +} diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index b69a579..2aeafa6 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -66,6 +66,18 @@ .quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; } .quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; } .quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; } +/* 读取任务状态:确认完之前不给看空表单,左侧整块也不能操作 —— 否则用户以为没任务又提交一次 */ +.quick-create-page .quick-create-shell.is-restoring .quick-state-restoring { display: grid; } +.quick-create-page .quick-create-shell.is-restoring .quick-state-ready, +.quick-create-page .quick-create-shell.is-restoring .quick-state-generating, +.quick-create-page .quick-create-shell.is-restoring .quick-state-complete, +.quick-create-page .quick-create-shell.is-restoring .quick-state-failed { display: none; } +.quick-create-page .quick-state-restoring { width: min(460px,100%); justify-items: center; text-align: center; gap: 14px; } +.quick-create-page .quick-state-restoring h2 { margin: 0; font-size: 23px; } +.quick-create-page .quick-state-restoring p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; } +.quick-create-page .quick-restoring-spinner { width: 34px; height: 34px; border-radius: 50%; border: 2px solid rgba(0,47,167,.16); border-top-color: var(--quick-blue); animation: quick-spinner-rotate 0.8s linear infinite; } +.quick-create-page .quick-form-panel.is-locked { pointer-events: none; opacity: .55; filter: saturate(.85); transition: opacity 160ms ease; } + .quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; } .quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; } .quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; } @@ -149,3 +161,10 @@ @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; } } @media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } } @media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } } + +/* 拖拽投放高亮 */ +.quick-upload.is-dragover { + border-style: solid; + border-color: var(--heat); + background: var(--heat-8); +} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index a2d8076..5bf34ca 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -29,6 +29,7 @@ import { } from "lucide-react"; import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; import { api } from "../api"; +import { useFileDrop } from "../components/use-file-drop"; import { imageModelPickerOptions } from "../model-display"; import { ModelLibrary } from "../components/model-library"; import { SkeletonRows, SystemLoading } from "../components/loading"; @@ -711,14 +712,19 @@ export function ImageWorkbenchPage({ // YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位 return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title); }; - function pickReference(event: ChangeEvent) { - const files = Array.from(event.target.files || []); + function acceptReferences(files: File[]) { if (!files.length) return; // 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传 setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]); + } + + function pickReference(event: ChangeEvent) { + acceptReferences(Array.from(event.target.files || [])); event.target.value = ""; } + const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") }); + const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); // 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1 const [priceMultiplier, setPriceMultiplier] = useState(1); @@ -1741,7 +1747,7 @@ export function ImageWorkbenchPage({
)}
-
+
{ const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
{ setSelected(new Set()); }, [tab]); + const modelDrop = useFileDrop( + (files) => { void acceptModelFile(files[0]); }, + { disabled: uploading, accept: (f) => f.type.startsWith("image/") } + ); + async function onPick(e: ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; + await acceptModelFile(file); + } + + async function acceptModelFile(file?: File | null) { if (!file) return; setUploading(true); try { @@ -357,7 +373,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
@@ -388,7 +404,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: { 点右上「添加模特」上传一张形象图,或在图片/视频流程里生成
) : ( -
+
{items.map((m) => { const selectable = !m.is_official; const isSelected = selected.has(m.id); diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 3157432..2643c50 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -888,9 +888,33 @@ export function PipelinePage(props: { delBusy(busyKey); } } - // 流程步骤4 · 生成人物立绘(App 层自动接力三视图) - async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) { - return await genBaseAsset("person", prompt, label, busyKey); + // 流程步骤4 · 生成人物立绘 → 出图后自动接力三视图(用户不必再进详情弹窗手点「AI 生成三视图」; + // 弹窗里那个按钮保留,用于重跑 / 补生成)。后端 generate-base-asset 已带 auto_triview 自动接力, + // 这里只做「保持转圈 + 兜底」:立绘落库后先确认后端已把三视图任务排上,排上了就把 loading 交给 + // pending-assets 轮询;真没排上(旧后端 / 接力异常)才补发一次,避免重复出图重复扣费。 + async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string) { + const res = await genBaseAsset("person", prompt, label, busyKey, referenceAssetId); + const portraitId = res?.adopted_asset || ""; + if (!portraitId) return res; + // 立绘的 busy 已在 genBaseAsset 里落回,这里立刻把三视图的 busy 顶上,卡片不闪「已就绪」 + const triKeys = [...new Set([busyKey, ...entityGenKeys("person", label)])].map((k) => `${k}:tri`); + triKeys.forEach(addBusy); + try { + let chained = false; + for (let i = 0; i < 3 && !chained; i += 1) { + try { + const pending = (await api.pendingAssets(project.id)).pending || []; + chained = pending.some((p) => p.is_triview && p.triview_of === portraitId); + } catch { + /* 网络抖动:下一轮再看 */ + } + if (!chained && i < 2) await new Promise((resolve) => window.setTimeout(resolve, 3000)); + } + if (!chained) await onGenerateTriview(portraitId); + } finally { + triKeys.forEach(delBusy); + } + return res; } // ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ── // idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏 @@ -906,8 +930,7 @@ export function PipelinePage(props: { for (const e of entities) { const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`; if (e.type === "character") { - if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); - else await genBaseAsset("person", e.visual_prompt, e.name, bk); + await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘出完自动接力三视图 } else { await genBaseAsset("scene", e.visual_prompt, e.name, bk); } @@ -2944,6 +2967,7 @@ export function PipelinePage(props: { if (activeDot !== 2 && viewStage !== 2) return; let stopped = false; let timer = 0; + let idleTicks = 0; // 连续「无在途」次数:立绘刚出片、后端接力的三视图任务可能晚半拍才入库,别一空就停 const tick = async () => { if (document.hidden) { if (!stopped) timer = window.setTimeout(tick, 4000); return; } // 后台不空跑(纯读),保留心跳回前台即恢复 try { @@ -2961,7 +2985,9 @@ export function PipelinePage(props: { prevPendingIdsRef.current = list.map((pending) => pending.id); setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of }))); // 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。 - if (list.length === 0 && genBusy.size === 0) { stopped = true; return; } + // 连空两轮(~8s)才停:立绘落库瞬间后端才接力建三视图任务,一空就停会把「三视图生成中」整条漏掉。 + idleTicks = list.length === 0 && genBusy.size === 0 ? idleTicks + 1 : 0; + if (idleTicks >= 2) { stopped = true; return; } } catch { /* 忽略,下一轮再试 */ } @@ -3722,7 +3748,7 @@ export function PipelinePage(props: { {kind === "scene" ? : } {kind === "scene" ? "场景库替换" : "模特库替换"} -
@@ -3738,7 +3764,6 @@ export function PipelinePage(props: { const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name)); const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity)); const busy = portraitBusy || triBusy; - const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…"; const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`; return ( @@ -3763,7 +3788,7 @@ export function PipelinePage(props: { onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }} > {kind === "person" && mainUrl ? : null} - {busy ? {loadingText} : null} + {busy ? 生成中… : null}

openAssetDetail(kind, entity)}>{entity.name}

@@ -3789,7 +3814,7 @@ export function PipelinePage(props: { {kind === "scene" ? : } {kind === "scene" ? "场景库替换" : "模特库替换"} -
@@ -4743,7 +4768,8 @@ export function PipelinePage(props: { // 重跑立绘 → 追加新立绘并自动接力该版三视图。 // 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。 const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined; - await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref); + if (isPerson) await genPersonPortrait(prompt, entity!.name, pBK, ref); + else await genBaseAsset("scene", prompt, entity!.name, pBK); setAdPortraitId(null); setAdTriId(null); } async function regenTri() { @@ -4770,7 +4796,7 @@ export function PipelinePage(props: { {/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
{!portraitUrl && (busyPortrait - ?
立绘生成中…
+ ?
生成中…
: 立绘)}
{portraitUrl && } @@ -4805,7 +4831,7 @@ export function PipelinePage(props: { 避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */}
{!triUrl && (busyTri - ?
三视图生成中…
+ ?
生成中…
: 正 / 侧 / 背 · 三视图)} {triUrl && busyTri &&
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 47890ea..8f6ca69 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react"; import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays"; +import { useFileDrop } from "../components/use-file-drop"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { ProductCreateDrawer } from "../components/product-create-drawer"; import { @@ -786,9 +787,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na return () => { alive = false; }; }, [product?.id, product.cover_asset, assetReload]); + const imgDrop = useFileDrop((files) => { void uploadProductImage(files[0]); }, { disabled: uploading }); + async function onPickProductImage(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; + await uploadProductImage(file); + } + + async function uploadProductImage(file?: File | null) { if (!file || !onUploadImage) return; setUploading(true); try { @@ -1163,7 +1170,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
); })} -
imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> +
imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}> {uploading ? ( 上传中… ) : ( diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx index adefb2d..9f4bc43 100644 --- a/core/frontend/src/routes/quick-create.tsx +++ b/core/frontend/src/routes/quick-create.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { api, ApiError, type QuickCreateJob } from "../api"; import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays"; +import { useFileDrop } from "../components/use-file-drop"; import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock"; import type { ModelConfig } from "../types"; import { @@ -135,6 +136,9 @@ export function QuickCreatePage({ const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); + // 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器, + // 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。 + const [restoring, setRestoring] = useState(true); const [serviceUnavailable, setServiceUnavailable] = useState(false); const [unavailableMessage, setUnavailableMessage] = useState(""); const [pollEpoch, setPollEpoch] = useState(0); @@ -183,8 +187,19 @@ export function QuickCreatePage({ })) .catch(() => undefined); void api.quickCreateHistory() - .then((payload) => setHistory((payload.results || []).filter(jobIsComplete))) - .catch(() => undefined); + .then((payload) => { + setHistory((payload.results || []).filter(jobIsComplete)); + const running = payload.inflight || null; + if (running) { + setJob(running); + setJobId(running.id); + rememberQuickCreateJob(running.id); + } else if (!savedJobId()) { + forgetQuickCreateJob(); + } + }) + .catch(() => undefined) + .finally(() => setRestoring(false)); }, []); useEffect(() => { @@ -325,7 +340,7 @@ export function QuickCreatePage({ setPlaying({ url, title: title || "预览视频" }); } - function selectImages(files: FileList | null) { + function selectImages(files: FileList | File[] | null) { const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/")); setImages((current) => { const room = Math.max(0, 9 - savedImages.length - current.length); @@ -386,6 +401,10 @@ export function QuickCreatePage({ } async function startGeneration() { + if (restoring) { + onNotify?.("info", "正在读取任务状态,请稍候"); + return; + } if (!name.trim() || (!images.length && !savedImages.length)) { onNotify?.("info", "请先填写商品名称并上传商品图片"); return; @@ -433,7 +452,16 @@ export function QuickCreatePage({ onNotify?.("success", "一键成片任务已启动"); onProjectCreated?.(); } catch (error) { - if (error instanceof ApiError && error.status === 503) { + if (error instanceof ApiError && error.status === 409) { + // 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。 + const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight; + if (running) { + setJob(running); + setJobId(running.id); + rememberQuickCreateJob(running.id); + } + onNotify?.("info", error.message || "已有一个一键成片正在进行中"); + } else if (error instanceof ApiError && error.status === 503) { setServiceUnavailable(true); setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试"); onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试"); @@ -520,6 +548,7 @@ export function QuickCreatePage({ } const isGenerating = submitting || job?.status === "queued" || job?.status === "running"; + const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating }); const isComplete = job?.status === "succeeded"; const isCancelled = job?.status === "cancelled"; const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable); @@ -547,6 +576,7 @@ export function QuickCreatePage({ const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240; const shellClass = [ "quick-create-shell", + restoring ? "is-restoring" : "", isGenerating ? "is-generating" : "", isComplete ? "is-complete" : "", isFailed ? "is-failed" : "", @@ -562,7 +592,11 @@ export function QuickCreatePage({
-
+

告诉我们这是什么商品

@@ -614,7 +648,10 @@ export function QuickCreatePage({
商品图片必填 · 最多9张 -
+
{ selectImages(event.target.files); event.currentTarget.value = ""; }} /> {imageCount ? (
@@ -686,6 +723,12 @@ export function QuickCreatePage({
+
+ +

核心参数可选,其余自动完成

diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index 8028492..b040d4f 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import type { LoginSession, Team, User, UserPreference } from "../types"; import { TeamModal } from "../components/overlays"; +import { useFileDrop } from "../components/use-file-drop"; type SectionKey = "profile" | "security" | "notify" | "pref" | "display"; @@ -346,13 +347,18 @@ export function SettingsPage({ setModal("avatar"); } - function onPickAvatar(event: ChangeEvent) { - const file = event.target.files?.[0]; + function acceptAvatar(file?: File | null) { if (!file) return; setAvatarFile(file); setAvatarPreview(URL.createObjectURL(file)); } + function onPickAvatar(event: ChangeEvent) { + acceptAvatar(event.target.files?.[0]); + } + + const avatarDrop = useFileDrop((files) => acceptAvatar(files[0]), { disabled: savingAvatar }); + async function handleUploadAvatar() { if (!avatarFile || savingAvatar) return; setSavingAvatar(true); @@ -717,7 +723,8 @@ export function SettingsPage({ onChange={onPickAvatar} />
-
点击选择 · 图片文件
+
{avatarDrop.dragging ? "松开即可上传" : "点击选择"} · 图片文件
JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256
diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index 74f1f36..c8dfb04 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -13,6 +13,7 @@ import { PanelsTopLeft, Play, RectangleVertical, + RefreshCw, Save, ScanLine, ScanSearch, @@ -21,6 +22,7 @@ import { } from "lucide-react"; import { api, ApiError } from "../api"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; +import { useFileDrop } from "../components/use-file-drop"; import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types"; import type { NavigateFn } from "./route-config"; @@ -167,6 +169,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: const [openHistoryId, setOpenHistoryId] = useState(""); const [playing, setPlaying] = useState(null); const [confirmCancel, setConfirmCancel] = useState(false); + // 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度 + const [restoring, setRestoring] = useState(true); const promptRef = useRef(null); const completedNoticeRef = useRef(""); const abortRef = useRef(null); @@ -236,21 +240,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: } let cancelled = false; void (async () => { - await loadHistory(); + const listed = await loadHistory(); if (cancelled) return; + // 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、 + // 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。 + const inflight = listed?.inflight || null; const stored = readJobId(); - if (!stored) return; + const restoreId = (inflight ? jobIdOf(inflight) : "") || stored; + if (!restoreId) { + forgetJob(); + setRestoring(false); + return; + } try { - const job = await api.getVideoDigest(stored); + const job = inflight && jobIdOf(inflight) === restoreId + ? inflight + : await api.getVideoDigest(restoreId); if (cancelled) return; if (job.status === "processing") { - if (!job.video_url && !job.cover_url) { - forgetJob(); - void api.cancelVideoDigest(stored).catch(() => undefined); - return; - } + // 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的 + // 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询, + // 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。 applyJobMeta(job); - setJobId(stored); + setJobId(restoreId); + rememberJob(restoreId); + setRestoring(false); return; } if (job.status === "succeeded") { @@ -260,7 +274,9 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: /* 过期 / 已取消 / 不存在:当没任务 */ } forgetJob(); - })(); + })().finally(() => { + if (!cancelled) setRestoring(false); + }); return () => { cancelled = true; }; @@ -322,6 +338,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: }; }, [pollId, onNotify]); + const videoDrop = useFileDrop( + (files) => { void pickFile(files[0] || null); }, + { disabled: analyzing } + ); + const pickFile = async (next: File | null) => { if (!next || analyzing) return; if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) { @@ -391,6 +412,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: setJobId(id); } catch (error) { if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return; + if (error instanceof ApiError && error.status === 409) { + // 后端单飞闸:已有提炼在跑。直接接上它,别让用户对着报错干瞪眼。 + const running = (error.payload as { inflight?: VideoDigestJob } | undefined)?.inflight; + if (running) { + const id = jobIdOf(running); + applyJobMeta(running); + rememberJob(id); + setWatchId(""); + setJobId(id); + } + onNotify("info", error.message || "已有一个视频正在提炼中"); + return; + } onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); } finally { setSubmitting(false); @@ -460,6 +494,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload"; const panelClass = [ "video-result-panel remix-information-panel", + restoring ? "is-restoring" : "", hasResult ? "has-result" : "", analyzing ? "is-analyzing" : "", ].filter(Boolean).join(" "); @@ -496,32 +531,54 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
-
+

上传参考视频

参考视频 - MP4 / MOV · 最长 60 秒 + MP4 / MOV · 最长 3 分钟 · ≤200MB
{previewUrl ? ( -
-
diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index 4c8dbe0..c0cebdb 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { api, ApiError } from "../api"; import { OverlayPortal, useBodyScrollLock } from "../components/overlays"; +import { useFileDrop } from "../components/use-file-drop"; import { DEFAULT_BILLING_RATES, FC_MODELS, @@ -37,6 +38,13 @@ const JOB_KEY = "airshelf:video-replace-job"; const MODE_KEY = "airshelf:video-replace-mode"; const CHARACTER_MARK = "[视频复刻·角色]"; const VIDEO_ACCEPT = "video/mp4,video/quicktime"; +// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒; +// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。 +const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; +const REF_SECONDS_MAX = { product: 60, character: 15 } as const; +const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const; +// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编。 +const SEEDANCE_MAX_OUTPUT_SECONDS = 15; type ProductSource = "library" | "temporary" | ""; type ReplaceMode = "product" | "character"; @@ -50,7 +58,7 @@ const REPLACE_MODE_COPY = { videoReady: "视频已就绪,将先提炼分镜稿再复刻", libraryTitle: "从商品库选择", libraryEmpty: "选择已创建的商品", - temporaryTitle: "临时上传商品", + temporaryTitle: "临时上传素材", temporaryEmpty: "仅用于本次任务 · 最多 9 张", temporaryNoun: "商品图", temporaryFallback: "临时商品素材", @@ -170,8 +178,9 @@ function formatClock(seconds: number) { } function clampDuration(seconds: number) { - const rounded = Math.round(Number(seconds) || 15); - return Math.min(15, Math.max(4, rounded || 15)); + const rounded = Math.round(Number(seconds) || SEEDANCE_MAX_OUTPUT_SECONDS); + // 60 秒参考视频不是错误:成片取火山能出的最长,分镜稿由模型压缩改编。 + return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS)); } function isCharacterRemix(task?: Partial | null) { @@ -240,6 +249,24 @@ function productImageCount(product: Product) { return product.images?.filter((image) => image.asset || image.preview_url).length || 0; } +/** 选中后这次实际会带给模型的参考图。三视图是 standalone 资产,不在 images 里,单独点名。 */ +function librarySelectionDetail(mode: ReplaceMode, product: Product | null, model: ModelEntity | null) { + if (mode === "character") { + if (!model) return ""; + const parts = [model.portrait ? "定妆照" : "", model.triview ? "三视图" : ""].filter(Boolean); + return parts.length ? `将带上 ${parts.join(" + ")}` : ""; + } + if (!product) return ""; + const count = productImageCount(product) || (product.cover_preview_url ? 1 : 0); + const parts = [count ? `${count} 张商品图` : ""]; + parts.push(product.triview_preview_url ? "三视图" : ""); + const kept = parts.filter(Boolean); + if (!kept.length) return ""; + return product.triview_preview_url + ? `将带上 ${kept.join(" + ")}` + : `将带上 ${kept.join("")} · 该商品还没有三视图`; +} + function modelCover(model: ModelEntity) { return model.portrait || model.triview || ""; } @@ -266,8 +293,12 @@ export function VideoReplacePage({ const [models, setModels] = useState([]); const [replaceMode, setReplaceMode] = useState(readReplaceMode); const [videoFile, setVideoFile] = useState(null); + // 选完立刻用本地 objectURL 放预览,不等上传回来 —— 用户要先看见自己选的片子 + const [videoPreview, setVideoPreview] = useState(""); const [videoRef, setVideoRef] = useState(null); const [videoUploading, setVideoUploading] = useState(false); + // 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度 + const [restoring, setRestoring] = useState(true); const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); const [source, setSource] = useState(""); const [selectedProduct, setSelectedProduct] = useState(null); @@ -314,6 +345,7 @@ export function VideoReplacePage({ const productReady = source === "library" ? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct)) : (tempFiles.length > 0 || tempAssetRefs.length > 0); + const librarySelected = source === "library" && Boolean(replaceMode === "character" ? selectedModel : selectedProduct); const libraryPreview = replaceMode === "character" ? (selectedModel ? modelCover(selectedModel) : "") : (selectedProduct ? productCover(selectedProduct) : ""); @@ -345,7 +377,9 @@ export function VideoReplacePage({ ], }, billingRates); const points = estimated.points || 220; - const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading; + // 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。 + // 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。 + const generating = Boolean(job && isInFlight(job.status)) || submitting; const digesting = Boolean(job && isDigesting(job)); // 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。 const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created"))); @@ -353,6 +387,7 @@ export function VideoReplacePage({ const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy; const panelClass = [ "video-result-panel replace-result-panel", + restoring ? "is-restoring" : "", generating ? "is-generating" : "", reviewing || digesting ? "is-reviewing" : "", hasResult ? "has-result" : "", @@ -367,11 +402,40 @@ export function VideoReplacePage({ try { const data = await api.videoReplaceTasks(0, 50); setHistory((data.results || []).filter((item) => item.status === "succeeded")); + return data; } catch { /* 历史失败不挡当前复刻 */ + return null; } }; + // 恢复在跑的任务。拉到之前一律显示「读取中」——否则用户以为没任务,又传一次。 + const restoreInflight = async () => { + const data = await loadHistory(); + const running = data?.inflight || null; + if (running) { + // 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面, + // 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。 + setJob(running); + setJobId(running.id); + rememberJob(running.id); + const mode = modeFromTask(running); + setReplaceMode(mode); + rememberReplaceMode(mode); + const video = videoRefFromTask(running); + if (video?.url) setVideoPreview(video.url); + setVideoRef(video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null); + setFilledSubjectName(subjectNameFromTask(running)); + setVideoMeta({ + duration: Number(video?.duration || running.duration || 0), + ...sizeFromRatio(running.aspect_ratio || "9:16"), + }); + } else { + forgetJob(); + } + setRestoring(false); + }; + useEffect(() => { void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined); void api.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined); @@ -382,7 +446,7 @@ export function VideoReplacePage({ multiplier: Number(config.team_price_multiplier) || 1, })) .catch(() => undefined); - void loadHistory(); + void restoreInflight(); }, []); useEffect(() => { @@ -455,7 +519,10 @@ export function VideoReplacePage({ const pickVideo = async (file: File | null) => { if (!file) return; - const check = await checkRefFile(file); + const check = await checkRefFile(file, { + maxSeconds: REF_SECONDS_MAX[replaceMode], + maxVideoBytes: REF_BYTES_MAX[replaceMode], + }); if (!check.ok) { onNotify("error", check.error); return; @@ -465,11 +532,13 @@ export function VideoReplacePage({ return; } setVideoFile(file); + setVideoPreview(URL.createObjectURL(file)); setVideoUploading(true); setJob((current) => (current && isInFlight(current.status) ? current : null)); if (job && !isInFlight(job.status)) setJob(null); const form = new FormData(); form.append("file", file); + if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE); try { const uploaded = await api.uploadFreeVideoRef(form); setVideoRef({ @@ -490,13 +559,14 @@ export function VideoReplacePage({ } catch (error) { setVideoFile(null); setVideoRef(null); + setVideoPreview(""); onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); } finally { setVideoUploading(false); } }; - const addTempImages = (files: FileList | null) => { + const addTempImages = (files: FileList | File[] | null) => { const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type)); if (!incoming.length) { onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); @@ -530,8 +600,33 @@ export function VideoReplacePage({ if (job && !isInFlight(job.status)) setJob(null); }; + useEffect(() => { + // 换一条预览或离开页面时释放 objectURL,不然一路选下去会攒一堆 blob + if (!videoPreview.startsWith("blob:")) return; + return () => URL.revokeObjectURL(videoPreview); + }, [videoPreview]); + + const tempDrop = useFileDrop( + (files) => addTempImages(files), + { disabled: generating } + ); + + const videoDrop = useFileDrop( + (files) => { void pickVideo(files[0] || null); }, + { disabled: generating || videoUploading } + ); + const switchReplaceMode = (next: ReplaceMode) => { if (next === replaceMode || generating) return; + // 商品复刻能传 60 秒,角色复刻只能 15 秒。带着超长视频切过去会一路走到提交才报错, + // 这里直接清掉并说明原因。 + const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5; + if (tooLongForNext) { + setVideoFile(null); + setVideoRef(null); + setVideoPreview(""); + setVideoMeta({ duration: 0, width: 0, height: 0 }); + } setReplaceMode(next); rememberReplaceMode(next); setSource(""); @@ -544,7 +639,12 @@ export function VideoReplacePage({ setPendingModelId(""); setLibraryOpen(false); if (job && !isInFlight(job.status)) setJob(null); - onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`); + onNotify( + "info", + tooLongForNext + ? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传` + : `已切换为${REPLACE_MODE_COPY[next].modeLabel}` + ); }; const confirmLibrarySelection = () => { @@ -647,6 +747,17 @@ export function VideoReplacePage({ forgetJob(); } } catch (error) { + if (error instanceof ApiError && error.status === 409) { + // 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。 + const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight; + if (running) { + setJob(running); + setJobId(running.id); + rememberJob(running.id); + } + onNotify("info", error.message || "已有一个视频正在复刻中"); + return; + } onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败"); } finally { setSubmitting(false); @@ -666,6 +777,8 @@ export function VideoReplacePage({ setReplaceMode(mode); rememberReplaceMode(mode); setVideoFile(null); + // 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选 + setVideoPreview(video.url || ""); setVideoRef({ ...video, type: "video", @@ -750,7 +863,11 @@ export function VideoReplacePage({
-
+

准备复刻素材

+
+ ) : ( + + )} +
@@ -810,30 +963,34 @@ export function VideoReplacePage({
tempInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { @@ -846,7 +1003,13 @@ export function VideoReplacePage({ {copy.temporaryTitle} - {tempDisplay.length ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` : copy.temporaryEmpty} + + {tempDrop.dragging + ? "松开即可添加" + : tempDisplay.length + ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` + : copy.temporaryEmpty} + {tempDisplay.length ? ( @@ -924,7 +1087,7 @@ export function VideoReplacePage({