fix(ui): 模板选择预览条缩短+素材列表改为竖屏9:16卡片网格 (#1521)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

This commit is contained in:
xiaoxia
2026-08-27 15:04:17 +08:00
parent 7cdd06802d
commit b12bb24d08
7 changed files with 171 additions and 178 deletions
@@ -169,22 +169,8 @@ def _writeback_edit_plan_config(
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id, old_title_text, new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
@@ -1,5 +1,5 @@
/**
* 手动选择素材列表
* 手动选择素材列表 — 竖屏 9:16 卡片网格
*/
import React, { useRef, useCallback } from "react"
import { Typography } from "antd"
@@ -14,18 +14,24 @@ interface ManualMaterialListProps {
onToggle: (materialId: string) => void
}
/** 秒数格式化为 mm:ss */
const fmtDuration = (seconds?: number): string => {
if (!seconds && seconds !== 0) return "--:--"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
selectedMaterials,
onToggle,
}) => {
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
if (
activeVideoRef.current &&
activeVideoRef.current !== video &&
@@ -56,143 +62,188 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
gap: 10,
}}
>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
const isVideo = m.mime_type?.startsWith("video/") ?? false
const thumbSrc = m.thumbnail_url || undefined
return (
<label
<div
key={m.id}
onClick={() => onToggle(m.id)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
position: "relative",
aspectRatio: "9 / 16",
borderRadius: 10,
overflow: "hidden",
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
? "2px solid var(--primary-color, #4f46e5)"
: "2px solid transparent",
boxShadow: checked
? "0 0 0 2px rgba(79, 70, 229, 0.2)"
: "0 1px 3px rgba(0, 0, 0, 0.1)",
background: "#1e293b",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(m.id)}
style={{
accentColor: "var(--primary-color, #4f46e5)",
flexShrink: 0,
}}
/>
{/* 缩略图预览 48×48 */}
{/* 视频/图片封面 */}
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #334155, #1e293b)",
color: "rgba(255,255,255,0.5)",
fontSize: 28,
}}
>
{isVideo ? "🎬" : "🎵"}
</div>
)}
{/* 底部渐变遮罩 */}
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
overflow: "hidden",
background: "#e2e8f0",
flexShrink: 0,
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
pointerEvents: "none",
}}
/>
{/* 播放按钮(中央) */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 32,
height: 32,
borderRadius: "50%",
background: "rgba(99, 102, 241, 0.85)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
cursor: "pointer",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = "flex"
}}
/>
) : null}
{!thumbSrc && !isVideo && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎵
</span>
)}
{!thumbSrc && isVideo && !m.file_url && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎬
</span>
)}
{/* img onError 时显示的 fallback(初始隐藏) */}
{thumbSrc && !(isVideo && m.file_url) && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "none",
}}
>
{isVideo ? "🎬" : "🎵"}
</span>
)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<span
{/* 文件名(左下角) */}
<div
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
minWidth: 0,
position: "absolute",
bottom: 6,
left: 6,
right: 50,
color: "white",
fontSize: 11,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
pointerEvents: "none",
zIndex: 1,
}}
>
{m.name}
</span>
<span
</div>
{/* 时长(右下角) */}
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
flexShrink: 0,
position: "absolute",
bottom: 6,
right: 6,
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "1px 5px",
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
pointerEvents: "none",
zIndex: 1,
}}
>
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
</span>
</label>
{fmtDuration(m.duration)}
</div>
{/* 选中勾选标记(左上角) */}
{checked && (
<div
style={{
position: "absolute",
top: 6,
left: 6,
width: 20,
height: 20,
borderRadius: "50%",
background: "var(--primary-color, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 12,
fontWeight: 700,
zIndex: 2,
pointerEvents: "none",
}}
>
</div>
)}
</div>
)
})}
</div>
+4 -4
View File
@@ -203,7 +203,7 @@
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px;
padding: 10px;
cursor: pointer;
transition: 0.18s ease;
text-align: center;
@@ -219,14 +219,14 @@
}
.xx-choice-thumb {
height: 60px;
height: 36px;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
color: var(--text-inverse);
font-size: 24px;
font-size: 16px;
font-weight: 700;
margin-bottom: 10px;
margin-bottom: 6px;
}
.xx-choice-item h4 {
@@ -598,7 +598,7 @@ class RenderAdapter:
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
str(result.output_path), plan_id, num_frames=3, title_text=""
)
if cover_candidates:
logger.info(
@@ -278,7 +278,6 @@ def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
*,
task_id: str = "",
num_frames: int = 3,
title_text: str = "",
title_color: str = "#ffffff",
@@ -292,7 +291,6 @@ def extract_and_upload_cover_frames(
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
num_frames: 抽取帧数(默认 3
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
@@ -340,7 +338,7 @@ def extract_and_upload_cover_frames(
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
@@ -379,7 +377,7 @@ def extract_and_upload_cover_frames(
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
+3 -3
View File
@@ -224,8 +224,8 @@ def _wrap_title_text(
if available_width <= 0:
return text
# 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染
# 换行宽度必须与实际渲染(补偿后的 ASS Fontsize)一致,否则换行位置会错位
ass_font_size = _compensate_ass_fontsize(font_size)
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
segments = text.split("\\N")
@@ -238,7 +238,7 @@ def _wrap_title_text(
for ch in seg:
# CJK 字符按全角估算,其他按半角
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
char_width = float(ass_font_size) if ord(ch) > 0x2E80 else ass_font_size * 0.55
if current_width + char_width > available_width and current_line:
lines.append(current_line)
@@ -15,7 +15,6 @@ from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_content,
build_ass_style,
escape_ass_text,
@@ -580,44 +579,3 @@ class TestConstants:
assert isinstance(TITLE_MARGIN_TOP, int)
assert isinstance(TITLE_MARGIN_BOTTOM, int)
assert isinstance(TITLE_MARGIN_SIDE, int)
# ============================================================
# _wrap_title_text 换行逻辑验证
# ============================================================
class TestWrapTitleTextFontSizeConsistency:
"""验证换行计算使用原始 font_size,与 CSS 预览一致。"""
def test_font_size_113_uses_original_not_compensated(self):
"""font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。"""
# 标题"永康拾掇脚阔头"共7个字
# 可用宽度 = 1080 - 60 - 60 = 960px
# 用 font_size=113char_width=113960/113 ≈ 8.5,每行8个字
# 7个字 < 8个字,应该在一行内
title = "永康拾掇脚阔头"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 不应该有换行
assert "\\N" not in result
assert result == title
def test_long_title_wraps_correctly(self):
"""长标题应该按 font_size 字宽正确换行。"""
# 16个中文字,每行8个字,应该换行为2行
title = "永康拾掇脚阔头来一个笑一个哈哈哈"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 应该有一个换行
assert result.count("\\N") == 1
# 每行8个字
lines = result.split("\\N")
assert len(lines) == 2
assert len(lines[0]) == 8
assert len(lines[1]) == 8
def test_mixed_cjk_and_ascii(self):
"""混合中英文时,英文按半角宽度计算。"""
# "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px
title = "测试test"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 总宽度474.6px < 960px,应该在一行内
assert "\\N" not in result