Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f8c0b566 | |||
| 1f01d6df25 | |||
| 5b931438e0 | |||
| b9acf8c86f | |||
| 6f4a95e00f |
@@ -61,24 +61,38 @@ def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
|
||||
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
|
||||
|
||||
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
|
||||
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
|
||||
"""
|
||||
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
|
||||
if not ids:
|
||||
# 先去重查询(IN 查询性能优化),但最终按原始 voice_ids 顺序返回
|
||||
raw_ids = list(voice_ids or [])
|
||||
if not raw_ids:
|
||||
return []
|
||||
# 去重且保序,用于 SQL IN 查询;空字符串/None 视为无效id → 0.0
|
||||
unique_ids: list[str] = []
|
||||
_seen: set[str] = set()
|
||||
for v in raw_ids:
|
||||
if v and v not in _seen:
|
||||
_seen.add(v)
|
||||
unique_ids.append(v)
|
||||
if not unique_ids:
|
||||
return [0.0 for _ in raw_ids]
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
|
||||
dur_map: dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
dur_map[row[0]] = float(row[1] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
dur_map[row[0]] = 0.0
|
||||
return [dur_map.get(v, 0.0) for v in ids]
|
||||
# 按原始 voice_ids 顺序返回,保持长度一致;空/None/未查到 → 0.0
|
||||
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
|
||||
return [0.0 for _ in ids]
|
||||
return [0.0 for _ in raw_ids]
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
@@ -552,7 +566,26 @@ def create_generation_task(
|
||||
) from clone_err
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
|
||||
# 变体 1..N-1 独立选片
|
||||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值
|
||||
def _collect_segments(pid):
|
||||
segs = {}
|
||||
_sk, _pg = 0, 500
|
||||
while True:
|
||||
_b = _plan_svc._clip_repo.list_by_plan(pid, skip=_sk, limit=_pg)
|
||||
if not _b:
|
||||
break
|
||||
for _c in _b:
|
||||
if _c.asset_id and float(_c.duration or 0) > 0:
|
||||
_st = float(_c.start_time or 0.0)
|
||||
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
|
||||
if len(_b) < _pg:
|
||||
break
|
||||
_sk += _pg
|
||||
return segs
|
||||
|
||||
_batch_segments = _collect_segments(_plan0.id)
|
||||
|
||||
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
|
||||
for task_index in range(1, count):
|
||||
variant = None
|
||||
last_err: Exception | None = None
|
||||
@@ -564,6 +597,7 @@ def create_generation_task(
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
|
||||
batch_segments=_batch_segments,
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
@@ -595,7 +629,16 @@ def create_generation_task(
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant.id)
|
||||
|
||||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
|
||||
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
|
||||
try:
|
||||
_new_segs = _collect_segments(variant.id)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
_batch_segments.setdefault(_aid, []).extend(_ivs)
|
||||
except Exception:
|
||||
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
|
||||
|
||||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配,
|
||||
# #1855:apply_voice_duration_to_plan 已内置幂等判断,重复调用安全)
|
||||
for _vi, _pid in enumerate(variant_plan_ids):
|
||||
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
|
||||
if _vd > 0:
|
||||
|
||||
@@ -473,6 +473,7 @@ class EditPlanService:
|
||||
name_suffix: str = "变体",
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
@@ -489,6 +490,8 @@ class EditPlanService:
|
||||
created_by_user_id: 新 plan 归属用户。
|
||||
name_suffix: plan 名后缀。
|
||||
rng: 可选随机数(测试注入种子)。
|
||||
batch_segments: 可选,外部传入的批次内已使用素材区间(前序变体避让用)。
|
||||
传入时作为初始避让对象;未传则保持原逻辑从源 plan clips 自建(向后兼容)。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
|
||||
@@ -537,16 +540,26 @@ class EditPlanService:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
rhythm_template_for_reselect = None
|
||||
if source.config:
|
||||
rhythm_template_for_reselect = source.config.get("rhythm_template")
|
||||
|
||||
if voice > 0 and source_clips_data:
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations
|
||||
|
||||
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
|
||||
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
|
||||
# #1855 P0:先占位durations为空dict,真正查durations在后面pool_ids确定后执行;
|
||||
# plan_clip_durations 的 asset_durations 参数在该函数中仅作最大段长钳制,
|
||||
# 这里先不依赖它(durations 还没查),传 None 让planner用默认策略;
|
||||
# 真正的asset_durations会在后面 clips_data 生成时传入 reselect_clips_for_variant
|
||||
target_durations = plan_clip_durations(
|
||||
len(source_clips_data),
|
||||
voice,
|
||||
transition_effects=_effects,
|
||||
transition_durations=_tdurs,
|
||||
rhythm_template=rhythm_template_for_reselect,
|
||||
asset_durations=None,
|
||||
)
|
||||
if target_durations:
|
||||
for _c, _d in zip(source_clips_data, target_durations, strict=False):
|
||||
@@ -582,12 +595,18 @@ class EditPlanService:
|
||||
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
|
||||
)
|
||||
|
||||
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for c in clips:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
# 批次内区间:外部传入时使用外部传入(含前序变体已用区间);
|
||||
# 否则保持原逻辑从源 plan clips 自建(向后兼容)
|
||||
if batch_segments is not None:
|
||||
batch_segments_resolved: dict[str, list[tuple[float, float]]] = {
|
||||
k: list(v) for k, v in batch_segments.items()
|
||||
}
|
||||
else:
|
||||
batch_segments_resolved = {}
|
||||
for c in clips:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
@@ -595,7 +614,7 @@ class EditPlanService:
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments,
|
||||
batch_segments=batch_segments_resolved,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
@@ -767,6 +786,17 @@ class EditPlanService:
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
# #1855 P0:幂等判断——如果已成功分配过且当前 total_duration 已接近 voice_duration,直接返回
|
||||
try:
|
||||
existing_mark = None
|
||||
if plan.config:
|
||||
existing_mark = plan.config.get("voice_duration_applied")
|
||||
cur_total = float(plan.total_duration or 0.0)
|
||||
if existing_mark is not None and abs(existing_mark - voice) < 1e-6 and abs(cur_total - voice) < 0.5:
|
||||
return plan
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
@@ -838,6 +868,10 @@ class EditPlanService:
|
||||
)
|
||||
try:
|
||||
plan.total_duration = net
|
||||
# #1855 P0:写入幂等标记,避免二次调用时只重分配 duration 不重算 start_time
|
||||
new_cfg = dict(plan.config or {})
|
||||
new_cfg["voice_duration_applied"] = voice
|
||||
plan.config = new_cfg
|
||||
db = self._clip_repo.session
|
||||
db.commit()
|
||||
except Exception:
|
||||
@@ -878,6 +912,69 @@ class EditPlanService:
|
||||
rng = rng or _random.Random()
|
||||
plan_ids: list[str] = []
|
||||
|
||||
# #1855 P0:先确定片段数 clip_count(用于节奏模板生成长度匹配)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
from packages.domain.variant_plan_selector import (
|
||||
generate_pixel_perturbation,
|
||||
generate_visual_perturbation,
|
||||
)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
# 从源 plan 获取片段数(分页读,避免关系加载问题)
|
||||
_sclips: list = []
|
||||
_sk, _pg = 0, 500
|
||||
while True:
|
||||
_b = self._clip_repo.list_by_plan(source_plan_id, skip=_sk, limit=_pg)
|
||||
if not _b:
|
||||
break
|
||||
_sclips.extend(_b)
|
||||
if len(_b) < _pg:
|
||||
break
|
||||
_sk += _pg
|
||||
clip_count = len(_sclips)
|
||||
|
||||
# 预先生成所有 N 个变体的节奏模板/BGM/扰动参数(时机提前到选片前写入config)
|
||||
rhythm_templates_for_variants: list = []
|
||||
for _idx in range(count):
|
||||
if clip_count > 0:
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
_tpl = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(_tpl)
|
||||
else:
|
||||
rhythm_templates_for_variants.append(None)
|
||||
|
||||
source_bgm_config: dict = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
def _build_variant_config_update(idx: int) -> dict:
|
||||
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
|
||||
upd: dict = {}
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
upd["visual_perturbation"] = perturbation
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
|
||||
try:
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
upd["pixel_perturbation"] = pixel_pert
|
||||
except Exception:
|
||||
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
|
||||
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
|
||||
if rt is not None:
|
||||
upd["rhythm_template"] = rt
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
upd["bgm"] = existing_bgm
|
||||
return upd
|
||||
|
||||
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
|
||||
plan0 = self.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
@@ -890,6 +987,15 @@ class EditPlanService:
|
||||
v0_voice = float(voice_durations[0] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
v0_voice = 0.0
|
||||
|
||||
# #1855 P0:在配音分配前先写入变体0的节奏模板/扰动/BGM,确保 apply_voice_duration_to_plan 能读到 rhythm_template
|
||||
try:
|
||||
_cfg0 = _build_variant_config_update(0)
|
||||
if _cfg0:
|
||||
self.update_plan_config(plan0.id, _cfg0)
|
||||
except Exception:
|
||||
logger.exception("变体0 配置写入失败(不阻断): plan=%s", plan0.id)
|
||||
|
||||
if v0_voice > 0:
|
||||
try:
|
||||
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
|
||||
@@ -897,7 +1003,27 @@ class EditPlanService:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# 变体 1..N-1:独立选片
|
||||
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值
|
||||
def _collect_plan_segments(pid: str) -> dict[str, list[tuple[float, float]]]:
|
||||
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 区间表。"""
|
||||
segs: dict[str, list[tuple[float, float]]] = {}
|
||||
_sk2, _pg2 = 0, 500
|
||||
while True:
|
||||
_b2 = self._clip_repo.list_by_plan(pid, skip=_sk2, limit=_pg2)
|
||||
if not _b2:
|
||||
break
|
||||
for _c in _b2:
|
||||
if _c.asset_id and float(_c.duration or 0) > 0:
|
||||
_st = float(_c.start_time or 0.0)
|
||||
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
|
||||
if len(_b2) < _pg2:
|
||||
break
|
||||
_sk2 += _pg2
|
||||
return segs
|
||||
|
||||
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id)
|
||||
|
||||
# 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让)
|
||||
for i in range(1, count):
|
||||
voice = 0.0
|
||||
if voice_durations and i < len(voice_durations):
|
||||
@@ -905,6 +1031,14 @@ class EditPlanService:
|
||||
voice = float(voice_durations[i] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
|
||||
# #1855 P0:在reselect前先为"变体i"准备配置更新——但reselect内部复制的是source.config,
|
||||
# 所以每个变体独立的节奏模板需要在reselect后单独写入config;
|
||||
# 但 plan_clip_durations 用的是 source.config.rhythm_template(即源plan的节奏模板),
|
||||
# 为了让每个变体在选片阶段就使用自己的节奏模板分配段长,这里采用:
|
||||
# - reselect 仍使用源 plan 的 rhythm_template(保持片段骨架一致)
|
||||
# - 选片完成后立即写入该变体自己的 rhythm_template/扰动/BGM 到config,
|
||||
# 后续不再二次 apply_voice_duration_to_plan(由幂等标记跳过)
|
||||
variant = self.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
candidate_asset_ids,
|
||||
@@ -912,73 +1046,26 @@ class EditPlanService:
|
||||
name_suffix=f"变体{i + 1}",
|
||||
voice_duration=voice,
|
||||
rng=rng,
|
||||
batch_segments=batch_segments_acc,
|
||||
)
|
||||
|
||||
# 选片完成后写入该变体的独立配置(节奏模板/扰动/BGM)
|
||||
try:
|
||||
_cfgi = _build_variant_config_update(i)
|
||||
if _cfgi:
|
||||
self.update_plan_config(variant.id, _cfgi)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 配置写入失败(不阻断): plan=%s", i, variant.id)
|
||||
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
# 从源 plan 获取片段数
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and hasattr(source_plan, "clips"):
|
||||
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
|
||||
|
||||
rhythm_templates_for_variants = []
|
||||
if clip_count > 0:
|
||||
for idx in range(len(plan_ids)):
|
||||
# 每个变体用不同的 seed 选择节奏模板
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
|
||||
source_bgm_config = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
for idx, pid in enumerate(plan_ids):
|
||||
# #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
config_update = {"visual_perturbation": perturbation}
|
||||
# #1764:写入节奏模板
|
||||
if idx < len(rhythm_templates_for_variants):
|
||||
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
|
||||
# #1765:写入像素级扰动滤镜
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db)
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
config_update["bgm"] = existing_bgm
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info(
|
||||
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
|
||||
idx,
|
||||
pid,
|
||||
perturbation,
|
||||
pixel_pert,
|
||||
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
|
||||
)
|
||||
_new_segs = _collect_plan_segments(variant.id)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
batch_segments_acc.setdefault(_aid, []).extend(_ivs)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
logger.exception("变体 %d 区间收集失败(不阻断): plan=%s", i, variant.id)
|
||||
|
||||
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
|
||||
for pid in plan_ids:
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest {
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
source_edit_plan_id?: string
|
||||
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
|
||||
voice_library_id?: string
|
||||
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
|
||||
voice_library_ids?: string[]
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
@@ -36,6 +40,8 @@ export interface VariantPlan {
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
|
||||
voice_duration?: number
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
|
||||
@@ -231,9 +231,13 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
|
||||
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
|
||||
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
|
||||
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
voiceDurationsByVariant: variantVoiceDurations,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
@@ -243,6 +247,9 @@ const GeneratePage: React.FC = () => {
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
|
||||
voiceLibraryId: batchVoiceLibraryId,
|
||||
voiceLibraryIds: voiceLibraryIds || [],
|
||||
voiceModePerVideo,
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
|
||||
@@ -476,6 +483,7 @@ const GeneratePage: React.FC = () => {
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
voiceDurations={variantVoiceDurations}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
|
||||
@@ -31,6 +31,12 @@ interface CanvasPreviewGridProps {
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
|
||||
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
|
||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
||||
*/
|
||||
voiceDurations?: (number | undefined)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
@@ -56,6 +62,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
voiceDurations,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
@@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
voiceDurationHint={voiceDurations?.[i]}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
|
||||
@@ -58,6 +58,11 @@ interface FrontendPreviewPlayerProps {
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
/**
|
||||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||||
*/
|
||||
voiceDurationHint?: number
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -116,6 +121,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
@@ -124,17 +130,26 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
// #1754:测量配音时长,计算缩放因子
|
||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||
// #1754→P0:配音时长作为音画时长锚点。
|
||||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||||
)
|
||||
// 当外部 hint 变化且尚未拿到自测值时先同步
|
||||
useEffect(() => {
|
||||
if (voiceDurationHint && voiceDurationHint > 0) {
|
||||
setVoiceDuration((prev) => (prev > 0 ? prev : voiceDurationHint))
|
||||
}
|
||||
}, [voiceDurationHint])
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
setVoiceDuration(0)
|
||||
setVoiceDuration(voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0)
|
||||
return
|
||||
}
|
||||
const audio = new Audio()
|
||||
audio.preload = "metadata"
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration)) {
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
setVoiceDuration(audio.duration)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +158,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
}, [voiceAudioUrl, voiceDurationHint])
|
||||
|
||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||
const rawClipsDuration = useMemo(() => {
|
||||
@@ -351,6 +366,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||
|
||||
// P0 fix:以配音时长为音画同步锚点。有配音时,播放器总时长 = 配音时长;
|
||||
// - 视频比配音短:播完后末帧冻结,直至配音结束自动暂停
|
||||
// - 视频比配音长:到配音时长点硬停,截断视频
|
||||
// 无配音时沿用视频总时长(单视频原声兜底路径)。
|
||||
const effectiveTotalDuration =
|
||||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
@@ -400,8 +422,95 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentSyncKey, isPlaying])
|
||||
|
||||
// ── 进度条拖拽状态(提前声明,供 tail useEffect 使用,避免 TDZ/no-use-before-define) ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||||
// 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播,
|
||||
// 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。
|
||||
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
|
||||
const tailStartRef = useRef<number>(0)
|
||||
const tailBaseRef = useRef<number>(0)
|
||||
useEffect(() => {
|
||||
// 进入尾段条件:有配音 + 视频已停 + 配音还没播完
|
||||
const needTail =
|
||||
!!voiceAudioUrl &&
|
||||
voiceDuration > 0 &&
|
||||
!isPlaying &&
|
||||
!isDragging &&
|
||||
typeof currentTime === "number" &&
|
||||
currentTime >= totalDuration - 0.1 &&
|
||||
currentTime < voiceDuration - 0.1
|
||||
if (needTail && tailCurrentTime === null) {
|
||||
tailBaseRef.current = currentTime
|
||||
tailStartRef.current = performance.now()
|
||||
setTailCurrentTime(currentTime)
|
||||
const a = audioRef.current
|
||||
if (a && a.src) {
|
||||
try {
|
||||
if (Math.abs(a.currentTime - currentTime) > 0.3) a.currentTime = currentTime
|
||||
a.play().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!needTail && tailCurrentTime !== null) {
|
||||
setTailCurrentTime(null)
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
voiceDuration,
|
||||
voiceAudioUrl,
|
||||
isDragging,
|
||||
tailCurrentTime,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (tailCurrentTime === null) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||||
setTailCurrentTime(t)
|
||||
const a = audioRef.current
|
||||
if (a && a.src && !a.paused && Math.abs(a.currentTime - t) > 0.5) {
|
||||
try {
|
||||
a.currentTime = t
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (t >= (voiceDuration || 0) - 0.05) {
|
||||
// 到达配音结尾:暂停音频,复位
|
||||
if (a) {
|
||||
try {
|
||||
a.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
setTailCurrentTime(null)
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||||
|
||||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频/Canvas 时间
|
||||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
// 退出尾段模式,回到视频驱动
|
||||
setTailCurrentTime(null)
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
@@ -452,18 +561,14 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
if (!progressRef.current || effectiveTotalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * effectiveTotalDuration)
|
||||
},
|
||||
[totalDuration, handleSeekTo],
|
||||
[effectiveTotalDuration, handleSeekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -477,10 +582,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
if (!progressRef.current || effectiveTotalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * effectiveTotalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
@@ -489,13 +594,65 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, handleSeekTo])
|
||||
}, [isDragging, effectiveTotalDuration, handleSeekTo])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
const progressPercent =
|
||||
effectiveTotalDuration > 0 ? (displayCurrentTime / effectiveTotalDuration) * 100 : 0
|
||||
|
||||
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// P0 fix:音画同步看门狗——有配音时,播放时间达到配音时长立即暂停视频+音频(末帧冻结),
|
||||
// 防止视频比配音长时继续播放、或视频播完后音频继续拖尾;音频自身 ended 也走同一暂停路径。
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
videoPause()
|
||||
}
|
||||
const a = audioRef.current
|
||||
if (a) {
|
||||
try {
|
||||
a.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [
|
||||
isPlaying,
|
||||
displayCurrentTime,
|
||||
voiceAudioUrl,
|
||||
voiceDuration,
|
||||
effectiveUseWebCodecs,
|
||||
canvasControls,
|
||||
videoPause,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// 音频自然结束时同步暂停视频(兜底:浏览器音频 ended 可能早于/晚于 currentTime 看门狗触发)
|
||||
useEffect(() => {
|
||||
const a = audioRef.current
|
||||
if (!a) return
|
||||
const onEnded = () => {
|
||||
if (!isPlaying) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
videoPause()
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}
|
||||
a.addEventListener("ended", onEnded)
|
||||
return () => {
|
||||
a.removeEventListener("ended", onEnded)
|
||||
}
|
||||
}, [isPlaying, effectiveUseWebCodecs, canvasControls, videoPause, playToken, onPlayTokenChange])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
@@ -888,7 +1045,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
{formatTime(displayCurrentTime)} / {formatTime(effectiveTotalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
|
||||
@@ -171,7 +171,6 @@ export function useBatchCovers({
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
for (const i of pending) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const ok = await generateOne(i)
|
||||
if (ok) okCount += 1
|
||||
else failCount += 1
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface BatchVariantClipsState {
|
||||
clipsByVariant: EditPlanClip[][]
|
||||
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
|
||||
planIdsByVariant: string[]
|
||||
/** 各变体的后端返回配音时长(秒);未就绪/未返回为 undefined */
|
||||
voiceDurationsByVariant: (number | undefined)[]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
loading: boolean
|
||||
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
|
||||
@@ -44,6 +46,12 @@ interface UseBatchVariantPlansOptions {
|
||||
assetIds: string[]
|
||||
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
|
||||
sourcePlanId?: string
|
||||
/** 统一配音 ID(共用配音模式),参考 useGenerateVideo voiceLibraryId 计算 */
|
||||
voiceLibraryId?: string
|
||||
/** 独立配音 ID 列表(每变体一条),voiceModePerVideo=true 时使用 */
|
||||
voiceLibraryIds?: string[]
|
||||
/** 是否启用独立配音模式(每变体各自一条配音) */
|
||||
voiceModePerVideo?: boolean
|
||||
}
|
||||
|
||||
export function useBatchVariantPlans({
|
||||
@@ -52,9 +60,13 @@ export function useBatchVariantPlans({
|
||||
templateId,
|
||||
assetIds,
|
||||
sourcePlanId = "",
|
||||
voiceLibraryId = "",
|
||||
voiceLibraryIds = [],
|
||||
voiceModePerVideo = false,
|
||||
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
|
||||
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -70,17 +82,30 @@ export function useBatchVariantPlans({
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
|
||||
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
|
||||
// - 统一配音:传 voice_library_id
|
||||
// - 都没选:不传
|
||||
const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {}
|
||||
if (voiceModePerVideo && voiceLibraryIds.length > 0) {
|
||||
voiceParam.voice_library_ids = voiceLibraryIds
|
||||
} else if (voiceLibraryId) {
|
||||
voiceParam.voice_library_id = voiceLibraryId
|
||||
}
|
||||
|
||||
const resp = await createBatchVariantPlans({
|
||||
template_id: templateId,
|
||||
asset_ids: assetIds,
|
||||
count,
|
||||
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
|
||||
...voiceParam,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
|
||||
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
|
||||
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
|
||||
const planIds: string[] = Array.from({ length: count }, () => "")
|
||||
const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined)
|
||||
for (const item of items) {
|
||||
const idx = item.variant_index
|
||||
if (idx < 0 || idx >= count) continue
|
||||
@@ -88,6 +113,9 @@ export function useBatchVariantPlans({
|
||||
clips[idx] = (item.clips || [])
|
||||
.filter((c) => c && c.asset_id && c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
if (typeof item.voice_duration === "number" && item.voice_duration > 0) {
|
||||
voiceDurs[idx] = item.voice_duration
|
||||
}
|
||||
}
|
||||
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
|
||||
const incomplete = clips.some((list) => list.length === 0)
|
||||
@@ -95,10 +123,12 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} else {
|
||||
setClipsByVariant(clips)
|
||||
setPlanIdsByVariant(planIds)
|
||||
setVoiceDurationsByVariant(voiceDurs)
|
||||
setError(false)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -107,11 +137,20 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [templateId, count, sourcePlanId, assetIds])
|
||||
}, [
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
|
||||
const retry = useCallback(() => {
|
||||
@@ -125,24 +164,40 @@ export function useBatchVariantPlans({
|
||||
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
|
||||
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setLoading((prev) => (prev === false ? prev : false))
|
||||
setError((prev) => (prev === false ? prev : false))
|
||||
lastKeyRef.current = ""
|
||||
return
|
||||
}
|
||||
const voiceKey = voiceModePerVideo
|
||||
? `per:${[...voiceLibraryIds].sort().join(",")}`
|
||||
: `one:${voiceLibraryId}`
|
||||
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
|
||||
.sort()
|
||||
.join(",")}`
|
||||
.join(",")}|${voiceKey}`
|
||||
if (key === lastKeyRef.current) return
|
||||
lastKeyRef.current = key
|
||||
load()
|
||||
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
|
||||
}, [
|
||||
enabled,
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
load,
|
||||
retryNonce,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
|
||||
|
||||
return {
|
||||
clipsByVariant,
|
||||
planIdsByVariant,
|
||||
voiceDurationsByVariant,
|
||||
loading,
|
||||
ready,
|
||||
error,
|
||||
|
||||
@@ -51,7 +51,6 @@ export function useTitleCoverSync({
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTemplate, setTitleSettings, setCoverSettings])
|
||||
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
|
||||
}
|
||||
|
||||
@@ -125,7 +125,6 @@ export function useVariantVoicePreview({
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
|
||||
@@ -2301,13 +2301,13 @@ class UnifiedRenderService:
|
||||
filters.append(f"eq=contrast={contrast:.3f}")
|
||||
|
||||
elif filt == "color_balance":
|
||||
# RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
|
||||
# RGB 通道偏移:colorbalance=rs=...:gs=...:bs=...
|
||||
r = pixel_pert.get("color_r", 0)
|
||||
g = pixel_pert.get("color_g", 0)
|
||||
b = pixel_pert.get("color_b", 0)
|
||||
if r != 0 or g != 0 or b != 0:
|
||||
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
|
||||
filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
|
||||
filters.append(f"colorbalance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
|
||||
|
||||
@staticmethod
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
|
||||
Reference in New Issue
Block a user