添加seedance2.5
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 / 后台可配),缺失回落 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"}
|
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)]
|
||||||
@@ -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 动"))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 有 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 {
|
.fc-page .fc-dd-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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" : ""}`}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user