添加seedance2.5

This commit is contained in:
Azmat@qq.com
2026-08-28 16:03:23 +08:00
parent c0a0b9603b
commit a618f02315
16 changed files with 416 additions and 80 deletions
+23
View File
@@ -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",
+50 -9
View File
@@ -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 / 后台可配),缺失回落 415。"""
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:
@@ -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)]
+12 -1
View File
@@ -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 动"))
+3 -2
View File
@@ -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))
+78 -21
View File
@@ -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()
+4 -2
View File
@@ -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
+28 -17
View File
@@ -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:
+2 -2
View File
@@ -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 秒之间"