feat(from-assets): create clips based on template segments instead of required_clips_count
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 14m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 472h32m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 472h32m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 472h32m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 472h32m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 472h37m15s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 472h37m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 472h46m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 472h46m13s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 473h6m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 473h20m11s

- Query template_segments/clip_configs by template_id, create exactly N clips matching segment count
- Each clip duration randomly chosen between segment duration_min and duration_max (1 decimal)
- Assets round-robin assigned across segments; same asset can produce multiple clips
- Delete all existing clips for the plan before creating new ones (idempotent)
- MediaKit recommendation only applied to the first clip using each asset; subsequent clips use random start times
- MediaKit timeout/poll settings unchanged (60s/2s/15)
- mark_clips_ready called immediately after creation
- Ignore required_clips_count from frontend
- Clips order starts from 0 since old clips are cleared
- Add _get_template_segments helper supporting both new (clip_configs) and old (template_segments) systems
- 28 unit tests covering segment query, clip creation, round-robin, deletion, duration bounds, MediaKit integration
This commit is contained in:
CI Bot
2026-08-27 22:31:40 +08:00
parent 00067907f2
commit a2b37e376d
2 changed files with 580 additions and 340 deletions
+130 -45
View File
@@ -21,10 +21,17 @@ import re
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository
import random
from app.dependencies import get_asset_repository, get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.plan_generator_utils import _calc_random_start_time
@@ -355,6 +362,59 @@ def batch_delete_editor_clips(
def _get_template_segments(
template_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
return sorted(
[
(
cc.order,
float(cc.min_duration or _DEFAULT_EDITOR_CLIP_DURATION),
float(
cc.max_duration
or cc.min_duration
or _DEFAULT_EDITOR_CLIP_DURATION
),
)
for cc in clip_configs
],
key=lambda x: x[0],
)
except Exception:
logger.debug("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
return sorted(
[
(s.segment_order, float(s.duration_min), float(s.duration_max))
for s in segments
],
key=lambda x: x[0],
)
except Exception:
logger.debug("旧模板系统查询segments失败", exc_info=True)
return []
def _recommended_time_conflicts(
start: float,
duration: float,
@@ -489,22 +549,38 @@ def create_clips_from_assets_editor(
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(支持同一素材切多个片段 + 随机起始时间 + 去重).
"""从素材批量创建片段(按模板segment配置创建).
逻辑:
1. 模板要求 N 个片段,必须创建 N 个(不管素材有几个)
2. 素材数量 < 片段数量时,同一素材轮询切多个片段
3. 每个片段从素材中随机选取不重复时间段
4. 素材时长不足 5s 时缩短 clip duration
5. 新片段追加到时间线末尾(order 在现有最大值基础上递增)
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count)
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
4. 先清空该 plan 下所有旧片段,再创建新的(避免重复)
5. MediaKit 智能选片:第一个使用某素材的片段用推荐起始时间,后续用随机
6. 片段创建后立即 mark_clips_ready
"""
_, plan_svc = services
tpl_svc, plan_svc = services
required_count = body.required_clips_count if body.required_clips_count is not None else len(body.asset_ids)
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
)
# 去重后批量获取素材实际时长,避免重复查询
# 2. 先清空旧片段
deleted_count = plan_svc.delete_all_clips(plan_id)
logger.info(
"from-assets 清空旧片段: plan_id=%s deleted=%d",
plan_id,
deleted_count,
)
# 3. 获取素材实际时长
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
@@ -512,74 +588,83 @@ def create_clips_from_assets_editor(
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算追加起始 order:当前 plan 已有片段的最大 order + 1
# list_clips 返回 List[EditPlanClip]
existing_clips_list = plan_svc.list_clips(plan_id)
next_order = max((c.order for c in existing_clips_list), default=-1) + 1
# 4. 获取 MediaKit 智能选片推荐(保持60s timeout + poll 2s + 15次)
mediakit_recommendations = _get_mediakit_recommendations(
unique_asset_ids, asset_repo
)
# 从已有片段中构建已使用时间段,避免跨任务重复使用同一段素材区域
# 5. 跟踪素材使用情况
asset_first_used: set[str] = set()
used_segments: dict[str, list[tuple[float, float]]] = {}
for _clip in existing_clips_list:
if _clip.asset_id and _clip.start_time is not None and _clip.duration is not None:
used_segments.setdefault(_clip.asset_id, []).append(
(float(_clip.start_time), float(_clip.start_time) + float(_clip.duration))
)
# 尝试获取 MediaKit 智能选片推荐
mediakit_recommendations = _get_mediakit_recommendations(unique_asset_ids, asset_repo)
clips = []
for i in range(required_count):
# 轮询分配素材:素材不够时同一素材切多个片段
for i, (seg_order, dur_min, dur_max) in enumerate(segments):
# 轮询分配素材
asset_id = body.asset_ids[i % len(body.asset_ids)]
asset_total = asset_durations.get(asset_id, 0.0)
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
clip_duration = round(raw_duration, 1)
# 素材时长不足时缩短 clip duration
asset_total = asset_durations.get(asset_id, 0.0)
if asset_total > 0:
clip_duration = min(_DEFAULT_EDITOR_CLIP_DURATION, asset_total)
else:
clip_duration = _DEFAULT_EDITOR_CLIP_DURATION
clip_duration = min(clip_duration, asset_total)
# 优先使用 MediaKit 推荐的起始时间,冲突时降级为随机
# 确定起始时间
is_first_use = asset_id not in asset_first_used
recommended_start = mediakit_recommendations.get(asset_id)
if (
recommended_start is not None
and recommended_start + clip_duration <= asset_durations.get(asset_id, float("inf"))
is_first_use
and recommended_start is not None
and asset_total > 0
and recommended_start + clip_duration <= asset_total
and not _recommended_time_conflicts(
recommended_start, clip_duration, used_segments.get(asset_id, [])
)
):
start_time = recommended_start
logger.info(
"使用MediaKit推荐起始时间: asset_id=%s start_time=%.2f",
asset_id, start_time,
"使用MediaKit推荐起始时间: asset_id=%s start_time=%.2f duration=%.1f",
asset_id,
start_time,
clip_duration,
)
else:
if recommended_start is not None:
if is_first_use and recommended_start is not None:
logger.info(
"MediaKit推荐时间冲突或越界,降级为随机: asset_id=%s recommended=%.2f",
asset_id, recommended_start,
asset_id,
recommended_start,
)
elif not is_first_use:
logger.info(
"素材%s非首次使用,使用随机起始时间",
asset_id,
)
# 随机选择起始时间,避开已使用的时间段
start_time = _calc_random_start_time(
asset_id, clip_duration, asset_durations, used_segments
)
if start_time is None:
# 素材时长信息缺失,无法计算随机起始时间
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长信息缺失,无法创建片段",
)
# 记录已使用时间段(用于去重)
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
# 记录已使用时间段
used_segments.setdefault(asset_id, []).append(
(start_time, start_time + clip_duration)
)
asset_first_used.add(asset_id)
try:
clip = plan_svc.create_clip(
plan_id,
clip_type=body.clip_type or "main",
order=next_order + i,
order=i,
duration=clip_duration,
start_time=start_time,
asset_id=asset_id,
@@ -590,7 +675,7 @@ def create_clips_from_assets_editor(
"创建片段失败: plan_id=%s asset_id=%s order=%d error=%s",
plan_id,
asset_id,
next_order + i,
i,
exc,
exc_info=True,
)
@@ -600,15 +685,15 @@ def create_clips_from_assets_editor(
) from exc
logger.info(
"模板编辑器从素材创建片段: template_id=%s plan_id=%s required=%d actual=%d by user=%s",
"from-assets按模板创建片段: template_id=%s plan_id=%s segments=%d actual=%d by user=%s",
template_id,
plan_id,
required_count,
len(segments),
len(clips),
current_user.user.id,
)
# 新创建的片段已分配素材,立即标记为 ready,否则渲染管线找不到就绪片段
# 片段已分配素材,立即标记为 ready
plan_svc.mark_clips_ready(plan_id)
return ClipsFromAssetsResponse(
created_count=len(clips),