Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 7ef7163463 fix: 恢复 ClipDetailSection.tsx 主入口,re-export 子目录保持路径兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m43s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 40s
AI Code Review / AI Code Review (pull_request) Successful in 1m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 12m14s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:41:39 +08:00
xiaoxia 8e2e3d69fe Merge branch 'develop' into refactor/clip-detail-section-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m1s
2026-07-27 12:34:16 +08:00
xiaoxia 8b441a3615 refactor(editing-planner): 拆分 ClipDetailSection,按职责拆为 3 个子组件
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m5s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m34s
AI Code Review / AI Code Review (pull_request) Successful in 4m4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 36s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
主文件 266→70 行(-74%),拆分为:
- ClipTypeAndDuration: 类型选择 + 时长设置
- AdvancedEntries: 转场/调速/TTS 高级入口按钮
- VoiceMaterialSection: 配音素材选择 + 起始时间(仅 voice 类型)
- index: 组合入口

导入路径保持向后兼容
2026-07-27 09:21:54 +08:00
24 changed files with 433 additions and 299 deletions
+1
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
@@ -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<ClipData>) => 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<ClipDetailSectionProps> = ({
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 (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
export { default } from "./clip-detail-section"
@@ -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<AdvancedEntriesProps> = ({
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 && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
</>
)
}
@@ -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<ClipData>) => void
}
/**
* 片段类型选择 + 时长设置
*/
export const ClipTypeAndDuration: React.FC<ClipTypeAndDurationProps> = ({
clip,
currentMode,
onClipUpdate,
}) => {
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
return (
<>
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
</>
)
}
@@ -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<ClipData>) => 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<VoiceMaterialSectionProps> = ({
clip,
voiceMaterials,
voiceMaterialsLoading,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
return (
<>
{/* 素材起始时间 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 配音素材选择 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button className="ep-voice-upload-btn" onClick={() => navigate("/app/voice-materials")}>
+
</button>
</div>
</>
)
}
@@ -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<ClipData>) => 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<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
<ClipTypeAndDuration clip={clip} currentMode={currentMode} onClipUpdate={onClipUpdate} />
<AdvancedEntries
clip={clip}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
/>
{clip.type === "voice" && (
<VoiceMaterialSection
clip={clip}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
previewingId={previewingId}
onPreviewVoice={onPreviewVoice}
onStopPreview={onStopPreview}
/>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -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)
+5 -3
View File
@@ -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__)
+10 -1
View File
@@ -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__)
+4 -2
View File
@@ -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 — 向后兼容
+1 -1
View File
@@ -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)
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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__)
+1 -1
View File
@@ -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__)
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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