添加seedance2.5
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)]
|
||||
@@ -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 动"))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 秒之间"
|
||||
|
||||
Reference in New Issue
Block a user