|
|
|
@@ -31,3 +31,349 @@ class EditPlanResult:
|
|
|
|
|
clips: list[EditClipPlan]
|
|
|
|
|
total_duration: float
|
|
|
|
|
summary: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import json as _json
|
|
|
|
|
import logging as _logging
|
|
|
|
|
from collections import defaultdict as _defaultdict
|
|
|
|
|
from packages.domain.entities import Asset, AssetStatus
|
|
|
|
|
from packages.domain.classification import AssetClassification
|
|
|
|
|
|
|
|
|
|
_logger = _logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
def _calculate_start_times(clips):
|
|
|
|
|
current_time = 0.0
|
|
|
|
|
for clip in clips:
|
|
|
|
|
clip.start_time = current_time
|
|
|
|
|
current_time += clip.duration
|
|
|
|
|
return clips
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SmartEditPlanGenerator:
|
|
|
|
|
"""
|
|
|
|
|
智能剪辑计划生成器
|
|
|
|
|
|
|
|
|
|
根据素材的分类结果和质量评分,自动编排剪辑计划。
|
|
|
|
|
支持多种剪辑模式:one_take, pip, voice_over, voice_pip
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, project_id: str, assets: list[Asset]):
|
|
|
|
|
self.project_id = project_id
|
|
|
|
|
# 筛选已就绪的视频素材
|
|
|
|
|
self.assets = [
|
|
|
|
|
a for a in assets
|
|
|
|
|
if a.status == AssetStatus.READY and a.mime_type.startswith("video/")
|
|
|
|
|
]
|
|
|
|
|
self.assets_by_classification: dict[str, list[Asset]] = defaultdict(list)
|
|
|
|
|
|
|
|
|
|
def _parse_classification(self, asset: Asset) -> str:
|
|
|
|
|
"""解析素材的分类结果"""
|
|
|
|
|
# 从 metadata 中获取分类
|
|
|
|
|
classification = asset.metadata.get("classification", "")
|
|
|
|
|
if not classification:
|
|
|
|
|
# 尝试从 classification_result 字段获取
|
|
|
|
|
classification = asset.metadata.get("classification_result", "")
|
|
|
|
|
|
|
|
|
|
# 如果是 JSON 字符串,解析它
|
|
|
|
|
if classification and isinstance(classification, str):
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(classification)
|
|
|
|
|
if isinstance(parsed, dict):
|
|
|
|
|
classification = parsed.get("classification", "other")
|
|
|
|
|
elif isinstance(parsed, str):
|
|
|
|
|
classification = parsed
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# 验证分类值是否有效
|
|
|
|
|
valid_classifications = [c.value for c in AssetClassification]
|
|
|
|
|
if classification not in valid_classifications:
|
|
|
|
|
classification = "other"
|
|
|
|
|
|
|
|
|
|
return classification
|
|
|
|
|
|
|
|
|
|
def _group_by_classification(self) -> None:
|
|
|
|
|
"""按分类结果对素材分组"""
|
|
|
|
|
for asset in self.assets:
|
|
|
|
|
classification = self._parse_classification(asset)
|
|
|
|
|
self.assets_by_classification[classification].append(asset)
|
|
|
|
|
|
|
|
|
|
def _sort_by_quality(self, assets: list[Asset]) -> list[Asset]:
|
|
|
|
|
"""按质量评分排序,高分在前"""
|
|
|
|
|
return sorted(
|
|
|
|
|
assets,
|
|
|
|
|
key=lambda a: (-(a.quality_score or 0), a.created_at)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _calculate_clip_duration(self, asset: Asset, target_duration: float, clip_count: int) -> float:
|
|
|
|
|
"""计算单个片段的时长"""
|
|
|
|
|
if asset.duration:
|
|
|
|
|
# 如果素材时长超过平均时长,取平均时长
|
|
|
|
|
avg_duration = target_duration / max(1, clip_count)
|
|
|
|
|
return min(float(asset.duration), avg_duration)
|
|
|
|
|
return target_duration / max(1, clip_count)
|
|
|
|
|
|
|
|
|
|
def _generate_one_take(self, target_duration: float = 30.0) -> EditPlanResult:
|
|
|
|
|
"""
|
|
|
|
|
One-Take 模式:按分类分组,组内按质量排序,顺序拼接
|
|
|
|
|
"""
|
|
|
|
|
self._group_by_classification()
|
|
|
|
|
|
|
|
|
|
clips: list[EditClipPlan] = []
|
|
|
|
|
sequence = 1
|
|
|
|
|
|
|
|
|
|
# 按优先级排序分类:person > scenic > product > other
|
|
|
|
|
priority_order = ["person", "scenic", "product", "animal", "food", "tech", "sport", "music", "other"]
|
|
|
|
|
sorted_classifications = sorted(
|
|
|
|
|
self.assets_by_classification.keys(),
|
|
|
|
|
key=lambda c: priority_order.index(c) if c in priority_order else len(priority_order)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for classification in sorted_classifications:
|
|
|
|
|
sorted_assets = self._sort_by_quality(self.assets_by_classification[classification])
|
|
|
|
|
for asset in sorted_assets:
|
|
|
|
|
duration = self._calculate_clip_duration(
|
|
|
|
|
asset, target_duration, len(self.assets)
|
|
|
|
|
)
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=duration,
|
|
|
|
|
layer="main",
|
|
|
|
|
reason=f"按分类 [{classification}] 排列,质量评分 {asset.quality_score or 0:.1f}"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
total_duration = sum(c.duration for c in clips)
|
|
|
|
|
_calculate_start_times(clips)
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode.ONE_TAKE,
|
|
|
|
|
clips=clips,
|
|
|
|
|
total_duration=total_duration,
|
|
|
|
|
summary=f"One-Take 模式:按 {len(sorted_classifications)} 个分类分组,共 {len(clips)} 段素材"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _generate_pip(self, target_duration: float = 30.0) -> EditPlanResult:
|
|
|
|
|
"""
|
|
|
|
|
PIP 模式:第一个高质量素材为主画面,其余为画中画
|
|
|
|
|
"""
|
|
|
|
|
sorted_assets = self._sort_by_quality(self.assets)
|
|
|
|
|
|
|
|
|
|
if not sorted_assets:
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode.PIP,
|
|
|
|
|
clips=[],
|
|
|
|
|
total_duration=0,
|
|
|
|
|
summary="无素材可用"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
clips: list[EditClipPlan] = []
|
|
|
|
|
sequence = 1
|
|
|
|
|
|
|
|
|
|
# 第一个高质量素材作为主画面
|
|
|
|
|
main_asset = sorted_assets[0]
|
|
|
|
|
main_duration = min(
|
|
|
|
|
float(main_asset.duration) if main_asset.duration else target_duration,
|
|
|
|
|
target_duration
|
|
|
|
|
)
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=main_asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=main_duration,
|
|
|
|
|
layer="main",
|
|
|
|
|
reason=f"高质量主画面 (质量评分: {main_asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
# 其余素材作为画中画
|
|
|
|
|
for asset in sorted_assets[1:]:
|
|
|
|
|
duration = self._calculate_clip_duration(asset, target_duration, len(sorted_assets))
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=duration,
|
|
|
|
|
layer="pip",
|
|
|
|
|
reason=f"画中画素材 (质量评分: {asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
total_duration = main_duration
|
|
|
|
|
_calculate_start_times(clips)
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode.PIP,
|
|
|
|
|
clips=clips,
|
|
|
|
|
total_duration=total_duration,
|
|
|
|
|
summary=f"PIP 模式:1 个主画面 + {len(sorted_assets) - 1} 个画中画"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _generate_voiceover(self, target_duration: float = 30.0) -> EditPlanResult:
|
|
|
|
|
"""
|
|
|
|
|
Voiceover 模式:person 类素材为主播口播,其余穿插为 B-roll
|
|
|
|
|
"""
|
|
|
|
|
self._group_by_classification()
|
|
|
|
|
|
|
|
|
|
person_assets = self._sort_by_quality(
|
|
|
|
|
self.assets_by_classification.get("person", [])
|
|
|
|
|
)
|
|
|
|
|
other_assets = self._sort_by_quality([
|
|
|
|
|
a for assets in self.assets_by_classification.values()
|
|
|
|
|
for a in assets
|
|
|
|
|
if self._parse_classification(a) != "person"
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
clips: list[EditClipPlan] = []
|
|
|
|
|
sequence = 1
|
|
|
|
|
|
|
|
|
|
# 合并口播和 B-roll
|
|
|
|
|
main_assets = person_assets if person_assets else other_assets
|
|
|
|
|
broll_assets = [a for a in other_assets if a not in person_assets] if person_assets else []
|
|
|
|
|
|
|
|
|
|
# 优先使用 person 素材作为口播
|
|
|
|
|
for i, asset in enumerate(main_assets):
|
|
|
|
|
duration = self._calculate_clip_duration(asset, target_duration, len(main_assets))
|
|
|
|
|
is_person = asset in person_assets
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=duration,
|
|
|
|
|
layer="main" if is_person else "broll",
|
|
|
|
|
reason=f"{'主播口播' if is_person else 'B-roll'} (质量评分: {asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
# 在口播之间穿插 B-roll
|
|
|
|
|
if is_person and broll_assets and i < len(main_assets) - 1:
|
|
|
|
|
broll_asset = broll_assets[i % len(broll_assets)]
|
|
|
|
|
broll_duration = self._calculate_clip_duration(
|
|
|
|
|
broll_asset, target_duration, len(main_assets) + len(broll_assets)
|
|
|
|
|
)
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=broll_asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=broll_duration,
|
|
|
|
|
layer="broll",
|
|
|
|
|
reason=f"B-roll 穿插 (质量评分: {broll_asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
total_duration = sum(c.duration for c in clips)
|
|
|
|
|
person_count = len(person_assets)
|
|
|
|
|
_calculate_start_times(clips)
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode.VOICE_OVER,
|
|
|
|
|
clips=clips,
|
|
|
|
|
total_duration=total_duration,
|
|
|
|
|
summary=f"Voiceover 模式:{person_count} 段口播 + {len(clips) - person_count} 段 B-roll"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _generate_voice_pip(self, target_duration: float = 30.0) -> EditPlanResult:
|
|
|
|
|
"""
|
|
|
|
|
Voice-PIP 模式:结合 voiceover 和 pip
|
|
|
|
|
第一个高质量 person 素材为主画面,其余为 PIP B-roll
|
|
|
|
|
"""
|
|
|
|
|
self._group_by_classification()
|
|
|
|
|
|
|
|
|
|
person_assets = self._sort_by_quality(
|
|
|
|
|
self.assets_by_classification.get("person", [])
|
|
|
|
|
)
|
|
|
|
|
other_assets = self._sort_by_quality([
|
|
|
|
|
a for assets in self.assets_by_classification.values()
|
|
|
|
|
for a in assets
|
|
|
|
|
if self._parse_classification(a) != "person"
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
clips: list[EditClipPlan] = []
|
|
|
|
|
sequence = 1
|
|
|
|
|
|
|
|
|
|
# 主画面:优先使用高质量 person 素材
|
|
|
|
|
main_asset = person_assets[0] if person_assets else (other_assets[0] if other_assets else None)
|
|
|
|
|
if main_asset:
|
|
|
|
|
main_duration = min(
|
|
|
|
|
float(main_asset.duration) if main_asset.duration else target_duration,
|
|
|
|
|
target_duration
|
|
|
|
|
)
|
|
|
|
|
is_person = main_asset in person_assets
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=main_asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=main_duration,
|
|
|
|
|
layer="main",
|
|
|
|
|
reason=f"{'主播口播' if is_person else '主画面'} (质量评分: {main_asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
# PIP 素材
|
|
|
|
|
pip_assets = [a for a in (person_assets[1:] + other_assets) if a != main_asset]
|
|
|
|
|
for asset in pip_assets:
|
|
|
|
|
duration = self._calculate_clip_duration(asset, target_duration, len(pip_assets) + 1)
|
|
|
|
|
clips.append(EditClipPlan(
|
|
|
|
|
asset_id=asset.id,
|
|
|
|
|
sequence=sequence,
|
|
|
|
|
start_time=0,
|
|
|
|
|
duration=duration,
|
|
|
|
|
layer="pip",
|
|
|
|
|
reason=f"PIP 素材 (质量评分: {asset.quality_score or 0:.1f})"
|
|
|
|
|
))
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
|
|
|
|
total_duration = sum(c.duration for c in clips)
|
|
|
|
|
_calculate_start_times(clips)
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode.VOICE_PIP,
|
|
|
|
|
clips=clips,
|
|
|
|
|
total_duration=total_duration,
|
|
|
|
|
summary=f"Voice-PIP 模式:1 个主画面 + {len(pip_assets)} 个 PIP 素材"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def generate_plan(
|
|
|
|
|
self,
|
|
|
|
|
editing_mode: str = "one_take",
|
|
|
|
|
target_duration: float = 30.0
|
|
|
|
|
) -> EditPlanResult:
|
|
|
|
|
"""
|
|
|
|
|
生成剪辑计划
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
editing_mode: 剪辑模式 (one_take/pip/voice_over/voice_pip)
|
|
|
|
|
target_duration: 目标时长(秒)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
EditPlanResult: 编排好的剪辑计划
|
|
|
|
|
"""
|
|
|
|
|
logger.info(f"Generating edit plan for project {self.project_id} with mode {editing_mode}")
|
|
|
|
|
|
|
|
|
|
if not self.assets:
|
|
|
|
|
logger.warning(f"No ready video assets found for project {self.project_id}")
|
|
|
|
|
return EditPlanResult(
|
|
|
|
|
project_id=self.project_id,
|
|
|
|
|
editing_mode=EditingMode(editing_mode),
|
|
|
|
|
clips=[],
|
|
|
|
|
total_duration=0,
|
|
|
|
|
summary="无素材可用"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
mode = EditingMode(editing_mode.lower())
|
|
|
|
|
|
|
|
|
|
if mode == EditingMode.ONE_TAKE:
|
|
|
|
|
return self._generate_one_take(target_duration)
|
|
|
|
|
elif mode == EditingMode.PIP:
|
|
|
|
|
return self._generate_pip(target_duration)
|
|
|
|
|
elif mode == EditingMode.VOICE_OVER:
|
|
|
|
|
return self._generate_voiceover(target_duration)
|
|
|
|
|
elif mode == EditingMode.VOICE_PIP:
|
|
|
|
|
return self._generate_voice_pip(target_duration)
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Unknown editing mode: {editing_mode}")
|
|
|
|
|