Files
video-gen/video-gen-api/app/services/hot_opening_video_prompt_service.py
T
2026-07-24 12:06:13 +08:00

1822 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import copy
import json
import re
from types import SimpleNamespace
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_VERSION,
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
VIDEO_SCHEMA_TIME_DESC_MAX_LEN,
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN,
)
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.utils.id_gen import generate_id
from app.services.resource_signed_url_service import build_resource_signed_url
DEFAULT_FRAME_RATE = "30fps"
DEFAULT_REFERENCE_VIDEO_FPS = 1
# 后台配置可以控制业务字段是否启用/可编辑,但视频规格字段属于接口参数,
# 必须强制保留并锁定,避免管理端误删/禁用后导致第 4 步和第 5 步参数不一致。
VIDEO_SPEC_SECTION_KEY = "画面属性"
VIDEO_SPEC_LOCKED_FIELDS = {"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"}
ALWAYS_ALLOWED_TOP_LEVEL_KEYS = {"schema_version", "schema_usage", "动态时间规划", "输出规格限制", VIDEO_SPEC_SECTION_KEY}
TIME_PLAN_PLACEHOLDER_TEXTS = {"", "无", "阶段说明", "说明", "null", "None", "none", "未提及", "不适用"}
CLIENT_SCHEMA_V1: dict[str, Any] = {
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
"基础分类": {
"生成类型": "文生视频/图生视频/视频生视频/数字人视频/无",
"视频大类": "产品广告视频/电商带货视频/口播讲解视频/剧情视频/教程视频/风景旅行视频/美食视频/宠物视频/动漫卡通视频/游戏视频/企业宣传视频/新闻资讯视频/直播切片视频/图文快闪视频/音乐舞蹈视频/运动健身视频/无",
"视频子类": "产品推广短视频/口播讲解/电商带货/剧情演绎/操作教程/旅行风景/美食展示/宠物互动/二次元动画/游戏宣传/企业介绍/资讯播报/直播高光/图文快闪/无",
"视频用途": "广告投放/社媒发布/产品展示/课程教学/品牌宣传/娱乐内容/信息科普/无",
"目标平台": "抖音/快手/小红书/微信视频号/B站/TikTok/YouTube Shorts/Instagram Reels/无",
},
"素材理解": {
"是否有参考图片": "是/否",
"是否有参考视频": "是/否",
"参考视频用途": "动作参考/镜头参考/风格参考/运镜参考/节奏参考/无",
"需要保留": [],
"允许改动": [],
"禁止改动": [],
},
"业务属性": {
"产品类型": "APP/实物商品/食品/服饰/美妆/电子产品/汽车/房产/课程/服务/无",
"产品名称": "无",
"品牌名称": "无",
"核心卖点": [],
"目标受众": "无",
"核心表达目标": "无",
"内容风格": "无",
"行动引导": "立即体验/立即下载/立即购买/点击了解/预约咨询/无",
},
"画面属性": {
"视频时长": "无",
"视频比例": "无",
"清晰度": "无",
"帧率": "无",
"主体描述": "无",
"主体数量": "无",
"主体位置": "无",
"主体占比": "无",
"场景描述": "无",
"构图方式": "无",
"画面风格": "无",
"光影色彩": "无",
},
"动作流程": [],
"镜头流程": [],
"字幕与口播": {
"是否需要字幕": "是/否",
"字幕内容": [],
"字幕位置": "无",
"字幕样式": "无",
"是否口播": "是/否",
"口播内容": "无",
"口播语气": "无",
"口播语速": "无",
"是否需要口型同步": "是/否/无",
},
"音频与节奏": {
"背景音乐": "无",
"音乐风格": "无",
"音乐节奏": "无",
"环境音": "无",
"动作音效": "无",
"整体节奏": "慢节奏/中等节奏/快节奏/卡点节奏/无",
},
"合规控制": {
"是否广告": "是/否",
"风险等级": "低/中/高",
"安全表达": "无",
"禁用词": [],
"合规说明": "无",
},
"质量控制": {
"主体一致性": "低/中/高/无",
"产品一致性": "低/中/高/无",
"动作自然度": "低/中/高/无",
"镜头稳定性": "低/中/高/无",
"字幕准确性": "低/中/高/无",
},
"最终提示词": {
"主提示词": "无",
"动作提示词": "无",
"镜头提示词": "无",
"字幕提示词": "无",
"音频提示词": "无",
"风格提示词": "无",
"负面提示词": "无",
},
}
def _safe_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _format_options(options: list[Any], fallback: str = "无") -> str:
values = [str(item) for item in _safe_list(options) if str(item).strip()]
return "/".join(values) if values else fallback
def get_recommended_resolution(video_ratio: str, resolution: str) -> str:
"""按比例和清晰度粗略计算推荐像素,不在服务内维护固定比例/分辨率白名单。"""
try:
width_ratio, height_ratio = [float(x) for x in str(video_ratio).split(":", 1)]
short_edge = int(str(resolution).lower().replace("p", ""))
if width_ratio >= height_ratio:
height = short_edge
width = round(short_edge * width_ratio / height_ratio)
else:
width = short_edge
height = round(short_edge * height_ratio / width_ratio)
return f"{width}x{height}"
except Exception:
return "无"
def _build_scaled_bounds(duration: int, ratios: list[float]) -> list[int]:
duration = max(1, int(duration))
raw = [0]
acc = 0.0
for ratio in ratios[:-1]:
acc += ratio
raw.append(max(raw[-1] + 1, min(duration - 1, round(duration * acc))))
raw.append(duration)
for index in range(1, len(raw)):
if raw[index] <= raw[index - 1]:
raw[index] = min(duration, raw[index - 1] + 1)
raw[-1] = duration
return raw
def _bounds_to_plan(bounds: list[int], stages: list[tuple[str, str]]) -> list[dict[str, str]]:
plan: list[dict[str, str]] = []
for idx, (stage, desc) in enumerate(stages):
start = bounds[idx]
end = bounds[idx + 1]
plan.append({"时间段": f"{start}-{end}秒", "阶段": stage, "说明": desc})
return plan
def _default_time_plan_rules() -> list[dict[str, Any]]:
return [
{
"min_duration": 1,
"max_duration": 5,
"ratios": [0.2, 0.4, 0.4],
"segments": [
{"stage": "开场吸引", "description": "快速建立主体、产品和画面风格"},
{"stage": "核心展示", "description": "展示主体动作、核心卖点或主要视觉内容"},
{"stage": "行动引导", "description": "强化记忆点并给出转化引导"},
],
},
{
"min_duration": 6,
"max_duration": 8,
"ratios": [0.15, 0.3, 0.35, 0.2],
"segments": [
{"stage": "开场吸引", "description": "快速吸引注意力"},
{"stage": "主体展示", "description": "展示主体和产品关系"},
{"stage": "核心卖点", "description": "突出新项目核心内容点"},
{"stage": "收尾引导", "description": "给出行动引导并稳定落版"},
],
},
{
"min_duration": 9,
"max_duration": 15,
"ratios": [0.13, 0.2, 0.27, 0.25, 0.15],
"segments": [
{"stage": "爆款开头", "description": "复刻参考素材开头节奏和视觉吸引点"},
{"stage": "主体建立", "description": "明确新项目主体和产品信息"},
{"stage": "卖点放大", "description": "围绕核心内容点展开动作和镜头"},
{"stage": "情绪推进", "description": "用动作、字幕或镜头变化强化记忆"},
{"stage": "转化收尾", "description": "给出清晰行动引导"},
],
},
]
def _normalize_bool(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() not in {"0", "false", "no", "off", "否", "禁用"}
def _normalize_int(value: Any, default: int, *, min_value: int = 1, max_value: int = 100000) -> int:
try:
number = int(value)
except Exception:
number = default
return max(min_value, min(max_value, number))
def _truncate_for_config(value: Any, limit: int) -> str:
text = str(value or "").strip()
return text[:limit]
def _schema_value_type(value: Any) -> str:
if isinstance(value, dict):
return "object"
if isinstance(value, list):
return "array"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
return "string"
def _default_node(key: str, value: Any, *, editable: bool = True) -> dict[str, Any]:
node: dict[str, Any] = {
"key": str(key),
"label": str(key),
"type": _schema_value_type(value),
"enabled": True,
"editable": editable,
"max_length": VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
}
if isinstance(value, dict):
node["children"] = [_default_node(child_key, child_value, editable=editable) for child_key, child_value in value.items()]
else:
node["value"] = copy.deepcopy(value)
return node
def default_video_prompt_schema_config() -> dict[str, Any]:
sections: list[dict[str, Any]] = []
for key, value in CLIENT_SCHEMA_V1.items():
if key in {"schema_version", "schema_usage"}:
continue
if key == "动作流程":
sections.append(
{
"key": "动作流程",
"label": "动作流程",
"type": "flow",
"enabled": True,
"editable": True,
"content_key": "动作内容",
"content_aliases": list(ACTION_FLOW_CONTENT_KEYS),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [
{"key": "动作内容", "label": "动作内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
],
}
)
elif key == "镜头流程":
sections.append(
{
"key": "镜头流程",
"label": "镜头流程",
"type": "flow",
"enabled": True,
"editable": True,
"content_key": "镜头内容",
"content_aliases": list(CAMERA_FLOW_CONTENT_KEYS),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [
{"key": "镜头内容", "label": "镜头内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
],
}
)
else:
sections.append(_default_node(key, value, editable=True))
return {
"version": VIDEO_SCHEMA_CONFIG_VERSION,
"enabled": True,
"sections": sections,
"time_plan_rules": _default_time_plan_rules(),
}
def _extract_config_data(schema_config_snapshot: Any | None) -> dict[str, Any] | None:
if not isinstance(schema_config_snapshot, dict):
return None
data = schema_config_snapshot.get("data")
if isinstance(data, dict):
return data
if schema_config_snapshot.get("sections") or schema_config_snapshot.get("time_plan_rules"):
return schema_config_snapshot
return None
def normalize_video_prompt_schema_config(config: Any | None) -> dict[str, Any]:
source = config if isinstance(config, dict) else {}
default_config = default_video_prompt_schema_config()
if not source:
return default_config
normalized: dict[str, Any] = {
"version": str(source.get("version") or VIDEO_SCHEMA_CONFIG_VERSION),
"enabled": _normalize_bool(source.get("enabled"), True),
"sections": [],
"time_plan_rules": [],
}
raw_sections = source.get("sections") if isinstance(source.get("sections"), list) else default_config["sections"]
for section in raw_sections:
if not isinstance(section, dict):
continue
key = _truncate_for_config(section.get("key") or section.get("label"), 64)
if not key:
continue
section_type = str(section.get("type") or "object")
if key == VIDEO_SPEC_SECTION_KEY:
section_type = "object"
item: dict[str, Any] = {
"key": key,
"label": _truncate_for_config(section.get("label") or key, 64),
"type": section_type,
"enabled": True if key == VIDEO_SPEC_SECTION_KEY else _normalize_bool(section.get("enabled"), True),
"editable": _normalize_bool(section.get("editable"), True),
"max_length": _normalize_int(section.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
}
if section_type == "object":
children = section.get("children") if isinstance(section.get("children"), list) else []
item["children"] = []
for child in children:
if not isinstance(child, dict):
continue
child_key = _truncate_for_config(child.get("key") or child.get("label"), 64)
if not child_key:
continue
is_locked_video_spec = key == VIDEO_SPEC_SECTION_KEY and child_key in VIDEO_SPEC_LOCKED_FIELDS
item["children"].append(
{
"key": child_key,
"label": _truncate_for_config(child.get("label") or child_key, 64),
"type": str(child.get("type") or _schema_value_type(child.get("value"))),
"enabled": True if is_locked_video_spec else _normalize_bool(child.get("enabled"), True),
"editable": False if is_locked_video_spec else _normalize_bool(child.get("editable"), True),
"max_length": _normalize_int(child.get("max_length") or child.get("maxLength"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
"value": copy.deepcopy(child.get("value", "无")),
}
)
if key == VIDEO_SPEC_SECTION_KEY:
existing_child_keys = {str(child.get("key") or "") for child in item["children"] if isinstance(child, dict)}
for locked_key in ("视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"):
if locked_key not in existing_child_keys:
item["children"].append(
{
"key": locked_key,
"label": locked_key,
"type": "string",
"enabled": True,
"editable": False,
"max_length": VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
"value": "无",
}
)
elif section_type == "flow":
content_key = _truncate_for_config(section.get("content_key") or section.get("contentKey") or ("镜头内容" if key == "镜头流程" else "动作内容"), 64)
aliases = section.get("content_aliases") or section.get("contentAliases")
if not isinstance(aliases, list) or not aliases:
aliases = list(CAMERA_FLOW_CONTENT_KEYS if key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS)
item["content_key"] = content_key
item["content_aliases"] = [_truncate_for_config(alias, 64) for alias in aliases if _truncate_for_config(alias, 64)]
raw_fields = section.get("item_fields") or section.get("itemFields")
if not isinstance(raw_fields, list) or not raw_fields:
raw_fields = [{"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""}]
item["item_fields"] = []
for field in raw_fields:
if not isinstance(field, dict):
continue
field_key = _truncate_for_config(field.get("key") or field.get("label"), 64)
if not field_key or field_key == "时间段":
continue
item["item_fields"].append(
{
"key": field_key,
"label": _truncate_for_config(field.get("label") or field_key, 64),
"enabled": _normalize_bool(field.get("enabled"), True),
"editable": _normalize_bool(field.get("editable"), True),
"max_length": _normalize_int(field.get("max_length"), item["max_length"], min_value=1, max_value=20000),
"value": copy.deepcopy(field.get("value", "")),
}
)
if not any(field["key"] == content_key for field in item["item_fields"]):
item["item_fields"].insert(0, {"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""})
else:
item["value"] = copy.deepcopy(section.get("value", [] if section_type == "array" else "无"))
normalized["sections"].append(item)
raw_rules = source.get("time_plan_rules") or source.get("timePlanRules")
if not isinstance(raw_rules, list) or not raw_rules:
raw_rules = default_config["time_plan_rules"]
for rule in raw_rules:
if not isinstance(rule, dict):
continue
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
normalized_segments: list[dict[str, str]] = []
for segment in segments:
if not isinstance(segment, dict):
continue
normalized_segments.append(
{
"stage": _truncate_for_config(segment.get("stage") or "无", VIDEO_SCHEMA_TIME_STAGE_MAX_LEN),
"description": _truncate_for_config(segment.get("description") or segment.get("desc") or "无", VIDEO_SCHEMA_TIME_DESC_MAX_LEN),
}
)
if not normalized_segments:
continue
try:
normalized_ratios = [float(item) for item in ratios]
except Exception:
normalized_ratios = []
if len(normalized_ratios) != len(normalized_segments):
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
ratio_total = sum(item for item in normalized_ratios if item > 0)
if ratio_total <= 0:
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
else:
normalized_ratios = [max(0.01, item) / ratio_total for item in normalized_ratios]
normalized["time_plan_rules"].append(
{
"min_duration": _normalize_int(rule.get("min_duration") or rule.get("minDuration"), 1, min_value=1, max_value=3600),
"max_duration": _normalize_int(rule.get("max_duration") or rule.get("maxDuration"), 15, min_value=1, max_value=3600),
"ratios": normalized_ratios,
"segments": normalized_segments,
}
)
if not normalized["time_plan_rules"]:
normalized["time_plan_rules"] = default_config["time_plan_rules"]
return normalized
def build_client_schema_from_config(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
if not _normalize_bool(data.get("enabled"), True):
data = default_video_prompt_schema_config()
schema: dict[str, Any] = {
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
}
for section in data.get("sections", []):
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
continue
key = str(section.get("key") or "").strip()
if not key:
continue
section_type = str(section.get("type") or "object")
if section_type == "object":
obj: dict[str, Any] = {}
for child in section.get("children") or []:
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
continue
child_key = str(child.get("key") or "").strip()
if not child_key:
continue
obj[child_key] = copy.deepcopy(child.get("value", "无"))
schema[key] = obj
elif section_type == "flow":
schema[key] = []
else:
schema[key] = copy.deepcopy(section.get("value", [] if section_type == "array" else "无"))
return schema
def build_schema_config_snapshot(schema_config: Any | None = None, *, source: str = VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config) or schema_config)
if not data:
data = default_video_prompt_schema_config()
return {"version": VIDEO_SCHEMA_CONFIG_VERSION, "source": source, "data": data}
def _select_time_plan_rule(duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any] | None:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
rules = data.get("time_plan_rules") if isinstance(data.get("time_plan_rules"), list) else []
for rule in rules:
if not isinstance(rule, dict):
continue
min_duration = _normalize_int(rule.get("min_duration"), 1, min_value=1, max_value=3600)
max_duration = _normalize_int(rule.get("max_duration"), min_duration, min_value=1, max_value=3600)
if min_duration <= duration <= max_duration:
return rule
return None
def build_time_plan(duration: int, schema_config_snapshot: Any | None = None) -> list[dict[str, str]]:
duration = max(1, int(duration))
rule = _select_time_plan_rule(duration, schema_config_snapshot)
if rule:
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
stages: list[tuple[str, str]] = []
for segment in segments:
if isinstance(segment, dict):
stages.append((str(segment.get("stage") or "无"), str(segment.get("description") or "无")))
if stages:
try:
ratio_values = [float(item) for item in ratios]
except Exception:
ratio_values = []
if len(ratio_values) != len(stages):
ratio_values = [1 / len(stages)] * len(stages)
ratio_total = sum(item for item in ratio_values if item > 0)
if ratio_total <= 0:
ratio_values = [1 / len(stages)] * len(stages)
else:
ratio_values = [max(0.01, item) / ratio_total for item in ratio_values]
return _bounds_to_plan(_build_scaled_bounds(duration, ratio_values), stages)
# fallback 保留旧规则,避免配置为空或异常时影响原流程。
return _bounds_to_plan(
_build_scaled_bounds(duration, [0.2, 0.4, 0.4]),
[
("开场吸引", "快速建立主体、产品和画面风格"),
("核心展示", "展示主体动作、核心卖点或主要视觉内容"),
("行动引导", "强化记忆点并给出转化引导"),
],
) if duration <= 5 else (
_bounds_to_plan(
_build_scaled_bounds(duration, [0.15, 0.3, 0.35, 0.2]),
[
("开场吸引", "快速吸引注意力"),
("主体展示", "展示主体和产品关系"),
("核心卖点", "突出新项目核心内容点"),
("收尾引导", "给出行动引导并稳定落版"),
],
) if duration <= 8 else _bounds_to_plan(
_build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]),
[
("爆款开头", "复刻参考素材开头节奏和视觉吸引点"),
("主体建立", "明确新项目主体和产品信息"),
("卖点放大", "围绕核心内容点展开动作和镜头"),
("情绪推进", "用动作、字幕或镜头变化强化记忆"),
("转化收尾", "给出清晰行动引导"),
],
)
)
def _time_plan_schema_for_ai_input(plan: list[dict[str, str]]) -> list[dict[str, str]]:
"""
AI 入参 schema 只固定时间段和字段结构,不把后台配置的阶段/说明当成最终分析结果。
阶段/说明应由 AI 结合参考素材和新项目重新填写,后续归一化仅在 AI 未填写时兜底。
"""
result: list[dict[str, str]] = []
for item in plan:
result.append(
{
"时间段": item.get("时间段") or "无",
"阶段": "",
"说明": "",
}
)
return result
def _time_plan_time_ranges_for_ai(plan: list[dict[str, str]]) -> list[dict[str, str]]:
return [{"时间段": item.get("时间段") or "无"} for item in plan]
def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
schema = build_client_schema_from_config(schema_config_snapshot)
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
frame = schema.setdefault("画面属性", {})
if not isinstance(frame, dict):
frame = {}
schema["画面属性"] = frame
frame.update(
{
"视频时长": f"{duration}秒",
"视频比例": video_ratio,
"清晰度": resolution,
"推荐分辨率": recommended_resolution,
"帧率": frame_rate,
}
)
time_plan = build_time_plan(duration, schema_config_snapshot)
schema["动态时间规划"] = _time_plan_schema_for_ai_input(time_plan)
# 预览/AI 入参阶段只锁定时间段和字段结构,不把后台阶段/说明/流程说明写死到 schema 值里。
# 阶段、说明、动作内容、动作详解、镜头内容、镜头详解都应由 AI 基于素材分析填写;
# 只有 AI 返回缺失/空/无/阶段说明等占位内容时,归一化阶段才使用后台规则兜底。
for flow_key in ("动作流程", "镜头流程"):
if flow_key not in schema:
continue
content_key = _flow_content_key(schema_config_snapshot, flow_key)
schema[flow_key] = _align_flow_time_ranges(
schema.get(flow_key),
time_plan,
content_key,
content_keys=_flow_content_aliases(schema_config_snapshot, flow_key),
item_fields=_flow_item_field_rules(schema_config_snapshot, flow_key),
fallback_to_plan=False,
)
schema["输出规格限制"] = {
"支持时长": _safe_list(video_config.get("supported_durations")),
"支持比例": _safe_list(video_config.get("supported_ratios")),
"支持分辨率": _safe_list(video_config.get("supported_resolutions")),
"当前推荐分辨率": recommended_resolution,
}
return schema
def infer_generation_type(references: list[dict[str, str]] | None) -> str:
has_image = any(item.get("type") == "image" for item in references or [])
has_video = any(item.get("type") == "video" for item in references or [])
if has_image and has_video:
return "图生视频/视频生视频"
if has_image:
return "图生视频"
if has_video:
return "视频生视频"
return "文生视频"
def infer_video_category(text: str) -> tuple[str, str, list[str]]:
text = text or ""
if any(key in text for key in ["APP", "应用", "下载", "社交", "脱单", "附近"]):
return "产品广告视频", "产品推广短视频", ["产品推广", "用户转化", "核心卖点展示"]
return "产品广告视频", "产品推广短视频", ["产品展示", "视觉吸引", "行动引导"]
def build_system_prompt() -> str:
return (
"你是专业短视频广告导演和AI视频提示词工程师。"
"你必须只输出一个合法 JSON 对象,不能输出 Markdown。"
"输出必须严格遵循用户提供的 schema 顶层结构。"
"所有未知、无法判断或不适用的字段填写'无',数组字段可填写 []。"
"必须根据参考视频复刻爆款开头的节奏、构图、动作和镜头语言,但不能照抄品牌、水印、字幕或侵权元素。"
)
def build_user_text(
*,
source_project_name: str,
target_project_name: str,
core_content_point: str,
target_platform: str,
references: list[dict[str, str]],
video_config: dict[str, Any],
client_schema: dict[str, Any],
schema_config_snapshot: Any | None = None,
) -> str:
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
category, sub_category, default_points = infer_video_category(" ".join([source_project_name, target_project_name, core_content_point]))
return json.dumps(
{
"任务": "基于参考素材视频和新项目图片,生成可用于AI视频生成的中文结构化提示词JSON",
"业务输入": {
"视频素材内容项目名称": source_project_name,
"生成项目名称": target_project_name,
"生成项目核心内容点": core_content_point,
"目标平台": target_platform,
"默认视频大类": category,
"默认视频子类": sub_category,
"建议核心卖点": default_points,
},
"视频规格": {
"视频时长": f"{duration}秒",
"视频比例": video_ratio,
"清晰度": resolution,
"帧率": frame_rate,
"支持时长": _safe_list(video_config.get("supported_durations")),
"支持比例": _safe_list(video_config.get("supported_ratios")),
"支持分辨率": _safe_list(video_config.get("supported_resolutions")),
"推荐分辨率": get_recommended_resolution(video_ratio, resolution),
},
# "参考素材": references,
"输出要求": {
"生成类型": infer_generation_type(references),
"动态时间规划时间段必须严格等于": _time_plan_time_ranges_for_ai(build_time_plan(duration, schema_config_snapshot)),
"动态时间规划兜底参考": build_time_plan(duration, schema_config_snapshot),
"动态时间规划填写要求": "时间段必须和给定时间段一致;阶段、说明必须结合参考素材、新项目、核心内容点、动作和镜头重新分析填写;只有无法判断时才允许使用兜底参考;不能原样复制后台配置里的阶段说明。",
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长,每一段都必须填写动作流程对象中的全部字段。",
"动作流程字段要求": [field.get("key") for field in _flow_item_field_rules(schema_config_snapshot, "动作流程")],
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长,每一段都必须填写镜头流程对象中的全部字段。",
"镜头流程字段要求": [field.get("key") for field in _flow_item_field_rules(schema_config_snapshot, "镜头流程")],
"字段启用规则": "只输出 schema 中存在的启用字段;不要输出已禁用字段;不要新增 schema 外字段。",
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
"禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"],
"语言要求": "所有输出内容必须使用简体中文,不得使用英文或其他语言。"
},
"必须按此schema输出": client_schema,
},
ensure_ascii=False,
)
def build_user_message(user_content: str, references: list[dict[str, str]], reference_video_fps: int) -> tuple[dict[str, Any], dict[str, Any]]:
content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}]
log_content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}]
for ref in references:
ref_type = ref.get("type")
ref_url = ref.get("url")
if not ref_url:
continue
if ref_type == "image":
content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
log_content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
elif ref_type == "video":
content_parts.append({"type": "video_url", "video_url": {"url": ref_url}})
log_content_parts.append({"type": "video_url", "video_url": {"url": ref_url}})
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}
def strip_json_code_fence(text: str) -> str:
text = (text or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I)
text = re.sub(r"\s*```$", "", text)
return text.strip()
def parse_model_json(content: str) -> dict[str, Any]:
content = strip_json_code_fence(content)
data = json.loads(content)
if not isinstance(data, dict):
raise ValueError("视频提词优化返回值不是 JSON 对象")
return data
def fill_none_with_wu(value: Any) -> Any:
if value is None or value == "":
return "无"
if isinstance(value, dict):
return {k: fill_none_with_wu(v) for k, v in value.items()}
if isinstance(value, list):
return [fill_none_with_wu(v) for v in value]
return value
TOP_LEVEL_SCHEMA_KEYS = tuple(CLIENT_SCHEMA_V1.keys()) + ("动态时间规划", "输出规格限制")
OBJECT_FIELD_WHITELISTS: dict[str, set[str]] = {
key: set(value.keys())
for key, value in CLIENT_SCHEMA_V1.items()
if isinstance(value, dict)
}
OBJECT_FIELD_WHITELISTS.setdefault("画面属性", set()).add("推荐分辨率")
OBJECT_FIELD_WHITELISTS["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
ACTION_FLOW_CONTENT_KEYS = ("动作内容", "动作", "动作说明", "内容", "说明", "主体动作", "动作变化")
CAMERA_FLOW_CONTENT_KEYS = ("镜头内容", "镜头", "镜头说明", "运镜", "运镜说明", "内容", "说明")
TIME_PLAN_ALLOWED_KEYS = ("时间段", "阶段", "说明")
PLACEHOLDER_FLOW_TEXTS = {
"展示主体动作、核心卖点或主要视觉内容",
"展示主要动作、核心卖点或主要视觉内容",
"展示核心卖点或主要视觉内容",
"展示主体动作",
"无",
}
EMPTY_VALUE_TEXTS = {"", "无", "null", "None", "none", "未提及", "不适用"}
def _clean_schema_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (dict, list)):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value).strip()
def _is_empty_schema_value(value: Any) -> bool:
return _clean_schema_text(value) in EMPTY_VALUE_TEXTS
def _schema_default_for_normalize(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
schema = build_client_schema_from_config(schema_config_snapshot)
schema.setdefault("schema_version", PromptSchemaVersionEnum.CLIENT_V1.value)
schema.setdefault("schema_usage", VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value)
return schema
def _object_field_whitelists(schema_config_snapshot: Any | None = None) -> dict[str, set[str]]:
default_schema = _schema_default_for_normalize(schema_config_snapshot)
whitelists: dict[str, set[str]] = {
key: set(value.keys())
for key, value in default_schema.items()
if isinstance(value, dict)
}
whitelists.setdefault("画面属性", set()).update({"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"})
whitelists["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
return whitelists
def _normalize_schema_object(section_key: str, value: Any, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
default_schema = _schema_default_for_normalize(schema_config_snapshot)
default_value = default_schema.get(section_key)
if not isinstance(default_value, dict):
default_value = {}
source = value if isinstance(value, dict) else {}
merged = copy.deepcopy(default_value)
allowed_keys = _object_field_whitelists(schema_config_snapshot).get(section_key, set(default_value.keys()))
for field_key in allowed_keys:
if field_key in source:
merged[field_key] = fill_none_with_wu(source.get(field_key))
return merged
def normalize_top_level_schema_fields(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。"""
source = result if isinstance(result, dict) else {}
default_schema = _schema_default_for_normalize(schema_config_snapshot)
whitelists = _object_field_whitelists(schema_config_snapshot)
top_level_schema_keys = tuple(default_schema.keys()) + ("动态时间规划", "输出规格限制")
normalized: dict[str, Any] = {}
for key in top_level_schema_keys:
if key in whitelists:
normalized[key] = _normalize_schema_object(key, source.get(key), schema_config_snapshot)
elif key in source:
normalized[key] = fill_none_with_wu(source.get(key))
elif key in default_schema:
normalized[key] = copy.deepcopy(default_schema[key])
for key, default_value in default_schema.items():
if key not in normalized:
normalized[key] = copy.deepcopy(default_value)
return normalized
def ensure_top_keys(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
return normalize_top_level_schema_fields(result, schema_config_snapshot)
def _flow_section_config(schema_config_snapshot: Any | None, flow_key: str) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
for section in data.get("sections", []):
if isinstance(section, dict) and section.get("key") == flow_key and section.get("type") == "flow" and _normalize_bool(section.get("enabled"), True):
return section
default_key = "镜头内容" if flow_key == "镜头流程" else "动作内容"
aliases = CAMERA_FLOW_CONTENT_KEYS if flow_key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS
return {
"key": flow_key,
"type": "flow",
"enabled": True,
"editable": True,
"content_key": default_key,
"content_aliases": list(aliases),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [{"key": default_key, "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}],
}
def _flow_content_key(schema_config_snapshot: Any | None, flow_key: str) -> str:
section = _flow_section_config(schema_config_snapshot, flow_key)
return str(section.get("content_key") or ("镜头内容" if flow_key == "镜头流程" else "动作内容"))
def _flow_content_aliases(schema_config_snapshot: Any | None, flow_key: str) -> tuple[str, ...]:
section = _flow_section_config(schema_config_snapshot, flow_key)
aliases = section.get("content_aliases") if isinstance(section.get("content_aliases"), list) else []
values = [str(item) for item in aliases if str(item).strip()]
content_key = _flow_content_key(schema_config_snapshot, flow_key)
if content_key not in values:
values.insert(0, content_key)
return tuple(values)
def _flow_item_field_rules(schema_config_snapshot: Any | None, flow_key: str) -> list[dict[str, Any]]:
section = _flow_section_config(schema_config_snapshot, flow_key)
fields = section.get("item_fields") if isinstance(section.get("item_fields"), list) else []
result: list[dict[str, Any]] = []
for field in fields:
if not isinstance(field, dict) or not _normalize_bool(field.get("enabled"), True):
continue
key = str(field.get("key") or "").strip()
if not key or key == "时间段":
continue
result.append(
{
"key": key,
"editable": _normalize_bool(field.get("editable"), True),
"max_length": _normalize_int(field.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000),
"value": field.get("value", ""),
}
)
content_key = _flow_content_key(schema_config_snapshot, flow_key)
if not any(item["key"] == content_key for item in result):
result.insert(0, {"key": content_key, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""})
return result
def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
for key in content_keys:
if key in item and not _is_empty_schema_value(item.get(key)):
return _clean_schema_text(item.get(key))
return ""
def _collect_extra_flow_texts(
item: dict[str, Any],
*,
content_keys: tuple[str, ...],
allowed_keys: set[str],
) -> list[str]:
extras: list[str] = []
for key, value in item.items():
if key in allowed_keys or key in content_keys:
continue
key_text = _clean_schema_text(key)
value_text = _clean_schema_text(value)
if key_text and key_text not in EMPTY_VALUE_TEXTS:
extras.append(key_text)
if value_text and value_text not in EMPTY_VALUE_TEXTS and value_text != key_text:
extras.append(value_text)
# 去重但保留顺序,避免 AI 重复写入同一句。
deduped: list[str] = []
for text in extras:
if text not in deduped:
deduped.append(text)
return deduped
def _merge_flow_content(base_content: str, extra_texts: list[str], fallback: str) -> str:
base_content = _clean_schema_text(base_content)
if extra_texts and (not base_content or base_content in PLACEHOLDER_FLOW_TEXTS or base_content == fallback):
return "".join(extra_texts)
parts = [base_content] if base_content and base_content not in EMPTY_VALUE_TEXTS else []
for text in extra_texts:
if text and text not in parts:
parts.append(text)
return "".join(parts) if parts else fallback
def _normalize_flow_item(
raw_item: Any,
*,
plan_item: dict[str, str],
content_key: str,
content_keys: tuple[str, ...],
item_fields: list[dict[str, Any]] | None = None,
fallback_to_plan: bool = True,
) -> dict[str, str]:
item = raw_item if isinstance(raw_item, dict) else {}
field_rules = item_fields or [{"key": content_key, "value": ""}]
allowed_keys = {"时间段", *(str(field.get("key")) for field in field_rules if field.get("key"))}
base_content = _pick_flow_content(item, content_keys)
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
fallback = plan_item.get("说明") or "无"
empty_fallback = "无" if fallback_to_plan else ""
result: dict[str, str] = {"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "无"}
for field in field_rules:
field_key = str(field.get("key") or "").strip()
if not field_key:
continue
if field_key == content_key:
if fallback_to_plan:
result[field_key] = _merge_flow_content(base_content, extra_texts, fallback)
else:
result[field_key] = _merge_flow_content(base_content, extra_texts, "")
else:
# AI 入参预览阶段不能把后台默认值写死到流程字段中;只输出字段结构。
# AI 返回归一化阶段才允许在缺失/无时用字段默认值或通用占位兜底。
raw_value = _clean_schema_text(item.get(field_key))
if raw_value and raw_value not in EMPTY_VALUE_TEXTS and raw_value not in TIME_PLAN_PLACEHOLDER_TEXTS:
result[field_key] = raw_value
elif fallback_to_plan:
result[field_key] = _clean_schema_text(field.get("value")) or "无"
else:
result[field_key] = empty_fallback
return result
def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[str, str]]:
source = value if isinstance(value, list) else []
normalized: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
raw_stage = _clean_schema_text(raw_item.get("阶段"))
raw_desc = _clean_schema_text(raw_item.get("说明"))
if raw_stage in TIME_PLAN_PLACEHOLDER_TEXTS:
raw_stage = ""
if raw_desc in TIME_PLAN_PLACEHOLDER_TEXTS:
raw_desc = ""
# 只有时间段由服务端锁定;阶段、说明优先保留 AI 结合素材分析后的结果。
# 仅当 AI 未填写、填写“无/阶段说明/说明”等占位内容时,才使用后台时间切片规则兜底。
item: dict[str, str] = {
"时间段": plan_item.get("时间段") or _clean_schema_text(raw_item.get("时间段")) or "无",
"阶段": raw_stage or plan_item.get("阶段") or "无",
"说明": raw_desc or plan_item.get("说明") or "无",
}
normalized.append(item)
return normalized
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
base_plan = build_time_plan(duration, schema_config_snapshot)
plan = _normalize_time_plan(base_plan, result.get("动态时间规划"))
action_key = _flow_content_key(schema_config_snapshot, "动作流程")
camera_key = _flow_content_key(schema_config_snapshot, "镜头流程")
result["动作流程"] = _align_flow_time_ranges(
result.get("动作流程"),
plan,
action_key,
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
)
result["镜头流程"] = _align_flow_time_ranges(
result.get("镜头流程"),
plan,
camera_key,
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
)
result["动态时间规划"] = plan
return result
def ensure_negative_prompt(result: dict[str, Any]) -> dict[str, Any]:
final = result.setdefault("最终提示词", {})
if not isinstance(final, dict):
final = {}
result["最终提示词"] = final
if not final.get("负面提示词") or final.get("负面提示词") == "无":
final["负面提示词"] = "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁"
return result
def build_final_video_prompt(result: dict[str, Any]) -> str:
final = result.get("最终提示词", {}) if isinstance(result.get("最终提示词"), dict) else {}
parts = [
final.get("主提示词"),
final.get("动作提示词"),
final.get("镜头提示词"),
final.get("字幕提示词"),
final.get("音频提示词"),
final.get("风格提示词"),
]
return "\n".join(str(item).strip() for item in parts if item and str(item).strip() != "无")
VIDEO_SPEC_PROMPT_KEYS = ("主提示词", "动作提示词", "镜头提示词", "字幕提示词", "音频提示词", "风格提示词", "负面提示词")
def _normalize_prompt_text(text: str) -> str:
text = re.sub(r"[,、,;:]\s*([,、,;:])", r"\1", text)
text = re.sub(r"\s{2,}", " ", text)
text = re.sub(r"^[,、,;:\s]+", "", text)
text = re.sub(r"[,、,;:\s]+$", "", text)
return text.strip() or "无"
def clean_video_spec_from_prompt_text(text: Any, video_config: dict[str, Any] | None = None) -> str:
"""清洗最终提示词里的视频规格参数。
视频时长、比例、清晰度、分辨率、帧率属于接口参数和锁定字段,
不能混入最终提示词,避免用户绕过扣费参数或与第5步视频生成参数冲突。
"""
if text is None:
return "无"
value = str(text).strip()
if not value or value == "无":
return "无"
cfg = video_config or {}
exact_values = {
str(cfg.get("aspect_ratio") or "").strip(),
str(cfg.get("resolution") or "").strip(),
str(cfg.get("frame_rate") or "").strip(),
}
try:
if cfg.get("duration") is not None:
exact_values.add(f"{int(cfg.get('duration'))}秒")
except Exception:
pass
try:
if cfg.get("aspect_ratio") and cfg.get("resolution"):
exact_values.add(get_recommended_resolution(str(cfg.get("aspect_ratio")), str(cfg.get("resolution"))))
except Exception:
pass
for item in sorted((v for v in exact_values if v and v != "无"), key=len, reverse=True):
value = value.replace(item, "")
patterns = [
r"\d+\s*秒",
r"\b\d+\s*[sS]\b",
r"\d+\s*[:]\s*\d+",
r"\d{3,4}\s*[pP]",
r"\d{2,4}\s*[xX×]\s*\d{2,4}",
r"\d+\s*(?:fps|FPS|帧)",
r"(?:竖屏|横屏|方屏|超清|高清|标清|蓝光|4K|8K)",
r"(?:视频时长|时长|视频比例|画面比例|比例|分辨率|清晰度|帧率|推荐分辨率)\s*[:]?\s*",
]
for pattern in patterns:
value = re.sub(pattern, "", value, flags=re.I)
return _normalize_prompt_text(value)
def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any] | None = None) -> dict[str, Any]:
final = schema.setdefault("最终提示词", {})
if not isinstance(final, dict):
final = {}
schema["最终提示词"] = final
for key in VIDEO_SPEC_PROMPT_KEYS:
final[key] = clean_video_spec_from_prompt_text(final.get(key), video_config)
return schema
def _align_flow_time_ranges(
flow: Any,
plan: list[dict[str, str]],
default_content_key: str,
*,
content_keys: tuple[str, ...] | None = None,
item_fields: list[dict[str, Any]] | None = None,
fallback_to_plan: bool = True,
) -> list[dict[str, str]]:
source = flow if isinstance(flow, list) else []
aliases = content_keys or (ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS)
aligned: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) else {}
aligned.append(
_normalize_flow_item(
raw_item,
plan_item=plan_item,
content_key=default_content_key,
content_keys=aliases,
item_fields=item_fields,
fallback_to_plan=fallback_to_plan,
)
)
return aligned
def _config_editable_field_rules(schema_config_snapshot: Any | None = None) -> dict[tuple[str, str], dict[str, Any]]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
rules: dict[tuple[str, str], dict[str, Any]] = {}
for section in data.get("sections", []):
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
continue
if str(section.get("type") or "object") != "object":
continue
section_key = str(section.get("key") or "").strip()
for child in section.get("children") or []:
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
continue
field_key = str(child.get("key") or "").strip()
if not section_key or not field_key:
continue
rules[(section_key, field_key)] = {
"editable": _normalize_bool(child.get("editable"), True),
"max_length": _normalize_int(child.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
}
return rules
def _validate_editable_value_length(path: str, value: Any, max_length: int) -> None:
if isinstance(value, list):
for index, item in enumerate(value):
_validate_editable_value_length(f"{path}[{index}]", item, max_length)
return
if isinstance(value, dict):
for key, item in value.items():
_validate_editable_value_length(f"{path}.{key}", item, max_length)
return
text = str(value or "")
if len(text) > max_length:
raise ValueError(f"字段【{path}】长度不能超过 {max_length} 个字符")
def _is_editable_config_field(section_key: str, field_key: str, schema_config_snapshot: Any | None = None) -> tuple[bool, int]:
# 视频规格、动态规划、输出限制、合规和质量控制继续按原逻辑锁定。
if section_key in {"合规控制", "质量控制", "输出规格限制"}:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
if section_key == "画面属性" and field_key in {"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"}:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
rules = _config_editable_field_rules(schema_config_snapshot)
rule = rules.get((section_key, field_key))
if rule is None:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
return bool(rule.get("editable", True)), int(rule.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN)
def _merge_editable_dict_fields(
section_key: str,
base: dict[str, Any],
patch: dict[str, Any],
allowed_keys: set[str],
schema_config_snapshot: Any | None = None,
) -> None:
for key in allowed_keys:
if key not in patch:
continue
editable, max_length = _is_editable_config_field(section_key, key, schema_config_snapshot)
if not editable:
continue
_validate_editable_value_length(f"{section_key}.{key}", patch.get(key), max_length)
base[key] = fill_none_with_wu(patch.get(key))
def _merge_flow_patch(base_flow: Any, patch_flow: Any, flow_key: str, schema_config_snapshot: Any | None = None) -> list[dict[str, Any]]:
base = [dict(item) for item in base_flow] if isinstance(base_flow, list) else []
patch = patch_flow if isinstance(patch_flow, list) else []
result: list[dict[str, Any]] = []
field_rules = {item["key"]: item for item in _flow_item_field_rules(schema_config_snapshot, flow_key)}
for index, base_item in enumerate(base):
merged = dict(base_item)
patch_item = patch[index] if index < len(patch) and isinstance(patch[index], dict) else {}
original_time_range = merged.get("时间段")
for key, value in patch_item.items():
if key == "时间段" or key not in field_rules:
continue
rule = field_rules[key]
if not _normalize_bool(rule.get("editable"), True):
continue
max_length = _normalize_int(rule.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000)
_validate_editable_value_length(f"{flow_key}[{index}].{key}", value, max_length)
merged[key] = fill_none_with_wu(value)
merged["时间段"] = original_time_range
result.append(merged)
return result
def _prune_schema_by_runtime_config(schema: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
default_schema = build_client_schema_from_config(schema_config_snapshot)
allowed_top_keys = set(default_schema.keys()) | ALWAYS_ALLOWED_TOP_LEVEL_KEYS
pruned: dict[str, Any] = {}
for key, value in schema.items():
if key not in allowed_top_keys:
continue
if isinstance(value, dict) and isinstance(default_schema.get(key), dict):
allowed_fields = set(default_schema[key].keys())
if key == VIDEO_SPEC_SECTION_KEY:
allowed_fields |= VIDEO_SPEC_LOCKED_FIELDS
pruned[key] = {field_key: field_value for field_key, field_value in value.items() if field_key in allowed_fields}
else:
pruned[key] = value
if VIDEO_SPEC_SECTION_KEY not in pruned or not isinstance(pruned.get(VIDEO_SPEC_SECTION_KEY), dict):
pruned[VIDEO_SPEC_SECTION_KEY] = {}
return pruned
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
dynamic_schema = build_dynamic_schema(video_config, schema_config_snapshot)
plan = build_time_plan(duration, schema_config_snapshot)
schema["schema_version"] = PromptSchemaVersionEnum.CLIENT_V1.value
schema["schema_usage"] = VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value
frame = schema.setdefault("画面属性", {})
if not isinstance(frame, dict):
frame = {}
schema["画面属性"] = frame
frame.update(
{
"视频时长": f"{duration}秒",
"视频比例": video_ratio,
"清晰度": resolution,
"帧率": frame_rate,
"推荐分辨率": recommended_resolution,
}
)
schema["动态时间规划"] = _normalize_time_plan(plan, schema.get("动态时间规划"))
schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {})
default_schema = build_client_schema_from_config(schema_config_snapshot)
if "动作流程" in default_schema:
schema["动作流程"] = _align_flow_time_ranges(
schema.get("动作流程"),
schema["动态时间规划"],
_flow_content_key(schema_config_snapshot, "动作流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
)
else:
schema.pop("动作流程", None)
if "镜头流程" in default_schema:
schema["镜头流程"] = _align_flow_time_ranges(
schema.get("镜头流程"),
schema["动态时间规划"],
_flow_content_key(schema_config_snapshot, "镜头流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
)
else:
schema.pop("镜头流程", None)
# 合规和质量控制不能被前端降低;仅当配置启用对应分组时才补齐,禁用分组必须从最终 schema 中移除。
if not isinstance(schema.get("合规控制"), dict) and isinstance(default_schema.get("合规控制"), dict):
schema["合规控制"] = copy.deepcopy(default_schema["合规控制"])
if not isinstance(schema.get("质量控制"), dict) and isinstance(default_schema.get("质量控制"), dict):
schema["质量控制"] = copy.deepcopy(default_schema["质量控制"])
return _prune_schema_by_runtime_config(clean_final_prompt_specs(schema, video_config), schema_config_snapshot)
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
duration = int(video_config["duration"])
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}), schema_config_snapshot)
normalized = ensure_flow_matches_time_plan(normalized, duration, schema_config_snapshot)
normalized = ensure_negative_prompt(normalized)
return apply_locked_video_schema_fields(normalized, video_config, schema_config_snapshot)
def patch_video_prompt_schema_from_client(
*,
server_schema: dict[str, Any],
client_schema: dict[str, Any],
video_config: dict[str, Any],
schema_config_snapshot: Any | None = None,
) -> dict[str, Any]:
"""以前端 JSON 作为 patch,回填到服务端已有 schema。
禁止整包覆盖:数组长度、时间段、视频规格、输出规格、质量控制、合规控制、schema 协议字段均以服务端为准。
仅对 schema_config_snapshot 中 enabled=true 且 editable=true 的用户可编辑字段做长度校验,不自动裁剪。
"""
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})), schema_config_snapshot)
patch = client_schema if isinstance(client_schema, dict) else {}
whitelists = _object_field_whitelists(schema_config_snapshot)
for section_key, allowed_keys in whitelists.items():
if section_key in {"画面属性", "输出规格限制", "合规控制", "质量控制"}:
continue
if isinstance(base.get(section_key), dict) and isinstance(patch.get(section_key), dict):
_merge_editable_dict_fields(section_key, base[section_key], patch[section_key], allowed_keys, schema_config_snapshot)
if isinstance(base.get("画面属性"), dict) and isinstance(patch.get("画面属性"), dict):
_merge_editable_dict_fields(
"画面属性",
base["画面属性"],
patch["画面属性"],
{"主体描述", "主体数量", "主体位置", "主体占比", "场景描述", "构图方式", "画面风格", "光影色彩"},
schema_config_snapshot,
)
if isinstance(patch.get("动作流程"), list):
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"), "动作流程", schema_config_snapshot)
if isinstance(patch.get("镜头流程"), list):
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"), "镜头流程", schema_config_snapshot)
# 动态时间规划保持服务端数组长度和时间段,只允许保留原值;不接受客户端 patch。
if isinstance(base.get("最终提示词"), dict) and isinstance(patch.get("最终提示词"), dict):
for key in VIDEO_SPEC_PROMPT_KEYS:
if key not in patch["最终提示词"]:
continue
editable, max_length = _is_editable_config_field("最终提示词", key, schema_config_snapshot)
if not editable:
continue
_validate_editable_value_length(f"最终提示词.{key}", patch["最终提示词"].get(key), max_length)
base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key))
return apply_locked_video_schema_fields(base, video_config, schema_config_snapshot)
def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str, Any]:
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
schema = build_dynamic_schema(video_config)
schema["基础分类"].update({"生成类型": "图生视频/视频生视频", "视频大类": "产品广告视频", "视频子类": "产品推广短视频", "视频用途": "社媒发布", "目标平台": target_platform})
schema["素材理解"].update({"是否有参考图片": "是", "是否有参考视频": "是", "参考视频用途": "动作参考/镜头参考/风格参考/节奏参考"})
schema["业务属性"].update({"产品类型": "APP", "核心表达目标": "突出新项目核心内容点", "内容风格": "轻快、活泼、广告感适中", "行动引导": "立即体验"})
schema["动作流程"] = [{"时间段": item["时间段"], "动作": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)]
schema["镜头流程"] = [{"时间段": item["时间段"], "镜头": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)]
schema["最终提示词"] = {
"主提示词": f"生成一段{duration}秒、{video_ratio}{resolution}的产品推广短视频,参考素材视频的爆款开头节奏,结合新项目图片进行自然展示。",
"动作提示词": "主体动作自然,产品展示稳定,节奏轻快。",
"镜头提示词": "镜头稳定,开头快速吸引注意,后续平滑推进。",
"字幕提示词": "字幕简洁清晰,突出核心内容点。",
"音频提示词": "轻快背景音乐,节奏自然。",
"风格提示词": "年轻化、明亮、真实、广告感适中。",
"负面提示词": "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁",
}
return schema
def _safe_response_json(response: httpx.Response | None) -> Any:
if response is None:
return None
try:
return response.json()
except Exception:
return {"raw_text": response.text}
def _extract_remote_request_id(data: Any) -> str | None:
if isinstance(data, dict):
error = data.get("error") if isinstance(data.get("error"), dict) else {}
request_id = data.get("request_id") or error.get("request_id")
message = error.get("message") or data.get("message")
if not request_id and isinstance(message, str):
import re
match = re.search(r"Request id:\s*([a-zA-Z0-9_.:-]+)", message, flags=re.IGNORECASE)
if match:
request_id = match.group(1)
return str(request_id) if request_id else None
return None
def _video_prompt_remote_event(module: str, *, started: bool = False, success: bool = False, parse_failed: bool = False, empty: bool = False) -> tuple[str, str]:
if module == ShotModuleCodeEnum.SHOT_REPLICATE.value:
action = ShotReplicateRemoteActionEnum.VIDEO_PROMPT_OPTIMIZE.value
if started:
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value, action
if success:
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value, action
if parse_failed:
return ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value, action
if empty:
return ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value, action
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value, action
action = HotOpeningRemoteActionEnum.VIDEO_PROMPT_OPTIMIZE.value
if started:
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_STARTED.value, action
if success:
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_SUCCESS.value, action
if parse_failed:
return HotOpeningLogEventEnum.VIDEO_PROMPT_RESPONSE_PARSE_FAILED.value, action
if empty:
return HotOpeningLogEventEnum.VIDEO_PROMPT_RESPONSE_EMPTY.value, action
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_FAILED.value, action
def _log_video_prompt_ai_event(
*,
module: str,
event_type: str,
action: str,
event_status: str,
config: ModelConfig,
trace_id: str | None,
call_id: str,
user_id: str | None,
project_id: str | None,
step_id: str | None,
request_data: dict[str, Any] | None = None,
response_data: Any = None,
token_usage: dict[str, Any] | None = None,
http_status: int | None = None,
remote_request_id: str | None = None,
message: str | None = None,
error: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
common = {
"source": LogSourceEnum.REMOTE_API.value,
"module": module,
"step_code": "video_prompt_generate",
"call_id": call_id,
"trace_id": trace_id,
"user_id": user_id,
"project_id": project_id,
"task_id": step_id,
"step_id": step_id,
"owner_type": "module_generation_step",
"owner_id": step_id or project_id,
"remote_action": action,
"remote_request_id": remote_request_id,
"model_config_id": str(config.id),
"model_config_name": config.name,
"model_name": config.model_name,
"provider": config.provider,
"api_base": config.api_base,
"http_status": http_status,
}
normalized_status = str(event_status or "").lower()
if normalized_status == str(LogEventStatusEnum.STARTED.value).lower():
log_ai_model_event(
event_type=event_type,
event_phase="REQUEST",
event_status=event_status,
request=request_data,
message=message,
detail=detail,
**common,
)
return
if response_data is not None:
log_ai_model_event(
event_type=event_type,
event_phase="RESPONSE",
event_status=event_status,
response=response_data,
token_usage=token_usage,
message=message,
detail=detail,
error=error if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() else None,
**common,
)
if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() or error:
log_ai_model_event(
event_type=event_type,
event_phase="ERROR",
event_status=LogEventStatusEnum.FAILED.value,
message=message,
detail=detail,
error=error or "AI model call failed",
**common,
)
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)).order_by(ModelConfig.priority.desc()).limit(1))
return result.scalar_one_or_none()
async def optimize_hot_opening_video_prompt(
db: AsyncSession,
*,
user_id: str,
source_project_name: str,
target_project_name: str,
core_content_point: str,
material_video_url: str,
generated_image_url: str | None,
video_config: dict[str, Any],
target_platform: str = "抖音",
schema_config_snapshot: Any | None = None,
module: str = HotModuleCodeEnum.HOT_OPENING_REPLICATE.value,
project_id: str | None = None,
step_id: str | None = None,
trace_id: str | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
call_id = generate_id()
duration = int(video_config["duration"])
from app.utils.media import media_to_base64, get_llm_media_as_base64
use_base64 = await get_llm_media_as_base64(db)
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
# if settings.LLM_MOCK:
# result = _mock_result(video_config, target_platform)
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
config_row = await _select_model_config(db)
config = (
SimpleNamespace(
id=str(config_row.id),
name=str(config_row.name or ""),
provider=str(config_row.provider or ""),
api_base=str(config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
)
if config_row is not None
else None
)
# 调用方已提交业务 claim。模型配置和媒体传输开关读取完成后立即释放
# 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。
await db.rollback()
if not config:
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
if use_base64:
video_url_final = await media_to_base64(material_video_url, "video/mp4")
else:
video_url_final = _build_file_url_or_data_uri(material_video_url)
references = [{"type": "video", "url": video_url_final}]
if generated_image_url:
image_url_final = (
await media_to_base64(generated_image_url, "image/png")
if use_base64
else _build_file_url_or_data_uri(generated_image_url)
)
references.append({"type": "image", "url": image_url_final})
user_text = build_user_text(
source_project_name=source_project_name,
target_project_name=target_project_name,
core_content_point=core_content_point,
target_platform=target_platform,
references=references,
video_config=video_config,
client_schema=client_schema,
schema_config_snapshot=schema_config_snapshot,
)
user_message, log_user_message = build_user_message(user_text, references, reference_video_fps)
request_data = {
"model": config.model_name,
"messages": [{"role": "system", "content": build_system_prompt()}, user_message],
"max_tokens": 10000,
"temperature": 0.15,
"response_format": {"type": "json_object"},
}
api_url = f"{config.api_base.rstrip('/')}/chat/completions"
log_request_data = {
**request_data,
"messages": [{"role": "system", "content": build_system_prompt()}, log_user_message],
"model_config_id": config.id,
"model_config_name": config.name,
"provider": config.provider,
"module": module,
"project_id": project_id,
"step_id": step_id,
"material_video_url": material_video_url,
"generated_image_url": generated_image_url,
"target_platform": target_platform,
"api_url": api_url,
}
started_event, remote_action = _video_prompt_remote_event(module, started=True)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=started_event,
action=remote_action,
event_status=LogEventStatusEnum.STARTED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
message="视频提词模型请求开始",
)
try:
async with httpx.AsyncClient(timeout=int(settings.CHATAPI_REQUEST_TIMEOUT_SECONDS or 180)) as client:
response = await client.post(
api_url,
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
json=request_data,
)
except Exception as exc:
failed_event, remote_action = _video_prompt_remote_event(module)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=failed_event,
action=remote_action,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
message="视频提词模型请求异常",
error=str(exc),
detail={"exception_type": type(exc).__name__},
)
raise
response_data = _safe_response_json(response)
remote_request_id = _extract_remote_request_id(response_data)
if response.status_code >= 400:
failed_event, remote_action = _video_prompt_remote_event(module)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=failed_event,
action=remote_action,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
response_data=response_data,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词模型请求失败",
error=f"HTTP {response.status_code}: {response.text}",
)
raise RuntimeError(f"视频提词优化失败 HTTP {response.status_code}: {response.text}")
try:
data = response.json()
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("视频提词模型响应 content 为空")
except Exception as exc:
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=parse_event,
action=remote_action,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
response_data=response_data,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词模型响应解析失败",
error=str(exc),
)
raise
usage = data.get("usage", {}) or {}
token_usage = {
"input_tokens": int(usage.get("prompt_tokens") or 0),
"output_tokens": int(usage.get("completion_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
# "log_user_message": log_user_message,
}
token_usage_id = generate_id()
db.add(
TokenUsage(
id=token_usage_id,
model_config_id=config.id,
user_id=user_id,
input_tokens=token_usage["input_tokens"],
output_tokens=token_usage["output_tokens"],
total_tokens=token_usage["total_tokens"],
)
)
await db.flush()
token_usage.update({
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
})
try:
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
except Exception as exc:
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=parse_event,
action=remote_action,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
response_data=data,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词业务 JSON 解析失败",
error=str(exc),
)
raise
success_event, remote_action = _video_prompt_remote_event(module, success=True)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
event_type=success_event,
action=remote_action,
event_status=LogEventStatusEnum.SUCCESS.value,
config=config,
trace_id=trace_id,
user_id=user_id,
project_id=project_id,
step_id=step_id,
request_data=log_request_data,
response_data=data,
token_usage=token_usage,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词模型请求成功",
)
return result, build_final_video_prompt(result), token_usage
def _build_file_url_or_data_uri(file_url: str) -> str:
"""
Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is.
"""
if file_url.startswith(("http://", "https://", "data:")):
return file_url
file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
return f"{settings.BASE_URL}{file_url_sign}"