"""剪辑计划 / 模板 config JSON 结构定义. 定义 EditPlan.config 和 EditTemplate.config 中 cover / title / subtitle / bgm 四个子结构的 Pydantic 模型,供 API 层做入参校验和默认值填充。 所有字段均有合理默认值,前端可只传需要修改的字段。 """ from __future__ import annotations import copy from enum import Enum from typing import Optional from pydantic import BaseModel, Field # ── 枚举类型 ────────────────────────────────────────────────────────────────── class CoverType(str, Enum): """封面来源类型""" AI_FRAME = "ai_frame" # AI 智能选帧 MANUAL = "manual" # 手动选择帧 UPLOAD = "upload" # 用户上传 AI_REGENERATE = "ai_regenerate" # AI 重新生成 class TextPosition(str, Enum): """文字位置""" TOP = "top" CENTER = "center" BOTTOM = "bottom" class TextAnimation(str, Enum): """文字动画效果""" NONE = "none" FADE_IN = "fade_in" SLIDE_UP = "slide_up" SLIDE_DOWN = "slide_down" SCALE = "scale" class BGMSource(str, Enum): """BGM 来源""" LIBRARY = "library" # 素材库 UPLOAD = "upload" # 用户上传 AI_RECOMMEND = "ai_recommend" # AI 推荐 # ── 子结构模型 ──────────────────────────────────────────────────────────────── class StrokeConfig(BaseModel): """文字描边配置""" enabled: bool = Field(default=False, description="是否启用描边") color: str = Field(default="#000000", description="描边颜色 (HEX)") width: int = Field(default=1, ge=1, le=10, description="描边宽度") class ShadowConfig(BaseModel): """文字阴影配置""" enabled: bool = Field(default=False, description="是否启用阴影") blur: int = Field(default=4, ge=0, le=20, description="模糊半径") offset_x: int = Field(default=2, description="X 偏移") offset_y: int = Field(default=2, description="Y 偏移") class CoverConfig(BaseModel): """封面配置 type 说明: - ai_frame: AI 从视频中智能选取最佳帧 - manual: 用户手动指定 frame_time 处的帧 - upload: 用户上传图片,image_url 为上传后的地址 - ai_regenerate: AI 重新生成封面图 """ type: CoverType = Field(default=CoverType.AI_FRAME, description="封面来源类型") image_url: str = Field(default="", description="封面图片 URL") frame_time: Optional[float] = Field(default=None, ge=0.0, description="截取帧时间点 (秒)") class TitleConfig(BaseModel): """标题配置""" enabled: bool = Field(default=True, description="是否显示标题") ai_auto: bool = Field(default=True, description="是否由 AI 自动生成标题文案") text: str = Field(default="", description="标题文案 (ai_auto=false 时由用户填写)") position: TextPosition = Field(default=TextPosition.TOP, description="标题位置") font: str = Field(default="思源黑体", description="字体名称") color: str = Field(default="#ffffff", description="文字颜色 (HEX)") size: int = Field(default=48, ge=12, le=120, description="字号") bold: bool = Field(default=True, description="是否加粗") italic: bool = Field(default=False, description="是否斜体") stroke: StrokeConfig = Field(default_factory=StrokeConfig, description="描边配置") shadow: ShadowConfig = Field(default_factory=ShadowConfig, description="阴影配置") class SubtitleConfig(BaseModel): """字幕配置""" enabled: bool = Field(default=True, description="是否显示字幕") position: TextPosition = Field(default=TextPosition.BOTTOM, description="字幕位置") font: str = Field(default="思源黑体", description="字体名称") color: str = Field(default="#ffffff", description="文字颜色 (HEX)") size: int = Field(default=24, ge=12, le=60, description="字号") animation: TextAnimation = Field(default=TextAnimation.FADE_IN, description="入场动画") # ASR 自动字幕 auto_generated: bool = Field(default=False, description="是否启用ASR自动生成字幕") language: str = Field(default="", description="字幕语言,空字符串表示自动检测(如 zh/en/ja)") max_chars_per_line: int = Field(default=20, ge=8, le=40, description="每行最多字符数") min_chars_per_segment: int = Field(default=8, ge=2, le=20, description="每段最少字符数(低于则合并)") class BGMConfig(BaseModel): """BGM 配置""" enabled: bool = Field(default=False, description="是否启用 BGM") source: BGMSource = Field(default=BGMSource.LIBRARY, description="BGM 来源") asset_id: str = Field(default="", description="BGM 素材 ID(来源为 library/upload 时使用)") preset_id: str = Field(default="", description="预设 BGM ID(来源为 ai_recommend 或使用内置库时使用)") audio_url: str = Field(default="", description="BGM 音频 URL(外部直链,优先级最高)") volume: float = Field(default=0.3, ge=0.0, le=1.0, description="BGM 音量 (0.0 ~ 1.0)") fade_in: float = Field(default=0.0, ge=0.0, le=30.0, description="淡入时长(秒)") fade_out: float = Field(default=0.0, ge=0.0, le=30.0, description="淡出时长(秒)") loop_enabled: bool = Field(default=True, description="BGM 是否循环播放以铺满整个视频时长") sidechain_enabled: bool = Field(default=False, description="是否启用人声闪避(有人声时 BGM 自动降低音量)") sidechain_ratio: float = Field( default=0.3, ge=0.0, le=1.0, description="人声闪避时 BGM 音量降低比例(0.3 = 降低30%)" ) sidechain_attack: float = Field(default=0.02, ge=0.001, le=1.0, description="人声闪避攻击时间(秒)") sidechain_release: float = Field(default=0.5, ge=0.01, le=5.0, description="人声闪避释放时间(秒)") sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB)") class ExportConfig(BaseModel): """导出配置 视频输出参数设置。 """ resolution: str = Field(default="1080x1920", description="输出分辨率,如 1080x1920 / 720x1280 / 2160x3840") fps: int = Field(default=30, ge=15, le=60, description="输出帧率 15~60") video_bitrate: int = Field(default=8000, ge=1000, le=20000, description="视频码率(kbps)") audio_bitrate: int = Field(default=128, ge=64, le=320, description="音频码率(kbps)") format: str = Field(default="mp4", description="输出格式:mp4 / mov") quality_preset: str = Field( default="balanced", description="质量预设:ultra_fast / fast / balanced / high / best", ) watermark_enabled: bool = Field(default=False, description="是否启用水印") watermark_text: str = Field(default="", description="水印文字") class FilterConfig(BaseModel): """滤镜调色配置 支持全局滤镜和按片段覆盖。 强度 0-100,0 表示不应用,100 表示全量应用预设。 """ enabled: bool = Field(default=False, description="是否启用滤镜") preset_id: str = Field(default="filter_none", description="滤镜预设 ID") intensity: int = Field(default=100, ge=0, le=100, description="滤镜强度 0-100") # 自定义微调参数(在预设基础上叠加调整) brightness: float = Field(default=0.0, ge=-1.0, le=1.0, description="亮度微调") contrast: float = Field(default=1.0, ge=0.0, le=2.0, description="对比度微调(倍率)") saturation: float = Field(default=1.0, ge=0.0, le=3.0, description="饱和度微调(倍率)") warmth: float = Field(default=0.0, ge=-1.0, le=1.0, description="色温微调(正=暖,负=冷)") # ── 完整 config 模型 ───────────────────────────────────────────────────────── class EditPlanConfigSchema(BaseModel): """EditPlan.config 完整结构 用于 API 层校验和默认值填充。所有子结构均可选, 未传入时使用各自默认值。 editing_mode 记录计划使用的剪辑模式。 """ cover: CoverConfig = Field(default_factory=CoverConfig, description="封面配置") title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置") subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置") bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置") export: ExportConfig = Field(default_factory=ExportConfig, description="导出配置") filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜调色配置") editing_mode: str = Field(default="one_take", description="剪辑模式") class EditTemplateConfigSchema(BaseModel): """EditTemplate.config 完整结构 模板级别的默认配置,创建计划时可作为初始值继承。 editing_mode 指定模板对应的剪辑模式,transition_enabled 控制是否启用转场。 """ cover: CoverConfig = Field(default_factory=CoverConfig, description="封面默认配置") title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置") subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置") bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置") export: ExportConfig = Field(default_factory=ExportConfig, description="导出默认配置") filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜默认配置") editing_mode: str = Field(default="one_take", description="剪辑模式") transition_enabled: bool = Field(default=True, description="是否启用转场") # ── 默认值常量 ──────────────────────────────────────────────────────────────── DEFAULT_EDIT_PLAN_CONFIG: dict = { "cover": { "type": "ai_frame", "image_url": "", "frame_time": None, }, "title": { "enabled": True, "ai_auto": True, "text": "", "position": "top", "font": "思源黑体", "color": "#ffffff", "size": 48, "bold": True, "italic": False, "stroke": {"enabled": False, "color": "#000000", "width": 1}, "shadow": {"enabled": False, "blur": 4, "offset_x": 2, "offset_y": 2}, }, "subtitle": { "enabled": True, "position": "bottom", "font": "思源黑体", "color": "#ffffff", "size": 24, "animation": "fade_in", }, "bgm": { "enabled": False, "source": "library", "asset_id": "", "preset_id": "", "audio_url": "", "volume": 0.3, "fade_in": 0.0, "fade_out": 0.0, "loop_enabled": True, "sidechain_enabled": False, "sidechain_ratio": 0.3, "sidechain_attack": 0.02, "sidechain_release": 0.5, "sidechain_threshold": -25.0, }, "export": { "resolution": "1080x1920", "fps": 30, "video_bitrate": 8000, "audio_bitrate": 128, "format": "mp4", "quality_preset": "balanced", "watermark_enabled": False, "watermark_text": "", }, "filter": { "enabled": False, "preset_id": "filter_none", "intensity": 100, "brightness": 0.0, "contrast": 1.0, "saturation": 1.0, "warmth": 0.0, }, "editing_mode": "one_take", } DEFAULT_EDIT_TEMPLATE_CONFIG: dict = { **DEFAULT_EDIT_PLAN_CONFIG, "transition_enabled": True, } # ── 工具函数 ────────────────────────────────────────────────────────────────── def normalize_plan_config(raw: dict | None) -> dict: """将原始 config dict 标准化,填充缺失字段为默认值。 用于创建/更新计划时确保 config 结构完整。 """ if raw is None: return copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) base = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) for section_key in ("cover", "title", "subtitle", "bgm"): if section_key in raw and isinstance(raw[section_key], dict): if section_key not in base: base[section_key] = {} base[section_key].update(raw[section_key]) # editing_mode 顶层字段 if "editing_mode" in raw and isinstance(raw["editing_mode"], str): base["editing_mode"] = raw["editing_mode"] # 保留非标准字段(如 generation_task_id) for key, value in raw.items(): if key not in ("cover", "title", "subtitle", "bgm", "editing_mode"): base[key] = value return base def normalize_template_config(raw: dict | None) -> dict: """将模板原始 config dict 标准化。 在 plan config 基础上额外支持 transition_enabled 字段。 """ if raw is None: return copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG) base = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG) for section_key in ("cover", "title", "subtitle", "bgm"): if section_key in raw and isinstance(raw[section_key], dict): if section_key not in base: base[section_key] = {} base[section_key].update(raw[section_key]) # 顶层字段 if "editing_mode" in raw and isinstance(raw["editing_mode"], str): base["editing_mode"] = raw["editing_mode"] if "transition_enabled" in raw and isinstance(raw["transition_enabled"], bool): base["transition_enabled"] = raw["transition_enabled"] # 保留非标准字段 for key, value in raw.items(): if key not in ("cover", "title", "subtitle", "bgm", "editing_mode", "transition_enabled"): base[key] = value return base