Merge branch 'develop' of https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas into fix/ci-code-quality-905-914
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m5s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 56s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m37s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m54s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1250h10m1s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1250h14m9s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1250h14m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1250h14m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1250h20m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1250h21m9s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1250h21m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1250h21m13s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1250h24m58s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1250h25m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1250h25m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1250h46m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1250h53m45s

This commit is contained in:
2026-07-26 12:59:03 +08:00
16 changed files with 598 additions and 321 deletions
+24 -37
View File
@@ -93,44 +93,31 @@ jobs:
shell: sh
run: |
set -eu
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
cd apps/web
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
npx --no-install tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
npx --no-install vite build
echo "=== Build completed ==="
ls -la dist/
- name: Install SSH client and rsync
shell: sh
@@ -10,7 +10,24 @@
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import {
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
TRACK_GAP,
ADD_PICKER_WIDTH,
} from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
import { TrimPreview } from "./timeline/TrimPreview"
import { ContextMenu } from "./timeline/ContextMenu"
interface TimelinePanelProps {
clips: ClipData[]
@@ -38,18 +55,6 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
@@ -135,7 +140,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(5)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
@@ -144,21 +149,17 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 面板尺寸 ── */
const PICKER_W = 240
const GAP = 6
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - GAP - roughHeight
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
@@ -181,9 +182,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - GAP - pickerH
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + GAP
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
@@ -192,7 +193,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
@@ -224,7 +225,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? 40
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
@@ -344,11 +345,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
useEffect(() => {
if (!trimDrag) return
const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / PX_PER_SECOND
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
@@ -359,10 +358,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
// 右手柄:调整出点
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
@@ -396,7 +398,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
@@ -428,25 +430,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setContextMenu(null)
}, [contextMenu, onClipRemove])
/* ── 时间标尺 ── */
const trackWidth = Math.max(totalDuration * pps, 300)
const rulerMarks: number[] = []
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
for (let t = 0; t <= totalDuration + step; t += step) {
rulerMarks.push(t)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`
}
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -460,7 +443,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
title="缩小"
>
−
@@ -468,15 +451,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
title="放大"
>
+
@@ -506,15 +489,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{rulerMarks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
)}
{/* 水平片段轨道 */}
@@ -536,115 +511,30 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
</div>
) : (
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
const isHovered = hoveredClipId === clip.id
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
✂
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
✂
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onClipRemove(clip.id)
}}
>
✕
</button>
</div>
</React.Fragment>
)
})
clips.map((clip, idx) => (
<ClipCard
key={clip.id}
clip={clip}
idx={idx}
isSelected={selectedClipId === clip.id}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
isHovered={hoveredClipId === clip.id}
pps={pps}
trimDragActive={!!trimDrag}
showTrimHandles={!!onClipTrim}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onSelect={onClipSelect}
onContextMenu={handleContextMenu}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
onTrimHandleMouseDown={handleTrimHandleMouseDown}
onRemove={onClipRemove}
/>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
@@ -663,109 +553,42 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 裁剪预览 tooltip */}
{trimPreview && (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label">入点</span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label">出点</span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label">时长</span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.duration)}</span>
</div>
</div>
<TrimPreview
startTime={trimPreview.startTime}
endTime={trimPreview.endTime}
duration={trimPreview.duration}
x={trimPreview.x}
y={trimPreview.y}
/>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon">✂️</span>
<span>分割片段</span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div className="ep-context-menu-item" onClick={handleContextResetTrim}>
<span className="ep-context-menu-icon">↩️</span>
<span>恢复原始长度</span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑️</span>
<span>删除片段</span>
</div>
</div>
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
menuRef={contextMenuRef}
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
onSplit={handleContextSplit}
onResetTrim={handleContextResetTrim}
onDelete={handleContextDelete}
/>
)}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title">添加片段</div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">类型:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">时长:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
/>
<span className="ep-add-clip-duration-unit">秒</span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={handleConfirmAdd}>
添加
</button>
</div>
<AddClipPicker
pickerRef={pickerRef}
position={pickerPos}
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
minDuration={MIN_ADD_DURATION}
maxDuration={MAX_ADD_DURATION}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
/>
)}
</div>
)
@@ -0,0 +1,80 @@
import React from "react"
import type { ClipType } from "../../types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline"
interface AddClipPickerProps {
pickerRef: React.RefObject<HTMLDivElement>
position: { top: number; right: number }
availableTypes: ClipType[]
addType: ClipType
addDuration: number
onTypeChange: (type: ClipType) => void
onDurationChange: (duration: number) => void
onConfirm: () => void
minDuration?: number
maxDuration?: number
}
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
pickerRef,
position,
availableTypes,
addType,
addDuration,
onTypeChange,
onDurationChange,
onConfirm,
minDuration = 1,
maxDuration = 120,
}) => {
return (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: position.top,
right: position.right,
}}
>
<div className="ep-add-clip-picker-title">添加片段</div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">类型:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => onTypeChange(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">时长:</span>
<input
type="number"
className="ep-duration-input"
min={minDuration}
max={maxDuration}
value={addDuration}
onChange={(e) =>
onDurationChange(
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
)
}
/>
<span className="ep-add-clip-duration-unit">秒</span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
添加
</button>
</div>
)
}
@@ -0,0 +1,155 @@
import React from "react"
import type { ClipData } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
interface ClipCardProps {
clip: ClipData
idx: number
isSelected: boolean
isDragging: boolean
isDragOver: boolean
isHovered: boolean
pps: number
trimDragActive: boolean
showTrimHandles: boolean
onDragStart: (e: React.DragEvent, idx: number) => void
onDragOver: (e: React.DragEvent, idx: number) => void
onDragEnd: () => void
onDrop: (e: React.DragEvent, idx: number) => void
onSelect: (clipId: string) => void
onContextMenu: (e: React.MouseEvent, clipId: string) => void
onMouseEnter: () => void
onMouseLeave: () => void
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
onRemove: (clipId: string) => void
}
export const ClipCard: React.FC<ClipCardProps> = ({
clip,
idx,
isSelected,
isDragging,
isDragOver,
isHovered,
pps,
trimDragActive,
showTrimHandles,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
onSelect,
onContextMenu,
onMouseEnter,
onMouseLeave,
onTrimHandleMouseDown,
onRemove,
}) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${isSelected ? "selected" : ""} ${isDragging ? "dragging" : ""} ${isDragOver ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
draggable={!trimDragActive}
onDragStart={(e) => onDragStart(e, idx)}
onDragOver={(e) => onDragOver(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => onDrop(e, idx)}
onClick={() => onSelect(clip.id)}
onContextMenu={(e) => onContextMenu(e, clip.id)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 左裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
✂
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
✂
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onRemove(clip.id)
}}
>
✕
</button>
</div>
</React.Fragment>
)
}
@@ -0,0 +1,49 @@
import React from "react"
interface ContextMenuProps {
x: number
y: number
menuRef: React.RefObject<HTMLDivElement>
hasTrim: boolean
onSplit: () => void
onResetTrim: () => void
onDelete: () => void
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
menuRef,
hasTrim,
onSplit,
onResetTrim,
onDelete,
}) => {
return (
<div
ref={menuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: x,
top: y,
}}
>
<div className="ep-context-menu-item" onClick={onSplit}>
<span className="ep-context-menu-icon">✂️</span>
<span>分割片段</span>
</div>
{hasTrim && (
<div className="ep-context-menu-item" onClick={onResetTrim}>
<span className="ep-context-menu-icon">↩️</span>
<span>恢复原始长度</span>
</div>
)}
<div className="ep-context-menu-divider" />
<div className="ep-context-menu-item ep-context-menu-item-danger" onClick={onDelete}>
<span className="ep-context-menu-icon">🗑️</span>
<span>删除片段</span>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import React from "react"
import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline"
import { generateRulerMarks } from "../../utils/timeline"
interface TimeRulerProps {
totalDuration: number
pps: number
onClick: (e: React.MouseEvent<HTMLDivElement>) => void
}
export const TimeRuler: React.FC<TimeRulerProps> = ({ totalDuration, pps, onClick }) => {
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
const step = getRulerStep(totalDuration)
const marks = generateRulerMarks(totalDuration, step)
return (
<div className="ep-time-ruler" onClick={onClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{marks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import React from "react"
import { formatTrimTime } from "../../utils/timeline"
interface TrimPreviewProps {
startTime: number
endTime: number
duration: number
x: number
y: number
}
export const TrimPreview: React.FC<TrimPreviewProps> = ({ startTime, endTime, duration, x, y }) => {
return (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: x + 12,
top: y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label">入点</span>
<span className="ep-trim-preview-value">{formatTrimTime(startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label">出点</span>
<span className="ep-trim-preview-value">{formatTrimTime(endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label">时长</span>
<span className="ep-trim-preview-value">{formatTrimTime(duration)}</span>
</div>
</div>
)
}
@@ -0,0 +1,56 @@
import type { ClipType } from "../types"
/** 片段类型图标 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 默认缩放:每秒像素数 */
export const DEFAULT_PIXELS_PER_SECOND = 40
/** 最小缩放 */
export const MIN_PIXELS_PER_SECOND = 10
/** 最大缩放 */
export const MAX_PIXELS_PER_SECOND = 120
/** 缩放步长 */
export const ZOOM_STEP = 10
/** 片段卡片最小宽度(px) */
export const MIN_CLIP_WIDTH = 60
/** 添加面板宽度(px) */
export const ADD_PICKER_WIDTH = 240
/** 轨道间距(px) */
export const TRACK_GAP = 6
/** 最小裁剪时长(秒) */
export const MIN_TRIM_DURATION = 1
/** 默认添加时长(秒) */
export const DEFAULT_ADD_DURATION = 5
/** 最小添加时长(秒) */
export const MIN_ADD_DURATION = 1
/** 最大添加时长(秒) */
export const MAX_ADD_DURATION = 120
/** 轨道最小宽度(px) */
export const MIN_TRACK_WIDTH = 300
/** 时间标尺刻度计算:根据总时长返回刻度步长(秒) */
export const getRulerStep = (totalDuration: number): number => {
if (totalDuration <= 30) return 5
if (totalDuration <= 60) return 10
return 15
}
@@ -0,0 +1,20 @@
/** 格式化时间为 mm:ss */
export const formatTime = (sec: number): string => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到 0.1 秒) */
export const formatTrimTime = (sec: number): string => {
return `${sec.toFixed(1)}s`
}
/** 生成时间标尺刻度 */
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
const marks: number[] = []
for (let t = 0; t <= totalDuration + step; t += step) {
marks.push(t)
}
return marks
}
@@ -10,9 +10,11 @@ import "@/pages/editing-planner/EditingPlanner"
// 常量
import "@/pages/editing-planner/constants"
import "@/pages/editing-planner/constants/timeline"
// 工具函数
import "@/pages/editing-planner/utils/selectors"
import "@/pages/editing-planner/utils/timeline"
// 子组件
import "@/pages/editing-planner/components/BgmSelector"
@@ -36,6 +38,11 @@ import "@/pages/editing-planner/components/StatusBar"
import "@/pages/editing-planner/components/StickerPanel"
import "@/pages/editing-planner/components/SubtitleStylePanel"
import "@/pages/editing-planner/components/TimelinePanel"
import "@/pages/editing-planner/components/timeline/ClipCard"
import "@/pages/editing-planner/components/timeline/TimeRuler"
import "@/pages/editing-planner/components/timeline/AddClipPicker"
import "@/pages/editing-planner/components/timeline/TrimPreview"
import "@/pages/editing-planner/components/timeline/ContextMenu"
import "@/pages/editing-planner/components/TopBar"
import "@/pages/editing-planner/components/TransitionSelector"
import "@/pages/editing-planner/components/TtsPanel"
+3 -2
View File
@@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代
from __future__ import annotations
import copy
import json
import logging
import random
@@ -87,7 +88,7 @@ def _fallback_recommend_clips(
order += 1
# 生成推荐 config
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
config["title"]["ai_auto"] = True
@@ -167,7 +168,7 @@ def _parse_recommend_response(
for i, clip in enumerate(clips):
clip["order"] = i
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
+3
View File
@@ -45,6 +45,9 @@ for i in 1 2 3; do
done
pytest --version
# --- 安装 ffmpeg(视频处理相关测试依赖)---
bash scripts/ci/step_install_ffmpeg.sh
# 双保险:确保numpy已安装
python3 -m pip install -q numpy==1.26.4 || true
+45 -12
View File
@@ -8,13 +8,9 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入
def _mock_module(**attrs):
"""创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__"""
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
m = MagicMock()
m.__spec__ = None
for k, v in attrs.items():
@@ -22,37 +18,60 @@ def _mock_module(**attrs):
return m
# cv2(视频处理依赖,纯算法测试不需要)
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
# This pattern ensures:
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
# 2. sys.modules is restored immediately so other test files are not polluted
# 3. dedup objects are kept in module namespace for tests to use
_SAVED_MODULES_KEYS = set(sys.modules.keys())
_SAVED_MODULES_VALUES = {
k: sys.modules.get(k)
for k in [
"cv2",
"celery",
"sqlalchemy",
"sqlalchemy.orm",
"sqlalchemy.engine",
"sqlalchemy.ext",
"sqlalchemy.ext.declarative",
"worker_app.db",
"worker_app.celery_app",
"worker_app.core.config",
"packages.adapters.sqlalchemy_impl.session",
"packages.adapters.sqlalchemy_impl.generated_video_repository",
"packages.shared.config",
"packages.shared.storage",
]
}
# Set up mocks
sys.modules["cv2"] = _mock_module()
# celery 及其子模块
_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
_mock_celery.__spec__ = None
sys.modules["celery"] = _mock_celery
# sqlalchemy(dedup 导入了 Session 类型)
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__spec__ = None
sys.modules["sqlalchemy"] = _mock_sqla
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
sys.modules["sqlalchemy"] = _mock_sqla
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包)
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包
# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
Base=MagicMock(),
build_engine=MagicMock(),
@@ -64,12 +83,26 @@ sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _m
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.shared.storage"] = _mock_module()
# Import dedup while mocks are active
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
VideoFingerprint,
hamming_distance,
)
# ── Restore sys.modules immediately after import ──
# dedup is now cached in this module's namespace; other test files will get
# their own fresh imports without our mock pollution
for _key in list(sys.modules.keys()):
if _key not in _SAVED_MODULES_KEYS:
del sys.modules[_key]
for _key, _value in _SAVED_MODULES_VALUES.items():
if _value is not None:
sys.modules[_key] = _value
elif _key in sys.modules:
del sys.modules[_key]
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
class TestHammingDistance:
"""hamming_distance 汉明距离计算测试."""
+1 -1
View File
@@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions:
def test_transition_to_invalid_string_raises(self):
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("invalid_status")
+1 -1
View File
@@ -403,7 +403,7 @@ class TestGenerationTaskTransitions:
def test_invalid_status_string(self, new_task):
"""测试无效状态字符串"""
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
new_task.transition_to("invalid_status")
+1 -1
View File
@@ -356,7 +356,7 @@ class TestTransitionTo:
def test_invalid_string_raises(self) -> None:
"""无效的状态字符串抛出 ValueError。"""
task = _make_task()
with pytest.raises(ValueError, match="无效状态"):
with pytest.raises(ValueError, match="非法状态转换"):
task.transition_to("invalid_status")
def test_enum_status(self) -> None: