From bc37ce2ee71db92ececa7d90065e29ef25117118 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 15:28:17 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(test):=20=E4=BF=AE=E5=A4=8Dtest=5Fagent?= =?UTF-8?q?=5Fdocs.py=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8CUnit=20Tests=20CI=E4=BB=8Eapps/api=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B9=9F=E8=83=BD=E9=80=9A=E8=BF=87=20(#989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/services/ai_service.py | 1 - apps/worker/video_processing/chroma_key_engine.py | 5 ++--- apps/worker/video_processing/color_grade_engine.py | 14 -------------- apps/worker/video_processing/ffmpeg_utils.py | 8 +++----- .../video_processing/noise_reduction_engine.py | 9 +++++++-- apps/worker/video_processing/pip_engine.py | 11 +---------- apps/worker/video_processing/render_subtitles.py | 3 --- apps/worker/video_processing/sticker_engine.py | 10 +++++++--- .../video_processing/subtitle_render_engine.py | 14 ++++---------- apps/worker/video_processing/trim_engine.py | 6 ++---- .../video_processing/unified_render_service.py | 1 - apps/worker/video_processing/watermark_engine.py | 3 +-- packages/domain/pip_config.py | 2 +- packages/domain/subtitle_style.py | 2 +- packages/domain/trim_config.py | 2 +- packages/domain/watermark_config.py | 2 +- tests/unit/test_color_grade_config.py | 4 ++-- tests/unit/test_pip_config.py | 2 +- tests/unit/test_sticker_config.py | 2 +- 19 files changed, 35 insertions(+), 66 deletions(-) diff --git a/apps/api/app/services/ai_service.py b/apps/api/app/services/ai_service.py index 13e465b2f..6e1291a11 100755 --- a/apps/api/app/services/ai_service.py +++ b/apps/api/app/services/ai_service.py @@ -12,7 +12,6 @@ from __future__ import annotations -import json import logging from typing import Any, Dict, List, Optional diff --git a/apps/worker/video_processing/chroma_key_engine.py b/apps/worker/video_processing/chroma_key_engine.py index 7499b2f61..d95df928f 100755 --- a/apps/worker/video_processing/chroma_key_engine.py +++ b/apps/worker/video_processing/chroma_key_engine.py @@ -9,12 +9,11 @@ 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, diff --git a/apps/worker/video_processing/color_grade_engine.py b/apps/worker/video_processing/color_grade_engine.py index da5b44d8d..9fb367373 100755 --- a/apps/worker/video_processing/color_grade_engine.py +++ b/apps/worker/video_processing/color_grade_engine.py @@ -195,17 +195,3 @@ 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) diff --git a/apps/worker/video_processing/ffmpeg_utils.py b/apps/worker/video_processing/ffmpeg_utils.py index 6cac1228b..9436c489f 100755 --- a/apps/worker/video_processing/ffmpeg_utils.py +++ b/apps/worker/video_processing/ffmpeg_utils.py @@ -22,11 +22,9 @@ 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, - XFADE_TRANSITION_MAP, - XFade_TRANSITION_NAMES, -) +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 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 diff --git a/apps/worker/video_processing/noise_reduction_engine.py b/apps/worker/video_processing/noise_reduction_engine.py index e55220d62..e8db5bb64 100755 --- a/apps/worker/video_processing/noise_reduction_engine.py +++ b/apps/worker/video_processing/noise_reduction_engine.py @@ -9,16 +9,21 @@ from __future__ import annotations import logging +# isort: off from packages.domain.noise_reduction_config import ( NoiseReductionConfig, - NoiseReductionLevel, + NoiseReductionLevel, # noqa: F401 ) 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__) diff --git a/apps/worker/video_processing/pip_engine.py b/apps/worker/video_processing/pip_engine.py index 74733853a..e4e2d3feb 100755 --- a/apps/worker/video_processing/pip_engine.py +++ b/apps/worker/video_processing/pip_engine.py @@ -18,22 +18,13 @@ 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 — 向后兼容:保留模块级导出 diff --git a/apps/worker/video_processing/render_subtitles.py b/apps/worker/video_processing/render_subtitles.py index 9b8f58e63..06c780463 100755 --- a/apps/worker/video_processing/render_subtitles.py +++ b/apps/worker/video_processing/render_subtitles.py @@ -13,9 +13,6 @@ 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 — 向后兼容 diff --git a/apps/worker/video_processing/sticker_engine.py b/apps/worker/video_processing/sticker_engine.py index 131287b0e..148c88963 100755 --- a/apps/worker/video_processing/sticker_engine.py +++ b/apps/worker/video_processing/sticker_engine.py @@ -14,16 +14,20 @@ import logging from pathlib import Path from typing import Any +# isort: off from packages.domain.sticker_config import ( - POSITION_PRESETS, - STICKER_CATEGORIES, + POSITION_PRESETS, # noqa: F401 + STICKER_CATEGORIES, # noqa: F401 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, ) diff --git a/apps/worker/video_processing/subtitle_render_engine.py b/apps/worker/video_processing/subtitle_render_engine.py index a227205aa..f37f8ba6f 100755 --- a/apps/worker/video_processing/subtitle_render_engine.py +++ b/apps/worker/video_processing/subtitle_render_engine.py @@ -25,28 +25,22 @@ 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 -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 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 wrap_text as _wrap_text logger = logging.getLogger(__name__) diff --git a/apps/worker/video_processing/trim_engine.py b/apps/worker/video_processing/trim_engine.py index 5e3a78ec6..6b9417de5 100755 --- a/apps/worker/video_processing/trim_engine.py +++ b/apps/worker/video_processing/trim_engine.py @@ -15,16 +15,14 @@ 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 diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 3a35dda9a..c8f6229c8 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -53,7 +53,6 @@ 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 diff --git a/apps/worker/video_processing/watermark_engine.py b/apps/worker/video_processing/watermark_engine.py index 797571b33..c92f3bb6a 100755 --- a/apps/worker/video_processing/watermark_engine.py +++ b/apps/worker/video_processing/watermark_engine.py @@ -14,10 +14,9 @@ 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 — 向后兼容 diff --git a/packages/domain/pip_config.py b/packages/domain/pip_config.py index e843504cb..522d696a9 100755 --- a/packages/domain/pip_config.py +++ b/packages/domain/pip_config.py @@ -186,7 +186,7 @@ class PiPConfig: """最大 z_index.""" if not self.layers: return 0 - return max(l.z_index for l in self.layers) + return max(layer.z_index for layer in self.layers) # ── 纯逻辑工具函数 ─────────────────────────────────────────────────────────── diff --git a/packages/domain/subtitle_style.py b/packages/domain/subtitle_style.py index 481e0d83c..293f684ab 100755 --- a/packages/domain/subtitle_style.py +++ b/packages/domain/subtitle_style.py @@ -6,7 +6,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any # ── 常量 ────────────────────────────────────────────────────────────────────── diff --git a/packages/domain/trim_config.py b/packages/domain/trim_config.py index 2cf912390..dee801748 100755 --- a/packages/domain/trim_config.py +++ b/packages/domain/trim_config.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) diff --git a/packages/domain/watermark_config.py b/packages/domain/watermark_config.py index 5aa05b47d..3373b317b 100755 --- a/packages/domain/watermark_config.py +++ b/packages/domain/watermark_config.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) diff --git a/tests/unit/test_color_grade_config.py b/tests/unit/test_color_grade_config.py index 0bc89a985..0af8a336d 100755 --- a/tests/unit/test_color_grade_config.py +++ b/tests/unit/test_color_grade_config.py @@ -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" diff --git a/tests/unit/test_pip_config.py b/tests/unit/test_pip_config.py index 6249b0e33..c2deafd2d 100755 --- a/tests/unit/test_pip_config.py +++ b/tests/unit/test_pip_config.py @@ -208,7 +208,7 @@ class TestPiPConfigFromDict: } ) assert cfg.layer_count == 3 - assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"] + assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"] def test_invalid_layer_skipped(self): cfg = PiPConfig.from_dict( diff --git a/tests/unit/test_sticker_config.py b/tests/unit/test_sticker_config.py index b42b2ea6b..e6048abc9 100755 --- a/tests/unit/test_sticker_config.py +++ b/tests/unit/test_sticker_config.py @@ -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 From 20b593dcf8f228f9cdb4f91d5107924b4bd06100 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 15:28:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(editing-planner):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20CoverSelector=20=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=8A=BD=E7=A6=BB=20Hook=20+=20=E6=A8=A1=E5=BC=8F=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CoverSelector.tsx | 287 +----------------- .../cover-selector/CoverModePanels.tsx | 143 +++++++++ .../components/cover-selector/index.tsx | 146 +++++++++ .../cover-selector/useCoverSelector.ts | 98 ++++++ 4 files changed, 390 insertions(+), 284 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx create mode 100644 apps/web/src/pages/editing-planner/components/cover-selector/index.tsx create mode 100644 apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts diff --git a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx index 7c1784175..f4c64ef26 100644 --- a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx @@ -1,286 +1,5 @@ /** - * 封面选择器 - * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + * 封面选择器入口(向后兼容) + * 实际实现位于 ./cover-selector/ 目录 */ -import React, { useCallback, useRef, useState } from "react" -import { Drawer } from "antd" -import type { CoverConfig, CoverMode } from "../types" -import { DEFAULT_COVER_CONFIG } from "../types" - -interface CoverSelectorProps { - open: boolean - onClose: () => void - config: CoverConfig - onChange: (config: CoverConfig) => void - totalDuration: number -} - -/** 封面模式标签 */ -const MODE_LABELS: Record = { - auto: "智能封面", - frame: "抽帧选封面", - upload: "上传封面", -} - -/** 封面模式图标 */ -const MODE_ICONS: Record = { - auto: "🤖", - frame: "🎞️", - upload: "📤", -} - -const CoverSelector: React.FC = ({ - open, - onClose, - config, - onChange, - totalDuration, -}) => { - const fileInputRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - - const update = useCallback( - (partial: Partial) => { - onChange({ ...config, ...partial }) - }, - [config, onChange], - ) - - const handleReset = useCallback(() => { - onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }) - }, [config.enabled, onChange]) - - /** 切换模式 */ - const handleModeChange = useCallback( - (mode: CoverMode) => { - update({ mode }) - }, - [update], - ) - - /** 处理文件上传 */ - const handleFileUpload = useCallback( - (file: File) => { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (e) => { - const url = e.target?.result as string - update({ upload_url: url, thumbnail_url: url, mode: "upload" }) - } - reader.readAsDataURL(file) - }, - [update], - ) - - /** 拖拽上传 */ - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - const file = e.dataTransfer.files[0] - if (file) handleFileUpload(file) - }, - [handleFileUpload], - ) - - /** 使用 AI 推荐时间 */ - const handleUseAiSuggestion = useCallback(() => { - if (config.ai_suggested_time !== null) { - update({ frame_time: config.ai_suggested_time, mode: "frame" }) - } - }, [config.ai_suggested_time, update]) - - /** 格式化时间 */ - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - const ms = Math.floor((seconds % 1) * 10) - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` - } - - return ( - - {/* 顶部开关 */} -
- 启用自定义封面 - -
- - {/* 模式选择 */} -
-
封面来源
-
- {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( - - ))} -
-
- - {/* 模式内容区 */} -
- {/* 智能封面 */} - {config.mode === "auto" && ( -
-
- AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 -
- {config.ai_suggested_time !== null ? ( -
-
AI 推荐
-
- 推荐时间点:{formatTime(config.ai_suggested_time)} -
- -
- ) : ( -
-
- AI 分析中...(生成视频后自动推荐) -
- )} -
- )} - - {/* 抽帧选封面 */} - {config.mode === "frame" && ( -
-
-
- 🎞️ - {formatTime(config.frame_time)} -
-
-
-
- 拖动选择封面帧 - {formatTime(config.frame_time)} -
- update({ frame_time: Number(e.target.value) })} - /> -
- 00:00 - {formatTime(totalDuration)} -
-
- {/* 快捷时间点 */} -
- 快捷选帧: - {[0, 0.25, 0.5, 0.75].map((ratio) => { - const t = totalDuration * ratio - return ( - - ) - })} -
-
- )} - - {/* 上传封面 */} - {config.mode === "upload" && ( -
-
{ - e.preventDefault() - setIsDragging(true) - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - > - {config.upload_url ? ( -
- 封面预览 -
点击更换
-
- ) : ( -
- 📤 - 点击或拖拽上传封面图片 - 支持 JPG / PNG,建议 16:9 比例 -
- )} - { - const file = e.target.files?.[0] - if (file) handleFileUpload(file) - }} - /> -
-
- )} -
- - {/* 封面预览 */} -
-
封面预览
-
- {config.upload_url ? ( - 封面预览 - ) : ( -
- 🖼️ - - {config.mode === "auto" - ? "AI 智能选择" - : config.mode === "frame" - ? `帧 ${formatTime(config.frame_time)}` - : "未上传封面"} - -
- )} -
16:9
-
-
- - {/* 底部 */} -
- -
- - ) -} - -export default CoverSelector +export { default } from "./cover-selector" diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx b/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx new file mode 100644 index 000000000..d13dd5072 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/CoverModePanels.tsx @@ -0,0 +1,143 @@ +import React from "react" +import type { CoverConfig } from "../../types" + +interface CoverAutoModeProps { + config: CoverConfig + formatTime: (s: number) => string + onUseAiSuggestion: () => void +} + +/** 智能封面模式面板 */ +export const CoverAutoMode: React.FC = ({ + config, + formatTime, + onUseAiSuggestion, +}) => ( +
+
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
+ {config.ai_suggested_time !== null ? ( +
+
AI 推荐
+
推荐时间点:{formatTime(config.ai_suggested_time)}
+ +
+ ) : ( +
+
+ AI 分析中...(生成视频后自动推荐) +
+ )} +
+) + +interface CoverFrameModeProps { + config: CoverConfig + totalDuration: number + formatTime: (s: number) => string + onFrameTimeChange: (time: number) => void +} + +/** 抽帧选封面模式面板 */ +export const CoverFrameMode: React.FC = ({ + config, + totalDuration, + formatTime, + onFrameTimeChange, +}) => ( +
+
+
+ 🎞️ + {formatTime(config.frame_time)} +
+
+
+
+ 拖动选择封面帧 + {formatTime(config.frame_time)} +
+ onFrameTimeChange(Number(e.target.value))} + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+
+ 快捷选帧: + {[0, 0.25, 0.5, 0.75].map((ratio) => { + const t = totalDuration * ratio + return ( + + ) + })} +
+
+) + +interface CoverUploadModeProps { + config: CoverConfig + isDragging: boolean + fileInputRef: React.RefObject + onDragOver: (e: React.DragEvent) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent) => void + onAreaClick: () => void + onFileChange: (file: File) => void +} + +/** 上传封面模式面板 */ +export const CoverUploadMode: React.FC = ({ + config, + isDragging, + fileInputRef, + onDragOver, + onDragLeave, + onDrop, + onAreaClick, + onFileChange, +}) => ( +
+
+ {config.upload_url ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + 点击或拖拽上传封面图片 + 支持 JPG / PNG,建议 16:9 比例 +
+ )} + { + const file = e.target.files?.[0] + if (file) onFileChange(file) + }} + /> +
+
+) diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx b/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx new file mode 100644 index 000000000..6bb5a6d96 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/index.tsx @@ -0,0 +1,146 @@ +/** + * 封面选择器 + * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + */ +import React from "react" +import { Drawer } from "antd" +import type { CoverConfig, CoverMode } from "../../types" +import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector" +import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels" + +interface CoverSelectorProps { + open: boolean + onClose: () => void + config: CoverConfig + onChange: (config: CoverConfig) => void + totalDuration: number +} + +const CoverSelector: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const { + fileInputRef, + isDragging, + setIsDragging, + update, + handleReset, + handleModeChange, + handleFileUpload, + handleDrop, + handleUseAiSuggestion, + formatTime, + } = useCoverSelector({ config, onChange }) + + return ( + + {/* 顶部开关 */} +
+ 启用自定义封面 + +
+ + {/* 模式选择 */} +
+
封面来源
+
+ {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( + + ))} +
+
+ + {/* 模式内容区 */} +
+ {config.mode === "auto" && ( + + )} + + {config.mode === "frame" && ( + update({ frame_time: t })} + /> + )} + + {config.mode === "upload" && ( + { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onAreaClick={() => fileInputRef.current?.click()} + onFileChange={handleFileUpload} + /> + )} +
+ + {/* 封面预览 */} +
+
封面预览
+
+ {config.upload_url ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {config.mode === "auto" + ? "AI 智能选择" + : config.mode === "frame" + ? `帧 ${formatTime(config.frame_time)}` + : "未上传封面"} + +
+ )} +
16:9
+
+
+ + {/* 底部 */} +
+ +
+
+ ) +} + +export default CoverSelector diff --git a/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts b/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts new file mode 100644 index 000000000..dbb649e22 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/cover-selector/useCoverSelector.ts @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState } from "react" +import type { CoverConfig, CoverMode } from "../../types" +import { DEFAULT_COVER_CONFIG } from "../../types" + +/** 封面模式标签 */ +export const MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +} + +/** 封面模式图标 */ +export const MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +} + +interface UseCoverSelectorOptions { + config: CoverConfig + onChange: (config: CoverConfig) => void +} + +/** + * 封面选择器 Hook + * 封装状态管理、文件上传、模式切换等逻辑 + */ +export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) { + const fileInputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }) + }, + [config, onChange], + ) + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }) + }, [config.enabled, onChange]) + + const handleModeChange = useCallback( + (mode: CoverMode) => { + update({ mode }) + }, + [update], + ) + + const handleFileUpload = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => { + const url = e.target?.result as string + update({ upload_url: url, thumbnail_url: url, mode: "upload" }) + } + reader.readAsDataURL(file) + }, + [update], + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFileUpload(file) + }, + [handleFileUpload], + ) + + const handleUseAiSuggestion = useCallback(() => { + if (config.ai_suggested_time !== null) { + update({ frame_time: config.ai_suggested_time, mode: "frame" }) + } + }, [config.ai_suggested_time, update]) + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + const ms = Math.floor((seconds % 1) * 10) + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` + } + + return { + fileInputRef, + isDragging, + setIsDragging, + update, + handleReset, + handleModeChange, + handleFileUpload, + handleDrop, + handleUseAiSuggestion, + formatTime, + } +} From 1d4c7d6aea1476b1a96da6bbc74f89396a6007e2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 15:28:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(editing-planner):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20LayerConfig=20=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E6=8B=86=E4=B8=BA=203=20=E4=B8=AA=E5=AD=90?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20(#1015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pip-config/LayerConfig.tsx | 287 +----------------- .../layer-config/LayerPositionSize.tsx | 172 +++++++++++ .../layer-config/LayerTimingAnimation.tsx | 87 ++++++ .../pip-config/layer-config/PipPreview.tsx | 33 ++ .../pip-config/layer-config/index.tsx | 57 ++++ 5 files changed, 352 insertions(+), 284 deletions(-) mode change 100755 => 100644 apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx diff --git a/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx old mode 100755 new mode 100644 index 2fb7842c5..7800c4f8c --- a/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx @@ -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) => void - onGridClick: (pos: PipGridPosition) => void - onWidthChange: (val: number) => void - onHeightChange: (val: number) => void -} - -const LayerConfig: React.FC = ({ - layer, - layers, - totalDuration, - onUpdate, - onGridClick, - onWidthChange, - onHeightChange, -}) => { - if (!layer) { - return ( -
-
选择或添加图层以配置
-
- ) - } - - return ( -
- {/* ── 迷你预览 ── */} -
- {layers.map((l, idx) => ( -
- {l.name} -
- ))} -
- - {/* ── 素材类型 ── */} -
- -
- - -
-
- - {/* ── 素材 URL ── */} -
- - onUpdate(layer.id, { material_url: e.target.value })} - /> -
- - {/* ── 位置:九宫格 + 坐标 ── */} -
- -
-
- {GRID_POSITIONS.map((pos) => ( - - ))} -
-
-
- - onUpdate(layer.id, { x: Number(e.target.value) })} - /> -
-
- - onUpdate(layer.id, { y: Number(e.target.value) })} - /> -
-
-
-
- - {/* ── 尺寸 ── */} -
- -
- - onWidthChange(Number(e.target.value))} - /> - {layer.width}% -
-
- - onHeightChange(Number(e.target.value))} - /> - {layer.height}% -
-
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} - > - {layer.aspect_lock ? "🔒" : "🔓"} - {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} -
-
- - {/* ── 圆角 ── */} -
- -
- onUpdate(layer.id, { border_radius: Number(e.target.value) })} - /> - {layer.border_radius}% -
-
- - {/* ── 透明度 ── */} -
- -
- onUpdate(layer.id, { opacity: Number(e.target.value) })} - /> - {layer.opacity}% -
-
- - {/* ── 时间 ── */} -
- -
-
- - onUpdate(layer.id, { start_time: Number(e.target.value) })} - /> -
-
- - onUpdate(layer.id, { duration: Number(e.target.value) })} - /> -
-
-
- - {/* ── 入场动画 ── */} -
- - -
- - {/* 滑入方向(仅 slide_in 时显示) */} - {layer.animation === "slide_in" && ( -
- - -
- )} -
- ) -} - -export default LayerConfig +export { default } from "./layer-config" diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx new file mode 100644 index 000000000..6c6eb10a6 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerPositionSize.tsx @@ -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) => void + onGridClick: (pos: PipGridPosition) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +/** + * 图层位置与尺寸配置面板 + */ +export const LayerPositionSize: React.FC = ({ + layer, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => ( + <> + {/* 素材类型 */} +
+ +
+ + +
+
+ + {/* 素材 URL */} +
+ + onUpdate(layer.id, { material_url: e.target.value })} + /> +
+ + {/* 位置:九宫格 + 坐标 */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + onUpdate(layer.id, { x: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { y: Number(e.target.value) })} + /> +
+
+
+
+ + {/* 尺寸 */} +
+ +
+ + onWidthChange(Number(e.target.value))} + /> + {layer.width}% +
+
+ + onHeightChange(Number(e.target.value))} + /> + {layer.height}% +
+
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} + > + {layer.aspect_lock ? "🔒" : "🔓"} + {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} +
+
+ + {/* 圆角 */} +
+ +
+ onUpdate(layer.id, { border_radius: Number(e.target.value) })} + /> + {layer.border_radius}% +
+
+ + {/* 透明度 */} +
+ +
+ onUpdate(layer.id, { opacity: Number(e.target.value) })} + /> + {layer.opacity}% +
+
+ +) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx new file mode 100644 index 000000000..a9138f5bc --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/LayerTimingAnimation.tsx @@ -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) => void +} + +/** + * 图层时间与动画配置面板 + */ +export const LayerTimingAnimation: React.FC = ({ + layer, + totalDuration, + onUpdate, +}) => ( + <> + {/* 时间 */} +
+ +
+
+ + onUpdate(layer.id, { start_time: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { duration: Number(e.target.value) })} + /> +
+
+
+ + {/* 入场动画 */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {layer.animation === "slide_in" && ( +
+ + +
+ )} + +) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx new file mode 100644 index 000000000..6f4356eec --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/PipPreview.tsx @@ -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 = ({ layers, selectedId }) => ( +
+ {layers.map((l, idx) => ( +
+ {l.name} +
+ ))} +
+) diff --git a/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx new file mode 100644 index 000000000..4f522e8cb --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/layer-config/index.tsx @@ -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) => void + onGridClick: (pos: PipGridPosition) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +const LayerConfig: React.FC = ({ + layer, + layers, + totalDuration, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => { + if (!layer) { + return ( +
+
选择或添加图层以配置
+
+ ) + } + + return ( +
+ {/* 迷你预览 */} + + + {/* 位置与尺寸 */} + + + {/* 时间与动画 */} + +
+ ) +} + +export default LayerConfig From b4bde72b44729c02fda59a87be4946a3513825f1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 15:28:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(editing-planner):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20ClipDetailSection=EF=BC=8C=E6=8C=89=E8=81=8C?= =?UTF-8?q?=E8=B4=A3=E6=8B=86=E4=B8=BA=203=20=E4=B8=AA=E5=AD=90=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=20(#1018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clip-properties/ClipDetailSection.tsx | 267 +----------------- .../clip-detail-section/AdvancedEntries.tsx | 85 ++++++ .../ClipTypeAndDuration.tsx | 69 +++++ .../VoiceMaterialSection.tsx | 132 +++++++++ .../clip-detail-section/index.tsx | 78 +++++ 5 files changed, 367 insertions(+), 264 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx index f2a48dbe9..af9aa97d3 100644 --- a/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx +++ b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx @@ -1,266 +1,5 @@ /** - * 片段详情区块 + * ClipDetailSection 入口(向后兼容) + * 实际实现位于 ./clip-detail-section/ 目录 */ -import React from "react" -import { useNavigate } from "react-router-dom" -import { TRANSITION_OPTIONS } from "@/api/template-editor" -import type { ClipData, ClipType } from "@/pages/editing-planner/types" -import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties" -import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties" -import type { AssetItem } from "@/api/assets" -import type { TemplateMode } from "@/api/editing-planner" - -interface ClipDetailSectionProps { - clip: ClipData - currentMode: TemplateMode - voiceMaterials?: AssetItem[] - voiceMaterialsLoading?: boolean - onClipUpdate: (clipId: string, data: Partial) => void - onRefreshVoiceMaterials?: () => void - onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void - onOpenTransitionDrawer?: (clipId: string) => void - onOpenSpeedDrawer?: (clipId: string) => void - onOpenTtsDrawer?: (clipId: string) => void - previewingId: string | null - onPreviewVoice: (asset: AssetItem) => void - onStopPreview: () => void -} - -const ClipDetailSection: React.FC = ({ - clip, - currentMode, - voiceMaterials = [], - voiceMaterialsLoading = false, - onClipUpdate, - onRefreshVoiceMaterials, - onClipVoiceSelect, - onOpenTransitionDrawer, - onOpenSpeedDrawer, - onOpenTtsDrawer, - previewingId, - onPreviewVoice, - onStopPreview, -}) => { - const navigate = useNavigate() - - const isTypeDisabled = (t: ClipType) => { - if (currentMode === "pip") return t !== "pip" - if (currentMode === "voice_over") return t !== "voice" - return false - } - - const transitionLabel = (() => { - const t = clip.transition - if (!t || t.type === "none") return "无转场" - const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type) - return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s` - })() - - const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x" - - const ttsLabel = (() => { - const tts = clip.tts_config - if (!tts || tts.mode === "none") return "无配音" - if (tts.mode === "upload") return "上传配音" - return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` - })() - - return ( -
-
- 🎞️ - 片段详情 -
- -
- {/* 类型选择器 */} -
-
类型
-
- {(["voice", "pip"] as ClipType[]).map((t) => { - const disabled = isTypeDisabled(t) - return ( - - ) - })} -
-
- - {/* 时长 */} -
-
时长
-
- - onClipUpdate(clip.id, { - duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), - }) - } - /> - -
-
- - {/* 转场效果入口 */} - {onOpenTransitionDrawer && ( -
- -
- )} - - {/* 播放速度入口 */} - {onOpenSpeedDrawer && ( -
- -
- )} - - {/* TTS 配音入口 */} - {onOpenTtsDrawer && ( -
- -
- )} - - {/* 素材起始时间 — 仅 voice 类型显示 */} - {clip.type === "voice" && ( -
-
素材起始时间
-
- - onClipUpdate(clip.id, { - startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), - }) - } - /> - -
-
- )} - - {/* 配音素材选择 — 仅 voice 类型显示 */} - {clip.type === "voice" && ( -
-
- 配音素材 - {onRefreshVoiceMaterials && ( - - )} -
- - {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( -
加载中...
- ) : ( - <> -
- - {clip.voice_asset_id && ( - - )} -
- {voiceMaterials.length === 0 && ( -
暂无配音素材,请先上传
- )} - - )} - - -
- )} -
-
- ) -} - -export default ClipDetailSection +export { default } from "./clip-detail-section" diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx new file mode 100644 index 000000000..0ab96c7be --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/AdvancedEntries.tsx @@ -0,0 +1,85 @@ +import React from "react" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import type { ClipData } from "@/pages/editing-planner/types" + +interface AdvancedEntriesProps { + clip: ClipData + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void +} + +/** + * 高级功能入口按钮(转场/调速/TTS) + */ +export const AdvancedEntries: React.FC = ({ + clip, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, +}) => { + const transitionLabel = (() => { + const t = clip.transition + if (!t || t.type === "none") return "无转场" + const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type) + return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s` + })() + + const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x" + + const ttsLabel = (() => { + const tts = clip.tts_config + if (!tts || tts.mode === "none") return "无配音" + if (tts.mode === "upload") return "上传配音" + return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` + })() + + return ( + <> + {/* 转场效果入口 */} + {onOpenTransitionDrawer && ( +
+ +
+ )} + + {/* 播放速度入口 */} + {onOpenSpeedDrawer && ( +
+ +
+ )} + + {/* TTS 配音入口 */} + {onOpenTtsDrawer && ( +
+ +
+ )} + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx new file mode 100644 index 000000000..af558e09a --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/ClipTypeAndDuration.tsx @@ -0,0 +1,69 @@ +import React from "react" +import type { ClipData, ClipType } from "@/pages/editing-planner/types" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties" +import type { TemplateMode } from "@/api/editing-planner" + +interface ClipTypeAndDurationProps { + clip: ClipData + currentMode: TemplateMode + onClipUpdate: (clipId: string, data: Partial) => void +} + +/** + * 片段类型选择 + 时长设置 + */ +export const ClipTypeAndDuration: React.FC = ({ + clip, + currentMode, + onClipUpdate, +}) => { + const isTypeDisabled = (t: ClipType) => { + if (currentMode === "pip") return t !== "pip" + if (currentMode === "voice_over") return t !== "voice" + return false + } + + return ( + <> + {/* 类型选择器 */} +
+
类型
+
+ {(["voice", "pip"] as ClipType[]).map((t) => { + const disabled = isTypeDisabled(t) + return ( + + ) + })} +
+
+ + {/* 时长 */} +
+
时长
+
+ + onClipUpdate(clip.id, { + duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), + }) + } + /> + +
+
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx new file mode 100644 index 000000000..9b77a1624 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/VoiceMaterialSection.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { useNavigate } from "react-router-dom" +import type { ClipData } from "@/pages/editing-planner/types" +import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties" +import type { AssetItem } from "@/api/assets" + +interface VoiceMaterialSectionProps { + clip: ClipData + voiceMaterials: AssetItem[] + voiceMaterialsLoading: boolean + onClipUpdate: (clipId: string, data: Partial) => void + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + previewingId: string | null + onPreviewVoice: (asset: AssetItem) => void + onStopPreview: () => void +} + +/** + * 配音素材选择区(仅 voice 类型显示) + */ +export const VoiceMaterialSection: React.FC = ({ + clip, + voiceMaterials, + voiceMaterialsLoading, + onClipUpdate, + onRefreshVoiceMaterials, + onClipVoiceSelect, + previewingId, + onPreviewVoice, + onStopPreview, +}) => { + const navigate = useNavigate() + + return ( + <> + {/* 素材起始时间 */} +
+
素材起始时间
+
+ + onClipUpdate(clip.id, { + startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), + }) + } + /> + +
+
+ + {/* 配音素材选择 */} +
+
+ 配音素材 + {onRefreshVoiceMaterials && ( + + )} +
+ + {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( +
加载中...
+ ) : ( + <> +
+ + {clip.voice_asset_id && ( + + )} +
+ {voiceMaterials.length === 0 && ( +
暂无配音素材,请先上传
+ )} + + )} + + +
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx new file mode 100644 index 000000000..5ba4ea27e --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/clip-detail-section/index.tsx @@ -0,0 +1,78 @@ +/** + * 片段详情区块 + */ +import React from "react" +import type { ClipData } from "@/pages/editing-planner/types" +import type { AssetItem } from "@/api/assets" +import type { TemplateMode } from "@/api/editing-planner" +import { ClipTypeAndDuration } from "./ClipTypeAndDuration" +import { AdvancedEntries } from "./AdvancedEntries" +import { VoiceMaterialSection } from "./VoiceMaterialSection" + +interface ClipDetailSectionProps { + clip: ClipData + currentMode: TemplateMode + voiceMaterials?: AssetItem[] + voiceMaterialsLoading?: boolean + onClipUpdate: (clipId: string, data: Partial) => void + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void + previewingId: string | null + onPreviewVoice: (asset: AssetItem) => void + onStopPreview: () => void +} + +const ClipDetailSection: React.FC = ({ + clip, + currentMode, + voiceMaterials = [], + voiceMaterialsLoading = false, + onClipUpdate, + onRefreshVoiceMaterials, + onClipVoiceSelect, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, + previewingId, + onPreviewVoice, + onStopPreview, +}) => { + return ( +
+
+ 🎞️ + 片段详情 +
+ +
+ + + + + {clip.type === "voice" && ( + + )} +
+
+ ) +} + +export default ClipDetailSection