添加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 秒之间"
|
||||
|
||||
@@ -9,17 +9,44 @@ export type LocalRef = FreeVideoRef & {
|
||||
uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交)
|
||||
};
|
||||
|
||||
// 只提供好看的标签/副标题;能出现哪些模型由后端返回的 modelConfigs 决定(见 free-create.tsx videoConfigs)。
|
||||
// 新模型上线只要后端有数据就会出现在下拉里,不再需要发一次前端。
|
||||
export const FC_MODELS = [
|
||||
{ name: "doubao-seedance-2-5-260628", label: "Seedance 2.5", desc: "最长 30 秒 · 支持 1080P / 4K" },
|
||||
{ name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" },
|
||||
{ name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" },
|
||||
{ name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" }
|
||||
] as const;
|
||||
export const FC_STANDARD_MODEL = FC_MODELS[0].name;
|
||||
// 默认选中的模型。按名字写死,不跟 FC_MODELS 的排序走 —— 调换顺序不该改默认档。
|
||||
export const FC_STANDARD_MODEL = "doubao-seedance-2-0-260128";
|
||||
|
||||
export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
|
||||
export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const;
|
||||
export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const;
|
||||
|
||||
type ModelCapabilities = { resolutions?: string[]; durations?: number[] };
|
||||
|
||||
function capabilitiesOf(config: ModelConfig | undefined): ModelCapabilities {
|
||||
const meta = (config?.metadata || {}) as Record<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: "首尾帧" };
|
||||
|
||||
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
||||
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
|
||||
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
|
||||
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
|
||||
// 换档时把超出新模型能力的选择夹回去。
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
FC_DURATIONS,
|
||||
FC_MODELS,
|
||||
FC_RATIOS,
|
||||
FC_RESOLUTIONS,
|
||||
FC_STANDARD_MODEL,
|
||||
MODE_LABELS,
|
||||
estimateCost,
|
||||
modelDurations,
|
||||
modelResolutions,
|
||||
type BillingRates,
|
||||
type FreeMode,
|
||||
type LocalRef
|
||||
@@ -28,6 +30,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
@@ -38,6 +41,15 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
|
||||
}, [open]);
|
||||
// 菜单封了高会滚动(时长有 27 档),打开时默认停在顶部会看不到当前选中项。
|
||||
// 直接改菜单的 scrollTop,不用 scrollIntoView —— 后者会连带把整个页面滚一下。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const menu = menuRef.current;
|
||||
const active = menu?.querySelector<HTMLElement>(".fc-dd-item.selected");
|
||||
if (!menu || !active) return;
|
||||
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
|
||||
<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 />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fc-dd-menu">
|
||||
<div className="fc-dd-menu" ref={menuRef}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -86,8 +98,10 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
const isStandard = model === FC_STANDARD_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 [seedOpen, setSeedOpen] = useState(false);
|
||||
const seedRef = useRef<HTMLDivElement>(null);
|
||||
@@ -106,11 +120,23 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
<div className="fc-chips">
|
||||
<FcDropdown
|
||||
label="模型"
|
||||
display={FC_MODELS.find((m) => m.name === model)?.label || model}
|
||||
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
|
||||
display={FC_MODELS.find((m) => m.name === model)?.label || config?.display_name || model}
|
||||
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) => {
|
||||
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
|
||||
@@ -129,15 +155,15 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
items={FC_RESOLUTIONS.map((r) => ({
|
||||
value: r,
|
||||
label: r.toUpperCase(),
|
||||
disabled: (r === "1080p" || r === "4k") && !isStandard,
|
||||
hint: "仅 Seedance 2.0 标准档支持"
|
||||
disabled: !allowedResolutions.includes(r),
|
||||
hint: "当前模型不支持这一档"
|
||||
}))}
|
||||
onSelect={onResolutionChange}
|
||||
/>
|
||||
<FcDropdown
|
||||
label="时长"
|
||||
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))}
|
||||
/>
|
||||
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
|
||||
|
||||
@@ -717,7 +717,20 @@
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
padding: 6px;
|
||||
/* 时长档随模型走(Seedance 2.5 有 4–30 共 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -102,8 +102,20 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
const onTaskSettledRef = useRef(onTaskSettled);
|
||||
onTaskSettledRef.current = onTaskSettled;
|
||||
const refreshGlobalShell = useCallback(() => onTaskSettledRef.current(), []);
|
||||
// 后端返回什么视频模型就给什么,不再拿 FC_MODELS 当白名单过滤 ——
|
||||
// 之前后台新加的模型(如 Seedance 2.5)因为不在这份写死的清单里,永远不会出现在下拉中。
|
||||
// FC_MODELS 现在只用来给已知模型配好看的标签,认不出的回落 display_name。
|
||||
// 排序按 FC_MODELS 的先后(2.5 在最前),不在清单里的模型排到后面、保持后端顺序。
|
||||
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]
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
|
||||
// 脚本来源 → 「来源」brief pill 文案
|
||||
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
||||
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
||||
// 上传自有脚本 / 上传视频提炼入口暂隐,改 true 即可恢复
|
||||
const SHOW_SCRIPT_IMPORT = false;
|
||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||
const VO_VOICES = [
|
||||
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
||||
@@ -3175,7 +3177,7 @@ export function PipelinePage(props: {
|
||||
<div className="shots-empty">
|
||||
<div className="empty-ico"><LayoutList /></div>
|
||||
<div className="empty-title">还没有镜头脚本</div>
|
||||
<div className="empty-hint">从右侧选择辅助生成,或上传已有脚本,镜头内容会展示在这里。</div>
|
||||
<div className="empty-hint">{SHOW_SCRIPT_IMPORT ? "从右侧选择辅助生成,或上传已有脚本,镜头内容会展示在这里。" : "从右侧选择辅助生成,镜头内容会展示在这里。"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3262,11 +3264,15 @@ export function PipelinePage(props: {
|
||||
) : (
|
||||
<div className="chat-empty">
|
||||
<div className="ce-title">选择脚本创建方式</div>
|
||||
<div className="ce-hint">使用商品信息快速辅助生成,或导入你已经准备好的脚本。</div>
|
||||
<div className="ce-hint">{SHOW_SCRIPT_IMPORT ? "使用商品信息快速辅助生成,或导入你已经准备好的脚本。" : "使用商品信息快速辅助生成第一版脚本。"}</div>
|
||||
<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>
|
||||
{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 === "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>
|
||||
)}
|
||||
@@ -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={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
|
||||
<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()}>
|
||||
{scriptFileBusy
|
||||
? <span className="spinner" aria-hidden="true"></span>
|
||||
@@ -3316,6 +3324,8 @@ export function PipelinePage(props: {
|
||||
? <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>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||
{scriptPickerModels.length > 0 ? (
|
||||
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
|
||||
@@ -540,7 +540,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 3 分钟 · ≤200MB</span>
|
||||
<span>MP4 / MOV · 最长 30 秒 · ≤200MB</span>
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div
|
||||
|
||||
@@ -38,13 +38,12 @@ const JOB_KEY = "airshelf:video-replace-job";
|
||||
const MODE_KEY = "airshelf:video-replace-mode";
|
||||
const CHARACTER_MARK = "[视频复刻·角色]";
|
||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||
// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒;
|
||||
// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。
|
||||
// 复刻(商品 / 角色)统一走 Seedance 2.5,单次能出 30 秒,参考视频也按 30 秒收口。
|
||||
const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
|
||||
const REF_SECONDS_MAX = { product: 60, character: 15 } as const;
|
||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const;
|
||||
// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编。
|
||||
const SEEDANCE_MAX_OUTPUT_SECONDS = 15;
|
||||
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
|
||||
// Seedance 2.5 单次出片上限。参考视频更长时不报错,成片按这个截断。
|
||||
const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
|
||||
|
||||
type ProductSource = "library" | "temporary" | "";
|
||||
type ReplaceMode = "product" | "character";
|
||||
@@ -471,9 +470,11 @@ export function VideoReplacePage({
|
||||
const data = await api.pollVideoReplace(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(data.task);
|
||||
const jobMode = modeFromTask(data.task);
|
||||
setReplaceMode(jobMode);
|
||||
rememberReplaceMode(jobMode);
|
||||
if (isInFlight(data.task.status)) {
|
||||
const jobMode = modeFromTask(data.task);
|
||||
setReplaceMode(jobMode);
|
||||
rememberReplaceMode(jobMode);
|
||||
}
|
||||
const stillDigesting = isDigesting(data.task);
|
||||
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
|
||||
@@ -498,6 +499,7 @@ export function VideoReplacePage({
|
||||
onNotify("error", data.task.error_message || "视频复刻未完成,请重试");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
@@ -538,7 +540,7 @@ export function VideoReplacePage({
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
try {
|
||||
const uploaded = await api.uploadFreeVideoRef(form);
|
||||
setVideoRef({
|
||||
|
||||
Reference in New Issue
Block a user