添加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", "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", "display_name": "Seedance-2.0",
"name": "doubao-seedance-2-0-260128", "name": "doubao-seedance-2-0-260128",
+50 -9
View File
@@ -38,11 +38,36 @@ from .video_pricing import get_resolution
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FREE_VIDEO_MODELS = { FREE_VIDEO_MODELS = {
"doubao-seedance-2-5-260628",
"doubao-seedance-2-0-260128", "doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128", "doubao-seedance-2-0-fast-260128",
"doubao-seedance-2-0-mini-260615", "doubao-seedance-2-0-mini-260615",
} }
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制) 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"} RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
RESOLUTIONS = {"480p", "720p", "1080p", "4k"} RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
MODES = {"universal", "keyframe"} MODES = {"universal", "keyframe"}
@@ -126,7 +151,9 @@ def _guard_asset_reference(asset: Asset, label: str) -> None:
raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用") 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 已替换)。 """references → 火山 content_items + api_prompt(@label 已替换)。
移植 jimeng views.py:369-567:URL 去重、blob: 拦截、素材库引用(FreeAsset → asset://)、 移植 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} 条,请减少后重试") raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试")
if audio_n > 0 and image_n + video_n == 0: if audio_n > 0 and image_n + video_n == 0:
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频") raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
if video_duration_total > REF_DURATION_MAX: if video_duration_total > max_ref_seconds:
raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试") # 上限跟着模型走:自由创作是 Seedance 2.0(15 秒),视频复刻走 2.5(30 秒)。
raise ValueError(f"参考视频总时长不能超过 {int(max_ref_seconds)} 秒,请缩短后重试")
# @label 替换:按 label 长度降序,防子串吞噬 # @label 替换:按 label 长度降序,防子串吞噬
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) 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("画面比例无效") raise ValueError("画面比例无效")
if resolution not in RESOLUTIONS: if resolution not in RESOLUTIONS:
raise ValueError("分辨率无效") 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 get_resolution(aspect_ratio, resolution) # 组合合法性 fail loud
orphan = find_orphan_material_mention(prompt, references) 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: if model_config is None:
raise ValueError("视频模型未配置,请联系管理员") 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) _reap_stale_free_video_tasks(team=team)
# 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发 # 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发
@@ -481,7 +516,10 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
if in_flight >= max_concurrent: if in_flight >= max_concurrent:
raise ValueError(f"当前有 {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 可配) # 统一计价引擎:¥成本 × 毛利系数 → 积分;预留 = 积分 × buffer(均为 BillingConfig 可配)
tokens, quote = quote_video_estimate( tokens, quote = quote_video_estimate(
@@ -583,7 +621,10 @@ def start_pending_free_video(task: AITask) -> AITask:
seed = -1 seed = -1
references = payload.get("references") or [] references = payload.get("references") or []
try: 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: except ValueError as exc:
message = str(exc) message = str(exc)
if "正在审核" in message or "已提交审核" in message or "尚未完成合规审核" in message: 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) self.assertNotIn("Request id", notification.body)
def test_fast_1080p_rejected(self): 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( submit_free_video(
team=self.team, user=self.user, params=self._params(model=FAST, resolution="1080p") 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): def test_orphan_mention_rejected(self):
with self.assertRaisesMessage(ValueError, "对应的内容为空"): with self.assertRaisesMessage(ValueError, "对应的内容为空"):
submit_free_video(team=self.team, user=self.user, params=self._params(prompt="让 @图片1 动")) 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")) frames_from_upload(self._upload("broken.mp4"))
def test_rejects_clip_longer_than_cap(self): def test_rejects_clip_longer_than_cap(self):
"""超长片抽样密度不够,拆出来是错的,宁可让用户先剪。""" """上限 30 秒 = 复刻用的 Seedance 2.5 单次出片上限;更长的参考片用不上,
抽样密度也不够,拆出来是错的,宁可让用户先剪。"""
with self.assertRaises(VideoDigestError) as ctx: with self.assertRaises(VideoDigestError) as ctx:
_digest_with_duration(_synth_clip(seconds=1), MAX_DURATION_SECONDS + 1) _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): def test_accepts_clip_within_cap_and_returns_frames(self):
frames, duration = frames_from_upload(_synth_clip(seconds=6)) 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["replace_mode"], "character")
self.assertEqual(task.request_payload["subject_name"], "薇薇") self.assertEqual(task.request_payload["subject_name"], "薇薇")
def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self): def test_both_modes_accept_up_to_30s(self):
"""商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒; """商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。"""
角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。"""
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) 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) 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=45000)
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) product_task = self._retire(self._submit(video_asset_id=str(mid.id)))
self.assertEqual(self._retire(self._submit(video_asset_id=str(long_ok.id))).status, AITask.Status.CANCELLED) self.assertEqual(product_task.status, AITask.Status.CANCELLED)
with self.assertRaisesMessage(ValueError, "不能超过 60 秒"): self.assertEqual(product_task.request_payload["model"], "doubao-seedance-2-5-260628")
self._submit(video_asset_id=str(too_long.id))
# 角色复刻:超过 15 秒仍然拒 character_task = self._retire(self._submit(
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"): replace_mode="character", product_id="", model_id=str(model.id),
self._submit( video_asset_id=str(mid.id),
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): def test_long_source_asks_model_to_condense(self):
"""60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。""" """60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。"""
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="分钟.mp4", duration_ms=58000) long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="分钟.mp4", duration_ms=28000)
# duration=None → 按参考视频推导成片时长(火山单次上限 15),与线上一致 # 显式要一段短成片:参考 28 秒、成片 10,差得多才要求模型压缩改编
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=None)) task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10))
prompt = task.request_payload["prompt"] prompt = task.request_payload["prompt"]
self.assertIn("压缩改编", prompt) self.assertIn("压缩改编", prompt)
self.assertIn("58 秒", prompt) self.assertIn("28 秒", prompt)
self.assertIn("15", prompt) self.assertIn("10", prompt)
def test_short_source_skips_condense_note(self): def test_short_source_skips_condense_note(self):
"""参考视频和成片时长差不多时别啰嗦。""" """参考视频和成片时长差不多时别啰嗦。"""
@@ -760,3 +760,60 @@ class VideoReplaceReviewGateTests(TestCase):
self.assertIn("合规审核", task.error_message or REVIEW_FAILED) self.assertIn("合规审核", task.error_message or REVIEW_FAILED)
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
self.assertFalse(self.provider.create_video_task.called) 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,也不花模型钱 # 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱
ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm") ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm")
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB 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。 # 官网直接传视频的上限大约是请求 20MB;base64 会胀到 4/3,所以原文件卡在 15MB。
INLINE_VIDEO_MAX_BYTES = 15 * 1024 * 1024 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: if duration > MAX_DURATION_SECONDS:
Path(path).unlink(missing_ok=True) Path(path).unlink(missing_ok=True)
raise VideoDigestError( raise VideoDigestError(
f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传" f"视频不能超过 {int(MAX_DURATION_SECONDS)} ,请剪出要参考的那一段再传"
) )
return path, suffix, size, duration return path, suffix, size, duration
+28 -17
View File
@@ -23,15 +23,16 @@ from apps.products.models import Product
from .free_video import ( from .free_video import (
FREE_VIDEO_MODELS, FREE_VIDEO_MODELS,
HIGH_RES_MODEL, HIGH_RES_MODEL,
REPLACE_MODEL,
IN_FLIGHT_STATUSES, IN_FLIGHT_STATUSES,
RATIOS, RATIOS,
RESOLUTIONS, RESOLUTIONS,
_reap_stale_free_video_tasks, _reap_stale_free_video_tasks,
model_duration_range,
serialize_free_video_task, serialize_free_video_task,
start_pending_free_video, start_pending_free_video,
submit_free_video, submit_free_video,
) )
from .media_probe import REF_DURATION_MAX
from .models import AITask, ModelConfig from .models import AITask, ModelConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,9 +44,10 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]"
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id), # 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。 # 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
TRIVIEW_LABEL = "目标商品三视图" TRIVIEW_LABEL = "目标商品三视图"
# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。 # 复刻统一走 Seedance 2.5,单次能出 30 秒,所以参考视频也按 30 秒收口:
# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒 # 商品模式的参考视频只喂提炼模型,角色模式直传火山,两边都不需要超过成片上限的素材
PRODUCT_REF_DURATION_MAX = 60.5 # 半秒容差同 media_probe 的老规矩(30 秒成片常被探成 30.02)。
REPLACE_REF_DURATION_MAX = 30.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试" REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试" REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_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 = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
video_seconds = _asset_duration_seconds(video) video_seconds = _asset_duration_seconds(video)
if replace_mode == "product": if video_seconds > REPLACE_REF_DURATION_MAX:
if video_seconds > PRODUCT_REF_DURATION_MAX: raise ValueError("参考视频不能超过 30 秒,请剪短后重试")
raise ValueError("参考视频不能超过 60 秒,请剪短后重试")
elif video_seconds > REF_DURATION_MAX:
# 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
if has_product: if has_product:
subject_name, image_refs, subject_source = _product_library_refs(team, product_id) 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 = { base_params = {
"mode": "universal", "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"), "aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
"resolution": str(params.get("resolution") or "720p"), "resolution": str(params.get("resolution") or "720p"),
"duration": duration, "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") aspect_ratio = str(params.get("aspect_ratio") or "9:16")
resolution = str(params.get("resolution") or "720p") resolution = str(params.get("resolution") or "720p")
try: try:
@@ -507,8 +506,9 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
raise ValueError("画面比例无效") raise ValueError("画面比例无效")
if resolution not in RESOLUTIONS: if resolution not in RESOLUTIONS:
raise ValueError("分辨率无效") raise ValueError("分辨率无效")
if not 4 <= duration <= 15: low, high = replace_duration_range()
raise ValueError("视频时长需在 4-15 秒之间") if not low <= duration <= high:
raise ValueError(f"视频时长需在 {low}-{high} 秒之间")
model_config = ( model_config = (
ModelConfig.objects.select_related("provider") ModelConfig.objects.select_related("provider")
@@ -754,16 +754,27 @@ def _asset_duration_seconds(asset: Asset) -> float:
return primary.duration_ms / 1000.0 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: def _output_duration(requested, video_seconds: float) -> int:
low, high = replace_duration_range()
try: try:
value = int(requested) if requested not in (None, "") else 0 value = int(requested) if requested not in (None, "") else 0
except (TypeError, ValueError): except (TypeError, ValueError):
value = 0 value = 0
if value: if value:
return min(15, max(4, value)) return min(high, max(low, value))
if video_seconds: if video_seconds:
return min(15, max(4, int(round(video_seconds)))) return min(high, max(low, int(round(video_seconds))))
return 15 return high
def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict: 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 秒校验兜底,越不过去。 # 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。
_REPLACE_SOURCE_PURPOSE = "video_replace_product" _REPLACE_SOURCE_PURPOSE = "video_replace_product"
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024 _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 _FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
@@ -1166,7 +1166,7 @@ class FreeVideoUploadView(APIView):
if long_source and kind == "video": if long_source and kind == "video":
in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX
range_hint = "视频时长需在 2-60 秒之间" range_hint = "视频时长需在 2-30 秒之间"
else: else:
in_range = duration_in_ref_range(duration) in_range = duration_in_ref_range(duration)
range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间" range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间"
@@ -9,17 +9,44 @@ export type LocalRef = FreeVideoRef & {
uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交) uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交)
}; };
// 只提供好看的标签/副标题;能出现哪些模型由后端返回的 modelConfigs 决定(见 free-create.tsx videoConfigs)。
// 新模型上线只要后端有数据就会出现在下拉里,不再需要发一次前端。
export const FC_MODELS = [ 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-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-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" } { name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" }
] as const; ] 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_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_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; 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<string, unknown>;
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<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" }; export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐) // 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
@@ -1,17 +1,19 @@
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。 // 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P // 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
// 换档时把超出新模型能力的选择夹回去。
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ArrowRight, ChevronDown } from "lucide-react"; import { ArrowRight, ChevronDown } from "lucide-react";
import type { ModelConfig } from "../../types"; import type { ModelConfig } from "../../types";
import { import {
DEFAULT_BILLING_RATES, DEFAULT_BILLING_RATES,
FC_DURATIONS,
FC_MODELS, FC_MODELS,
FC_RATIOS, FC_RATIOS,
FC_RESOLUTIONS, FC_RESOLUTIONS,
FC_STANDARD_MODEL,
MODE_LABELS, MODE_LABELS,
estimateCost, estimateCost,
modelDurations,
modelResolutions,
type BillingRates, type BillingRates,
type FreeMode, type FreeMode,
type LocalRef type LocalRef
@@ -28,6 +30,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDown = (event: MouseEvent) => { const onDown = (event: MouseEvent) => {
@@ -38,6 +41,15 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]); }, [open]);
// 菜单封了高会滚动(时长有 27 档),打开时默认停在顶部会看不到当前选中项。
// 直接改菜单的 scrollTop,不用 scrollIntoView —— 后者会连带把整个页面滚一下。
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
const active = menu?.querySelector<HTMLElement>(".fc-dd-item.selected");
if (!menu || !active) return;
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
}, [open]);
return ( return (
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}> <div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
<button type="button" className="fc-chip" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}> <button type="button" className="fc-chip" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
@@ -45,7 +57,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
<ChevronDown /> <ChevronDown />
</button> </button>
{open && ( {open && (
<div className="fc-dd-menu"> <div className="fc-dd-menu" ref={menuRef}>
{items.map((item) => ( {items.map((item) => (
<button <button
key={item.value} key={item.value}
@@ -86,8 +98,10 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
onClear: () => void; onClear: () => void;
onSend: () => void; onSend: () => void;
}) { }) {
const isStandard = model === FC_STANDARD_MODEL;
const config = videoConfigs.find((c) => c.name === model); const config = videoConfigs.find((c) => c.name === model);
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
const allowedResolutions = modelResolutions(config);
const allowedDurations = modelDurations(config);
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES); const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
const [seedOpen, setSeedOpen] = useState(false); const [seedOpen, setSeedOpen] = useState(false);
const seedRef = useRef<HTMLDivElement>(null); const seedRef = useRef<HTMLDivElement>(null);
@@ -106,11 +120,23 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
<div className="fc-chips"> <div className="fc-chips">
<FcDropdown <FcDropdown
label="模型" label="模型"
display={FC_MODELS.find((m) => m.name === model)?.label || model} display={FC_MODELS.find((m) => m.name === model)?.label || config?.display_name || model}
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))} items={videoConfigs.map((c) => {
const known = FC_MODELS.find((m) => m.name === c.name);
return { value: c.name, label: known?.label || c.display_name || c.name, desc: known?.desc };
})}
onSelect={(value) => { onSelect={(value) => {
onModelChange(value); onModelChange(value);
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p"); // 换档后把分辨率/时长夹回新模型支持的范围,否则会带着上一档的选择提交然后被后端拒
const next = videoConfigs.find((c) => c.name === value);
const resolutions = modelResolutions(next);
if (!resolutions.includes(resolution)) {
onResolutionChange(resolutions.includes("720p") ? "720p" : resolutions[0]);
}
const durations = modelDurations(next);
if (!durations.includes(duration)) {
onDurationChange(durations.includes(5) ? 5 : durations[0]);
}
}} }}
/> />
<FcDropdown <FcDropdown
@@ -129,15 +155,15 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
items={FC_RESOLUTIONS.map((r) => ({ items={FC_RESOLUTIONS.map((r) => ({
value: r, value: r,
label: r.toUpperCase(), label: r.toUpperCase(),
disabled: (r === "1080p" || r === "4k") && !isStandard, disabled: !allowedResolutions.includes(r),
hint: "仅 Seedance 2.0 标准档支持" hint: "当前模型不支持这一档"
}))} }))}
onSelect={onResolutionChange} onSelect={onResolutionChange}
/> />
<FcDropdown <FcDropdown
label="时长" label="时长"
display={`${duration}s`} display={`${duration}s`}
items={FC_DURATIONS.map((d) => ({ value: String(d), label: `${d}s` }))} items={allowedDurations.map((d) => ({ value: String(d), label: `${d}s` }))}
onSelect={(value) => onDurationChange(Number(value))} onSelect={(value) => onDurationChange(Number(value))}
/> />
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}> <div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
+13
View File
@@ -717,7 +717,20 @@
border-radius: 12px; border-radius: 12px;
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12); box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 6px; padding: 6px;
/* 时长档随模型走(Seedance 2.5 有 430 共 27 项),菜单会顶穿视口。
封顶后内部滚动;上限跟着视口收,矮屏也不会被切掉。 */
max-height: min(320px, calc(100vh - 160px));
overflow-y: auto;
overscroll-behavior: contain;
} }
/* 细滚动条:菜单本来就窄,系统默认那条会压掉一截可读宽度 */
.fc-page .fc-dd-menu::-webkit-scrollbar { width: 6px; }
.fc-page .fc-dd-menu::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(34, 42, 54, 0.18);
}
.fc-page .fc-dd-menu::-webkit-scrollbar-track { background: transparent; }
.fc-page .fc-dd-item { .fc-page .fc-dd-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+13 -1
View File
@@ -102,8 +102,20 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
const onTaskSettledRef = useRef(onTaskSettled); const onTaskSettledRef = useRef(onTaskSettled);
onTaskSettledRef.current = onTaskSettled; onTaskSettledRef.current = onTaskSettled;
const refreshGlobalShell = useCallback(() => onTaskSettledRef.current(), []); const refreshGlobalShell = useCallback(() => onTaskSettledRef.current(), []);
// 后端返回什么视频模型就给什么,不再拿 FC_MODELS 当白名单过滤 ——
// 之前后台新加的模型(如 Seedance 2.5)因为不在这份写死的清单里,永远不会出现在下拉中。
// FC_MODELS 现在只用来给已知模型配好看的标签,认不出的回落 display_name。
// 排序按 FC_MODELS 的先后(2.5 在最前),不在清单里的模型排到后面、保持后端顺序。
const videoConfigs = useMemo( const videoConfigs = useMemo(
() => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)), () => modelConfigs
.filter((c) => c.capability === "video")
.sort((a, b) => {
const rank = (name: string) => {
const index = FC_MODELS.findIndex((m) => m.name === name);
return index === -1 ? FC_MODELS.length : index;
};
return rank(a.name) - rank(b.name);
}),
[modelConfigs] [modelConfigs]
); );
+12 -2
View File
@@ -34,6 +34,8 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
// 脚本来源 → 「来源」brief pill 文案 // 脚本来源 → 「来源」brief pill 文案
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。 // 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" }; const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
// 上传自有脚本 / 上传视频提炼入口暂隐,改 true 即可恢复
const SHOW_SCRIPT_IMPORT = false;
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐) // 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
const VO_VOICES = [ const VO_VOICES = [
{ key: "BV700_streaming", label: "灿灿 · 活力女声" }, { key: "BV700_streaming", label: "灿灿 · 活力女声" },
@@ -3175,7 +3177,7 @@ export function PipelinePage(props: {
<div className="shots-empty"> <div className="shots-empty">
<div className="empty-ico"><LayoutList /></div> <div className="empty-ico"><LayoutList /></div>
<div className="empty-title"></div> <div className="empty-title"></div>
<div className="empty-hint"></div> <div className="empty-hint">{SHOW_SCRIPT_IMPORT ? "从右侧选择辅助生成,或上传已有脚本,镜头内容会展示在这里。" : "从右侧选择辅助生成,镜头内容会展示在这里。"}</div>
</div> </div>
)} )}
</div> </div>
@@ -3262,11 +3264,15 @@ export function PipelinePage(props: {
) : ( ) : (
<div className="chat-empty"> <div className="chat-empty">
<div className="ce-title"></div> <div className="ce-title"></div>
<div className="ce-hint">使</div> <div className="ce-hint">{SHOW_SCRIPT_IMPORT ? "使用商品信息快速辅助生成,或导入你已经准备好的脚本。" : "使用商品信息快速辅助生成第一版脚本。"}</div>
<div className="chat-modes"> <div className="chat-modes">
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><Sparkles /><span></span></button> <button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><Sparkles /><span></span></button>
{SHOW_SCRIPT_IMPORT && (
<>
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("manual")}><Upload /><span></span></button> <button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("manual")}><Upload /><span></span></button>
<button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg><span></span></button> <button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg><span></span></button>
</>
)}
</div> </div>
</div> </div>
)} )}
@@ -3305,6 +3311,8 @@ export function PipelinePage(props: {
<input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} /> <input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} />
<input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} /> <input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
<div className="chat-input-foot"> <div className="chat-input-foot">
{SHOW_SCRIPT_IMPORT && (
<>
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}> <button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}>
{scriptFileBusy {scriptFileBusy
? <span className="spinner" aria-hidden="true"></span> ? <span className="spinner" aria-hidden="true"></span>
@@ -3316,6 +3324,8 @@ export function PipelinePage(props: {
? <span className="spinner" aria-hidden="true"></span> ? <span className="spinner" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>} : <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
</button> </button>
</>
)}
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */} {/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
{scriptPickerModels.length > 0 ? ( {scriptPickerModels.length > 0 ? (
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}> <div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
+1 -1
View File
@@ -540,7 +540,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<div className="video-flow-step"> <div className="video-flow-step">
<div className="video-flow-step-head"> <div className="video-flow-step-head">
<strong></strong> <strong></strong>
<span>MP4 / MOV · 3 · 200MB</span> <span>MP4 / MOV · 30 · 200MB</span>
</div> </div>
{previewUrl ? ( {previewUrl ? (
<div <div
+12 -10
View File
@@ -38,13 +38,12 @@ const JOB_KEY = "airshelf:video-replace-job";
const MODE_KEY = "airshelf:video-replace-mode"; const MODE_KEY = "airshelf:video-replace-mode";
const CHARACTER_MARK = "[视频复刻·角色]"; const CHARACTER_MARK = "[视频复刻·角色]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime"; const VIDEO_ACCEPT = "video/mp4,video/quicktime";
// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒; // 复刻(商品 / 角色)统一走 Seedance 2.5,单次能出 30 秒,参考视频也按 30 秒收口。
// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。
const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
const REF_SECONDS_MAX = { product: 60, character: 15 } as const; const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编 // Seedance 2.5 单次出片上限。参考视频更长时不报错,成片按这个截断。
const SEEDANCE_MAX_OUTPUT_SECONDS = 15; const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
type ProductSource = "library" | "temporary" | ""; type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character"; type ReplaceMode = "product" | "character";
@@ -471,9 +470,11 @@ export function VideoReplacePage({
const data = await api.pollVideoReplace(jobId); const data = await api.pollVideoReplace(jobId);
if (cancelled) return; if (cancelled) return;
setJob(data.task); setJob(data.task);
const jobMode = modeFromTask(data.task); if (isInFlight(data.task.status)) {
setReplaceMode(jobMode); const jobMode = modeFromTask(data.task);
rememberReplaceMode(jobMode); setReplaceMode(jobMode);
rememberReplaceMode(jobMode);
}
const stillDigesting = isDigesting(data.task); const stillDigesting = isDigesting(data.task);
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created"; const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current; if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
@@ -498,6 +499,7 @@ export function VideoReplacePage({
onNotify("error", data.task.error_message || "视频复刻未完成,请重试"); onNotify("error", data.task.error_message || "视频复刻未完成,请重试");
onTaskSettled?.(); onTaskSettled?.();
} }
setJobId("");
forgetJob(); forgetJob();
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
@@ -538,7 +540,7 @@ export function VideoReplacePage({
if (job && !isInFlight(job.status)) setJob(null); if (job && !isInFlight(job.status)) setJob(null);
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE); form.append("purpose", PRODUCT_SOURCE_PURPOSE);
try { try {
const uploaded = await api.uploadFreeVideoRef(form); const uploaded = await api.uploadFreeVideoRef(form);
setVideoRef({ setVideoRef({