diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index c322a4f80..152974ac7 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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: diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 1d66f366d..6f2a4949d 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -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: diff --git a/apps/web/src/api/generation/variantPlans.ts b/apps/web/src/api/generation/variantPlans.ts index b8fa4b116..554ad09f1 100644 --- a/apps/web/src/api/generation/variantPlans.ts +++ b/apps/web/src/api/generation/variantPlans.ts @@ -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 } /** 批量变体计划响应 */ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 54c0863b9..6a68de6fa 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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} diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx index bcf2876c2..20a9891ef 100644 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx @@ -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 = ({ titles, titleSettings, voiceAudioUrls, + voiceDurations, variantClips, clipsLoading = false, clipsError = false, @@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC = ({ serverClips={variantClips[i]} variantTitle={titles[i] || ""} voiceAudioUrl={voiceAudioUrls?.[i] || undefined} + voiceDurationHint={voiceDurations?.[i]} activePlayToken={activePlayToken} onPlayTokenChange={setActivePlayToken} compact diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 432861953..77ee23172 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -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 = ({ ready, serverClips, voiceAudioUrl, + voiceDurationHint, titleSettings, onTitlePositionChange, playToken, @@ -124,17 +130,26 @@ const FrontendPreviewPlayer: React.FC = ({ activePlayToken = null, onPlayTokenChange, }) => { - // #1754:测量配音时长,计算缩放因子 - const [voiceDuration, setVoiceDuration] = useState(0) + // #1754→P0:配音时长作为音画时长锚点。 + // 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。 + const [voiceDuration, setVoiceDuration] = useState(() => + 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 = ({ return () => { audio.removeEventListener("loadedmetadata", onLoaded) } - }, [voiceAudioUrl]) + }, [voiceAudioUrl, voiceDurationHint]) // #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) const rawClipsDuration = useMemo(() => { @@ -351,6 +366,13 @@ const FrontendPreviewPlayer: React.FC = ({ const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false + // P0 fix:以配音时长为音画同步锚点。有配音时,播放器总时长 = 配音时长; + // - 视频比配音短:播完后末帧冻结,直至配音结束自动暂停 + // - 视频比配音长:到配音时长点硬停,截断视频 + // 无配音时沿用视频总时长(单视频原声兜底路径)。 + const effectiveTotalDuration = + !!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration + // ── 配音音频同步 ── const audioRef = useRef(null) const prevIsPlayingRef = useRef(false) @@ -400,8 +422,95 @@ const FrontendPreviewPlayer: React.FC = ({ // 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(null) + + // P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。 + // 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播, + // 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。 + const [tailCurrentTime, setTailCurrentTime] = useState(null) + const tailStartRef = useRef(0) + const tailBaseRef = useRef(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 = ({ onPlayTokenChange, ]) - // ── 进度条拖拽 ── - const [isDragging, setIsDragging] = useState(false) - const progressRef = useRef(null) - const handleProgressClick = useCallback( (e: React.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) }, - [totalDuration, handleSeekTo], + [effectiveTotalDuration, handleSeekTo], ) const handleMouseDown = useCallback( @@ -477,10 +582,10 @@ const FrontendPreviewPlayer: React.FC = ({ 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 = ({ 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(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 = ({ letterSpacing: 0.2, }} > - {formatTime(currentTime)} / {formatTime(totalDuration)} + {formatTime(displayCurrentTime)} / {formatTime(effectiveTotalDuration)}
([]) const [planIdsByVariant, setPlanIdsByVariant] = useState([]) + 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, diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/useTitleCoverSync.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/useTitleCoverSync.ts index 6ee207733..787db90f5 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/useTitleCoverSync.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/useTitleCoverSync.ts @@ -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 真正变化时触发 } diff --git a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts b/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts index 2e2e2501c..5eb6adc19 100644 --- a/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts +++ b/apps/web/src/pages/generate/hooks/useVariantVoicePreview.ts @@ -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 || ""