Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff2798c97 | |||
| 00348a2154 | |||
| c00c56a742 | |||
| 8e4a8a8184 | |||
| 6a4085452d | |||
| 0896f3e161 |
@@ -10,8 +10,6 @@ Changes:
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
@@ -73,7 +71,7 @@ def upgrade() -> None:
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=json.dumps(config),
|
||||
config=config,
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""修复 cover_templates.config 双重序列化
|
||||
|
||||
Revision ID: 056_fix_cover_templates_config
|
||||
Revises: 055_cover_templates
|
||||
Create Date: 2026-08-13
|
||||
|
||||
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
|
||||
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
|
||||
|
||||
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "056_fix_cover_templates_config"
|
||||
down_revision = "055_cover_templates"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
|
||||
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE cover_templates SET config = (config#>>'{}')::json "
|
||||
"WHERE jsonb_typeof(config::jsonb) = 'string'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No safe rollback — the original data was incorrect
|
||||
pass
|
||||
@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
@@ -258,9 +258,12 @@ test.describe("Core generation flow", () => {
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
@@ -56,10 +56,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
|
||||
if (ratio.includes(":")) {
|
||||
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
|
||||
const [rw, rh] = ratio.split(":").map(Number)
|
||||
if (rw > 0 && rh > 0) {
|
||||
// 基准:长边 1920,短边按比例计算
|
||||
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
||||
const baseLong = 1920
|
||||
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
||||
// 确保偶数(FFmpeg 要求)
|
||||
const evenShort = baseShort - (baseShort % 2)
|
||||
if (rw < rh) {
|
||||
outputWidth = evenShort
|
||||
outputHeight = baseLong
|
||||
} else {
|
||||
outputWidth = baseLong
|
||||
outputHeight = evenShort
|
||||
}
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
} else if (ratio.includes("x")) {
|
||||
// 分辨率格式,如 "1080x1920"
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
outputWidth = parseInt(wStr, 10) || 1080
|
||||
outputHeight = parseInt(hStr, 10) || 1920
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
|
||||
@@ -47,7 +47,14 @@ def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
|
||||
w, h = resolution_str.lower().split("x", 1)
|
||||
width = int(w.strip())
|
||||
height = int(h.strip())
|
||||
if width <= 0 or height <= 0:
|
||||
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
|
||||
if width < 100 or height < 100:
|
||||
logger.warning(
|
||||
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
|
||||
width,
|
||||
height,
|
||||
resolution_str,
|
||||
)
|
||||
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
|
||||
return width, height
|
||||
except (ValueError, TypeError):
|
||||
@@ -576,7 +583,13 @@ class RenderAdapter:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||||
|
||||
cover_candidates = extract_and_upload_cover_frames(str(result.output_path), plan_id, num_frames=3)
|
||||
# 从 plan config 提取标题文字,叠加到封面候选帧上
|
||||
_title_cfg = (plan_config or {}).get("title", {}) or {}
|
||||
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
|
||||
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
|
||||
|
||||
@@ -120,6 +120,92 @@ def _format_seek_time(seconds: float) -> str:
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def _overlay_title_on_image(
|
||||
image_path: str,
|
||||
title_text: str,
|
||||
*,
|
||||
timeout: int = 15,
|
||||
) -> str:
|
||||
"""在封面图上叠加标题文字(居中、白色、带阴影)。
|
||||
|
||||
使用 FFmpeg drawtext 滤镜,原地覆盖 image_path。
|
||||
|
||||
Args:
|
||||
image_path: 输入图片路径(覆盖写入)
|
||||
title_text: 要叠加的标题文字
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
处理后的图片路径(与输入相同)
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
if not title_text or not title_text.strip():
|
||||
return image_path
|
||||
|
||||
# 转义 drawtext 特殊字符
|
||||
# FFmpeg drawtext 需要转义: ' : % \ [ ]
|
||||
escaped = (
|
||||
title_text.replace("\\", "\\\\")
|
||||
.replace("'", "’")
|
||||
.replace(":", "\\:")
|
||||
.replace("%", "%%")
|
||||
.replace("[", "\\[")
|
||||
.replace("]", "\\]")
|
||||
)
|
||||
# 截断过长标题
|
||||
if len(escaped) > 60:
|
||||
escaped = escaped[:57] + "..."
|
||||
|
||||
# 使用中文字体
|
||||
font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
||||
|
||||
# drawtext 滤镜参数:
|
||||
# - 白色文字,字号按画面宽度自适应(约 1/18 宽度)
|
||||
# - 黑色阴影偏移 2px
|
||||
# - 水平居中,垂直偏下(距底部约 15%)
|
||||
drawtext_filter = (
|
||||
f"drawtext=fontfile='{font_path}'"
|
||||
f":text='{escaped}'"
|
||||
f":fontsize=h/14"
|
||||
f":fontcolor=white"
|
||||
f":shadowcolor=black@0.7"
|
||||
f":shadowx=2:shadowy=2"
|
||||
f":x=(w-text_w)/2"
|
||||
f":y=h*0.82-text_h/2"
|
||||
f":borderw=0"
|
||||
)
|
||||
|
||||
tmp_out = image_path + ".tmp.jpg"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
image_path,
|
||||
"-vf",
|
||||
drawtext_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
tmp_out,
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
# 覆盖原文件
|
||||
import shutil
|
||||
|
||||
shutil.move(tmp_out, image_path)
|
||||
logger.info("封面标题叠加成功: text=%s", title_text[:30])
|
||||
except Exception as e:
|
||||
logger.warning("封面标题叠加失败(使用原图): %s", e)
|
||||
try:
|
||||
Path(tmp_out).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return image_path
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
@@ -163,6 +249,7 @@ def extract_cover_candidates(
|
||||
*,
|
||||
width: int = 640,
|
||||
timeout: int = 30,
|
||||
title_text: str = "",
|
||||
) -> list[dict]:
|
||||
"""在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。
|
||||
|
||||
@@ -218,6 +305,9 @@ def extract_cover_candidates(
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
|
||||
if Path(output_path).exists() and Path(output_path).stat().st_size > 0:
|
||||
# 叠加标题文字
|
||||
if title_text and title_text.strip():
|
||||
_overlay_title_on_image(output_path, title_text, timeout=timeout)
|
||||
results.append(
|
||||
{
|
||||
"local_path": output_path,
|
||||
@@ -237,6 +327,8 @@ def extract_and_upload_cover_frames(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
num_frames: int = 3,
|
||||
*,
|
||||
title_text: str = "",
|
||||
) -> list[dict]:
|
||||
"""抽取封面候选帧并上传到 OSS。
|
||||
|
||||
@@ -248,7 +340,7 @@ def extract_and_upload_cover_frames(
|
||||
Returns:
|
||||
[{"image_url": "https://...", "frame_time": 5.0, "storage_key": "covers/xxx/frame_0.jpg"}, ...]
|
||||
"""
|
||||
candidates = extract_cover_candidates(video_path, num_frames=num_frames)
|
||||
candidates = extract_cover_candidates(video_path, num_frames=num_frames, title_text=title_text)
|
||||
if not candidates:
|
||||
logger.warning("封面候选帧抽取为空: plan_id=%s", plan_id)
|
||||
return []
|
||||
|
||||
@@ -1123,6 +1123,7 @@ def _render_video(
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
custom_title: str = "",
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1169,6 +1170,20 @@ def _render_video(
|
||||
merged_bgm.get("source", ""),
|
||||
)
|
||||
|
||||
# 用户自定义标题覆盖模板标题(用户指定优先级最高)
|
||||
if custom_title and custom_title.strip():
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
title_cfg = dict(plan_cfg.get("title", {}) or {})
|
||||
title_cfg["text"] = custom_title.strip()
|
||||
title_cfg["enabled"] = True
|
||||
plan_cfg["title"] = title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义标题已注入: title=%s",
|
||||
task_id,
|
||||
custom_title[:50],
|
||||
)
|
||||
|
||||
# 确保输出分辨率配置存在
|
||||
# 优先级:用户指定 > 模板配置 > 默认 1280x720
|
||||
# 预览模式:强制 854x480 + 低码率
|
||||
@@ -1501,6 +1516,16 @@ def generate_video(self, task_id: str) -> dict:
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
# 防护:前端可能误传宽高比(如 parseInt("9:16") = 9),宽度 < 100 时忽略
|
||||
if _ow < 100 or _oh < 100:
|
||||
logger.warning(
|
||||
"[task_id=%s] output_width/output_height 异常 (%dx%d),回退到默认",
|
||||
task_id,
|
||||
_ow,
|
||||
_oh,
|
||||
)
|
||||
_ow = OUTPUT_WIDTH
|
||||
_oh = OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
@@ -1519,6 +1544,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
resolution=_resolved_resolution,
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
custom_title=task_info.get("custom_title", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
Reference in New Issue
Block a user