From a618f02315d34eec9153f63b1200a9d61bed436b Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 28 Aug 2026 16:03:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0seedance2.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/catalog.py | 23 ++++ core/backend/apps/ai/free_video.py | 59 +++++++++-- .../0033_seedance_25_capabilities.py | 100 ++++++++++++++++++ core/backend/apps/ai/test_free_video.py | 13 ++- core/backend/apps/ai/test_video_digest.py | 5 +- core/backend/apps/ai/test_video_replace.py | 99 +++++++++++++---- core/backend/apps/ai/video_digest.py | 6 +- core/backend/apps/ai/video_replace.py | 45 +++++--- core/backend/apps/ai/views.py | 4 +- .../src/components/free-create/constants.ts | 29 ++++- .../src/components/free-create/toolbar.tsx | 48 +++++++-- core/frontend/src/free-create-page.css | 13 +++ core/frontend/src/routes/free-create.tsx | 14 ++- core/frontend/src/routes/pipeline.tsx | 14 ++- core/frontend/src/routes/video-remix.tsx | 2 +- core/frontend/src/routes/video-replace.tsx | 22 ++-- 16 files changed, 416 insertions(+), 80 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0033_seedance_25_capabilities.py diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py index 6ba0021..df00b21 100644 --- a/core/backend/apps/ai/catalog.py +++ b/core/backend/apps/ai/catalog.py @@ -65,6 +65,29 @@ VOLCANO_MODELS = [ "source": "video-flow/data/vendor/volcengine.ts", }, }, + { + # 视频复刻(商品 / 角色)固定用这一档:只有它支持 30 秒单次出片。 + # ⚠️ pricing 暂用 Seedance-2.0 标准档的数字占位 —— 火山 2.5 的官方价目待核对。 + # 偏低只会让平台少收,不会多扣用户;核准后改这里(或直接在运营后台改 metadata.pricing)。 + "display_name": "Seedance-2.5", + "name": "doubao-seedance-2-5-260628", + "capability": "video", + "endpoint": "contents/generations/tasks", + "metadata": { + "audio": "optional", + "modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"], + "durations": list(range(4, 31)), + "resolutions": ["480p", "720p", "1080p", "4k"], + "watermark": False, + "source": "volcengine ark · Seedance 2.5", + "pricing": { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 46, "with_ref_video": 28}, + "1080p": {"no_ref_video": 51, "with_ref_video": 31}, + "4k": {"no_ref_video": 26, "with_ref_video": 16}, + }, + }, + }, { "display_name": "Seedance-2.0", "name": "doubao-seedance-2-0-260128", diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 5436ae7..f03554e 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -38,11 +38,36 @@ from .video_pricing import get_resolution logger = logging.getLogger(__name__) FREE_VIDEO_MODELS = { + "doubao-seedance-2-5-260628", "doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128", "doubao-seedance-2-0-mini-260615", } HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制) +# 视频复刻固定用这一档:只有它支持 30 秒单次出片 +REPLACE_MODEL = "doubao-seedance-2-5-260628" +# 出片时长的兜底上限。真实上限按 ModelConfig.metadata.durations 取(见 model_duration_range), +# 元数据缺失时回落这里 —— 别写死 15,否则新模型上来就被老常量卡住。 +DEFAULT_MAX_DURATION = 15 +MIN_DURATION = 4 + + +def model_duration_range(model_config) -> tuple[int, int]: + """模型支持的出片时长区间。取 metadata.durations(catalog / 后台可配),缺失回落 4–15。""" + meta = (model_config.metadata or {}) if model_config is not None else {} + durations = (meta.get("capabilities") or {}).get("durations") or meta.get("durations") or [] + values = [int(v) for v in durations if str(v).isdigit()] + if not values: + return MIN_DURATION, DEFAULT_MAX_DURATION + return min(values), max(values) + + +def model_resolutions(model_config) -> set[str]: + """模型支持的分辨率档。缺失回落全集,交给火山自己拒 —— 总比把能用的档误拒好。""" + meta = (model_config.metadata or {}) if model_config is not None else {} + listed = (meta.get("capabilities") or {}).get("resolutions") or meta.get("resolutions") or [] + values = {str(v) for v in listed if str(v) in RESOLUTIONS} + return values or set(RESOLUTIONS) RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} RESOLUTIONS = {"480p", "720p", "1080p", "4k"} MODES = {"universal", "keyframe"} @@ -126,7 +151,9 @@ def _guard_asset_reference(asset: Asset, label: str) -> None: raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用") -def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict: +def build_content_items( + *, team, prompt: str, mode: str, references: list, max_ref_seconds: float = REF_DURATION_MAX +) -> dict: """references → 火山 content_items + api_prompt(@label 已替换)。 移植 jimeng views.py:369-567:URL 去重、blob: 拦截、素材库引用(FreeAsset → asset://)、 @@ -322,8 +349,9 @@ 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 > REF_DURATION_MAX: - raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试") + if video_duration_total > max_ref_seconds: + # 上限跟着模型走:自由创作是 Seedance 2.0(15 秒),视频复刻走 2.5(30 秒)。 + raise ValueError(f"参考视频总时长不能超过 {int(max_ref_seconds)} 秒,请缩短后重试") # @label 替换:按 label 长度降序,防子串吞噬 ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) @@ -444,10 +472,6 @@ def submit_free_video(*, team, user, params: dict) -> AITask: raise ValueError("画面比例无效") if resolution not in RESOLUTIONS: raise ValueError("分辨率无效") - if not 4 <= duration <= 15: - raise ValueError("视频时长需在 4-15 秒之间") - if resolution in ("1080p", "4k") and model_name != HIGH_RES_MODEL: - raise ValueError(f"{resolution} 仅标准档模型支持,请切换模型或降低分辨率") get_resolution(aspect_ratio, resolution) # 组合合法性 fail loud orphan = find_orphan_material_mention(prompt, references) @@ -471,6 +495,17 @@ def submit_free_video(*, team, user, params: dict) -> AITask: if model_config is None: raise ValueError("视频模型未配置,请联系管理员") + # 分辨率与时长都按所选模型自己的能力判,不再写死「1080p/4k 仅 2.0 标准档」—— + # Seedance 2.5 同样支持 1080p/4k,写死会把它误拒。能力表见 ModelConfig.metadata。 + allowed_resolutions = model_resolutions(model_config) + if resolution not in allowed_resolutions: + raise ValueError( + f"{model_config.display_name or model_name} 不支持 {resolution},请切换模型或降低分辨率" + ) + min_seconds, max_seconds = model_duration_range(model_config) + if not min_seconds <= duration <= max_seconds: + raise ValueError(f"视频时长需在 {min_seconds}-{max_seconds} 秒之间") + _reap_stale_free_video_tasks(team=team) # 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发 @@ -481,7 +516,10 @@ def submit_free_video(*, team, user, params: dict) -> AITask: if in_flight >= max_concurrent: raise ValueError(f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交") - built = build_content_items(team=team, prompt=prompt, mode=mode, references=references) + built = build_content_items( + team=team, prompt=prompt, mode=mode, references=references, + max_ref_seconds=max_seconds, + ) # 统一计价引擎:¥成本 × 毛利系数 → 积分;预留 = 积分 × buffer(均为 BillingConfig 可配) tokens, quote = quote_video_estimate( @@ -583,7 +621,10 @@ def start_pending_free_video(task: AITask) -> AITask: seed = -1 references = payload.get("references") or [] try: - built = build_content_items(team=task.team, prompt=prompt, mode=mode, references=references) + built = build_content_items( + team=task.team, prompt=prompt, mode=mode, references=references, + max_ref_seconds=model_duration_range(task.model_config)[1], + ) except ValueError as exc: message = str(exc) if "正在审核" in message or "已提交审核" in message or "尚未完成合规审核" in message: diff --git a/core/backend/apps/ai/migrations/0033_seedance_25_capabilities.py b/core/backend/apps/ai/migrations/0033_seedance_25_capabilities.py new file mode 100644 index 0000000..c1ae5bf --- /dev/null +++ b/core/backend/apps/ai/migrations/0033_seedance_25_capabilities.py @@ -0,0 +1,100 @@ +"""给 Seedance-2.5 补齐能力与定价元数据。 + +这条模型已经在生产库里(后台加的),但 metadata 装的是 Seedance-2.0 的能力 +(durations 只到 15),而且缺 pricing —— 缺 pricing 会让 get_token_price 直接抛 +ValueError,每次提交都失败;durations 停在 15 则拿不到「支持 30 秒」这个信息。 +视频复刻(商品 / 角色)固定走这一档,两样都必须是对的。 + +覆盖策略分两类: +- 能力事实(modes / durations / resolutions / capabilities / routing / audio)**强制覆盖** —— + 它们描述模型本身能做什么,不是运营偏好。留着 2.0 抄过来的 15 秒会让 30 秒静默失效。 +- 运营可调项(pricing / watermark / source)只在缺失时填 —— 后台改过的价目不能被迁移冲掉。 + +⚠️ pricing 暂用 Seedance-2.0 标准档数字占位,火山 2.5 官方价目待核对(见 catalog.py 注释)。 +幂等:可重复 apply。 +""" +from django.db import migrations + +NAME = "doubao-seedance-2-5-260628" +DURATIONS = list(range(4, 31)) +RESOLUTIONS = ["480p", "720p", "1080p", "4k"] +MODES = ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"] + +# 与 apps/ai/catalog.py 的 Seedance-2.5 条目逐键对齐(那边由 _enrich_catalog_routing 自动生成) +CAPABILITY_FACTS = { + "audio": "optional", + "modes": MODES, + "durations": DURATIONS, + "resolutions": RESOLUTIONS, + "routing": {"fallback_on_failure": False, "fallback_candidate": True}, + "capabilities": { + "operations": ["video_generate"], + "features": [ + "text_to_video", "start_frame", "image_reference", + "video_reference", "audio_reference", "generate_audio", + ], + "max_reference_images": 9, + "max_reference_videos": 3, + "max_reference_audios": 3, + "aspect_ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"], + "resolutions": RESOLUTIONS, + "durations": DURATIONS, + }, +} + +OPERATOR_TUNABLE = { + "watermark": False, + "source": "volcengine ark · Seedance 2.5", + "pricing": { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 46, "with_ref_video": 28}, + "1080p": {"no_ref_video": 51, "with_ref_video": 31}, + "4k": {"no_ref_video": 26, "with_ref_video": 16}, + }, +} + + +def seed(apps, schema_editor): + ModelProvider = apps.get_model("ai", "ModelProvider") + ModelConfig = apps.get_model("ai", "ModelConfig") + + provider, _ = ModelProvider.objects.get_or_create( + name="volcengine", + defaults={ + "display_name": "火山引擎(豆包)", + "status": "active", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + }, + ) + # 生产库里这行已存在,只修元数据;其它环境(测试 / 全新部署)则建出来。 + # created_at 晚于现有视频模型 → get_default_model(VIDEO) 仍取原默认,主流程零影响。 + ModelConfig.objects.get_or_create( + provider=provider, + name=NAME, + capability="video", + defaults={ + "display_name": "Seedance-2.5", + "endpoint": "contents/generations/tasks", + "status": "active", + "is_default": False, + "metadata": {**OPERATOR_TUNABLE, **CAPABILITY_FACTS}, + }, + ) + for config in ModelConfig.objects.filter(name=NAME, capability="video"): + metadata = dict(config.metadata or {}) + for key, value in OPERATOR_TUNABLE.items(): + metadata.setdefault(key, value) + metadata.update(CAPABILITY_FACTS) # 能力事实强制纠正 + config.metadata = metadata + if not config.endpoint: + config.endpoint = "contents/generations/tasks" + config.save(update_fields=["metadata", "endpoint", "updated_at"]) + + +def noop(apps, schema_editor): + """只补元数据,回滚不删 —— 删了会让在途任务计价失败。""" + + +class Migration(migrations.Migration): + dependencies = [("ai", "0032_drop_storyboard_prompt_template")] + operations = [migrations.RunPython(seed, noop)] diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index ab4a74d..4a64663 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -326,11 +326,22 @@ class SubmitFreeVideoTests(TestCase): self.assertNotIn("Request id", notification.body) def test_fast_1080p_rejected(self): - with self.assertRaisesMessage(ValueError, "仅标准档"): + """分辨率按模型自己的 metadata.resolutions 判(Fast 只有 480p/720p), + 不再写死「仅 2.0 标准档」—— 那会把同样支持 1080p 的 Seedance 2.5 一起误拒。""" + with self.assertRaisesMessage(ValueError, "不支持 1080p"): submit_free_video( team=self.team, user=self.user, params=self._params(model=FAST, resolution="1080p") ) + def test_seedance_25_allows_1080p_and_30s(self): + """2.5 的能力表里有 1080p 和 30 秒,不能被老常量卡住。""" + from apps.ai.free_video import model_duration_range, model_resolutions + + config = ModelConfig.objects.get(name="doubao-seedance-2-5-260628", capability="video") + self.assertIn("1080p", model_resolutions(config)) + self.assertIn("4k", model_resolutions(config)) + self.assertEqual(model_duration_range(config), (4, 30)) + def test_orphan_mention_rejected(self): with self.assertRaisesMessage(ValueError, "对应的内容为空"): submit_free_video(team=self.team, user=self.user, params=self._params(prompt="让 @图片1 动")) diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index dea297d..c9a8239 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -85,10 +85,11 @@ class UploadGuardTests(SimpleTestCase): frames_from_upload(self._upload("broken.mp4")) def test_rejects_clip_longer_than_cap(self): - """超长片抽样密度不够,拆出来是错的,宁可让用户先剪。""" + """上限 30 秒 = 复刻用的 Seedance 2.5 单次出片上限;更长的参考片用不上, + 抽样密度也不够,拆出来是错的,宁可让用户先剪。""" with self.assertRaises(VideoDigestError) as ctx: _digest_with_duration(_synth_clip(seconds=1), MAX_DURATION_SECONDS + 1) - self.assertIn("分钟", str(ctx.exception)) + self.assertIn("30 秒", str(ctx.exception)) def test_accepts_clip_within_cap_and_returns_frames(self): frames, duration = frames_from_upload(_synth_clip(seconds=6)) diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 898d2d6..1ff1a93 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -473,37 +473,37 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(task.request_payload["replace_mode"], "character") self.assertEqual(task.request_payload["subject_name"], "薇薇") - def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self): - """商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒; - 角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。""" + def test_both_modes_accept_up_to_30s(self): + """商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。""" 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) + too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=45000) - # 商品复刻: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)) + # 单飞闸只允许一条在跑,所以每次提交后收掉再试下一条 + product_task = self._retire(self._submit(video_asset_id=str(mid.id))) + self.assertEqual(product_task.status, AITask.Status.CANCELLED) + self.assertEqual(product_task.request_payload["model"], "doubao-seedance-2-5-260628") - # 角色复刻:超过 15 秒仍然拒 - with self.assertRaisesMessage(ValueError, "不能超过 15 秒"): - self._submit( - replace_mode="character", product_id="", model_id=str(model.id), - video_asset_id=str(mid.id), - ) + character_task = self._retire(self._submit( + replace_mode="character", product_id="", model_id=str(model.id), + video_asset_id=str(mid.id), + )) + self.assertEqual(character_task.request_payload["model"], "doubao-seedance-2-5-260628") + + for mode, extra in (("product", {}), ("character", {"product_id": "", "model_id": str(model.id)})): + with self.assertRaisesMessage(ValueError, "不能超过 30 秒"): + self._submit(replace_mode=mode, video_asset_id=str(too_long.id), **extra) 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)) + long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=28000) + # 显式要一段短成片:参考 28 秒、成片 10 秒,差得多才要求模型压缩改编 + task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10)) prompt = task.request_payload["prompt"] self.assertIn("压缩改编", prompt) - self.assertIn("58 秒", prompt) - self.assertIn("15 秒", prompt) + self.assertIn("28 秒", prompt) + self.assertIn("10 秒", prompt) def test_short_source_skips_condense_note(self): """参考视频和成片时长差不多时别啰嗦。""" @@ -760,3 +760,60 @@ class VideoReplaceReviewGateTests(TestCase): self.assertIn("合规审核", task.error_message or REVIEW_FAILED) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) self.assertFalse(self.provider.create_video_task.called) + + +class Seedance25MetadataTests(TestCase): + """复刻固定走 Seedance-2.5,它的元数据必须是对的: + · 缺 pricing → get_token_price 抛 ValueError,每次提交都失败 + · durations 停在 15(从 2.0 抄来的)→ 30 秒静默失效 + """ + + def _apply_migration(self): + import importlib + + return importlib.import_module("apps.ai.migrations.0033_seedance_25_capabilities") + + def test_catalog_and_db_agree_on_30s(self): + from apps.ai.catalog import VOLCANO_MODELS + + entry = next(m for m in VOLCANO_MODELS if m["name"] == "doubao-seedance-2-5-260628") + self.assertEqual(max(entry["metadata"]["durations"]), 30) + self.assertEqual(max(entry["metadata"]["capabilities"]["durations"]), 30) + self.assertIn("pricing", entry["metadata"]) + + config = ModelConfig.objects.filter( + name="doubao-seedance-2-5-260628", capability="video", status="active" + ).first() + self.assertIsNotNone(config, "迁移应保证各环境都有这条模型") + self.assertEqual(max(config.metadata["durations"]), 30) + + def test_migration_corrects_stale_20_capabilities_but_keeps_operator_pricing(self): + """后台从 2.0 抄过来的元数据要被纠正;但运营自己改过的价目不能被冲掉。""" + module = self._apply_migration() + config = ModelConfig.objects.get(name="doubao-seedance-2-5-260628", capability="video") + config.metadata = { + "durations": list(range(4, 16)), # 2.0 的能力,过时 + "capabilities": {"durations": list(range(4, 16))}, + "pricing": {"unit": "cny_per_million_tokens", + "default": {"no_ref_video": 99, "with_ref_video": 66}}, # 运营核过的真价 + } + config.save(update_fields=["metadata"]) + + module.seed(apps_stub(), None) + + config.refresh_from_db() + self.assertEqual(max(config.metadata["durations"]), 30) # 能力被纠正 + self.assertEqual(max(config.metadata["capabilities"]["durations"]), 30) + self.assertEqual(config.metadata["pricing"]["default"]["no_ref_video"], 99) # 价目保住 + + +def apps_stub(): + """迁移里只用到 apps.get_model,直接给真模型即可。""" + from apps.ai.models import ModelConfig as _MC, ModelProvider as _MP + + class _Apps: + @staticmethod + def get_model(app_label, model_name): + return {"ModelConfig": _MC, "ModelProvider": _MP}[model_name] + + return _Apps() diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index cf2200c..27e888a 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -30,7 +30,9 @@ from django.conf import settings # 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱 ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm") MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB -MAX_DURATION_SECONDS = 180 # 3 分钟。带货参考片远短于此;更长的帧采样密度不够,拆出来也是错的 +# 30 秒。复刻走 Seedance 2.5,单次出片上限就是 30 秒 —— 参考片再长也用不上, +# 而且更长的帧采样密度不够,拆出来也是错的。半秒容差同 media_probe 的老规矩。 +MAX_DURATION_SECONDS = 30.5 # 官网直接传视频的上限大约是请求 20MB;base64 会胀到 4/3,所以原文件卡在 15MB。 INLINE_VIDEO_MAX_BYTES = 15 * 1024 * 1024 @@ -269,7 +271,7 @@ def _materialize_upload(upload) -> tuple[str, str, int, float]: if duration > MAX_DURATION_SECONDS: Path(path).unlink(missing_ok=True) raise VideoDigestError( - f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传" + f"视频不能超过 {int(MAX_DURATION_SECONDS)} 秒,请剪出要参考的那一段再传" ) return path, suffix, size, duration diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index a54f176..194a953 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -23,15 +23,16 @@ from apps.products.models import Product from .free_video import ( FREE_VIDEO_MODELS, HIGH_RES_MODEL, + REPLACE_MODEL, IN_FLIGHT_STATUSES, RATIOS, RESOLUTIONS, _reap_stale_free_video_tasks, + model_duration_range, serialize_free_video_task, start_pending_free_video, submit_free_video, ) -from .media_probe import REF_DURATION_MAX from .models import AITask, ModelConfig logger = logging.getLogger(__name__) @@ -43,9 +44,10 @@ 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 +# 复刻统一走 Seedance 2.5,单次能出 30 秒,所以参考视频也按 30 秒收口: +# 商品模式的参考视频只喂提炼模型,角色模式直传火山,两边都不需要超过成片上限的素材。 +# 半秒容差同 media_probe 的老规矩(30 秒成片常被探成 30.02)。 +REPLACE_REF_DURATION_MAX = 30.5 REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试" REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试" REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试" @@ -257,12 +259,8 @@ def submit_video_replace(*, team, user, params: dict): video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频") video_seconds = _asset_duration_seconds(video) - 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 video_seconds > REPLACE_REF_DURATION_MAX: + raise ValueError("参考视频不能超过 30 秒,请剪短后重试") if has_product: subject_name, image_refs, subject_source = _product_library_refs(team, product_id) @@ -282,7 +280,8 @@ def submit_video_replace(*, team, user, params: dict): } base_params = { "mode": "universal", - "model": str(params.get("model") or HIGH_RES_MODEL), + # 商品和角色都固定走 Seedance 2.5:只有它支持 30 秒单次出片,不接受前端指定别的档 + "model": REPLACE_MODEL, "aspect_ratio": str(params.get("aspect_ratio") or "9:16"), "resolution": str(params.get("resolution") or "720p"), "duration": duration, @@ -494,7 +493,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F 两种情况共用:①角色复刻的素材还在审核 ②商品复刻的参考视频还没拆解。 """ - model_name = str(params.get("model") or HIGH_RES_MODEL) + model_name = str(params.get("model") or REPLACE_MODEL) aspect_ratio = str(params.get("aspect_ratio") or "9:16") resolution = str(params.get("resolution") or "720p") try: @@ -507,8 +506,9 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F raise ValueError("画面比例无效") if resolution not in RESOLUTIONS: raise ValueError("分辨率无效") - if not 4 <= duration <= 15: - raise ValueError("视频时长需在 4-15 秒之间") + low, high = replace_duration_range() + if not low <= duration <= high: + raise ValueError(f"视频时长需在 {low}-{high} 秒之间") model_config = ( ModelConfig.objects.select_related("provider") @@ -754,16 +754,27 @@ def _asset_duration_seconds(asset: Asset) -> float: return primary.duration_ms / 1000.0 +def replace_duration_range() -> tuple[int, int]: + """复刻模型(Seedance 2.5)支持的出片时长区间。模型没配好时回落 4–15,不至于炸。""" + model = ( + ModelConfig.objects.filter( + name=REPLACE_MODEL, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE + ).first() + ) + return model_duration_range(model) + + def _output_duration(requested, video_seconds: float) -> int: + low, high = replace_duration_range() try: value = int(requested) if requested not in (None, "") else 0 except (TypeError, ValueError): value = 0 if value: - return min(15, max(4, value)) + return min(high, max(low, value)) if video_seconds: - return min(15, max(4, int(round(video_seconds)))) - return 15 + return min(high, max(low, int(round(video_seconds)))) + return high def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict: diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index ea8116e..4c23260 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1083,7 +1083,7 @@ _FREE_REF_VIDEO_MAX = 50 * 1024 * 1024 # 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。 _REPLACE_SOURCE_PURPOSE = "video_replace_product" _REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024 -_REPLACE_SOURCE_DURATION_MAX = 60.5 +_REPLACE_SOURCE_DURATION_MAX = 30.5 _FREE_REF_AUDIO_MAX = 15 * 1024 * 1024 @@ -1166,7 +1166,7 @@ class FreeVideoUploadView(APIView): if long_source and kind == "video": in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX - range_hint = "视频时长需在 2-60 秒之间" + range_hint = "视频时长需在 2-30 秒之间" else: in_range = duration_in_ref_range(duration) range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间" diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts index 57034ca..1f1454c 100644 --- a/core/frontend/src/components/free-create/constants.ts +++ b/core/frontend/src/components/free-create/constants.ts @@ -9,17 +9,44 @@ export type LocalRef = FreeVideoRef & { uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交) }; +// 只提供好看的标签/副标题;能出现哪些模型由后端返回的 modelConfigs 决定(见 free-create.tsx videoConfigs)。 +// 新模型上线只要后端有数据就会出现在下拉里,不再需要发一次前端。 export const FC_MODELS = [ + { name: "doubao-seedance-2-5-260628", label: "Seedance 2.5", desc: "最长 30 秒 · 支持 1080P / 4K" }, { name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" }, { name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" }, { name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" } ] as const; -export const FC_STANDARD_MODEL = FC_MODELS[0].name; +// 默认选中的模型。按名字写死,不跟 FC_MODELS 的排序走 —— 调换顺序不该改默认档。 +export const FC_STANDARD_MODEL = "doubao-seedance-2-0-260128"; export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const; export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const; export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const; +type ModelCapabilities = { resolutions?: string[]; durations?: number[] }; + +function capabilitiesOf(config: ModelConfig | undefined): ModelCapabilities { + const meta = (config?.metadata || {}) as Record; + const caps = (meta.capabilities || {}) as ModelCapabilities; + return { + resolutions: caps.resolutions || (meta.resolutions as string[] | undefined), + durations: caps.durations || (meta.durations as number[] | undefined) + }; +} + +/** 该模型支持的分辨率档。后端没配就回落全集(由后端提交校验兜底)。 */ +export function modelResolutions(config: ModelConfig | undefined): string[] { + const list = capabilitiesOf(config).resolutions; + return list?.length ? list : [...FC_RESOLUTIONS]; +} + +/** 该模型支持的出片时长。Seedance 2.5 能到 30 秒,2.0 只有 15 —— 写死会把新模型卡住。 */ +export function modelDurations(config: ModelConfig | undefined): number[] { + const list = capabilitiesOf(config).durations; + return list?.length ? list : [...FC_DURATIONS]; +} + export const MODE_LABELS: Record = { universal: "全能参考", keyframe: "首尾帧" }; // 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐) diff --git a/core/frontend/src/components/free-create/toolbar.tsx b/core/frontend/src/components/free-create/toolbar.tsx index ad4e02c..3dae202 100644 --- a/core/frontend/src/components/free-create/toolbar.tsx +++ b/core/frontend/src/components/free-create/toolbar.tsx @@ -1,17 +1,19 @@ // 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。 -// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。 +// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。 +// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities, +// 换档时把超出新模型能力的选择夹回去。 import { useEffect, useRef, useState } from "react"; import { ArrowRight, ChevronDown } from "lucide-react"; import type { ModelConfig } from "../../types"; import { DEFAULT_BILLING_RATES, - FC_DURATIONS, FC_MODELS, FC_RATIOS, FC_RESOLUTIONS, - FC_STANDARD_MODEL, MODE_LABELS, estimateCost, + modelDurations, + modelResolutions, type BillingRates, type FreeMode, type LocalRef @@ -28,6 +30,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: { }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); + const menuRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (event: MouseEvent) => { @@ -38,6 +41,15 @@ function FcDropdown({ label, display, items, onSelect, disabled }: { document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); + // 菜单封了高会滚动(时长有 27 档),打开时默认停在顶部会看不到当前选中项。 + // 直接改菜单的 scrollTop,不用 scrollIntoView —— 后者会连带把整个页面滚一下。 + useEffect(() => { + if (!open) return; + const menu = menuRef.current; + const active = menu?.querySelector(".fc-dd-item.selected"); + if (!menu || !active) return; + menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2; + }, [open]); return (
{open && ( -
+
{items.map((item) => ( + {SHOW_SCRIPT_IMPORT && ( + <> + + )}
)} @@ -3305,6 +3311,8 @@ export function PipelinePage(props: { void onPickScriptFile(event)} /> void onPickVideoFile(event)} />
+ {SHOW_SCRIPT_IMPORT && ( + <> + + )} {/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */} {scriptPickerModels.length > 0 ? (
diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index c8dfb04..f65fb52 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -540,7 +540,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
参考视频 - MP4 / MOV · 最长 3 分钟 · ≤200MB + MP4 / MOV · 最长 30 秒 · ≤200MB
{previewUrl ? (