Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 204f9462ec | |||
| 7f5bfae234 | |||
| 9df36da1bd | |||
| a6ebb762e6 | |||
| b45531e4db | |||
| e85f79c739 | |||
| 60dc6dbb3c |
@@ -8,21 +8,13 @@
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
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 { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||||
import { formatTime } from "../utils/timeline"
|
||||
import { useClipDrag } from "../hooks/useClipDrag"
|
||||
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||
import { ClipCard } from "./timeline/ClipCard"
|
||||
import { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||
@@ -55,25 +47,6 @@ interface TimelinePanelProps {
|
||||
totalDuration?: number
|
||||
}
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
type TrimDirection = "left" | "right"
|
||||
|
||||
/** 裁剪拖拽状态 */
|
||||
interface TrimDragState {
|
||||
clipId: string
|
||||
direction: TrimDirection
|
||||
startX: number
|
||||
originalTrim: TrimConfig
|
||||
originalDuration: number
|
||||
}
|
||||
|
||||
/** 右键菜单状态 */
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -91,143 +64,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
const dragRef = useRef<number | null>(null)
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
/* ── 裁剪拖拽 ── */
|
||||
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDrop,
|
||||
handleEmptyDragOver,
|
||||
} = useClipDrag(onClipReorder, !!trimDrag)
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
/* ── 菜单 & 面板 ── */
|
||||
const {
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
|
||||
[currentMode],
|
||||
)
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
const defaultType =
|
||||
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
|
||||
setAddType(defaultType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
@@ -271,165 +152,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setPlayheadDragging(true)
|
||||
}, [])
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
||||
if (trimDrag) return
|
||||
dragRef.current = idx
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
setDragOverIdx(idx)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragRef.current = null
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIdx(null)
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr)
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 空轨道区域不接受素材拖入 ── */
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
/* ── 裁剪手柄拖拽 ── */
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const clip = clips.find((c) => c.id === clipId)
|
||||
if (!clip) return
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
}
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
})
|
||||
},
|
||||
[clips],
|
||||
)
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX
|
||||
const dtSec = dx / pps
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||
if (!clip) return
|
||||
|
||||
const origTrim = trimDrag.originalTrim
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
||||
let newStart = origTrim.start_time
|
||||
let newEnd = origTrim.end_time
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + MIN_TRIM_DURATION,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
)
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
||||
}
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
||||
}
|
||||
setTrimDrag(null)
|
||||
setTrimPreview(null)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
if (onClipSplit) {
|
||||
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
|
||||
}
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
if (onClipResetTrim) {
|
||||
onClipResetTrim(contextMenu.clipId)
|
||||
}
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
@@ -443,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
@@ -451,15 +173,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={MIN_PIXELS_PER_SECOND}
|
||||
max={MAX_PIXELS_PER_SECOND}
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
@@ -583,8 +305,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
availableTypes={availableTypes}
|
||||
addType={addType}
|
||||
addDuration={addDuration}
|
||||
minDuration={MIN_ADD_DURATION}
|
||||
maxDuration={MAX_ADD_DURATION}
|
||||
onTypeChange={setAddType}
|
||||
onDurationChange={setAddDuration}
|
||||
onConfirm={handleConfirmAdd}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 片段拖拽排序 Hook
|
||||
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
|
||||
*/
|
||||
export const useClipDrag = (
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void,
|
||||
disabled?: boolean,
|
||||
) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
if (disabled) return
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
setDragOverIdx(idx)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIdx(null)
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr)
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx)
|
||||
}
|
||||
}
|
||||
setDragIdx(null)
|
||||
},
|
||||
[onClipReorder],
|
||||
)
|
||||
|
||||
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDrop,
|
||||
handleEmptyDragOver,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线菜单 Hook
|
||||
* 管理右键菜单和添加片段面板的状态与交互
|
||||
*/
|
||||
export const useTimelineMenus = (
|
||||
_clips: ClipData[],
|
||||
currentMode: string,
|
||||
onAddClip: (type: ClipType, duration: number) => void,
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void,
|
||||
onClipResetTrim?: (clipId: string) => void,
|
||||
onClipRemove?: (clipId: string) => void,
|
||||
) => {
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 添加片段面板 ── */
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||
[currentMode],
|
||||
)
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = useCallback(() => {
|
||||
if (!showAddPicker) {
|
||||
setAddType(defaultAddType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipResetTrim?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
return {
|
||||
// 右键菜单
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
// 添加面板
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
// 悬停状态
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import type { ClipData, TrimConfig } from "../types"
|
||||
|
||||
interface TrimDragState {
|
||||
clipId: string
|
||||
direction: "left" | "right"
|
||||
startX: number
|
||||
originalTrim: TrimConfig
|
||||
originalDuration: number
|
||||
}
|
||||
|
||||
interface TrimPreviewState {
|
||||
clipId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const MIN_TRIM_DURATION = 1
|
||||
|
||||
/**
|
||||
* 裁剪拖拽 Hook
|
||||
* 拖拽片段两端手柄调整入点/出点,实时显示预览
|
||||
*/
|
||||
export const useTrimDrag = (
|
||||
clips: ClipData[],
|
||||
pps: number,
|
||||
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
|
||||
) => {
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
|
||||
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const clip = clips.find((c) => c.id === clipId)
|
||||
if (!clip) return
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
}
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
})
|
||||
},
|
||||
[clips],
|
||||
)
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX
|
||||
const dtSec = dx / pps
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||
if (!clip) return
|
||||
|
||||
const origTrim = trimDrag.originalTrim
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
||||
let newStart = origTrim.start_time
|
||||
let newEnd = origTrim.end_time
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
||||
} else {
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + MIN_TRIM_DURATION,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
)
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
||||
}
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
||||
}
|
||||
setTrimDrag(null)
|
||||
setTrimPreview(null)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
|
||||
|
||||
return {
|
||||
trimDrag,
|
||||
trimPreview,
|
||||
handleTrimHandleMouseDown,
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ import "@/pages/editing-planner/hooks/usePipLayers"
|
||||
import "@/pages/editing-planner/hooks/useStickerItems"
|
||||
import "@/pages/editing-planner/hooks/useTtsPanel"
|
||||
import "@/pages/editing-planner/hooks/useWatermarkConfig"
|
||||
import "@/pages/editing-planner/hooks/useTimelineMenus"
|
||||
import "@/pages/editing-planner/hooks/useTrimDrag"
|
||||
import "@/pages/editing-planner/hooks/useClipDrag"
|
||||
|
||||
describe("EditingPlanner module smoke test", () => {
|
||||
it("should load all editing-planner modules", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力。
|
||||
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力.
|
||||
|
||||
支持:
|
||||
- 0.25x ~ 4x 变速范围
|
||||
@@ -6,147 +6,57 @@
|
||||
- 音频调速(atempo,多级串联处理超范围值)
|
||||
- 音调修正(pitch_correct,默认开启)
|
||||
- 边界自动钳制,不阻断渲染
|
||||
|
||||
注:核心领域模型已抽离到 packages/domain/speed_config.py,
|
||||
本模块保留薄包装层,确保向后兼容。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# ─── 常量 ───────────────────────────────────────────────
|
||||
MIN_SPEED = 0.25
|
||||
MAX_SPEED = 4.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
|
||||
# atempo 单级有效范围
|
||||
_ATEMPO_MIN = 0.5
|
||||
_ATEMPO_MAX = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeedConfig:
|
||||
"""调速配置。
|
||||
|
||||
Attributes:
|
||||
speed: 播放速度,0.25~4.0,1.0 为原速
|
||||
pitch_correct: 是否保持音调(默认 True,用 atempo 时间拉伸算法)
|
||||
"""
|
||||
|
||||
speed: float = DEFAULT_SPEED
|
||||
pitch_correct: bool = True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: Optional[dict]) -> "SpeedConfig":
|
||||
"""从 dict 解析配置,无效值回退到默认。"""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
speed = data.get("speed", DEFAULT_SPEED)
|
||||
if not isinstance(speed, (int, float)):
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
pitch_correct = data.get("pitch_correct", True)
|
||||
if not isinstance(pitch_correct, bool):
|
||||
pitch_correct = True
|
||||
|
||||
config = cls(speed=float(speed), pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return config
|
||||
|
||||
def clamp(self) -> None:
|
||||
"""将速度钳制到合法范围。"""
|
||||
if self.speed <= 0:
|
||||
self.speed = DEFAULT_SPEED
|
||||
elif self.speed < MIN_SPEED:
|
||||
self.speed = MIN_SPEED
|
||||
elif self.speed > MAX_SPEED:
|
||||
self.speed = MAX_SPEED
|
||||
|
||||
@property
|
||||
def is_original(self) -> bool:
|
||||
"""是否原速(无需调速)。"""
|
||||
return abs(self.speed - 1.0) < 1e-6
|
||||
from packages.domain.speed_config import ( # noqa: F401 — 向后兼容
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
_split_atempo_stages,
|
||||
adjust_duration as _adjust_duration_base,
|
||||
build_audio_filter as _build_audio_filter_base,
|
||||
build_video_filter as _build_video_filter_base,
|
||||
resolve_clip_speed as _resolve_clip_speed_base,
|
||||
)
|
||||
|
||||
|
||||
class SpeedEngine:
|
||||
"""调速引擎 — 生成 FFmpeg 调速滤镜链。
|
||||
"""调速引擎 — 生成 FFmpeg 调速滤镜链.
|
||||
|
||||
用法:
|
||||
engine = SpeedEngine()
|
||||
video_filter = engine.build_video_filter(config)
|
||||
audio_filter = engine.build_audio_filter(config)
|
||||
new_duration = engine.adjust_duration(duration, config)
|
||||
薄包装层,实际逻辑委托给 packages.domain.speed_config。
|
||||
"""
|
||||
|
||||
def build_video_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成视频调速滤镜字符串。
|
||||
|
||||
返回 setpts 滤镜表达式,原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
# setpts=PTS/speed — speed>1 加速,speed<1 减速
|
||||
return f"setpts=PTS/{config.speed:.4f}"
|
||||
"""生成视频调速滤镜字符串."""
|
||||
return _build_video_filter_base(config)
|
||||
|
||||
def build_audio_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成音频调速滤镜字符串。
|
||||
|
||||
atempo 单级范围 0.5~2.0,超出范围时自动多级串联:
|
||||
- 0.25x → atempo=0.5,atempo=0.5
|
||||
- 4x → atempo=2.0,atempo=2.0
|
||||
- 0.3x → atempo=0.5,atempo=0.6
|
||||
- 3x → atempo=2.0,atempo=1.5
|
||||
|
||||
原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
|
||||
speed = config.speed
|
||||
stages: list[float] = self._split_atempo_stages(speed)
|
||||
return ",".join(f"atempo={s:.4f}" for s in stages)
|
||||
"""生成音频调速滤镜字符串."""
|
||||
return _build_audio_filter_base(config)
|
||||
|
||||
@staticmethod
|
||||
def _split_atempo_stages(speed: float) -> list[float]:
|
||||
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内。"""
|
||||
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
|
||||
return [speed]
|
||||
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
|
||||
# 加速场景(speed > 2.0)
|
||||
if speed > _ATEMPO_MAX:
|
||||
while remaining > _ATEMPO_MAX:
|
||||
stages.append(_ATEMPO_MAX)
|
||||
remaining /= _ATEMPO_MAX
|
||||
stages.append(remaining)
|
||||
|
||||
# 减速场景(speed < 0.5)
|
||||
else:
|
||||
while remaining < _ATEMPO_MIN:
|
||||
stages.append(_ATEMPO_MIN)
|
||||
remaining /= _ATEMPO_MIN
|
||||
stages.append(remaining)
|
||||
|
||||
return stages
|
||||
"""将速度拆分为多级 atempo 串联(内部方法,向后兼容)."""
|
||||
return _split_atempo_stages(speed)
|
||||
|
||||
def adjust_duration(self, original_duration: float, config: SpeedConfig) -> float:
|
||||
"""计算调速后的时长。
|
||||
|
||||
加速 → 时长变短;减速 → 时长变长。
|
||||
"""
|
||||
if config.is_original or original_duration <= 0:
|
||||
return original_duration
|
||||
return original_duration / config.speed
|
||||
"""计算调速后的时长."""
|
||||
return _adjust_duration_base(original_duration, config)
|
||||
|
||||
def build_clip_speed_filter(
|
||||
self,
|
||||
speed: float,
|
||||
pitch_correct: bool = True,
|
||||
) -> tuple[str, str, SpeedConfig]:
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜。
|
||||
|
||||
返回 (video_filter, audio_filter, config)。
|
||||
"""
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜."""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
@@ -160,8 +70,5 @@ class SpeedEngine:
|
||||
clip_config: dict,
|
||||
global_speed: float = DEFAULT_SPEED,
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度。"""
|
||||
speed = clip_config.get("playback_speed", 0) if clip_config else 0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return global_speed
|
||||
return float(speed)
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度."""
|
||||
return _resolve_clip_speed_base(clip_config, global_speed)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""调速配置领域模型 — 纯逻辑,无FFmpeg依赖.
|
||||
|
||||
抽离自 speed_engine.py,包含:
|
||||
- SpeedConfig 数据类(解析/钳制/原速判断)
|
||||
- 视频/音频调速滤镜构建
|
||||
- atempo 多级拆分算法
|
||||
- 时长计算
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# ─── 常量 ───────────────────────────────────────────────
|
||||
MIN_SPEED = 0.25
|
||||
MAX_SPEED = 4.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
|
||||
# atempo 单级有效范围
|
||||
_ATEMPO_MIN = 0.5
|
||||
_ATEMPO_MAX = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeedConfig:
|
||||
"""调速配置.
|
||||
|
||||
Attributes:
|
||||
speed: 播放速度,0.25~4.0,1.0 为原速
|
||||
pitch_correct: 是否保持音调(默认 True,用 atempo 时间拉伸算法)
|
||||
"""
|
||||
|
||||
speed: float = DEFAULT_SPEED
|
||||
pitch_correct: bool = True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: dict[str, Any] | None) -> SpeedConfig:
|
||||
"""从 dict 解析配置,无效值回退到默认."""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
speed = data.get("speed", DEFAULT_SPEED)
|
||||
if not isinstance(speed, (int, float)):
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
pitch_correct = data.get("pitch_correct", True)
|
||||
if not isinstance(pitch_correct, bool):
|
||||
pitch_correct = True
|
||||
|
||||
config = cls(speed=float(speed), pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return config
|
||||
|
||||
def clamp(self) -> None:
|
||||
"""将速度钳制到合法范围."""
|
||||
if self.speed <= 0:
|
||||
self.speed = DEFAULT_SPEED
|
||||
elif self.speed < MIN_SPEED:
|
||||
self.speed = MIN_SPEED
|
||||
elif self.speed > MAX_SPEED:
|
||||
self.speed = MAX_SPEED
|
||||
|
||||
@property
|
||||
def is_original(self) -> bool:
|
||||
"""是否原速(无需调速)."""
|
||||
return abs(self.speed - 1.0) < 1e-6
|
||||
|
||||
@property
|
||||
def is_fast(self) -> bool:
|
||||
"""是否加速播放."""
|
||||
return self.speed > 1.0
|
||||
|
||||
@property
|
||||
def is_slow(self) -> bool:
|
||||
"""是否减速播放."""
|
||||
return self.speed < 1.0
|
||||
|
||||
|
||||
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_video_filter(config: SpeedConfig) -> str:
|
||||
"""生成视频调速滤镜字符串.
|
||||
|
||||
返回 setpts 滤镜表达式,原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
# setpts=PTS/speed — speed>1 加速,speed<1 减速
|
||||
return f"setpts=PTS/{config.speed:.4f}"
|
||||
|
||||
|
||||
def build_audio_filter(config: SpeedConfig) -> str:
|
||||
"""生成音频调速滤镜字符串.
|
||||
|
||||
atempo 单级范围 0.5~2.0,超出范围时自动多级串联:
|
||||
- 0.25x → atempo=0.5,atempo=0.5
|
||||
- 4x → atempo=2.0,atempo=2.0
|
||||
- 0.3x → atempo=0.5,atempo=0.6
|
||||
- 3x → atempo=2.0,atempo=1.5
|
||||
|
||||
原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
|
||||
speed = config.speed
|
||||
stages: list[float] = _split_atempo_stages(speed)
|
||||
return ",".join(f"atempo={s:.4f}" for s in stages)
|
||||
|
||||
|
||||
def _split_atempo_stages(speed: float) -> list[float]:
|
||||
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内."""
|
||||
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
|
||||
return [speed]
|
||||
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
|
||||
# 加速场景(speed > 2.0)
|
||||
if speed > _ATEMPO_MAX:
|
||||
while remaining > _ATEMPO_MAX:
|
||||
stages.append(_ATEMPO_MAX)
|
||||
remaining /= _ATEMPO_MAX
|
||||
stages.append(remaining)
|
||||
|
||||
# 减速场景(speed < 0.5)
|
||||
else:
|
||||
while remaining < _ATEMPO_MIN:
|
||||
stages.append(_ATEMPO_MIN)
|
||||
remaining /= _ATEMPO_MIN
|
||||
stages.append(remaining)
|
||||
|
||||
return stages
|
||||
|
||||
|
||||
# ── 时长计算 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def adjust_duration(original_duration: float, config: SpeedConfig) -> float:
|
||||
"""计算调速后的时长.
|
||||
|
||||
加速 → 时长变短;减速 → 时长变长。
|
||||
"""
|
||||
if config.is_original or original_duration <= 0:
|
||||
return original_duration
|
||||
return original_duration / config.speed
|
||||
|
||||
|
||||
# ── 便捷方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_clip_speed_filter(
|
||||
speed: float,
|
||||
pitch_correct: bool = True,
|
||||
) -> tuple[str, str, SpeedConfig]:
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜.
|
||||
|
||||
返回 (video_filter, audio_filter, config)。
|
||||
"""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
build_video_filter(config),
|
||||
build_audio_filter(config),
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def resolve_clip_speed(
|
||||
clip_config: dict[str, Any] | None,
|
||||
global_speed: float = DEFAULT_SPEED,
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度."""
|
||||
speed = clip_config.get("playback_speed", 0) if clip_config else 0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return global_speed
|
||||
return float(speed)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""speed_config 领域模型单测."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
adjust_duration,
|
||||
build_audio_filter,
|
||||
build_video_filter,
|
||||
build_clip_speed_filter,
|
||||
resolve_clip_speed,
|
||||
)
|
||||
|
||||
# ── 常量测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_min_speed(self):
|
||||
assert MIN_SPEED == 0.25
|
||||
|
||||
def test_max_speed(self):
|
||||
assert MAX_SPEED == 4.0
|
||||
|
||||
def test_default_speed(self):
|
||||
assert DEFAULT_SPEED == 1.0
|
||||
|
||||
|
||||
# ── SpeedConfig.parse 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigParse:
|
||||
def test_none_returns_default(self):
|
||||
cfg = SpeedConfig.parse(None)
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
assert cfg.pitch_correct is True
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = SpeedConfig.parse({})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_invalid_type_returns_default(self):
|
||||
cfg = SpeedConfig.parse("not_a_dict")
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_valid_speed(self):
|
||||
cfg = SpeedConfig.parse({"speed": 2.0})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_speed_clamped_low(self):
|
||||
cfg = SpeedConfig.parse({"speed": 0.1})
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_speed_clamped_high(self):
|
||||
cfg = SpeedConfig.parse({"speed": 5.0})
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_zero_speed_returns_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": 0})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_speed_returns_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": -1.0})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_pitch_correct_false(self):
|
||||
cfg = SpeedConfig.parse({"pitch_correct": False})
|
||||
assert cfg.pitch_correct is False
|
||||
|
||||
def test_pitch_correct_invalid_type_defaults_true(self):
|
||||
cfg = SpeedConfig.parse({"pitch_correct": "yes"})
|
||||
assert cfg.pitch_correct is True
|
||||
|
||||
def test_string_speed_invalid_uses_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": "fast"})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
|
||||
# ── SpeedConfig.clamp 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClamp:
|
||||
def test_already_valid_unchanged(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == 1.5
|
||||
|
||||
def test_below_min_clamped(self):
|
||||
cfg = SpeedConfig(speed=0.1)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
cfg = SpeedConfig(speed=10.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_zero_defaults(self):
|
||||
cfg = SpeedConfig(speed=0.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_defaults(self):
|
||||
cfg = SpeedConfig(speed=-2.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_exact_min_stays(self):
|
||||
cfg = SpeedConfig(speed=MIN_SPEED)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_exact_max_stays(self):
|
||||
cfg = SpeedConfig(speed=MAX_SPEED)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
|
||||
# ── is_original / is_fast / is_slow 测试 ─────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedProperties:
|
||||
def test_is_original_true(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert cfg.is_original is True
|
||||
|
||||
def test_is_original_false_fast(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_original is False
|
||||
|
||||
def test_is_original_false_slow(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_original is False
|
||||
|
||||
def test_is_original_near_one(self):
|
||||
cfg = SpeedConfig(speed=1.0000001)
|
||||
assert cfg.is_original is True
|
||||
|
||||
def test_is_fast_true(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_fast is True
|
||||
|
||||
def test_is_fast_false(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_fast is False
|
||||
|
||||
def test_is_false_for_original(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert cfg.is_fast is False
|
||||
assert cfg.is_slow is False
|
||||
|
||||
def test_is_slow_true(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_slow is True
|
||||
|
||||
def test_is_slow_false(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_slow is False
|
||||
|
||||
|
||||
# ── build_video_filter 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildVideoFilter:
|
||||
def test_original_speed_empty(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert build_video_filter(cfg) == ""
|
||||
|
||||
def test_fast_speed_setpts(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
result = build_video_filter(cfg)
|
||||
assert "setpts=PTS/2.0000" in result
|
||||
|
||||
def test_slow_speed_setpts(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
result = build_video_filter(cfg)
|
||||
assert "setpts=PTS/0.5000" in result
|
||||
|
||||
def test_format_four_decimals(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
result = build_video_filter(cfg)
|
||||
assert "1.5000" in result
|
||||
|
||||
|
||||
# ── build_audio_filter 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAudioFilter:
|
||||
def test_original_speed_empty(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert build_audio_filter(cfg) == ""
|
||||
|
||||
def test_single_stage_within_range(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result == "atempo=1.5000"
|
||||
assert result.count("atempo") == 1
|
||||
|
||||
def test_fast_two_stages(self):
|
||||
cfg = SpeedConfig(speed=3.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 2.0 * 1.5 = 3.0
|
||||
assert "atempo=2.0000" in result
|
||||
assert "atempo=1.5000" in result
|
||||
|
||||
def test_max_speed_two_stages(self):
|
||||
cfg = SpeedConfig(speed=4.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 2.0 * 2.0 = 4.0
|
||||
assert result == "atempo=2.0000,atempo=2.0000"
|
||||
|
||||
def test_slow_two_stages(self):
|
||||
cfg = SpeedConfig(speed=0.25)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 0.5 * 0.5 = 0.25
|
||||
assert result == "atempo=0.5000,atempo=0.5000"
|
||||
|
||||
def test_slow_single_stage(self):
|
||||
cfg = SpeedConfig(speed=0.8)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result == "atempo=0.8000"
|
||||
assert result.count("atempo") == 1
|
||||
|
||||
def test_exactly_two_point_zero_single(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 1
|
||||
assert "atempo=2.0000" in result
|
||||
|
||||
def test_exactly_half_single(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 1
|
||||
assert "atempo=0.5000" in result
|
||||
|
||||
|
||||
# ── adjust_duration 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdjustDuration:
|
||||
def test_original_speed_unchanged(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert adjust_duration(10.0, cfg) == 10.0
|
||||
|
||||
def test_double_speed_halved(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(10.0, cfg) == 5.0
|
||||
|
||||
def test_half_speed_doubled(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert adjust_duration(10.0, cfg) == 20.0
|
||||
|
||||
def test_zero_duration_unchanged(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(0.0, cfg) == 0.0
|
||||
|
||||
def test_negative_duration_unchanged(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(-5.0, cfg) == -5.0
|
||||
|
||||
|
||||
# ── build_clip_speed_filter 测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildClipSpeedFilter:
|
||||
def test_normal_speed(self):
|
||||
vf, af, cfg = build_clip_speed_filter(2.0)
|
||||
assert vf == "setpts=PTS/2.0000"
|
||||
assert "atempo=2.0000" in af
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_clamped_speed(self):
|
||||
vf, af, cfg = build_clip_speed_filter(10.0)
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_pitch_correct_param(self):
|
||||
vf, af, cfg = build_clip_speed_filter(1.5, pitch_correct=False)
|
||||
assert cfg.pitch_correct is False
|
||||
|
||||
def test_original_speed_empty_filters(self):
|
||||
vf, af, cfg = build_clip_speed_filter(1.0)
|
||||
assert vf == ""
|
||||
assert af == ""
|
||||
|
||||
|
||||
# ── resolve_clip_speed 测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveClipSpeed:
|
||||
def test_none_config_uses_global(self):
|
||||
assert resolve_clip_speed(None, 1.5) == 1.5
|
||||
|
||||
def test_no_playback_speed_uses_global(self):
|
||||
assert resolve_clip_speed({}, 1.5) == 1.5
|
||||
|
||||
def test_zero_speed_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
|
||||
|
||||
def test_valid_speed_returns_speed(self):
|
||||
assert resolve_clip_speed({"playback_speed": 2.0}, 1.0) == 2.0
|
||||
|
||||
def test_invalid_type_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
|
||||
|
||||
def test_default_global_speed(self):
|
||||
assert resolve_clip_speed({}) == DEFAULT_SPEED
|
||||
Reference in New Issue
Block a user