Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2023ddc8cf | |||
| 7ea0a1397e | |||
| 9f61f76019 |
@@ -12,6 +12,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
Executable → Regular
+3
-284
@@ -1,286 +1,5 @@
|
||||
/**
|
||||
* 混剪单图层配置区
|
||||
* LayerConfig 入口(向后兼容)
|
||||
* 实际实现位于 ./layer-config/ 目录
|
||||
*/
|
||||
import React from "react"
|
||||
import type {
|
||||
PipLayer,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
PipGridPosition,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import {
|
||||
GRID_POSITIONS,
|
||||
ANIM_OPTIONS,
|
||||
SLIDE_DIR_OPTIONS,
|
||||
LAYER_COLORS,
|
||||
} from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerConfigProps {
|
||||
layer: PipLayer | null
|
||||
layers: PipLayer[]
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
layers,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
if (!layer) {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* ── 迷你预览 ── */}
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((l, idx) => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
width: `${l.width}%`,
|
||||
height: `${l.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: l.opacity / 100,
|
||||
borderRadius: `${l.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{l.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerConfig
|
||||
export { default } from "./layer-config"
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerPositionSizeProps {
|
||||
layer: PipLayer
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层位置与尺寸配置面板
|
||||
*/
|
||||
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
|
||||
layer,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => (
|
||||
<>
|
||||
{/* 素材类型 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材 URL */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置:九宫格 + 坐标 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 圆角 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
|
||||
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerTimingAnimationProps {
|
||||
layer: PipLayer
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层时间与动画配置面板
|
||||
*/
|
||||
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
|
||||
layer,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
}) => (
|
||||
<>
|
||||
{/* 时间 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入场动画 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "@/pages/editing-planner/types"
|
||||
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface PipPreviewProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PIP 图层迷你预览组件
|
||||
*/
|
||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((l, idx) => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
width: `${l.width}%`,
|
||||
height: `${l.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: l.opacity / 100,
|
||||
borderRadius: `${l.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{l.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 混剪单图层配置区
|
||||
*/
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { PipPreview } from "./PipPreview"
|
||||
import { LayerPositionSize } from "./LayerPositionSize"
|
||||
import { LayerTimingAnimation } from "./LayerTimingAnimation"
|
||||
|
||||
interface LayerConfigProps {
|
||||
layer: PipLayer | null
|
||||
layers: PipLayer[]
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
layers,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
if (!layer) {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* 迷你预览 */}
|
||||
<PipPreview layers={layers} selectedId={layer.id} />
|
||||
|
||||
{/* 位置与尺寸 */}
|
||||
<LayerPositionSize
|
||||
layer={layer}
|
||||
onUpdate={onUpdate}
|
||||
onGridClick={onGridClick}
|
||||
onWidthChange={onWidthChange}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
|
||||
{/* 时间与动画 */}
|
||||
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerConfig
|
||||
@@ -9,11 +9,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.chroma_key_config import CHROMA_KEY_PRESETS # noqa: F401
|
||||
from packages.domain.chroma_key_config import apply_chroma_key_if_needed # noqa: F401
|
||||
from packages.domain.chroma_key_config import (
|
||||
CHROMA_KEY_PRESETS,
|
||||
ChromaKeyConfig,
|
||||
apply_chroma_key_if_needed,
|
||||
)
|
||||
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
|
||||
build_chromakey_filter as _build_chromakey_filter_base,
|
||||
|
||||
@@ -195,3 +195,17 @@ class ColorGradeEngine:
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_preset_names() -> list[tuple[str, str]]:
|
||||
"""获取所有预设名称列表.
|
||||
|
||||
Returns:
|
||||
[(preset_key, display_name), ...]
|
||||
"""
|
||||
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
|
||||
|
||||
|
||||
def get_preset_params(preset: str) -> dict[str, float] | None:
|
||||
"""获取指定预设的参数."""
|
||||
return PRESET_PARAMS.get(preset)
|
||||
|
||||
@@ -22,9 +22,11 @@ from shared.ffmpeg_utils import ( # noqa: F401
|
||||
|
||||
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
|
||||
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
|
||||
from packages.domain.xfade_builder import SUPPORTED_TRANSITIONS # noqa: F401
|
||||
from packages.domain.xfade_builder import XFADE_TRANSITION_MAP # noqa: F401
|
||||
from packages.domain.xfade_builder import XFade_TRANSITION_NAMES # noqa: F401
|
||||
from packages.domain.xfade_builder import (
|
||||
SUPPORTED_TRANSITIONS,
|
||||
XFADE_TRANSITION_MAP,
|
||||
XFade_TRANSITION_NAMES,
|
||||
)
|
||||
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
|
||||
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
|
||||
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
|
||||
|
||||
@@ -9,21 +9,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
# isort: off
|
||||
from packages.domain.noise_reduction_config import (
|
||||
NoiseReductionConfig,
|
||||
NoiseReductionLevel, # noqa: F401
|
||||
NoiseReductionLevel,
|
||||
)
|
||||
from packages.domain.noise_reduction_config import (
|
||||
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
|
||||
)
|
||||
from packages.domain.noise_reduction_config import (
|
||||
build_afftdn_filter as _build_afftdn_filter_base,
|
||||
) # noqa: F401 — 向后兼容
|
||||
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
|
||||
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
|
||||
|
||||
# isort: on
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -18,13 +18,22 @@ from pathlib import Path
|
||||
|
||||
# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
|
||||
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
|
||||
from packages.domain.pip_config import PiPConfig # noqa: F401
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SCALE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
POSITION_BOTTOM_LEFT,
|
||||
POSITION_BOTTOM_RIGHT,
|
||||
POSITION_CENTER,
|
||||
POSITION_CENTER_LEFT,
|
||||
POSITION_CENTER_RIGHT,
|
||||
POSITION_TOP_CENTER,
|
||||
POSITION_TOP_LEFT,
|
||||
POSITION_TOP_RIGHT,
|
||||
PiPConfig,
|
||||
PiPLayerConfig,
|
||||
)
|
||||
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
|
||||
|
||||
@@ -13,6 +13,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.ass_subtitle_builder import (
|
||||
TITLE_MARGIN_BOTTOM,
|
||||
TITLE_MARGIN_SIDE,
|
||||
TITLE_MARGIN_TOP,
|
||||
build_ass_content,
|
||||
)
|
||||
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
|
||||
|
||||
@@ -14,20 +14,16 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# isort: off
|
||||
from packages.domain.sticker_config import (
|
||||
POSITION_PRESETS, # noqa: F401
|
||||
STICKER_CATEGORIES, # noqa: F401
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
ImageStickerConfig,
|
||||
StickerOverlayResult,
|
||||
TextStickerConfig,
|
||||
)
|
||||
from packages.domain.sticker_config import (
|
||||
get_sticker_categories as _get_sticker_categories_base,
|
||||
) # noqa: F401 向后兼容导出
|
||||
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
|
||||
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
|
||||
from packages.domain.sticker_config import (
|
||||
# isort: on
|
||||
resolve_sticker_position,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,22 +25,28 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
from packages.domain.subtitle_style import (
|
||||
ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_MAX_CHARS_PER_LINE,
|
||||
DEFAULT_POSITION,
|
||||
DEFAULT_STROKE_COLOR,
|
||||
DEFAULT_STROKE_WIDTH,
|
||||
POSITION_ALIASES,
|
||||
POSITION_ALIGNMENT,
|
||||
SubtitleSegment,
|
||||
SubtitleStyle,
|
||||
)
|
||||
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
|
||||
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
|
||||
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr # noqa: F401
|
||||
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color # noqa: F401
|
||||
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha # noqa: F401
|
||||
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
|
||||
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
|
||||
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
|
||||
from packages.domain.subtitle_style import wrap_text as _wrap_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,14 +15,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.trim_config import MIN_TRIM_DURATION # noqa: F401
|
||||
from packages.domain.trim_config import extract_trim_from_clip_config # noqa: F401
|
||||
from packages.domain.trim_config import (
|
||||
MIN_TRIM_DURATION,
|
||||
TrimConfig,
|
||||
TrimSegment,
|
||||
)
|
||||
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
|
||||
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
|
||||
from packages.domain.trim_config import (
|
||||
extract_trim_from_clip_config,
|
||||
)
|
||||
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
|
||||
from packages.domain.trim_config import resolve_segments as _resolve_segments
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
|
||||
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
|
||||
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
|
||||
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
|
||||
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.watermark_config import WATERMARK_POSITIONS # noqa: F401
|
||||
from packages.domain.watermark_config import (
|
||||
WATERMARK_POSITIONS,
|
||||
WatermarkConfig,
|
||||
)
|
||||
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
|
||||
|
||||
@@ -186,7 +186,7 @@ class PiPConfig:
|
||||
"""最大 z_index."""
|
||||
if not self.layers:
|
||||
return 0
|
||||
return max(layer.z_index for layer in self.layers)
|
||||
return max(l.z_index for l in self.layers)
|
||||
|
||||
|
||||
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestConstants:
|
||||
|
||||
def test_preset_params_complete(self):
|
||||
assert set(PRESET_PARAMS.keys()) == VALID_PRESETS
|
||||
for _preset, params in PRESET_PARAMS.items():
|
||||
for preset, params in PRESET_PARAMS.items():
|
||||
assert set(params.keys()) == set(ALL_PARAM_KEYS)
|
||||
|
||||
def test_default_params_keys(self):
|
||||
@@ -54,7 +54,7 @@ class TestConstants:
|
||||
assert min_val <= DEFAULT_PARAMS[key] <= max_val
|
||||
|
||||
def test_all_presets_within_ranges(self):
|
||||
for _preset, params in PRESET_PARAMS.items():
|
||||
for preset, params in PRESET_PARAMS.items():
|
||||
for key in ALL_PARAM_KEYS:
|
||||
min_val, max_val = PARAM_RANGES[key]
|
||||
assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]} out of range"
|
||||
|
||||
@@ -208,7 +208,7 @@ class TestPiPConfigFromDict:
|
||||
}
|
||||
)
|
||||
assert cfg.layer_count == 3
|
||||
assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"]
|
||||
assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"]
|
||||
|
||||
def test_invalid_layer_skipped(self):
|
||||
cfg = PiPConfig.from_dict(
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestConstants:
|
||||
assert len(POSITION_PRESETS) == 9
|
||||
|
||||
def test_position_presets_normalized(self):
|
||||
for _name, (x, y) in POSITION_PRESETS.items():
|
||||
for name, (x, y) in POSITION_PRESETS.items():
|
||||
assert 0.0 <= x <= 1.0
|
||||
assert 0.0 <= y <= 1.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user