From 7e63bf7e269307e727a626df9a90bfe128b87807 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 09:52:10 +0800 Subject: [PATCH 1/8] refactor(timeline-panel): Phase 1 - extract constants and utils --- .../components/TimelinePanel.tsx | 30 ++++++---- .../editing-planner/constants/timeline.ts | 56 +++++++++++++++++++ .../pages/editing-planner/utils/timeline.ts | 20 +++++++ 3 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/constants/timeline.ts create mode 100644 apps/web/src/pages/editing-planner/utils/timeline.ts diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 64d3f961a..de3acf8c1 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -11,6 +11,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 { + CLIP_TYPE_ICONS, + CLIP_TYPE_LABELS, + DEFAULT_PIXELS_PER_SECOND, + MIN_PIXELS_PER_SECOND, + MAX_PIXELS_PER_SECOND, + ZOOM_STEP, + MIN_CLIP_WIDTH, + ADD_PICKER_WIDTH, + TRACK_GAP, + MIN_TRIM_DURATION, + DEFAULT_ADD_DURATION, + MIN_ADD_DURATION, + MAX_ADD_DURATION, + MIN_TRACK_WIDTH, + getRulerStep, +} from "../constants/timeline" +import { formatTime, formatTrimTime, generateRulerMarks } from "../utils/timeline" interface TimelinePanelProps { clips: ClipData[] @@ -38,18 +56,6 @@ interface TimelinePanelProps { totalDuration?: number } -/** 片段类型图标 */ -const CLIP_TYPE_ICONS: Record = { - voice: "🎙️", - pip: "🖼️", -} - -/** 片段类型标签 */ -const CLIP_TYPE_LABELS: Record = { - voice: "口播", - pip: "混剪", -} - /** 裁剪拖拽方向 */ type TrimDirection = "left" | "right" diff --git a/apps/web/src/pages/editing-planner/constants/timeline.ts b/apps/web/src/pages/editing-planner/constants/timeline.ts new file mode 100644 index 000000000..c93de9812 --- /dev/null +++ b/apps/web/src/pages/editing-planner/constants/timeline.ts @@ -0,0 +1,56 @@ +import type { ClipType } from "../types" + +/** 片段类型图标 */ +export const CLIP_TYPE_ICONS: Record = { + voice: "🎙️", + pip: "🖼️", +} + +/** 片段类型标签 */ +export const CLIP_TYPE_LABELS: Record = { + 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 +} diff --git a/apps/web/src/pages/editing-planner/utils/timeline.ts b/apps/web/src/pages/editing-planner/utils/timeline.ts new file mode 100644 index 000000000..d0bd0b97c --- /dev/null +++ b/apps/web/src/pages/editing-planner/utils/timeline.ts @@ -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 +} From c71352c2b72622690c37b935938e8743b924e735 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 11:05:40 +0800 Subject: [PATCH 2/8] fix(phase1): remove duplicate local definitions, use imported utils --- .../components/TimelinePanel.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index de3acf8c1..a62a783fe 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -436,22 +436,8 @@ const TimelinePanel: React.FC = ({ /* ── 时间标尺 ── */ 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` - } + const step = getRulerStep(totalDuration) + const rulerMarks = generateRulerMarks(totalDuration, step) return (
From c704f9b844508d7681ba7cb586cf8d4ecb527d67 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 11:13:30 +0800 Subject: [PATCH 3/8] fix(phase1): replace all magic numbers with named constants - Replace hardcoded values with imported constants - Fix unused import errors (TS6133) - MIN/MAX pixels per second, zoom step, clip width, trim duration, add duration, track width, picker width, track gap --- .../components/TimelinePanel.tsx | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index a62a783fe..a29731b27 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -141,7 +141,7 @@ const TimelinePanel: React.FC = ({ /* ── "+" 卡片:类型+时长选择状态 ── */ const [addType, setAddType] = useState(defaultAddType) - const [addDuration, setAddDuration] = useState(5) + const [addDuration, setAddDuration] = useState(DEFAULT_ADD_DURATION) /* ── 模式切换时自动同步默认添加类型 ── */ useEffect(() => { @@ -150,21 +150,17 @@ const TimelinePanel: React.FC = ({ } }, [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 }) }, []) @@ -187,9 +183,9 @@ const TimelinePanel: React.FC = ({ 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 @@ -198,7 +194,7 @@ const TimelinePanel: React.FC = ({ 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]) @@ -230,7 +226,7 @@ const TimelinePanel: React.FC = ({ }, [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 ── */ @@ -350,11 +346,9 @@ const TimelinePanel: React.FC = ({ 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 @@ -365,10 +359,10 @@ const TimelinePanel: React.FC = ({ 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 @@ -435,7 +429,7 @@ const TimelinePanel: React.FC = ({ }, [contextMenu, onClipRemove]) /* ── 时间标尺 ── */ - const trackWidth = Math.max(totalDuration * pps, 300) + const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH) const step = getRulerStep(totalDuration) const rulerMarks = generateRulerMarks(totalDuration, step) @@ -452,7 +446,7 @@ const TimelinePanel: React.FC = ({
-
- - ) - }) + clips.map((clip, idx) => ( + setHoveredClipId(clip.id)} + onMouseLeave={() => setHoveredClipId(null)} + onTrimHandleMouseDown={handleTrimHandleMouseDown} + onRemove={onClipRemove} + /> + )) )} {/* ── 轨道末尾 "+" 添加卡片 ── */} @@ -652,114 +554,42 @@ const TimelinePanel: React.FC = ({ {/* 裁剪预览 tooltip */} {trimPreview && ( -
-
- 入点 - {formatTrimTime(trimPreview.startTime)} -
-
- 出点 - {formatTrimTime(trimPreview.endTime)} -
-
- 时长 - {formatTrimTime(trimPreview.duration)} -
-
+ )} {/* 右键菜单 */} {contextMenu && ( -
-
- ✂️ - 分割片段 -
- {clips.find((c) => c.id === contextMenu.clipId)?.trim_config && ( -
- ↩️ - 恢复原始长度 -
- )} -
-
- 🗑️ - 删除片段 -
-
+ c.id === contextMenu.clipId)?.trim_config} + onSplit={handleContextSplit} + onResetTrim={handleContextResetTrim} + onDelete={handleContextDelete} + /> )} {/* 类型+时长选择面板 */} {showAddPicker && ( -
-
添加片段
- - {/* 类型选择 */} -
- 类型: - {availableTypes.map((t) => ( - - ))} -
- - {/* 时长输入 */} -
- 时长: - - setAddDuration( - Math.max( - MIN_ADD_DURATION, - Math.min(MAX_ADD_DURATION, Number(e.target.value) || MIN_ADD_DURATION), - ), - ) - } - /> - 秒 -
- - {/* 确认按钮 */} - -
+ )}
) diff --git a/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx new file mode 100644 index 000000000..ecab58aa5 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/AddClipPicker.tsx @@ -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 + 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 = ({ + pickerRef, + position, + availableTypes, + addType, + addDuration, + onTypeChange, + onDurationChange, + onConfirm, + minDuration = 1, + maxDuration = 120, +}) => { + return ( +
+
添加片段
+ + {/* 类型选择 */} +
+ 类型: + {availableTypes.map((t) => ( + + ))} +
+ + {/* 时长输入 */} +
+ 时长: + + onDurationChange( + Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)), + ) + } + /> + 秒 +
+ + {/* 确认按钮 */} + +
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx new file mode 100644 index 000000000..a7c402ba7 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ClipCard.tsx @@ -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 = ({ + 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 ( + + {/* 转场指示器 */} + {showTransition && transOpt && ( +
+ {transOpt.icon} + {trans!.duration.toFixed(1)}s +
+ )} + +
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 && ( +
onTrimHandleMouseDown(e, clip.id, "left")} + title="拖动调整入点" + > +
+
+ )} + + {/* 类型图标 */} +
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
+ + {/* 片段信息 */} +
+ + {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} + + + {clip.duration}s + {hasTrim && ( + + ✂ + + )} + +
+ + {/* 速度徽章 */} + {showSpeed && {speed!.rate.toFixed(1)}x} + + {/* 裁剪徽章 */} + {hasTrim && ( + + ✂ + + )} + + {/* 右裁剪手柄 */} + {isHovered && showTrimHandles && ( +
onTrimHandleMouseDown(e, clip.id, "right")} + title="拖动调整出点" + > +
+
+ )} + + {/* 删除按钮 */} + +
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx b/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx new file mode 100644 index 000000000..8042aa563 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/ContextMenu.tsx @@ -0,0 +1,49 @@ +import React from "react" + +interface ContextMenuProps { + x: number + y: number + menuRef: React.RefObject + hasTrim: boolean + onSplit: () => void + onResetTrim: () => void + onDelete: () => void +} + +export const ContextMenu: React.FC = ({ + x, + y, + menuRef, + hasTrim, + onSplit, + onResetTrim, + onDelete, +}) => { + return ( +
+
+ ✂️ + 分割片段 +
+ {hasTrim && ( +
+ ↩️ + 恢复原始长度 +
+ )} +
+
+ 🗑️ + 删除片段 +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx b/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx new file mode 100644 index 000000000..5d1588360 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/TimeRuler.tsx @@ -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) => void +} + +export const TimeRuler: React.FC = ({ totalDuration, pps, onClick }) => { + const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH) + const step = getRulerStep(totalDuration) + const marks = generateRulerMarks(totalDuration, step) + + return ( +
+
+ {marks.map((t) => ( + + {t}s + + ))} +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx b/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx new file mode 100644 index 000000000..84399fb3c --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/timeline/TrimPreview.tsx @@ -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 = ({ startTime, endTime, duration, x, y }) => { + return ( +
+
+ 入点 + {formatTrimTime(startTime)} +
+
+ 出点 + {formatTrimTime(endTime)} +
+
+ 时长 + {formatTrimTime(duration)} +
+
+ ) +} diff --git a/apps/web/src/test/pages/editing-planner/smoke.test.tsx b/apps/web/src/test/pages/editing-planner/smoke.test.tsx index 1ef59fc39..ae48d3995 100644 --- a/apps/web/src/test/pages/editing-planner/smoke.test.tsx +++ b/apps/web/src/test/pages/editing-planner/smoke.test.tsx @@ -38,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" From 7136773ee514c2fd9056214d967a65a87f0693c3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 26 Jul 2026 04:39:54 +0000 Subject: [PATCH 7/8] style: auto-format with black + isort + prettier --- apps/web/src/pages/editing-planner/components/TimelinePanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index c89dccc9c..5b6f037a4 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -430,7 +430,6 @@ const TimelinePanel: React.FC = ({ setContextMenu(null) }, [contextMenu, onClipRemove]) - return (
{/* 时间线头部 */} From 30457629da4a49018f37028f93fcb2fb9245aab2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 12:58:22 +0800 Subject: [PATCH 8/8] fix(ci): fix preview deploy frontend build - remove DooD, use direct runner (#919) --- .gitea/workflows/preview-deploy.yml | 61 +++++++++-------------- packages/shared/ai_service.py | 5 +- scripts/ci/run_unit_tests.sh | 3 ++ tests/unit/test_dedup_pure.py | 57 ++++++++++++++++----- tests/unit/test_generation_task.py | 2 +- tests/unit/test_generation_task_domain.py | 2 +- tests/unit/test_generation_task_status.py | 2 +- 7 files changed, 78 insertions(+), 54 deletions(-) diff --git a/.gitea/workflows/preview-deploy.yml b/.gitea/workflows/preview-deploy.yml index e0319be33..97039ec3d 100755 --- a/.gitea/workflows/preview-deploy.yml +++ b/.gitea/workflows/preview-deploy.yml @@ -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 diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index a44517431..892f47c9f 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -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) diff --git a/scripts/ci/run_unit_tests.sh b/scripts/ci/run_unit_tests.sh index d1804b723..3a0faadcf 100755 --- a/scripts/ci/run_unit_tests.sh +++ b/scripts/ci/run_unit_tests.sh @@ -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 diff --git a/tests/unit/test_dedup_pure.py b/tests/unit/test_dedup_pure.py index ca584abde..c9d71375e 100755 --- a/tests/unit/test_dedup_pure.py +++ b/tests/unit/test_dedup_pure.py @@ -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 汉明距离计算测试.""" diff --git a/tests/unit/test_generation_task.py b/tests/unit/test_generation_task.py index 96213144c..43ed05096 100755 --- a/tests/unit/test_generation_task.py +++ b/tests/unit/test_generation_task.py @@ -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") diff --git a/tests/unit/test_generation_task_domain.py b/tests/unit/test_generation_task_domain.py index 6604d9294..6c2e86ac2 100755 --- a/tests/unit/test_generation_task_domain.py +++ b/tests/unit/test_generation_task_domain.py @@ -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") diff --git a/tests/unit/test_generation_task_status.py b/tests/unit/test_generation_task_status.py index d2d2dd04d..e27d64e17 100755 --- a/tests/unit/test_generation_task_status.py +++ b/tests/unit/test_generation_task_status.py @@ -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: