Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 201a3f0af5 | |||
| 695a491c5d | |||
| 5d4e07d4f4 | |||
| a56b3f7b42 | |||
| b51fffd9b5 | |||
| d8effd8e77 | |||
| 2fb987bbee |
@@ -30,6 +30,74 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None:
|
||||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。
|
||||
|
||||
前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id
|
||||
时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。
|
||||
|
||||
Returns:
|
||||
template_id(字符串);失败时返回 None。
|
||||
"""
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
|
||||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||||
|
||||
# 1. 先查已有有效模板(is_active=True 且存在片段配置)
|
||||
existing = (
|
||||
db.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
# 验证该模板是否有片段配置;若没有继续尝试创建默认
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
|
||||
# 2. 无有效模板 → 自动创建默认配音模板
|
||||
try:
|
||||
repo = SQLAlchemyTemplateRepository(db)
|
||||
cmd = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name="默认配音模板",
|
||||
mode="voice_over",
|
||||
category="default",
|
||||
tags=[],
|
||||
title_config={},
|
||||
subtitle_config={},
|
||||
bgm_config={},
|
||||
estimated_duration=0.0,
|
||||
segments=[
|
||||
SegmentCommand(
|
||||
segment_order=0,
|
||||
duration_min=1.0,
|
||||
duration_max=30.0,
|
||||
material_type=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(repo)
|
||||
tpl = use_case.execute(cmd)
|
||||
logger.info(
|
||||
"[variant-plans] 自动创建默认模板: user=%s tpl=%s",
|
||||
user_id,
|
||||
tpl.id,
|
||||
)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
class VariantPlanRequest(BaseModel):
|
||||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||||
|
||||
@@ -43,8 +111,8 @@ class VariantPlanRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "VariantPlanRequest":
|
||||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||||
# 不再强制要求 template_id / source_edit_plan_id:
|
||||
# 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
@@ -94,8 +162,15 @@ def create_variant_plans(
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or ""
|
||||
template_id = request.template_id.strip()
|
||||
|
||||
# P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id;
|
||||
# 此时自动为该用户查找/创建默认模板。
|
||||
if not source_plan_id and not template_id:
|
||||
template_id = _get_or_create_default_template_id(db, user_id) or ""
|
||||
|
||||
if not source_plan_id and template_id:
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or ""
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -193,7 +193,23 @@ def get_lipsync_job(
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if job.status not in ("completed", "failed"):
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
# 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task
|
||||
# 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。
|
||||
from datetime import datetime as _dt
|
||||
from datetime import timezone as _tz
|
||||
|
||||
_now = _dt.now(_tz.utc)
|
||||
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
|
||||
if _stale:
|
||||
try:
|
||||
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
|
||||
if refreshed is not None:
|
||||
job = refreshed
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
else:
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -95,6 +95,49 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_default_template(
|
||||
user_id: str,
|
||||
template_repository: SQLAlchemyTemplateRepository,
|
||||
):
|
||||
"""当用户无任何有效模板时,自动创建一个默认 voice_over 模式模板。
|
||||
|
||||
前端 #1911 删除了模板选择 UI(6步→5步),改为后台自动选第一个有效模板。
|
||||
为避免新用户/无模板用户在剪辑页卡死在"独立选片中...",当 valid_only 查询
|
||||
结果为空时自动创建一条默认配音模板(含 1 个 order=0 的通用片段配置),
|
||||
让 from-assets 能正常分配片段。
|
||||
|
||||
Returns:
|
||||
创建的默认 Template 实体;创建失败返回 None。
|
||||
"""
|
||||
try:
|
||||
cmd = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name="默认配音模板",
|
||||
mode="voice_over",
|
||||
category="default",
|
||||
tags=[],
|
||||
title_config={},
|
||||
subtitle_config={},
|
||||
bgm_config={},
|
||||
estimated_duration=0.0,
|
||||
segments=[
|
||||
SegmentCommand(
|
||||
segment_order=0,
|
||||
duration_min=1.0,
|
||||
duration_max=30.0,
|
||||
material_type=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(template_repository)
|
||||
tpl = use_case.execute(cmd)
|
||||
logger.info("[template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id)
|
||||
return tpl
|
||||
except Exception:
|
||||
logger.exception("自动创建默认模板失败: user_id=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
|
||||
@@ -127,6 +170,14 @@ def list_templates(
|
||||
count_use_case = CountTemplatesUseCase(template_repository)
|
||||
total = count_use_case.execute(user_id, filter=tpl_filter)
|
||||
|
||||
# P0 兜底:剪辑页(valid_only=true)首次访问且用户无任何有效模板时,
|
||||
# 自动创建一条默认配音模板,避免前端 selectedTemplate 永远为空导致卡死。
|
||||
if valid_only and total == 0 and skip == 0:
|
||||
default_tpl = _ensure_default_template(user_id, template_repository)
|
||||
if default_tpl is not None:
|
||||
templates = [default_tpl]
|
||||
total = 1
|
||||
|
||||
# 批量查询使用次数
|
||||
items = []
|
||||
for t in templates:
|
||||
|
||||
Executable → Regular
+12
-3
@@ -177,6 +177,7 @@ def synthesize(
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"emotion": request.emotion or "",
|
||||
"language": request.language or "zh-CN",
|
||||
}
|
||||
if request.metadata_:
|
||||
synthesis_meta.update(request.metadata_)
|
||||
@@ -462,10 +463,17 @@ def save_tts_job_to_library(
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", str(tmp_path),
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
str(tmp_path),
|
||||
],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
fmt = json.loads(proc.stdout).get("format", {})
|
||||
@@ -576,6 +584,7 @@ def preview_tts(
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
emotion=request.emotion,
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -67,7 +67,7 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
||||
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
|
||||
|
||||
@@ -16,7 +16,10 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
output_name: str = Field("", description="输出文件名")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)")
|
||||
emotion: str = Field(
|
||||
"",
|
||||
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
|
||||
)
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
format: str = Field("mp3", description="输出格式(mp3/wav/pcm)")
|
||||
@@ -110,7 +113,8 @@ class TTSPreviewRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
|
||||
voice_id: str = Field(..., min_length=1, description="音色 ID")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文)")
|
||||
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
|
||||
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.domain.sentence_timings import (
|
||||
compute_sentence_timings,
|
||||
probe_audio_duration,
|
||||
@@ -121,7 +121,8 @@ class LipsyncService:
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=normalize_emotion(emotion),
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||||
@@ -304,7 +305,7 @@ class LipsyncService:
|
||||
voice_id=voice_id or "",
|
||||
script_text=script_text or "",
|
||||
speed=speed,
|
||||
emotion=normalize_emotion(emotion) if is_tts_mode else (emotion or ""),
|
||||
emotion=emotion or "",
|
||||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
)
|
||||
@@ -325,7 +326,7 @@ class LipsyncService:
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
normalize_emotion(emotion),
|
||||
emotion or "",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -382,7 +383,8 @@ class LipsyncService:
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=normalize_emotion(emotion),
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||||
@@ -489,39 +491,52 @@ class LipsyncService:
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
|
||||
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
temp_url = result.get("video_url", "")
|
||||
job.output_video_url = temp_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
temp_url = result.get("video_url", "")
|
||||
job.output_video_url = temp_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
# 异步转存自家 OSS
|
||||
try:
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
# 异步转存自家 OSS
|
||||
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
|
||||
logger.error(
|
||||
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
|
||||
job_id,
|
||||
mk_status,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ def tts_synthesize_and_submit(
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
|
||||
@@ -282,6 +283,18 @@ def tts_synthesize_and_submit(
|
||||
job.error_code = exc.code
|
||||
logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc)
|
||||
|
||||
# 三层防御 ③:链式触发 Celery 兜底轮询——MediaKit 提交成功后由 worker
|
||||
# 主动拉取状态到终态,不依赖前端轮询触发的 FastAPI background task
|
||||
# (background task 可能静默失败导致永久卡 running)。
|
||||
if job.status == "submitted" and job.mediakit_task_id:
|
||||
try:
|
||||
poll_mediakit_status.apply_async(
|
||||
kwargs={"job_id": job_id, "user_id": user_id},
|
||||
countdown=10, # 10 秒后开始轮询,给 MediaKit 一点处理时间
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_tts] 提交兜底轮询任务失败(不影响主流程): job_id=%s err=%s", job_id, exc)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception:
|
||||
@@ -300,6 +313,99 @@ def tts_synthesize_and_submit(
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.poll_mediakit_status",
|
||||
max_retries=60, # 最多轮询 60 次
|
||||
default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟)
|
||||
)
|
||||
def poll_mediakit_status(self, job_id: str, user_id: str):
|
||||
"""Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。
|
||||
|
||||
不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。
|
||||
"""
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
from app.db import SessionLocal # type: ignore
|
||||
|
||||
db: DBSession = SessionLocal()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
|
||||
if job is None:
|
||||
logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 已终态,不需要再轮询
|
||||
if job.status in ("completed", "failed", "cancelled"):
|
||||
return
|
||||
|
||||
if not job.mediakit_task_id:
|
||||
logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status)
|
||||
return
|
||||
|
||||
from app.services.lipsync_service import STATUS_COMPLETED as _SC
|
||||
from app.services.lipsync_service import STATUS_FAILED as _SF
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
try:
|
||||
status_data = client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc)
|
||||
raise self.retry(exc=exc) from exc
|
||||
|
||||
mk_status = status_data.get("status", "running")
|
||||
|
||||
if mk_status in ("succeeded", _SC):
|
||||
|
||||
svc = LipsyncService(db)
|
||||
result = status_data.get("result", {})
|
||||
job.status = "completed"
|
||||
output_url = result.get("video_url", "")
|
||||
try:
|
||||
job.output_video_url = svc._persist_output_video(output_url, job_id, user_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_poll] 转存 OSS 失败,保留临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
job.output_video_url = output_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
|
||||
elif mk_status in ("failed", "error", _SF):
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
|
||||
else:
|
||||
# 中间状态,更新时间戳,继续重试
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
db.commit()
|
||||
logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id)
|
||||
raise self.retry()
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise self.retry(exc=exc) from exc
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_tts.persist_output_video",
|
||||
max_retries=2,
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface TTSPreviewRequest {
|
||||
speed?: number
|
||||
pitch?: number
|
||||
language?: string
|
||||
emotion?: string // 情绪参数:natural/excited/calm/friendly
|
||||
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
AppstoreOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
VideoCameraOutlined,
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
@@ -70,24 +68,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
|
||||
{
|
||||
key: "generate",
|
||||
@@ -150,12 +130,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -191,18 +165,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ export const createLipsyncJob = async (data: {
|
||||
script_text?: string
|
||||
/** 语速 0.5~2.0,默认 1.0(TTS 直生模式用) */
|
||||
speed?: number
|
||||
/** 情绪英文枚举:natural/excited/calm/friendly(TTS 直生模式用) */
|
||||
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
|
||||
emotion?: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
VOICE_LANGUAGE_OPTIONS,
|
||||
PRESET_VOICE_LANGUAGE_OPTIONS,
|
||||
CLONE_VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
|
||||
interface PanelVoiceSelectorProps {
|
||||
@@ -91,6 +92,12 @@ export function PanelVoiceSelector({
|
||||
|
||||
const NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
|
||||
|
||||
// 系统预置音色仅支持 zh/en;克隆音色支持全语言
|
||||
const languageOptions =
|
||||
voiceSource === "clone" ? CLONE_VOICE_LANGUAGE_OPTIONS : PRESET_VOICE_LANGUAGE_OPTIONS
|
||||
// 当前语言不在可选列表(切回预置时 ja/ko/cantonese/mandarin 失效)→ 自动回退到中文
|
||||
const effectiveLanguage = languageOptions.some((o) => o.value === language) ? language : "zh"
|
||||
|
||||
/** 用指定 URL 真实播放(抽取公共) */
|
||||
const playAudioUrl = (voiceId: string, url: string) => {
|
||||
// 临时兼容:后端 /tts/preview 返回 HTTP URL,staging 是 HTTPS,Mixed Content 会阻止加载
|
||||
@@ -289,10 +296,12 @@ export function PanelVoiceSelector({
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
value={effectiveLanguage}
|
||||
onChange={(e) => {
|
||||
onLanguageChange(e.target.value as VoiceLanguage)
|
||||
}}
|
||||
>
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
{languageOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
|
||||
@@ -34,9 +34,9 @@ export function useAiAvatar() {
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("zh")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
|
||||
@@ -6,25 +6,42 @@ import type { AssetItem } from "@/api/assets"
|
||||
/* ── 音色来源切换 ── */
|
||||
export type VoiceSource = "preset" | "clone"
|
||||
|
||||
/* ── 情绪 ── */
|
||||
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
|
||||
/* ── 情绪(对齐 CosyVoice 7 种情绪) ── */
|
||||
export type VoiceEmotion =
|
||||
"neutral" | "happy" | "sad" | "angry" | "surprised" | "fearful" | "disgusted"
|
||||
|
||||
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
{ value: "neutral", label: "自然" },
|
||||
{ value: "happy", label: "开心" },
|
||||
{ value: "sad", label: "难过" },
|
||||
{ value: "angry", label: "生气" },
|
||||
{ value: "surprised", label: "惊讶" },
|
||||
{ value: "fearful", label: "恐惧" },
|
||||
{ value: "disgusted", label: "厌恶" },
|
||||
]
|
||||
|
||||
/* ── 语言 ── */
|
||||
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
|
||||
/** 系统预置音色支持的语言(zh/en) */
|
||||
export type PresetVoiceLanguage = "zh" | "en"
|
||||
/** 克隆音色支持的完整语言列表 */
|
||||
export type CloneVoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
export type VoiceLanguage = PresetVoiceLanguage | CloneVoiceLanguage
|
||||
|
||||
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "mandarin", label: "普通话" },
|
||||
{ value: "english", label: "English" },
|
||||
{ value: "cantonese", label: "粤语" },
|
||||
export const PRESET_VOICE_LANGUAGE_OPTIONS: { value: PresetVoiceLanguage; label: string }[] = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
]
|
||||
|
||||
export const CLONE_VOICE_LANGUAGE_OPTIONS: { value: CloneVoiceLanguage; label: string }[] = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "ko", label: "한국어" },
|
||||
]
|
||||
|
||||
/** 默认(预置音色)语言选项 */
|
||||
export const VOICE_LANGUAGE_OPTIONS = PRESET_VOICE_LANGUAGE_OPTIONS
|
||||
|
||||
/* ── 对口型任务状态 ── */
|
||||
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
|
||||
@@ -6,21 +6,50 @@
|
||||
*/
|
||||
import type { AiAvatarTitleConfig, AiAvatarCoverConfig, VoiceEmotion } from "../types"
|
||||
|
||||
/* ── 情绪:中文 → 英文(防御性映射;state 默认已是英文) ── */
|
||||
const EMOTION_ZH_TO_EN: Record<string, VoiceEmotion> = {
|
||||
自然: "natural",
|
||||
兴奋: "excited",
|
||||
沉稳: "calm",
|
||||
亲切: "friendly",
|
||||
/* ── 情绪:中文/旧枚举 → CosyVoice 7 种英文枚举 ── */
|
||||
const EMOTION_ALIAS: Record<string, VoiceEmotion> = {
|
||||
// 新英文枚举
|
||||
neutral: "neutral",
|
||||
happy: "happy",
|
||||
sad: "sad",
|
||||
angry: "angry",
|
||||
surprised: "surprised",
|
||||
fearful: "fearful",
|
||||
disgusted: "disgusted",
|
||||
// 旧英文枚举(4 种,向前兼容)
|
||||
natural: "neutral",
|
||||
excited: "happy",
|
||||
calm: "neutral",
|
||||
friendly: "happy",
|
||||
// 中文
|
||||
自然: "neutral",
|
||||
开心: "happy",
|
||||
难过: "sad",
|
||||
生气: "angry",
|
||||
惊讶: "surprised",
|
||||
恐惧: "fearful",
|
||||
厌恶: "disgusted",
|
||||
// 旧中文
|
||||
兴奋: "happy",
|
||||
沉稳: "neutral",
|
||||
亲切: "happy",
|
||||
}
|
||||
const VALID_EMOTIONS: VoiceEmotion[] = ["natural", "excited", "calm", "friendly"]
|
||||
const VALID_EMOTIONS: VoiceEmotion[] = [
|
||||
"neutral",
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"surprised",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
]
|
||||
|
||||
/** 归一化为后端英文枚举 natural/excited/calm/friendly;非法/空值回退 natural。 */
|
||||
/** 归一化为后端英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted;非法/空值回退 neutral。 */
|
||||
export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
|
||||
if (!raw) return "natural"
|
||||
if (!raw) return "neutral"
|
||||
const v = raw.trim()
|
||||
if ((VALID_EMOTIONS as string[]).includes(v)) return v as VoiceEmotion
|
||||
return EMOTION_ZH_TO_EN[v] ?? "natural"
|
||||
return EMOTION_ALIAS[v] ?? "neutral"
|
||||
}
|
||||
|
||||
/* ── 标题:前端 state → 后端 build_title_drawtext_filter 字段(单个 title_config dict) ── */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 — 制作/编辑剪辑模板
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 全局配置 → hooks/useGlobalSettings
|
||||
* 配音素材 → hooks/useVoiceMaterials
|
||||
* 撤销重做 → hooks/useUndoRedo
|
||||
* 抽屉管理 → hooks/useEditorDrawers
|
||||
* 播放控制 → hooks/usePlaybackControl
|
||||
* 片段操作 → hooks/useClipOperations
|
||||
* 模板管理 → hooks/useTemplateManagement
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
import TimelinePanel from "./components/TimelinePanel"
|
||||
import TopBar from "./components/TopBar"
|
||||
import ModeBar from "./components/ModeBar"
|
||||
import RightPanel from "./components/RightPanel"
|
||||
import StatusBar from "./components/StatusBar"
|
||||
import EditorDrawers from "./components/EditorDrawers"
|
||||
import SaveModal from "./components/SaveModal"
|
||||
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import { useEditorDrawers } from "./hooks/useEditorDrawers"
|
||||
import { usePlaybackControl } from "./hooks/usePlaybackControl"
|
||||
import { useClipOperations } from "./hooks/useClipOperations"
|
||||
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
|
||||
import { useGlobalSettings } from "./hooks/useGlobalSettings"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
|
||||
import type { ClipData } from "./types"
|
||||
import "./EditingPlanner.css"
|
||||
|
||||
const EditingPlanner: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlTemplateId = searchParams.get("templateId") || ""
|
||||
const urlPlanId = searchParams.get("planId") || ""
|
||||
|
||||
/* ── 片段(撤销/重做) ── */
|
||||
const {
|
||||
state: clips,
|
||||
set: setClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetClips,
|
||||
} = useUndoRedo<ClipData[]>([])
|
||||
|
||||
/* ── 全局配置 ── */
|
||||
const {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
subtitleSettings,
|
||||
setSubtitleSettings,
|
||||
bgmSettings,
|
||||
setBgmSettings,
|
||||
watermarkSettings,
|
||||
setWatermarkSettings,
|
||||
introOutroSettings,
|
||||
setIntroOutroSettings,
|
||||
pipSettings,
|
||||
setPipSettings,
|
||||
filterSettings,
|
||||
setFilterSettings,
|
||||
chromaKeySettings,
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
loading: voiceMaterialsLoading,
|
||||
refetch: refetchVoiceMaterials,
|
||||
} = useVoiceMaterials()
|
||||
|
||||
/* ── 派生计算 ── */
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
|
||||
|
||||
/* ── Hook: 抽屉管理 ── */
|
||||
const drawers = useEditorDrawers()
|
||||
|
||||
/* ── Hook: 播放控制 ── */
|
||||
const playback = usePlaybackControl(totalDuration)
|
||||
|
||||
/* ── Hook: 片段操作 ── */
|
||||
const clipOps = useClipOperations({ clips, setClips })
|
||||
|
||||
/* ── Hook: 模板管理 ── */
|
||||
const tpl = useTemplateManagement({
|
||||
urlTemplateId,
|
||||
urlPlanId,
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId: clipOps.setSelectedClipId,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
watermarkSettings,
|
||||
introOutroSettings,
|
||||
pipSettings,
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
})
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
return (
|
||||
<div className="ep-v8-root">
|
||||
{/* ═══ 第1行:顶栏 42px ═══ */}
|
||||
<TopBar
|
||||
currentTemplate={tpl.currentTemplate}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onOpenSaveModal={tpl.handleOpenSaveModal}
|
||||
/>
|
||||
|
||||
{/* ═══ 第2行:模式栏 56px ═══ */}
|
||||
<ModeBar
|
||||
modeList={MODE_LIST}
|
||||
currentMode={tpl.currentMode}
|
||||
onModeChange={tpl.handleModeChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 第3行:三栏主体 ═══ */}
|
||||
<div className="ep-main-body">
|
||||
{/* 左栏 220px:模板列表 */}
|
||||
<MediaPanel
|
||||
templates={tpl.filteredTemplates}
|
||||
loading={tpl.loadingTemplates}
|
||||
searchQuery={tpl.searchQuery}
|
||||
currentFilter={tpl.currentFilter}
|
||||
filterCategories={FILTER_CATEGORIES}
|
||||
loadedTemplateId={tpl.loadedTemplateId}
|
||||
onLoadTemplate={tpl.handleLoadTemplate}
|
||||
onSearchChange={tpl.setSearchQuery}
|
||||
onFilterChange={tpl.setCurrentFilter}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
<div className="ep-center-col">
|
||||
{/* 上半部:视频预览 + 封面预览 */}
|
||||
<PreviewPlayer
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
isPlaying={playback.isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onPlayPause={() => playback.setIsPlaying(!playback.isPlaying)}
|
||||
/>
|
||||
|
||||
{/* 下半部:水平时间线 */}
|
||||
<TimelinePanel
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
currentMode={tpl.currentMode}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onClipReorder={clipOps.handleClipReorder}
|
||||
onClipRemove={clipOps.handleClipRemove}
|
||||
onAddClip={clipOps.handleAddClip}
|
||||
onClipTrim={clipOps.handleClipTrim}
|
||||
onClipSplit={clipOps.handleClipSplit}
|
||||
onClipResetTrim={clipOps.handleClipResetTrim}
|
||||
currentTime={playback.currentTime}
|
||||
pixelsPerSecond={playback.pixelsPerSecond}
|
||||
onZoomChange={playback.handleZoomChange}
|
||||
onSeek={playback.handleSeek}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<RightPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={setTitleConfig}
|
||||
rightTab={rightTab}
|
||||
onTabChange={setRightTab}
|
||||
selectedClip={clipOps.selectedClip}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={tpl.currentMode}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
|
||||
onClipUpdate={clipOps.handleClipUpdate}
|
||||
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onRefreshVoiceMaterials={refetchVoiceMaterials}
|
||||
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={drawers.openTransitionDrawer}
|
||||
onOpenSpeedDrawer={drawers.openSpeedDrawer}
|
||||
onOpenTtsDrawer={drawers.openTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => drawers.setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onClipMoveUp={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId)
|
||||
if (idx > 0) clipOps.handleClipReorder(idx, idx - 1)
|
||||
}}
|
||||
onClipMoveDown={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId)
|
||||
if (idx < clips.length - 1) clipOps.handleClipReorder(idx, idx + 1)
|
||||
}}
|
||||
onClipRemove={clipOps.handleClipRemove}
|
||||
onClipAdd={() => clipOps.handleAddClip("pip", 3)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||||
<StatusBar
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentModeLabel={MODE_LABELS[tpl.currentMode]}
|
||||
templateSegments={tpl.currentTemplate?.segments.length || 0}
|
||||
/>
|
||||
|
||||
{/* ═══ 弹窗 ═══ */}
|
||||
<SaveModal
|
||||
open={tpl.saveModalOpen}
|
||||
loading={tpl.saveLoading}
|
||||
isUpdate={!!tpl.loadedTemplateId}
|
||||
draftName={tpl.draftName}
|
||||
draftCategory={tpl.draftCategory}
|
||||
draftTags={tpl.draftTags}
|
||||
categories={tpl.categories}
|
||||
estimatedDuration={totalDuration}
|
||||
onNameChange={tpl.setDraftName}
|
||||
onCategoryChange={tpl.setDraftCategory}
|
||||
onTagsChange={tpl.setDraftTags}
|
||||
onSave={tpl.handleSave}
|
||||
onCancel={() => tpl.setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ Drawer 集合 ═══ */}
|
||||
<EditorDrawers
|
||||
bgmDrawerOpen={drawers.bgmDrawerOpen}
|
||||
bgmSettings={bgmSettings}
|
||||
onBgmSettingsChange={setBgmSettings}
|
||||
onCloseBgmDrawer={() => drawers.setBgmDrawerOpen(false)}
|
||||
subtitleDrawerOpen={drawers.subtitleDrawerOpen}
|
||||
subtitleSettings={subtitleSettings}
|
||||
onSubtitleSettingsChange={setSubtitleSettings}
|
||||
onCloseSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(false)}
|
||||
transitionDrawerOpen={drawers.transitionDrawerOpen}
|
||||
transitionTargetClipId={drawers.transitionTargetClipId}
|
||||
clips={clips}
|
||||
onTransitionChange={(config) =>
|
||||
clipOps.handleTransitionChange(drawers.transitionTargetClipId, config)
|
||||
}
|
||||
onCloseTransitionDrawer={() => drawers.setTransitionDrawerOpen(false)}
|
||||
speedDrawerOpen={drawers.speedDrawerOpen}
|
||||
speedTargetClipId={drawers.speedTargetClipId}
|
||||
onSpeedChange={(config) => clipOps.handleSpeedChange(drawers.speedTargetClipId, config)}
|
||||
onApplySpeedAll={clipOps.handleApplySpeedAll}
|
||||
onCloseSpeedDrawer={() => drawers.setSpeedDrawerOpen(false)}
|
||||
ttsDrawerOpen={drawers.ttsDrawerOpen}
|
||||
ttsTargetClipId={drawers.ttsTargetClipId}
|
||||
onTtsChange={(config) => clipOps.handleTtsChange(drawers.ttsTargetClipId, config)}
|
||||
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
|
||||
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
|
||||
watermarkSettings={watermarkSettings}
|
||||
onWatermarkChange={setWatermarkSettings}
|
||||
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
|
||||
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
|
||||
introOutroSettings={introOutroSettings}
|
||||
onIntroOutroChange={setIntroOutroSettings}
|
||||
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
|
||||
pipDrawerOpen={drawers.pipDrawerOpen}
|
||||
pipSettings={pipSettings}
|
||||
totalDuration={totalDuration}
|
||||
onPipChange={setPipSettings}
|
||||
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
|
||||
filterDrawerOpen={drawers.filterDrawerOpen}
|
||||
filterSettings={filterSettings}
|
||||
onFilterChange={setFilterSettings}
|
||||
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
|
||||
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={chromaKeySettings}
|
||||
onChromaKeyChange={setChromaKeySettings}
|
||||
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
|
||||
stickerDrawerOpen={drawers.stickerDrawerOpen}
|
||||
stickerSettings={stickerSettings}
|
||||
onStickerChange={setStickerSettings}
|
||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditingPlanner
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* BGM 选择器入口(向后兼容)
|
||||
* 实际实现位于 ./bgm-selector/ 目录
|
||||
*/
|
||||
export { default } from "./bgm-selector"
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
|
||||
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
|
||||
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
|
||||
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
|
||||
import ClipDetailSection from "./clip-properties/ClipDetailSection"
|
||||
import StatsSection from "./clip-properties/StatsSection"
|
||||
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
}) => {
|
||||
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
|
||||
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 — #1789 ═══ */}
|
||||
{titleConfig && onTitleConfigChange && (
|
||||
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
|
||||
)}
|
||||
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<SubtitleSettingsSection
|
||||
settings={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 设置 ═══ */}
|
||||
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🔖</span>
|
||||
水印设置
|
||||
</div>
|
||||
{onOpenWatermarkDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🔖</span>
|
||||
<span className="ep-advanced-btn-label">水印配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片头片尾设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎬</span>
|
||||
片头片尾
|
||||
</div>
|
||||
{onOpenIntroOutroDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 混剪 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
混剪
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置混剪图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 滤镜调色 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎨</span>
|
||||
滤镜调色
|
||||
</div>
|
||||
{onOpenFilterDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎨</span>
|
||||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 绿幕抠像 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🟩</span>
|
||||
绿幕抠像
|
||||
</div>
|
||||
{onOpenGreenScreenDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🟩</span>
|
||||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 贴纸 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🏷️</span>
|
||||
贴纸
|
||||
</div>
|
||||
{onOpenStickerDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<ClipDetailSection
|
||||
clip={selectedClip}
|
||||
currentMode={currentMode}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
previewingId={previewingId}
|
||||
onPreviewVoice={handlePreviewVoice}
|
||||
onStopPreview={stopPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 统计信息(未选中时显示) ═══ */}
|
||||
{!selectedClip && (
|
||||
<StatsSection
|
||||
clipsCount={clipsCount}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipPropertiesPanel
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 所有弹窗和 Drawer 组件的集合
|
||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||
*/
|
||||
import React from "react"
|
||||
import SaveModal from "./SaveModal"
|
||||
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
|
||||
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
|
||||
import type { EditingDrawersProps } from "./editing-drawers/types"
|
||||
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
|
||||
const {
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
clips,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 保存弹窗 */}
|
||||
<SaveModal
|
||||
open={saveModalOpen}
|
||||
loading={saveLoading}
|
||||
isUpdate={isUpdate}
|
||||
draftName={draftName}
|
||||
draftCategory={draftCategory}
|
||||
draftTags={draftTags}
|
||||
categories={categories}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onNameChange={onNameChange}
|
||||
onCategoryChange={onCategoryChange}
|
||||
onTagsChange={onTagsChange}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelSave}
|
||||
/>
|
||||
|
||||
{/* 片段级抽屉(转场/调速/TTS) */}
|
||||
<ClipLevelDrawers
|
||||
clips={clips}
|
||||
transitionDrawerOpen={props.transitionDrawerOpen}
|
||||
transitionTargetClipId={props.transitionTargetClipId}
|
||||
onCloseTransitionDrawer={props.onCloseTransitionDrawer}
|
||||
onTransitionChange={props.onTransitionChange}
|
||||
speedDrawerOpen={props.speedDrawerOpen}
|
||||
speedTargetClipId={props.speedTargetClipId}
|
||||
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
|
||||
onSpeedChange={props.onSpeedChange}
|
||||
onApplySpeedAll={props.onApplySpeedAll}
|
||||
ttsDrawerOpen={props.ttsDrawerOpen}
|
||||
ttsTargetClipId={props.ttsTargetClipId}
|
||||
onCloseTtsDrawer={props.onCloseTtsDrawer}
|
||||
onTtsChange={props.onTtsChange}
|
||||
/>
|
||||
|
||||
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
|
||||
<GlobalDrawers
|
||||
bgmDrawerOpen={props.bgmDrawerOpen}
|
||||
bgmSettings={props.bgmSettings}
|
||||
onCloseBgmDrawer={props.onCloseBgmDrawer}
|
||||
onChangeBgmSettings={props.onChangeBgmSettings}
|
||||
subtitleDrawerOpen={props.subtitleDrawerOpen}
|
||||
subtitleSettings={props.subtitleSettings}
|
||||
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
|
||||
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
|
||||
totalDuration={props.totalDuration}
|
||||
watermarkDrawerOpen={props.watermarkDrawerOpen}
|
||||
watermarkSettings={props.watermarkSettings}
|
||||
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
|
||||
onWatermarkChange={props.onWatermarkChange}
|
||||
introOutroDrawerOpen={props.introOutroDrawerOpen}
|
||||
introOutroSettings={props.introOutroSettings}
|
||||
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
|
||||
onIntroOutroChange={props.onIntroOutroChange}
|
||||
pipDrawerOpen={props.pipDrawerOpen}
|
||||
pipSettings={props.pipSettings}
|
||||
onClosePipDrawer={props.onClosePipDrawer}
|
||||
onPipChange={props.onPipChange}
|
||||
filterDrawerOpen={props.filterDrawerOpen}
|
||||
filterSettings={props.filterSettings}
|
||||
onCloseFilterDrawer={props.onCloseFilterDrawer}
|
||||
onFilterChange={props.onFilterChange}
|
||||
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={props.chromaKeySettings}
|
||||
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
|
||||
onChromaKeyChange={props.onChromaKeyChange}
|
||||
stickerDrawerOpen={props.stickerDrawerOpen}
|
||||
stickerSettings={props.stickerSettings}
|
||||
onCloseStickerDrawer={props.onCloseStickerDrawer}
|
||||
onStickerChange={props.onStickerChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditingDrawers
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react"
|
||||
import { Button, Tooltip, Empty } from "antd"
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
onSelect: (clipId: string) => void
|
||||
onMoveUp: (clipId: string) => void
|
||||
onMoveDown: (clipId: string) => void
|
||||
onRemove: (clipId: string) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
}
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelect,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}) => {
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-clip-list-empty">
|
||||
<Empty
|
||||
description="暂无片段"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ margin: "40px 0" }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-clip-list">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="ep-clip-list-toolbar">
|
||||
<span className="ep-clip-list-count">
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-clip-list-scroll">
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${selectedClipId === clip.id ? " selected" : ""}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
<div className="ep-clip-item-head">
|
||||
<span className="ep-clip-item-index">{index + 1}</span>
|
||||
<span className="ep-clip-item-type">
|
||||
{clipTypeIcon[clip.type] || <ScissorOutlined />}
|
||||
<span className="ep-clip-item-type-label">
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
{clip.script_text && (
|
||||
<div className="ep-clip-item-text">
|
||||
{clip.script_text.slice(0, 40)}
|
||||
{clip.script_text.length > 40 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="ep-clip-item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMoveUp(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={index === clips.length - 1}
|
||||
onClick={() => onMoveDown(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorClipList
|
||||
@@ -1,246 +0,0 @@
|
||||
import React from "react"
|
||||
import BgmSelector from "./BgmSelector"
|
||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
||||
import TransitionSelector from "./TransitionSelector"
|
||||
import SpeedPanel from "./SpeedPanel"
|
||||
import TtsPanel from "./TtsPanel"
|
||||
import WatermarkPanel from "./WatermarkPanel"
|
||||
import IntroOutroPanel from "./IntroOutroPanel"
|
||||
import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
|
||||
interface EditorDrawersProps {
|
||||
// BGM
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onBgmSettingsChange: (config: BgmMixConfig) => void
|
||||
onCloseBgmDrawer: () => void
|
||||
// 字幕
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
|
||||
onCloseSubtitleDrawer: () => void
|
||||
// 转场
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
clips: ClipData[]
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
onCloseTransitionDrawer: () => void
|
||||
// 调速
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
onCloseSpeedDrawer: () => void
|
||||
// TTS
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
onCloseTtsDrawer: () => void
|
||||
// 水印
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
onCloseWatermarkDrawer: () => void
|
||||
// 片头片尾
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
// 混剪
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
totalDuration: number
|
||||
onPipChange: (config: PipConfig) => void
|
||||
onClosePipDrawer: () => void
|
||||
// 滤镜
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
onCloseFilterDrawer: () => void
|
||||
// 绿幕
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
// 贴纸
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
onCloseStickerDrawer: () => void
|
||||
}
|
||||
|
||||
const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onBgmSettingsChange,
|
||||
onCloseBgmDrawer,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onSubtitleSettingsChange,
|
||||
onCloseSubtitleDrawer,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
clips,
|
||||
onTransitionChange,
|
||||
onCloseTransitionDrawer,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
onCloseSpeedDrawer,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onTtsChange,
|
||||
onCloseTtsDrawer,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onWatermarkChange,
|
||||
onCloseWatermarkDrawer,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onIntroOutroChange,
|
||||
onCloseIntroOutroDrawer,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
totalDuration,
|
||||
onPipChange,
|
||||
onClosePipDrawer,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onFilterChange,
|
||||
onCloseFilterDrawer,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onChromaKeyChange,
|
||||
onCloseChromaKeyDrawer,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onStickerChange,
|
||||
onCloseStickerDrawer,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* BGM 选择器 Drawer */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onBgmSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 字幕样式配置 Drawer */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 转场特效选择器 Drawer */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* 片段调速面板 Drawer */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TTS 配音面板 Drawer */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 水印配置面板 */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* 片头片尾配置面板 */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* 混剪配置面板 */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 滤镜调色面板 */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* 绿幕抠像面板 */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* 贴纸面板 */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorDrawers
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
|
||||
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
|
||||
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
|
||||
import FilterPresetGrid from "./filter/FilterPresetGrid"
|
||||
import FilterManualAdjust from "./filter/FilterManualAdjust"
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
}
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
)
|
||||
|
||||
const handleManualChange = useCallback(
|
||||
(key: keyof FilterConfig, value: number) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="filter-header">
|
||||
<span className="filter-header-label">启用滤镜</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
|
||||
|
||||
{/* 手动调节 */}
|
||||
<FilterManualAdjust config={config} onChange={handleManualChange} />
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">效果预览</div>
|
||||
<div
|
||||
className="filter-preview-block"
|
||||
style={{
|
||||
background: PRESET_GRADIENTS[config.preset],
|
||||
filter: [
|
||||
`brightness(${100 + config.brightness}%)`,
|
||||
`contrast(${100 + config.contrast}%)`,
|
||||
`saturate(${100 + config.saturation}%)`,
|
||||
].join(" "),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="filter-footer">
|
||||
<button className="filter-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPanel
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
|
||||
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
|
||||
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
|
||||
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
|
||||
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: ChromaKeyConfig
|
||||
onChange: (config: ChromaKeyConfig) => void
|
||||
}
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
color_preset: preset,
|
||||
color: CHROMA_KEY_PRESET_COLORS[preset],
|
||||
})
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
update({ color })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GreenScreenPresets
|
||||
selectedPreset={config.color_preset}
|
||||
onPresetSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
|
||||
|
||||
<GreenScreenSliders
|
||||
similarity={config.similarity}
|
||||
blend={config.blend}
|
||||
spill={config.spill}
|
||||
onSimilarityChange={(v) => update({ similarity: v })}
|
||||
onBlendChange={(v) => update({ blend: v })}
|
||||
onSpillChange={(v) => update({ spill: v })}
|
||||
/>
|
||||
|
||||
<GreenScreenPreview color={config.color} blend={config.blend} />
|
||||
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default GreenScreenPanel
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 片头片尾配置面板 — Drawer 形式
|
||||
* 两个区块:片头(Intro)/ 片尾(Outro)
|
||||
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type {
|
||||
IntroOutroConfig,
|
||||
IntroOutroItem,
|
||||
IntroOutroKind,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
|
||||
import IntroOutroBlock from "./intro-outro/IntroOutroBlock"
|
||||
|
||||
interface IntroOutroPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: IntroOutroConfig
|
||||
onChange: (config: IntroOutroConfig) => void
|
||||
}
|
||||
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
/* ── 更新片头 ── */
|
||||
const handleIntroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 更新片尾 ── */
|
||||
const handleOutroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 切换片头类型 ── */
|
||||
const handleIntroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" })
|
||||
},
|
||||
[handleIntroChange],
|
||||
)
|
||||
|
||||
/* ── 切换片尾类型 ── */
|
||||
const handleOutroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" })
|
||||
},
|
||||
[handleOutroChange],
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO })
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
>
|
||||
{/* ═══ 片头区块 ═══ */}
|
||||
<IntroOutroBlock
|
||||
title="片头"
|
||||
icon="🎞️"
|
||||
item={config.intro}
|
||||
transitionLabel="进入过渡动画"
|
||||
onKindChange={handleIntroKindChange}
|
||||
onChange={handleIntroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片尾区块 ═══ */}
|
||||
<IntroOutroBlock
|
||||
title="片尾"
|
||||
icon="🏁"
|
||||
item={config.outro}
|
||||
transitionLabel="退出过渡动画"
|
||||
onKindChange={handleOutroKindChange}
|
||||
onChange={handleOutroChange}
|
||||
/>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="iop-footer">
|
||||
<button className="iop-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntroOutroPanel
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 左侧面板 — 模板列表
|
||||
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
loading: boolean
|
||||
searchQuery: string
|
||||
currentFilter: string
|
||||
filterCategories: string[]
|
||||
loadedTemplateId: string | null
|
||||
onLoadTemplate: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onFilterChange: (f: string) => void
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
templates,
|
||||
loading,
|
||||
searchQuery,
|
||||
currentFilter,
|
||||
filterCategories,
|
||||
loadedTemplateId,
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaPanel
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from "react"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ModeBarProps {
|
||||
modeList: { key: TemplateMode; label: string; icon: string }[]
|
||||
currentMode: TemplateMode
|
||||
onModeChange: (mode: TemplateMode) => void
|
||||
}
|
||||
|
||||
const ModeBar: React.FC<ModeBarProps> = ({ modeList, currentMode, onModeChange }) => {
|
||||
return (
|
||||
<div className="ep-mode-bar">
|
||||
<span className="ep-mode-bar-label">剪辑模式:</span>
|
||||
{modeList.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
|
||||
onClick={() => onModeChange(m.key)}
|
||||
>
|
||||
<span className="ep-mode-icon">{m.icon}</span>
|
||||
<span className="ep-mode-label">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeBar
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../../types"
|
||||
import { GRID_POSITIONS, ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "../constants"
|
||||
|
||||
export interface LayerConfigProps {
|
||||
layer: PipLayer
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
export const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* ── 素材类型 ── */}
|
||||
<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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { x: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { y: val })
|
||||
}}
|
||||
/>
|
||||
</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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onWidthChange(val)
|
||||
}}
|
||||
/>
|
||||
<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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onHeightChange(val)
|
||||
}}
|
||||
/>
|
||||
<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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onUpdate(layer.id, { border_radius: val })
|
||||
}}
|
||||
/>
|
||||
<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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val)) onUpdate(layer.id, { opacity: val })
|
||||
}}
|
||||
/>
|
||||
<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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= totalDuration)
|
||||
onUpdate(layer.id, { start_time: val })
|
||||
}}
|
||||
/>
|
||||
</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) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val > 0 && val <= totalDuration)
|
||||
onUpdate(layer.id, { duration: val })
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "../../../types"
|
||||
import { LAYER_COLORS } from "../constants"
|
||||
|
||||
export interface LayerListProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onDelete }) => {
|
||||
if (layers.length === 0) {
|
||||
return (
|
||||
<div className="pip-layer-list">
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-layer-list">
|
||||
{layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url || undefined}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(layer.id)
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "../../../types"
|
||||
import { LAYER_COLORS } from "../constants"
|
||||
|
||||
export interface PipPreviewProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => {
|
||||
return (
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
width: `${layer.width}%`,
|
||||
height: `${layer.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: layer.opacity / 100,
|
||||
borderRadius: `${layer.border_radius}%`,
|
||||
zIndex: layer.z_index,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../../types"
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
}
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
export const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
]
|
||||
|
||||
/** 入场动画选项 */
|
||||
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
]
|
||||
|
||||
/** 滑入方向选项 */
|
||||
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
]
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
export const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
]
|
||||
|
||||
let layerIdCounter = 0
|
||||
export const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import type { PipConfig, PipLayer, PipGridPosition } from "../../../types"
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../../../types"
|
||||
import { GRID_POSITION_MAP, genLayerId } from "../constants"
|
||||
|
||||
const MIN_DIMENSION = 10
|
||||
const MAX_DIMENSION = 80
|
||||
|
||||
interface UsePipConfigPanelOptions {
|
||||
config: PipConfig
|
||||
onChange: (config: PipConfig) => void
|
||||
}
|
||||
|
||||
export const usePipConfigPanel = ({ config, onChange }: UsePipConfigPanelOptions) => {
|
||||
const [selectedId, setSelectedId] = useState<string>("")
|
||||
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
)
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
const newLayer: PipLayer = {
|
||||
...DEFAULT_PIP_LAYER,
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
})
|
||||
setSelectedId(newLayer.id)
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id)
|
||||
onChange({ ...config, layers: newLayers })
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
)
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG })
|
||||
setSelectedId("")
|
||||
}, [onChange])
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return
|
||||
const coords = GRID_POSITION_MAP[pos]
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
})
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
/* ── 宽高变化 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { width: val }
|
||||
if (selectedLayer.aspect_lock && selectedLayer.height > 0) {
|
||||
const ratio = selectedLayer.width / selectedLayer.height
|
||||
const newHeight = Math.round(val / ratio)
|
||||
partial.height = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newHeight))
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { height: val }
|
||||
if (selectedLayer.aspect_lock && selectedLayer.width > 0 && selectedLayer.height > 0) {
|
||||
const ratio = selectedLayer.width / selectedLayer.height
|
||||
const newWidth = Math.round(val * ratio)
|
||||
partial.width = Math.max(MIN_DIMENSION, Math.min(MAX_DIMENSION, newWidth))
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedLayer,
|
||||
handleAddLayer,
|
||||
handleDeleteLayer,
|
||||
updateLayer,
|
||||
handleEnableToggle,
|
||||
handleReset,
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 混剪配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { PipConfig } from "../../types"
|
||||
|
||||
import { usePipConfigPanel } from "./hooks/usePipConfigPanel"
|
||||
import { LayerList } from "./components/LayerList"
|
||||
import { PipPreview } from "./components/PipPreview"
|
||||
import { LayerConfig } from "./components/LayerConfig"
|
||||
|
||||
export interface PipConfigPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: PipConfig
|
||||
onChange: (config: PipConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedLayer,
|
||||
handleAddLayer,
|
||||
handleDeleteLayer,
|
||||
updateLayer,
|
||||
handleEnableToggle,
|
||||
handleReset,
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
} = usePipConfigPanel({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🖼️ 混剪设置"
|
||||
placement="right"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="pip-config-panel-drawer"
|
||||
>
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
<Switch size="small" checked={config.enabled} onChange={handleEnableToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<LayerList
|
||||
layers={config.layers}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onDelete={handleDeleteLayer}
|
||||
/>
|
||||
|
||||
{/* 右侧配置区 */}
|
||||
{!selectedLayer ? (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PipPreview layers={config.layers} selectedId={selectedId} />
|
||||
<LayerConfig
|
||||
layer={selectedLayer}
|
||||
totalDuration={totalDuration}
|
||||
onUpdate={updateLayer}
|
||||
onGridClick={handleGridClick}
|
||||
onWidthChange={handleWidthChange}
|
||||
onHeightChange={handleHeightChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="pip-footer">
|
||||
<button className="pip-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipConfigPanel
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* 预览播放器样式 — V21 设计系统
|
||||
* 任务 2.16
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
预览播放器容器
|
||||
============================================================ */
|
||||
.ep-preview {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* ── 预览画面 ── */
|
||||
.ep-preview-screen {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
background: #0f0f14;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ep-preview-visual {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
transition: background 0.4s ease;
|
||||
}
|
||||
|
||||
/* 片段类型大图标 */
|
||||
.ep-preview-type-icon {
|
||||
font-size: 56px;
|
||||
opacity: 0.7;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
|
||||
animation: ep-preview-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ep-preview-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文案字幕 */
|
||||
.ep-preview-subtitle {
|
||||
max-width: 80%;
|
||||
padding: 8px 20px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 片段序号角标 */
|
||||
.ep-preview-clip-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 素材类型标签 */
|
||||
.ep-preview-material-tag {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(6px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 11px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.ep-preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.ep-preview-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-sm);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ep-preview-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
控制栏
|
||||
============================================================ */
|
||||
.ep-preview-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: 8px var(--space-lg);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #16161e;
|
||||
}
|
||||
|
||||
/* 时间显示 */
|
||||
.ep-preview-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.ep-preview-time-current {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-preview-time-sep {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.ep-preview-time-total {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 播放按钮组 */
|
||||
.ep-preview-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ep-preview-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.ep-preview-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ep-preview-btn-play {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
.ep-preview-btn-play:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
|
||||
/* 片段信息 */
|
||||
.ep-preview-clip-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 12px;
|
||||
min-width: 90px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ep-preview-clip-idx {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.ep-preview-clip-dur {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
background: rgba(79, 70, 229, 0.15);
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
进度条(可拖拽)
|
||||
============================================================ */
|
||||
.ep-preview-progress {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
padding: 7px var(--space-lg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #16161e;
|
||||
}
|
||||
|
||||
.ep-preview-progress-track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ep-preview-progress-segment {
|
||||
height: 100%;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ep-preview-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ep-preview-progress-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 0.1s ease;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ep-preview-progress:hover .ep-preview-progress-handle {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
迷你时间线
|
||||
============================================================ */
|
||||
.ep-preview-timeline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
gap: 2px;
|
||||
padding: 0 var(--space-lg);
|
||||
background: #12121a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ep-preview-timeline-seg {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
min-width: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ep-preview-timeline-seg:hover {
|
||||
opacity: 0.85;
|
||||
transform: scaleY(1.08);
|
||||
}
|
||||
|
||||
.ep-preview-timeline-seg.active {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.ep-preview-timeline-seg.selected {
|
||||
box-shadow: 0 0 0 2px var(--primary-color);
|
||||
}
|
||||
|
||||
.ep-preview-timeline-seg-label {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 播放头 */
|
||||
.ep-preview-playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
片段内进度条
|
||||
============================================================ */
|
||||
.ep-preview-clip-progress {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ep-preview-clip-progress-fill {
|
||||
height: 100%;
|
||||
transition: width 0.1s linear;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@media (max-width: 1200px) {
|
||||
.ep-preview-screen {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ep-preview-screen {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.ep-preview-type-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.ep-preview-subtitle {
|
||||
font-size: 13px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.ep-preview-time {
|
||||
min-width: 70px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ep-preview-clip-info {
|
||||
min-width: 70px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ep-preview-screen {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.ep-preview-controls {
|
||||
padding: 6px var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.ep-preview-btn-play {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ep-preview-timeline {
|
||||
height: 22px;
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ep-preview-screen {
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.ep-preview-type-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.ep-preview-subtitle {
|
||||
font-size: 12px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ep-preview-clip-badge,
|
||||
.ep-preview-material-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.ep-preview-clip-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ep-preview-time {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getFontFamily } from "@/pages/generate/constants"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
titleConfig?: TitleConfig
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onPlayPause: () => void
|
||||
}
|
||||
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
}
|
||||
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
onPlayPause,
|
||||
}) => {
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId)
|
||||
const displayClip = selectedClip || clips[0]
|
||||
|
||||
return (
|
||||
<div className="ep-preview-area">
|
||||
{/* 手机模型预览 */}
|
||||
<div className="ep-phone-preview">
|
||||
<div className="ep-phone-status-bar">
|
||||
<span>9:41</span>
|
||||
<span>📶 🔋</span>
|
||||
</div>
|
||||
<div className="ep-phone-content">
|
||||
{displayClip ? (
|
||||
<>
|
||||
<button className="ep-phone-play-btn" onClick={onPlayPause}>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<div className="ep-phone-progress">
|
||||
<div
|
||||
className="ep-phone-progress-fill"
|
||||
style={{ width: isPlaying ? "45%" : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-phone-clip-label">
|
||||
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}{" "}
|
||||
{CLIP_TYPE_LABELS[displayClip.type] || "片段"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="ep-phone-empty-hint">暂无片段</span>
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 */}
|
||||
{titleConfig && !titleConfig.ai_auto_select && titleConfig.content && (
|
||||
<div
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
|
||||
fontFamily: getFontFamily(titleConfig.font_preset),
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.6)",
|
||||
top:
|
||||
titleConfig.position === "top"
|
||||
? "6.25%"
|
||||
: titleConfig.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: titleConfig.position === "bottom" ? "30px" : "auto",
|
||||
transform: titleConfig.position === "center" ? "translateY(-50%)" : "none",
|
||||
color: titleConfig.font_color,
|
||||
}}
|
||||
>
|
||||
{titleConfig.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字幕实时预览 */}
|
||||
{subtitleSettings?.enabled && (
|
||||
<div
|
||||
className="ep-preview-subtitle"
|
||||
style={{
|
||||
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
|
||||
fontFamily: getFontFamily(subtitleSettings.font),
|
||||
top:
|
||||
subtitleSettings.position === "top"
|
||||
? "8px"
|
||||
: subtitleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: subtitleSettings.position === "bottom" ? "8px" : "auto",
|
||||
transform: subtitleSettings.position === "center" ? "translateY(-50%)" : "none",
|
||||
}}
|
||||
>
|
||||
字幕预览文字
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewPlayer
|
||||
@@ -1,176 +0,0 @@
|
||||
import React from "react"
|
||||
import ClipPropertiesPanel from "./ClipPropertiesPanel"
|
||||
import EditorClipList from "./EditorClipList"
|
||||
import type { ClipData } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface RightPanelProps {
|
||||
titleConfig?: TitleConfig
|
||||
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
rightTab: "properties" | "clips"
|
||||
onTabChange: (tab: "properties" | "clips") => void
|
||||
// 属性 tab
|
||||
selectedClip: ClipData | null
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
bgmSettings: BgmMixConfig
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentMode: TemplateMode
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleStyleConfig>) => void
|
||||
onBgmSettingsChange: (partial: Partial<BgmMixConfig>) => void
|
||||
onClipUpdate: (clipId: string, updates: Partial<ClipData>) => void
|
||||
onOpenBgmDrawer: () => void
|
||||
onOpenSubtitleDrawer: () => void
|
||||
voiceMaterials: AssetItem[]
|
||||
voiceMaterialsLoading: boolean
|
||||
onRefreshVoiceMaterials: () => void
|
||||
onClipVoiceSelect: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer: (clipId?: string) => void
|
||||
onOpenSpeedDrawer: (clipId: string) => void
|
||||
onOpenTtsDrawer: (clipId: string) => void
|
||||
onOpenWatermarkDrawer: () => void
|
||||
onOpenIntroOutroDrawer: () => void
|
||||
onOpenPipDrawer: () => void
|
||||
onOpenFilterDrawer: () => void
|
||||
onOpenGreenScreenDrawer: () => void
|
||||
onOpenStickerDrawer: () => void
|
||||
// 片段 tab
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
onClipSelect: (clipId: string) => void
|
||||
onClipMoveUp: (clipId: string) => void
|
||||
onClipMoveDown: (clipId: string) => void
|
||||
onClipRemove: (clipId: string) => void
|
||||
onClipAdd: () => void
|
||||
}
|
||||
|
||||
const RightPanel: React.FC<RightPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
rightTab,
|
||||
onTabChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials,
|
||||
voiceMaterialsLoading,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
clips,
|
||||
selectedClipId,
|
||||
onClipSelect,
|
||||
onClipMoveUp,
|
||||
onClipMoveDown,
|
||||
onClipRemove,
|
||||
onClipAdd,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-right-tabs">
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
|
||||
onClick={() => onTabChange("properties")}
|
||||
>
|
||||
属性
|
||||
</button>
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
|
||||
onClick={() => onTabChange("clips")}
|
||||
>
|
||||
片段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 属性 Tab */}
|
||||
{rightTab === "properties" && (
|
||||
<div className="ep-right-tab-content">
|
||||
{(() => {
|
||||
// ClipPropertiesPanel 内部类型与主文件类型结构一致但字段细节不同
|
||||
// 使用 unknown 作为中间类型避免 any 警告
|
||||
const sub = subtitleSettings as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["subtitleSettings"]
|
||||
const bgm = bgmSettings as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["bgmSettings"]
|
||||
const onSubChange = onSubtitleSettingsChange as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["onSubtitleSettingsChange"]
|
||||
const onBgmChange = onBgmSettingsChange as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["onBgmSettingsChange"]
|
||||
return (
|
||||
<ClipPropertiesPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={onTitleConfigChange}
|
||||
selectedClip={selectedClip}
|
||||
subtitleSettings={sub}
|
||||
bgmSettings={bgm}
|
||||
clipsCount={clipsCount}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onSubtitleSettingsChange={onSubChange}
|
||||
onBgmSettingsChange={onBgmChange}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onOpenBgmDrawer={onOpenBgmDrawer}
|
||||
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={onOpenWatermarkDrawer}
|
||||
onOpenIntroOutroDrawer={onOpenIntroOutroDrawer}
|
||||
onOpenPipDrawer={onOpenPipDrawer}
|
||||
onOpenFilterDrawer={onOpenFilterDrawer}
|
||||
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
|
||||
onOpenStickerDrawer={onOpenStickerDrawer}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段 Tab */}
|
||||
{rightTab === "clips" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<EditorClipList
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
onSelect={onClipSelect}
|
||||
onMoveUp={onClipMoveUp}
|
||||
onMoveDown={onClipMoveDown}
|
||||
onRemove={onClipRemove}
|
||||
onAdd={onClipAdd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightPanel
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 保存/更新模板弹窗 — V21 设计系统
|
||||
* 分类使用 Select 关联后端分类 API(P1-5)
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal, Input, Select } from "@/components/ui"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
|
||||
interface SaveModalProps {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
isUpdate: boolean
|
||||
draftName: string
|
||||
draftCategory: string
|
||||
draftTags: string
|
||||
categories: TemplateCategory[]
|
||||
estimatedDuration: number
|
||||
onNameChange: (v: string) => void
|
||||
onCategoryChange: (v: string) => void
|
||||
onTagsChange: (v: string) => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const SaveModal: React.FC<SaveModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={isUpdate ? "更新模板" : "保存模板"}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onSave}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="ep-modal-field">
|
||||
<label>模板名称 *</label>
|
||||
<Input
|
||||
placeholder="输入模板名称"
|
||||
value={draftName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ep-modal-field">
|
||||
<label>分类</label>
|
||||
<Select
|
||||
placeholder="选择分类"
|
||||
value={draftCategory || undefined}
|
||||
onChange={(v: string) => onCategoryChange(v || "")}
|
||||
allowClear
|
||||
showSearch
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ep-modal-field">
|
||||
<label>标签(逗号分隔)</label>
|
||||
<Input
|
||||
placeholder="例如:vlog, 日常"
|
||||
value={draftTags}
|
||||
onChange={(e) => onTagsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ep-modal-info">
|
||||
⏱️ 预估时长:<strong>~{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveModal
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 片段调速面板 — Drawer 形式
|
||||
* 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关
|
||||
* 支持应用到当前片段 / 所有片段
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Slider } from "antd"
|
||||
import type { SpeedConfig } from "../types"
|
||||
import { DEFAULT_SPEED } from "../types"
|
||||
|
||||
/* ──────────── 预设速度 ──────────── */
|
||||
const SPEED_PRESETS: { rate: number; label: string }[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
]
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface SpeedPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前片段调速配置 */
|
||||
config: SpeedConfig
|
||||
onChange: (config: SpeedConfig) => void
|
||||
/** 应用到所有片段 */
|
||||
onApplyAll?: (config: SpeedConfig) => void
|
||||
}
|
||||
|
||||
const SpeedPanel: React.FC<SpeedPanelProps> = ({ open, onClose, config, onChange, onApplyAll }) => {
|
||||
/* ── 修改速度 ── */
|
||||
const handleChangeRate = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 切换音调修正 ── */
|
||||
const handleTogglePitch = useCallback(() => {
|
||||
onChange({ ...config, pitchCorrection: !config.pitchCorrection })
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 选择预设 ── */
|
||||
const handlePreset = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 应用到所有片段 ── */
|
||||
const handleApplyAll = useCallback(() => {
|
||||
onApplyAll?.(config)
|
||||
}, [config, onApplyAll])
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_SPEED })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 速度描述文字 ── */
|
||||
const speedLabel =
|
||||
config.rate < 1
|
||||
? "慢速(慢动作)"
|
||||
: config.rate === 1
|
||||
? "原速"
|
||||
: config.rate < 2
|
||||
? "快速"
|
||||
: "极速"
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
>
|
||||
{/* ── 速度滑块 ── */}
|
||||
<div className="sp-speed-section">
|
||||
<div className="sp-speed-header">
|
||||
<span className="sp-speed-label">播放速度</span>
|
||||
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.05}
|
||||
value={config.rate}
|
||||
onChange={handleChangeRate}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="sp-speed-marks">
|
||||
<span>0.25x</span>
|
||||
<span>1x</span>
|
||||
<span>2x</span>
|
||||
<span>4x</span>
|
||||
</div>
|
||||
<div className="sp-speed-desc">{speedLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* ── 预设快捷按钮 ── */}
|
||||
<div className="sp-presets">
|
||||
<div className="sp-presets-label">快捷预设</div>
|
||||
<div className="sp-presets-row">
|
||||
{SPEED_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.rate}
|
||||
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
|
||||
onClick={() => handlePreset(p.rate)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 音调修正开关 ── */}
|
||||
<div className="sp-pitch-section">
|
||||
<div className="sp-pitch-info">
|
||||
<span className="sp-pitch-label">音调修正</span>
|
||||
<span className="sp-pitch-desc">
|
||||
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
|
||||
onClick={handleTogglePitch}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="sp-footer">
|
||||
<button className="sp-reset-btn" onClick={handleReset}>
|
||||
重置原速
|
||||
</button>
|
||||
{onApplyAll && (
|
||||
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
|
||||
应用到所有片段
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpeedPanel
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface StatusBarProps {
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentModeLabel: string
|
||||
templateSegments: number
|
||||
}
|
||||
|
||||
const StatusBar: React.FC<StatusBarProps> = ({
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentModeLabel,
|
||||
templateSegments,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-status-bar">
|
||||
<div className="ep-status-left">
|
||||
<span>📋 片段: {clipsCount}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>⏱️ 总时长: {totalDuration.toFixed(1)}s</span>
|
||||
</div>
|
||||
<div className="ep-status-right">
|
||||
<span>🎬 {currentModeLabel}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>📐 模板片段: {templateSegments}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusBar
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "../../../types"
|
||||
|
||||
export interface StickerListProps {
|
||||
items: StickerItem[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export const StickerList: React.FC<StickerListProps> = ({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRemove,
|
||||
}) => {
|
||||
if (items.length === 0) return null
|
||||
|
||||
const getStickerDisplay = (item: StickerItem) => {
|
||||
if (item.type === "emoji") return item.content
|
||||
if (item.type === "text") return item.content.slice(0, 10)
|
||||
return "🖼"
|
||||
}
|
||||
|
||||
const getStickerName = (item: StickerItem) => {
|
||||
if (item.type === "text") return item.content.slice(0, 10)
|
||||
if (item.type === "emoji") return "表情贴纸"
|
||||
return "图片贴纸"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">已添加贴纸 ({items.length})</div>
|
||||
<div className="sticker-list">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">{getStickerDisplay(item)}</span>
|
||||
<span className="sticker-list-name">{getStickerName(item)}</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(item.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "../../../types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "../../../types"
|
||||
import { TEXT_PRESET_STYLES } from "../constants"
|
||||
|
||||
export interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
totalDuration: number
|
||||
onChange: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
totalDuration,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<StickerItem>) => {
|
||||
onChange(sticker.id, partial)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 X */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.x}
|
||||
onChange={(e) => update({ x: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.x}%</span>
|
||||
</div>
|
||||
|
||||
{/* 位置 Y */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.y}
|
||||
onChange={(e) => update({ y: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={sticker.width}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={sticker.rotation}
|
||||
onChange={(e) => update({ rotation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.rotation}°</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sticker.opacity}
|
||||
onChange={(e) => update({ opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.opacity}%</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.start_time}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val >= 0 && val <= totalDuration) update({ start_time: val })
|
||||
}}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={sticker.duration}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value)
|
||||
if (!isNaN(val) && val > 0 && val <= totalDuration) update({ duration: val })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) => update({ text_preset: e.target.value as TextStickerPreset })}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => update({ font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => update({ text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import type { StickerType, TextStickerPreset } from "../../../types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "../../../types"
|
||||
import { EMOJI_LIST, TEXT_PRESET_STYLES } from "../constants"
|
||||
|
||||
export interface StickerTabsProps {
|
||||
activeTab: StickerType
|
||||
onTabChange: (tab: StickerType) => void
|
||||
textInput: string
|
||||
onTextInputChange: (val: string) => void
|
||||
onAddEmoji: (emoji: string) => void
|
||||
onAddImage: (url: string) => void
|
||||
onAddText: (text: string) => void
|
||||
}
|
||||
|
||||
export const StickerTabs: React.FC<StickerTabsProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
textInput,
|
||||
onTextInputChange,
|
||||
onAddEmoji,
|
||||
onAddImage,
|
||||
onAddText,
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState("")
|
||||
|
||||
const handleImageAdd = () => {
|
||||
if (imageUrl.trim()) {
|
||||
onAddImage(imageUrl.trim())
|
||||
setImageUrl("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextAdd = () => {
|
||||
if (textInput.trim()) {
|
||||
onAddText(textInput.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
||||
onClick={() => onTabChange(t)}
|
||||
>
|
||||
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button key={emoji} className="sticker-emoji-btn" onClick={() => onAddEmoji(emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && imageUrl.trim()) {
|
||||
onAddImage(imageUrl.trim())
|
||||
setImageUrl("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="sticker-url-add-btn" onClick={handleImageAdd}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => onTextInputChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={handleTextAdd}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import type { TextStickerPreset } from "../../types"
|
||||
|
||||
/** 常用 emoji 素材 */
|
||||
export const EMOJI_LIST = [
|
||||
"😀",
|
||||
"😂",
|
||||
"🥰",
|
||||
"😎",
|
||||
"🤩",
|
||||
"😱",
|
||||
"🤔",
|
||||
"😴",
|
||||
"🥳",
|
||||
"😍",
|
||||
"❤️",
|
||||
"🔥",
|
||||
"⭐",
|
||||
"✨",
|
||||
"💯",
|
||||
"👍",
|
||||
"👏",
|
||||
"🎉",
|
||||
"🎵",
|
||||
"💪",
|
||||
"📌",
|
||||
"💡",
|
||||
"🎯",
|
||||
"✅",
|
||||
"❌",
|
||||
"⬆️",
|
||||
"⬇️",
|
||||
"➡️",
|
||||
"⭕",
|
||||
"🔔",
|
||||
]
|
||||
|
||||
/** 文字花字预设对应的 CSS 样式预览 */
|
||||
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, CSSProperties> = {
|
||||
normal: { color: "#fff", textShadow: "none" },
|
||||
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
||||
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
||||
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
||||
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
||||
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
||||
gradient: {
|
||||
color: "transparent",
|
||||
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
||||
WebkitBackgroundClip: "text",
|
||||
},
|
||||
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
||||
}
|
||||
|
||||
export const genStickerId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import type { StickerConfig, StickerItem, StickerType } from "../../../types"
|
||||
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM } from "../../../types"
|
||||
import { genStickerId } from "../constants"
|
||||
|
||||
interface UseStickerPanelOptions {
|
||||
config: StickerConfig
|
||||
onChange: (config: StickerConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
export const useStickerPanel = ({ config, onChange, totalDuration }: UseStickerPanelOptions) => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
|
||||
const [textInput, setTextInput] = useState("")
|
||||
|
||||
const selectedSticker = useMemo(
|
||||
() => config.items.find((s) => s.id === selectedId) ?? null,
|
||||
[config.items, selectedId],
|
||||
)
|
||||
|
||||
/** 更新单个贴纸 */
|
||||
const updateItem = useCallback(
|
||||
(id: string, partial: Partial<StickerItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/** 添加贴纸 */
|
||||
const addSticker = useCallback(
|
||||
(type: StickerType, content: string) => {
|
||||
const newItem: StickerItem = {
|
||||
...DEFAULT_STICKER_ITEM,
|
||||
id: genStickerId(),
|
||||
type,
|
||||
content,
|
||||
duration: totalDuration > 0 ? totalDuration : 5,
|
||||
z_index: config.items.length > 0 ? Math.max(...config.items.map((i) => i.z_index)) + 1 : 1,
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
items: [...config.items, newItem],
|
||||
})
|
||||
setSelectedId(newItem.id)
|
||||
},
|
||||
[config, onChange, totalDuration],
|
||||
)
|
||||
|
||||
/** 删除贴纸 */
|
||||
const removeSticker = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.filter((s) => s.id !== id),
|
||||
})
|
||||
if (selectedId === id) setSelectedId(null)
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
)
|
||||
|
||||
/** 重置所有 */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
|
||||
setSelectedId(null)
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedSticker,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
textInput,
|
||||
setTextInput,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { StickerConfig } from "../../types"
|
||||
|
||||
import { useStickerPanel } from "./hooks/useStickerPanel"
|
||||
import { StickerTabs } from "./components/StickerTabs"
|
||||
import { StickerList } from "./components/StickerList"
|
||||
import { StickerPropsEditor } from "./components/StickerPropsEditor"
|
||||
|
||||
export interface StickerPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: StickerConfig
|
||||
onChange: (config: StickerConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedSticker,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
textInput,
|
||||
setTextInput,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
} = useStickerPanel({ config, onChange, totalDuration })
|
||||
|
||||
const handleEmojiAdd = (emoji: string) => {
|
||||
addSticker("emoji", emoji)
|
||||
}
|
||||
|
||||
const handleImageAdd = (url: string) => {
|
||||
addSticker("image", url)
|
||||
}
|
||||
|
||||
const handleTextAdd = (text: string) => {
|
||||
addSticker("text", text)
|
||||
setTextInput("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="贴纸"
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="sticker-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="sticker-header">
|
||||
<span className="sticker-header-label">启用贴纸</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => onChange({ ...config, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab 选择器 + 内容 */}
|
||||
<StickerTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
textInput={textInput}
|
||||
onTextInputChange={setTextInput}
|
||||
onAddEmoji={handleEmojiAdd}
|
||||
onAddImage={handleImageAdd}
|
||||
onAddText={handleTextAdd}
|
||||
/>
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
<StickerList
|
||||
items={config.items}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onRemove={removeSticker}
|
||||
/>
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
{selectedSticker && (
|
||||
<StickerPropsEditor
|
||||
sticker={selectedSticker}
|
||||
totalDuration={totalDuration}
|
||||
onChange={updateItem}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickerPanel
|
||||
@@ -1,147 +0,0 @@
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import {
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
ASR_LANGUAGE_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
|
||||
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
|
||||
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
|
||||
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: SubtitleStyleConfig
|
||||
onChange: (config: SubtitleStyleConfig) => void
|
||||
}
|
||||
|
||||
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<SubtitleStyleConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle${config.enabled ? " active" : ""}`}
|
||||
onClick={() => update({ enabled: !config.enabled })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
|
||||
</div>
|
||||
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.asrLanguage}
|
||||
onChange={(v) => update({ asrLanguage: v })}
|
||||
options={ASR_LANGUAGE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={12}
|
||||
max={48}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => update({ fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
<ColorPicker
|
||||
value={config.fontColor}
|
||||
onChange={(_color: Color, hex: string) => update({ fontColor: hex })}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.font}
|
||||
onChange={(v) => update({ font: v })}
|
||||
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<SubtitlePositionSelector
|
||||
position={config.position}
|
||||
onPositionChange={(position) => update({ position })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<SubtitleEffectButtons
|
||||
stroke={config.stroke}
|
||||
shadow={config.shadow}
|
||||
onStrokeChange={(stroke) => update({ stroke })}
|
||||
onShadowChange={(shadow) => update({ shadow })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.animation}
|
||||
onChange={(v) => update({ animation: v })}
|
||||
options={ANIMATION_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubtitleStylePanel
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||||
* 时间标尺 + 片段轨道 + HTML5拖拽排序
|
||||
*
|
||||
* 子组件(timeline/):
|
||||
* - TimeRuler - 时间标尺
|
||||
* - ClipCard - 片段卡片
|
||||
* - ClipTrack - 片段轨道(播放头+片段列表+添加卡片)
|
||||
* - TimelineHeader - 时间线头部(标题+缩放+操作按钮)
|
||||
* - TrimPreview - 裁剪预览 tooltip
|
||||
* - ContextMenu - 右键菜单
|
||||
*
|
||||
* Hooks:
|
||||
* - useClipDrag - 片段拖拽排序
|
||||
* - useTrimDrag - 裁剪拖拽
|
||||
* - useTimelineMenus - 菜单 & 面板
|
||||
* - usePlayheadDrag - 播放头拖拽
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||||
import { useClipDrag } from "../hooks/useClipDrag"
|
||||
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||
import { usePlayheadDrag } from "./timeline/hooks/usePlayheadDrag"
|
||||
import { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { ClipTrack } from "./timeline/ClipTrack"
|
||||
import { TimelineHeader } from "./timeline/TimelineHeader"
|
||||
import { TrimPreview } from "./timeline/TrimPreview"
|
||||
import { ContextMenu } from "./timeline/ContextMenu"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
currentMode: string
|
||||
onClipSelect: (clipId: string) => void
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void
|
||||
onClipRemove: (clipId: string) => void
|
||||
onAddClip: (type: ClipType, duration: number) => void
|
||||
/** 裁剪更新:调整片段的 trim_config 和 duration */
|
||||
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void
|
||||
/** 在指定位置分割片段 */
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void
|
||||
/** 恢复片段原始长度 */
|
||||
onClipResetTrim?: (clipId: string) => void
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime?: number
|
||||
/** 缩放:每秒像素数 */
|
||||
pixelsPerSecond?: number
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void
|
||||
/** 总时长(秒),可选(默认由 clips 计算) */
|
||||
totalDuration?: number
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
currentMode,
|
||||
onClipSelect,
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAddClip,
|
||||
onClipTrim,
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
currentTime = 0,
|
||||
pixelsPerSecond = 40,
|
||||
onZoomChange,
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 裁剪拖拽 ── */
|
||||
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDrop,
|
||||
handleEmptyDragOver,
|
||||
} = useClipDrag(onClipReorder, !!trimDrag)
|
||||
|
||||
/* ── 菜单 & 面板 ── */
|
||||
const {
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
addCardRef,
|
||||
handleConfirmAdd,
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||
|
||||
/* ── 播放头拖拽 ── */
|
||||
const { trackRef, handlePlayheadMouseDown, handleRulerClick } = usePlayheadDrag({
|
||||
currentTime,
|
||||
pps,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
})
|
||||
|
||||
/* ── 撤销最后一个片段 ── */
|
||||
const handleUndoClip = () => {
|
||||
if (clips.length > 1) {
|
||||
const last = clips[clips.length - 1]
|
||||
onClipRemove(last.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
<TimelineHeader
|
||||
totalDuration={totalDuration}
|
||||
pps={pps}
|
||||
onZoomChange={onZoomChange}
|
||||
onUndoClip={handleUndoClip}
|
||||
clipsCount={clips.length}
|
||||
/>
|
||||
|
||||
{/* 一镜到底提示 */}
|
||||
{currentMode === "one_take" && <div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>}
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
|
||||
)}
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<ClipTrack
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
pps={pps}
|
||||
currentTime={currentTime}
|
||||
totalDuration={totalDuration}
|
||||
dragIdx={dragIdx}
|
||||
dragOverIdx={dragOverIdx}
|
||||
hoveredClipId={hoveredClipId}
|
||||
trimDragActive={!!trimDrag}
|
||||
showTrimHandles={!!onClipTrim}
|
||||
trackRef={trackRef}
|
||||
addCardRef={addCardRef}
|
||||
onPlayheadMouseDown={handlePlayheadMouseDown}
|
||||
onClipSelect={onClipSelect}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onEmptyDragOver={handleEmptyDragOver}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipMouseEnter={setHoveredClipId}
|
||||
onClipMouseLeave={() => setHoveredClipId(null)}
|
||||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||||
onClipRemove={onClipRemove}
|
||||
onTogglePicker={handleConfirmAdd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<TrimPreview
|
||||
startTime={trimPreview.startTime}
|
||||
endTime={trimPreview.endTime}
|
||||
duration={trimPreview.duration}
|
||||
x={trimPreview.x}
|
||||
y={trimPreview.y}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
menuRef={contextMenuRef}
|
||||
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
|
||||
onSplit={handleContextSplit}
|
||||
onResetTrim={handleContextResetTrim}
|
||||
onDelete={handleContextDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimelinePanel
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface TopBarProps {
|
||||
currentTemplate: EditingTemplate | null
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onOpenSaveModal: () => void
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({
|
||||
currentTemplate,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onOpenSaveModal,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-top-bar">
|
||||
<div className="ep-top-bar-left">
|
||||
<span className="ep-logo">✂️</span>
|
||||
<span className="ep-app-title">模板制作</span>
|
||||
<span className="ep-divider">|</span>
|
||||
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
|
||||
</div>
|
||||
<div className="ep-top-bar-right">
|
||||
<button
|
||||
className="ep-btn ep-btn-secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="撤销 (Ctrl+Z)"
|
||||
>
|
||||
⬅️ 撤销
|
||||
</button>
|
||||
<button
|
||||
className="ep-btn ep-btn-secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="重做 (Ctrl+Shift+Z)"
|
||||
>
|
||||
➡️ 重做
|
||||
</button>
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onOpenSaveModal}>
|
||||
💾 保存模板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBar
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 转场特效选择器 — Drawer 形式
|
||||
* 14 种转场预设卡片网格 + 转场时长滑块
|
||||
* 支持全局默认转场 + 单个片段间独立设置
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Slider } from "antd"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { TransitionConfig, TransitionType } from "../types"
|
||||
import { DEFAULT_TRANSITION } from "../types"
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TransitionSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前转场配置 */
|
||||
config: TransitionConfig
|
||||
onChange: (config: TransitionConfig) => void
|
||||
/** 标题提示(区分全局 / 片段间) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
title = "转场特效",
|
||||
}) => {
|
||||
/* ── 选择转场类型 ── */
|
||||
const handleSelectType = useCallback(
|
||||
(type: TransitionType) => {
|
||||
onChange({ ...config, type })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 修改时长 ── */
|
||||
const handleChangeDuration = useCallback(
|
||||
(duration: number) => {
|
||||
onChange({ ...config, duration })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 重置为无转场 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TRANSITION })
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
>
|
||||
{/* ── 时长滑块 ── */}
|
||||
<div className="ts-duration-section">
|
||||
<div className="ts-duration-header">
|
||||
<span className="ts-duration-label">转场时长</span>
|
||||
<span className="ts-duration-value">{config.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.duration}
|
||||
onChange={handleChangeDuration}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
|
||||
/>
|
||||
<div className="ts-duration-marks">
|
||||
<span>0.3s</span>
|
||||
<span>1.0s</span>
|
||||
<span>2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 转场类型卡片网格 ── */}
|
||||
<div className="ts-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => {
|
||||
const isActive = config.type === opt.value
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`ts-card${isActive ? " active" : ""}`}
|
||||
onClick={() => handleSelectType(opt.value)}
|
||||
>
|
||||
<div className="ts-card-icon">{opt.icon}</div>
|
||||
<div className="ts-card-name">{opt.label}</div>
|
||||
{isActive && <span className="ts-card-check">✓</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="ts-footer">
|
||||
<button className="ts-reset-btn" onClick={handleReset}>
|
||||
重置为无转场
|
||||
</button>
|
||||
<div className="ts-current">
|
||||
当前:
|
||||
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ?? "无转场"}
|
||||
{" · "}
|
||||
{config.duration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransitionSelector
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
|
||||
export interface TtsSliderProps {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
onChange: (val: number) => void
|
||||
formatter: (val: number) => string
|
||||
marks?: string[]
|
||||
}
|
||||
|
||||
export const TtsSlider: React.FC<TtsSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
formatter,
|
||||
marks,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">{label}</span>
|
||||
<span className="tts-slider-value">{formatter(value)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tooltip={{ formatter: (v) => (v != null ? formatter(v as number) : "") }}
|
||||
/>
|
||||
{marks && (
|
||||
<div className="tts-slider-marks">
|
||||
{marks.map((m, i) => (
|
||||
<span key={i}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface VoiceSelectorProps {
|
||||
presetVoices: UnifiedVoiceItem[]
|
||||
cloneVoices: UnifiedVoiceItem[]
|
||||
selectedVoiceId: string
|
||||
loading: boolean
|
||||
playingVoiceId: string | null
|
||||
loadingSampleVoiceId: string | null
|
||||
onSelect: (voiceId: string) => void
|
||||
onPlaySample: (voice: UnifiedVoiceItem) => void
|
||||
}
|
||||
|
||||
type VoiceTab = "preset" | "clone"
|
||||
|
||||
const GENDER_ICON: Record<string, string> = {
|
||||
male: "👨",
|
||||
female: "👩",
|
||||
young: "🧑",
|
||||
}
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
selectedVoiceId,
|
||||
loading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
onSelect,
|
||||
onPlaySample,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<VoiceTab>("preset")
|
||||
|
||||
const currentList = activeTab === "preset" ? presetVoices : cloneVoices
|
||||
|
||||
return (
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{loading && <span className="tts-voice-loading">加载中...</span>}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="tts-voice-tabs">
|
||||
<button
|
||||
className={`tts-voice-tab${activeTab === "preset" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("preset")}
|
||||
>
|
||||
预设音色
|
||||
</button>
|
||||
<button
|
||||
className={`tts-voice-tab${activeTab === "clone" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("clone")}
|
||||
>
|
||||
我的克隆
|
||||
{cloneVoices.length > 0 && (
|
||||
<span className="tts-voice-tab-count">{cloneVoices.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="tts-voice-empty">⏳ 加载中...</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="tts-voice-empty">
|
||||
{activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tts-voice-list">
|
||||
{currentList.map((voice) => {
|
||||
const isSelected = selectedVoiceId === voice.voice_id
|
||||
const isPlaying = playingVoiceId === voice.voice_id
|
||||
const isLoadingSample = loadingSampleVoiceId === voice.voice_id
|
||||
const icon = GENDER_ICON[voice.gender] ?? "✨"
|
||||
return (
|
||||
<div
|
||||
key={voice.voice_id}
|
||||
className={`tts-voice-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(voice.voice_id)}
|
||||
>
|
||||
<div className="tts-voice-item-icon">{icon}</div>
|
||||
<div className="tts-voice-item-info">
|
||||
<div className="tts-voice-item-name">{voice.name}</div>
|
||||
<div className="tts-voice-item-desc">{voice.description || voice.language}</div>
|
||||
</div>
|
||||
<button
|
||||
className="tts-voice-item-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlaySample(voice)
|
||||
}}
|
||||
disabled={isLoadingSample}
|
||||
title={isPlaying ? "暂停" : isLoadingSample ? "合成中" : "试听"}
|
||||
>
|
||||
{isLoadingSample ? <LoadingOutlined /> : isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { TtsMode } from "../../types"
|
||||
|
||||
/** 音色卡片分类图标 */
|
||||
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
}
|
||||
|
||||
/** 配音模式选项 */
|
||||
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
|
||||
{ mode: "none", icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload", icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
|
||||
]
|
||||
@@ -1,271 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TtsConfig, TtsMode } from "../../../types"
|
||||
import { DEFAULT_TTS_CONFIG } from "../../../types"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"
|
||||
|
||||
interface UseTtsPanelOptions {
|
||||
open: boolean
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
|
||||
/* ── 音色列表(统一音色:预设 + 克隆) ── */
|
||||
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [voicesLoading, setVoicesLoading] = useState(false)
|
||||
|
||||
// 用 ref 保存最新 config 和 onChange,避免 useEffect 闭包陷阱
|
||||
const configRef = useRef(config)
|
||||
const onChangeRef = useRef(onChange)
|
||||
configRef.current = config
|
||||
onChangeRef.current = onChange
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
/* ── 加载音色列表 + 重置挂载状态 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
mountedRef.current = true
|
||||
let active = true
|
||||
setVoicesLoading(true)
|
||||
fetchVoices({ status: "ready", limit: 100 })
|
||||
.then((res) => {
|
||||
if (!active) return
|
||||
const presets = res.items.filter((v) => v.type === "preset")
|
||||
const clones = res.items.filter((v) => v.type === "clone")
|
||||
setPresetVoices(presets)
|
||||
setCloneVoices(clones)
|
||||
// 如果当前没有选中音色,默认选第一个预设(用 ref 读最新值,避免闭包旧值覆盖用户选择)
|
||||
if (!configRef.current.voice_id && presets.length > 0) {
|
||||
onChangeRef.current({
|
||||
...configRef.current,
|
||||
voice_id: presets[0].voice_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) message.error("加载音色列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setVoicesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000)
|
||||
onChange({ ...config, text })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本")
|
||||
return
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色")
|
||||
return
|
||||
}
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200),
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(res.audio_url)
|
||||
audioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (mountedRef.current) message.success("试听播放中")
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) message.error("播放失败")
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current) audioRef.current = null
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) message.error("试听生成失败")
|
||||
} finally {
|
||||
if (mountedRef.current) setPreviewLoading(false)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
/* ── 单音色示例试听 ── */
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
|
||||
const [loadingSampleVoiceId, setLoadingSampleVoiceId] = useState<string | null>(null)
|
||||
const handlePlayVoiceSample = useCallback(
|
||||
async (voice: UnifiedVoiceItem) => {
|
||||
const voiceId = voice.voice_id
|
||||
if (playingVoiceId === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
return
|
||||
}
|
||||
// 暂停上一个音频并清除事件监听,避免竞态
|
||||
if (audioRef.current) {
|
||||
audioRef.current.onended = null
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
let audioUrl: string | null = voice.preview_url ?? voice.audio_url
|
||||
|
||||
// 克隆音色:调用试听接口实时合成
|
||||
if (voice.type === "clone" && voice.voice_clone_profile_id) {
|
||||
setLoadingSampleVoiceId(voiceId)
|
||||
try {
|
||||
const preview = await getVoiceClonePreview(voice.voice_clone_profile_id)
|
||||
audioUrl = preview.audio_url
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
message.error("试听音频生成失败,请重试")
|
||||
}
|
||||
setLoadingSampleVoiceId(null)
|
||||
return
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoadingSampleVoiceId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioUrl) {
|
||||
message.warning("该音色暂无示例音频")
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
// 只有当前活跃的音频实例才能更新状态,防止快速切换竞态
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
setPlayingVoiceId(voiceId)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
message.error("播放失败")
|
||||
audioRef.current = null
|
||||
}
|
||||
})
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current && audioRef.current === audio) {
|
||||
setPlayingVoiceId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[playingVoiceId],
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
mountedRef.current = false
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
return {
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handlePlayVoiceSample,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择(预设+克隆) + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { TtsConfig } from "../../types"
|
||||
|
||||
import { useTtsPanel } from "./hooks/useTtsPanel"
|
||||
import { VoiceSelector } from "./components/VoiceSelector"
|
||||
import { TtsSlider } from "./components/TtsSlider"
|
||||
import { TTS_MODE_OPTIONS } from "./constants"
|
||||
|
||||
export interface TtsPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前片段 TTS 配置 */
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
}
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
presetVoices,
|
||||
cloneVoices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
playingVoiceId,
|
||||
loadingSampleVoiceId,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handlePlayVoiceSample,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
} = useTtsPanel({ open, config, onChange, onClose })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎙️ TTS 配音"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="tts-panel-drawer"
|
||||
>
|
||||
{/* ── 配音模式切换 ── */}
|
||||
<div className="tts-mode-section">
|
||||
<div className="tts-mode-label">配音模式</div>
|
||||
<div className="tts-mode-group">
|
||||
{TTS_MODE_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m.mode)}
|
||||
>
|
||||
<span className="tts-mode-icon">{m.icon}</span>
|
||||
<span className="tts-mode-text">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TTS 配置(仅 tts 模式显示) ── */}
|
||||
{config.mode === "tts" && (
|
||||
<>
|
||||
{/* 文本输入 */}
|
||||
<div className="tts-text-section">
|
||||
<div className="tts-text-header">
|
||||
<span className="tts-text-label">合成文本</span>
|
||||
<span className="tts-text-count">{config.text.length}/5000</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<VoiceSelector
|
||||
presetVoices={presetVoices}
|
||||
cloneVoices={cloneVoices}
|
||||
selectedVoiceId={config.voice_id}
|
||||
loading={voicesLoading}
|
||||
playingVoiceId={playingVoiceId}
|
||||
loadingSampleVoiceId={loadingSampleVoiceId}
|
||||
onSelect={handleVoiceSelect}
|
||||
onPlaySample={handlePlayVoiceSample}
|
||||
/>
|
||||
|
||||
{/* 语速 */}
|
||||
<TtsSlider
|
||||
label="语速"
|
||||
value={config.speed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
onChange={handleSpeedChange}
|
||||
formatter={(v) => `${v.toFixed(2)}x`}
|
||||
marks={["0.5x", "1.0x", "2.0x"]}
|
||||
/>
|
||||
|
||||
{/* 语调 */}
|
||||
<TtsSlider
|
||||
label="语调"
|
||||
value={config.pitch}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
onChange={handlePitchChange}
|
||||
formatter={(v) => `${v > 0 ? "+" : ""}${v} 半音`}
|
||||
marks={["-12", "0", "+12"]}
|
||||
/>
|
||||
|
||||
{/* 音量 */}
|
||||
<TtsSlider
|
||||
label="音量"
|
||||
value={config.volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={handleVolumeChange}
|
||||
formatter={(v) => `${v}%`}
|
||||
/>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
<div className="tts-preview-section">
|
||||
<button className="tts-preview-btn" onClick={handlePreview} disabled={previewLoading}>
|
||||
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 字幕联动 */}
|
||||
<div className="tts-subtitle-section">
|
||||
<div className="tts-subtitle-info">
|
||||
<span className="tts-subtitle-label">字幕联动</span>
|
||||
<span className="tts-subtitle-desc">
|
||||
{config.subtitle_sync ? "TTS 文本自动同步到字幕" : "字幕需手动编辑"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
|
||||
onClick={handleSubtitleSyncToggle}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 上传配音模式提示 ── */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="tts-upload-hint">
|
||||
<p>请在右侧面板的「配音素材」中选择已上传的配音文件。</p>
|
||||
<p>如需上传新配音,请前往配音库页面。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
{config.mode === "tts" && (
|
||||
<div className="tts-footer">
|
||||
<button className="tts-reset-btn" onClick={handleReset}>
|
||||
重置默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsPanel
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 水印配置面板 — Drawer 形式
|
||||
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
|
||||
* 通用设置:位置、不透明度
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { WatermarkConfig } from "@/pages/editing-planner/types"
|
||||
import WatermarkTypeTabs from "./watermark/WatermarkTypeTabs"
|
||||
import ImageWatermarkSection from "./watermark/ImageWatermarkSection"
|
||||
import TextWatermarkSection from "./watermark/TextWatermarkSection"
|
||||
import ScrollWatermarkSection from "./watermark/ScrollWatermarkSection"
|
||||
import WatermarkCommonSection from "./watermark/WatermarkCommonSection"
|
||||
import { useWatermarkConfig } from "@/pages/editing-planner/hooks/useWatermarkConfig"
|
||||
|
||||
interface WatermarkPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: WatermarkConfig
|
||||
onChange: (config: WatermarkConfig) => void
|
||||
}
|
||||
|
||||
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
localImageUrl,
|
||||
handleTypeChange,
|
||||
handlePositionChange,
|
||||
handleOpacityChange,
|
||||
handleImageUrlChange,
|
||||
handleImageWidthChange,
|
||||
handleImageHeightChange,
|
||||
handleTextChange,
|
||||
handleFontSizeChange,
|
||||
handleColorChange,
|
||||
handleScrollDirectionChange,
|
||||
handleScrollSpeedChange,
|
||||
handleReset,
|
||||
} = useWatermarkConfig({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
>
|
||||
{/* ── Tab 切换 ── */}
|
||||
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
|
||||
|
||||
{/* ── 无水印提示 ── */}
|
||||
{config.type === "none" && (
|
||||
<div className="wp-empty-hint">
|
||||
<span className="wp-empty-icon">🚫</span>
|
||||
<p>当前未启用水印</p>
|
||||
<p className="wp-empty-desc">选择上方标签启用水印功能</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 图片水印配置 ── */}
|
||||
{config.type === "image" && (
|
||||
<ImageWatermarkSection
|
||||
imageUrl={config.image_url || ""}
|
||||
localImageUrl={localImageUrl}
|
||||
width={config.width}
|
||||
height={config.height}
|
||||
onImageUrlChange={handleImageUrlChange}
|
||||
onWidthChange={handleImageWidthChange}
|
||||
onHeightChange={handleImageHeightChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 文字水印配置 ── */}
|
||||
{config.type === "text" && (
|
||||
<TextWatermarkSection
|
||||
text={config.text || ""}
|
||||
fontSize={config.font_size ?? 24}
|
||||
color={config.color ?? "#ffffff"}
|
||||
onTextChange={handleTextChange}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 滚动水印配置 ── */}
|
||||
{config.type === "scroll" && (
|
||||
<ScrollWatermarkSection
|
||||
text={config.text || ""}
|
||||
scrollDirection={config.scroll_direction ?? "horizontal"}
|
||||
scrollSpeed={config.scroll_speed ?? 50}
|
||||
fontSize={config.font_size ?? 24}
|
||||
color={config.color ?? "#ffffff"}
|
||||
onTextChange={handleTextChange}
|
||||
onScrollDirectionChange={handleScrollDirectionChange}
|
||||
onScrollSpeedChange={handleScrollSpeedChange}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 通用设置(非 none 时显示) ── */}
|
||||
{config.type !== "none" && (
|
||||
<WatermarkCommonSection
|
||||
position={config.position}
|
||||
opacity={config.opacity}
|
||||
onPositionChange={handlePositionChange}
|
||||
onOpacityChange={handleOpacityChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="wp-footer">
|
||||
<button className="wp-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default WatermarkPanel
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from "react"
|
||||
import type { BgmPreset } from "@/api/bgm"
|
||||
|
||||
interface BgmItemProps {
|
||||
bgm: BgmPreset
|
||||
isSelected: boolean
|
||||
isPlaying: boolean
|
||||
onSelect: () => void
|
||||
onPreview: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 BGM 列表项组件
|
||||
*/
|
||||
export const BgmItem: React.FC<BgmItemProps> = ({
|
||||
bgm,
|
||||
isSelected,
|
||||
isPlaying,
|
||||
onSelect,
|
||||
onPreview,
|
||||
}) => {
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = String(Math.floor(seconds % 60)).padStart(2, "0")
|
||||
return `${mins}:${secs}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bgm-item${isSelected ? " selected" : ""}`} onClick={onSelect}>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">{formatDuration(bgm.duration)}</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm"
|
||||
|
||||
interface BgmMixConfigProps {
|
||||
config: BgmMixConfigType
|
||||
selectedBgm: BgmPreset | undefined
|
||||
onChange: (config: BgmMixConfigType) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* BGM 混音配置面板
|
||||
* 音量、淡入淡出、人声闪避等设置
|
||||
*/
|
||||
export const BgmMixConfig: React.FC<BgmMixConfigProps> = ({
|
||||
config,
|
||||
selectedBgm,
|
||||
onChange,
|
||||
onClear,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={onClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入 <span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出 <span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Input, Tag } from "antd"
|
||||
import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm"
|
||||
import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector"
|
||||
import { BgmItem } from "./BgmItem"
|
||||
import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig"
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
interface BgmSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
/** 模板/草稿 ID,用于请求 BGM 预设 */
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
templateId,
|
||||
}) => {
|
||||
const {
|
||||
presets,
|
||||
loading,
|
||||
activeCategory,
|
||||
setActiveCategory,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewingId,
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
} = useBgmSelector(open, templateId)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgmId: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgmId,
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
stopPreview()
|
||||
onClose()
|
||||
}, [stopPreview, onClose])
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
stopPreview()
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
|
||||
}, [stopPreview, onChange])
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* BGM 列表 */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && <div className="bgm-empty">暂无 BGM 数据</div>}
|
||||
{presets.map((bgm) => (
|
||||
<BgmItem
|
||||
key={bgm.id}
|
||||
bgm={bgm}
|
||||
isSelected={config.music_id === bgm.id}
|
||||
isPlaying={previewingId === bgm.id}
|
||||
onSelect={() => handleSelect(bgm.id)}
|
||||
onPreview={() => handlePreview(bgm)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 混音配置 */}
|
||||
{config.enabled && config.music_id && (
|
||||
<BgmMixConfigPanel
|
||||
config={config}
|
||||
selectedBgm={selectedBgm}
|
||||
onChange={onChange}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default BgmSelector
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm"
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
export const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all"
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
]
|
||||
|
||||
/**
|
||||
* BGM 选择器数据与交互 Hook
|
||||
* 封装列表加载、搜索、分类筛选、试听播放逻辑
|
||||
*/
|
||||
export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
if (!templateId) {
|
||||
setPresets([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(templateId, params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword, templateId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
}, [open, loadPresets])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(bgm.url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
audio.onended = () => setPreviewingId(null)
|
||||
setPreviewingId(bgm.id)
|
||||
},
|
||||
[previewingId],
|
||||
)
|
||||
|
||||
/* ── 停止播放(关闭/移除时调用) ── */
|
||||
const stopPreview = useCallback(() => {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
presets,
|
||||
loading,
|
||||
activeCategory,
|
||||
setActiveCategory,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewingId,
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* BGM 设置区块
|
||||
*/
|
||||
import React from "react"
|
||||
import type { BgmSettings } from "@/pages/editing-planner/types/clipProperties"
|
||||
|
||||
interface BgmSettingsSectionProps {
|
||||
settings: BgmSettings
|
||||
onOpenBgmDrawer?: () => void
|
||||
}
|
||||
|
||||
const BgmSettingsSection: React.FC<BgmSettingsSectionProps> = ({ settings, onOpenBgmDrawer }) => {
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎵</span>
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
{settings.enabled && settings.music_id ? (
|
||||
<div className="ep-bgm-current">
|
||||
<span className="ep-bgm-current-label">🎵 已选择 BGM</span>
|
||||
<span className="ep-bgm-current-id">{settings.music_id}</span>
|
||||
{settings.volume !== undefined && (
|
||||
<span className="ep-bgm-current-vol">音量 {settings.volume}%</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-bgm-empty">未选择背景音乐</div>
|
||||
)}
|
||||
|
||||
{onOpenBgmDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
|
||||
🎵 {settings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BgmSettingsSection
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* ClipDetailSection 入口(向后兼容)
|
||||
* 实际实现位于 ./clip-detail-section/ 目录
|
||||
*/
|
||||
export { default } from "./clip-detail-section"
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 编辑统计区块
|
||||
*/
|
||||
import React from "react"
|
||||
import { formatModeLabel } from "@/pages/editing-planner/utils/clipProperties"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface StatsSectionProps {
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentMode: TemplateMode
|
||||
}
|
||||
|
||||
const StatsSection: React.FC<StatsSectionProps> = ({ clipsCount, totalDuration, currentMode }) => {
|
||||
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-detail-value">{clipsCount}</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">总时长</div>
|
||||
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">当前模式</div>
|
||||
<div className="ep-clip-detail-value">{formatModeLabel(currentMode)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatsSection
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* 字幕设置区块
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/clipProperties"
|
||||
import type { SubtitleSettings } from "@/pages/editing-planner/types/clipProperties"
|
||||
|
||||
interface SubtitleSettingsSectionProps {
|
||||
settings: SubtitleSettings
|
||||
onChange: (partial: Partial<SubtitleSettings>) => void
|
||||
onOpenSubtitleDrawer?: () => void
|
||||
}
|
||||
|
||||
const SubtitleSettingsSection: React.FC<SubtitleSettingsSectionProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
onOpenSubtitleDrawer,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">💬</span>
|
||||
字幕设置
|
||||
</div>
|
||||
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle ${settings.enabled ? "active" : ""}`}
|
||||
onClick={() => onChange({ enabled: !settings.enabled })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={settings.position}
|
||||
onChange={(e) => onChange({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={settings.font}
|
||||
onChange={(e) => onChange({ font: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => onChange({ fontSize: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{settings.fontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">动画</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={settings.animation}
|
||||
onChange={(e) => onChange({ animation: e.target.value })}
|
||||
>
|
||||
{ANIMATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{onOpenSubtitleDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
|
||||
🎨 高级字幕样式配置
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubtitleSettingsSection
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 标题设置区块 — #1789
|
||||
* 提供字号滑块、字体预设、位置、颜色等控制入口
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
|
||||
interface TitleSettingsSectionProps {
|
||||
config: TitleConfig
|
||||
onChange: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
}
|
||||
|
||||
const TITLE_COLOR_PRESETS = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ff4444",
|
||||
"#ffaa00",
|
||||
"#44ff44",
|
||||
"#4488ff",
|
||||
"#ff44ff",
|
||||
"#ffff44",
|
||||
]
|
||||
|
||||
const TitleSettingsSection: React.FC<TitleSettingsSectionProps> = ({ config, onChange }) => {
|
||||
const update = (partial: Partial<TitleConfig>) => {
|
||||
onChange((prev: TitleConfig) => ({ ...prev, ...partial }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${config.ai_auto_select ? "active" : ""}`}
|
||||
onClick={() => update({ ai_auto_select: !config.ai_auto_select })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.ai_auto_select && (
|
||||
<>
|
||||
{/* 标题文本 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">标题文本</label>
|
||||
<input
|
||||
className="ep-form-select"
|
||||
type="text"
|
||||
placeholder="输入标题内容"
|
||||
value={config.content}
|
||||
onChange={(e) => update({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.position}
|
||||
onChange={(e) => update({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字体预设 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={config.font_preset}
|
||||
onChange={(e) => update({ font_preset: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字号</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size}
|
||||
onChange={(e) => update({ font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{config.font_size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色 */}
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">颜色</label>
|
||||
<div className="ep-color-presets">
|
||||
{TITLE_COLOR_PRESETS.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
className={`ep-color-swatch${config.font_color === color ? " active" : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => update({ font_color: color })}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
className="ep-color-picker"
|
||||
value={config.font_color}
|
||||
onChange={(e) => update({ font_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleSettingsSection
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 片段详情区块
|
||||
*/
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types"
|
||||
import TransitionSelector from "../TransitionSelector"
|
||||
import SpeedPanel from "../SpeedPanel"
|
||||
import TtsPanel from "../TtsPanel"
|
||||
import type { ClipLevelDrawersProps } from "./types"
|
||||
|
||||
/**
|
||||
* 片段级抽屉(转场/调速/TTS)
|
||||
* 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置
|
||||
*/
|
||||
export const ClipLevelDrawers: React.FC<ClipLevelDrawersProps> = ({
|
||||
clips,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
onCloseTransitionDrawer,
|
||||
onTransitionChange,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onCloseSpeedDrawer,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onCloseTtsDrawer,
|
||||
onTtsChange,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
const speedConfig = speedTargetClipId
|
||||
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
|
||||
: DEFAULT_SPEED
|
||||
|
||||
const ttsConfig = ttsTargetClipId
|
||||
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
|
||||
: DEFAULT_TTS_CONFIG
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 转场特效选择器 */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* 片段调速面板 */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={speedConfig}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TTS 配音面板 */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={ttsConfig}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import React from "react"
|
||||
import BgmSelector from "../BgmSelector"
|
||||
import SubtitleStylePanel from "../SubtitleStylePanel"
|
||||
import WatermarkPanel from "../WatermarkPanel"
|
||||
import IntroOutroPanel from "../IntroOutroPanel"
|
||||
import PipConfigPanel from "../PipConfigPanel"
|
||||
import FilterPanel from "../FilterPanel"
|
||||
import GreenScreenPanel from "../GreenScreenPanel"
|
||||
import StickerPanel from "../StickerPanel"
|
||||
import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types"
|
||||
|
||||
/**
|
||||
* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸)
|
||||
*/
|
||||
export const GlobalDrawers: React.FC<BgmDrawerProps & SubtitleDrawerProps & GlobalDrawersProps> = ({
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onCloseBgmDrawer,
|
||||
onChangeBgmSettings,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onCloseSubtitleDrawer,
|
||||
onChangeSubtitleSettings,
|
||||
totalDuration,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onCloseWatermarkDrawer,
|
||||
onWatermarkChange,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onCloseIntroOutroDrawer,
|
||||
onIntroOutroChange,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
onClosePipDrawer,
|
||||
onPipChange,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onCloseFilterDrawer,
|
||||
onFilterChange,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onCloseChromaKeyDrawer,
|
||||
onChromaKeyChange,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onCloseStickerDrawer,
|
||||
onStickerChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* BGM 选择器 */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onChangeBgmSettings}
|
||||
/>
|
||||
|
||||
{/* 字幕样式配置 */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onChangeSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* 水印配置面板 */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* 片头片尾配置面板 */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* 混剪配置面板 */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 滤镜调色面板 */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* 绿幕抠像面板 */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* 贴纸面板 */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../../types"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
/** 保存弹窗 Props */
|
||||
export interface SaveModalDrawerProps {
|
||||
saveModalOpen: boolean
|
||||
saveLoading: boolean
|
||||
isUpdate: boolean
|
||||
draftName: string
|
||||
draftCategory: string
|
||||
draftTags: string
|
||||
categories: TemplateCategory[]
|
||||
estimatedDuration: number
|
||||
onNameChange: (name: string) => void
|
||||
onCategoryChange: (cat: string) => void
|
||||
onTagsChange: (tags: string) => void
|
||||
onSave: () => Promise<void>
|
||||
onCancelSave: () => void
|
||||
}
|
||||
|
||||
/** BGM 抽屉 Props */
|
||||
export interface BgmDrawerProps {
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onCloseBgmDrawer: () => void
|
||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
||||
}
|
||||
|
||||
/** 字幕抽屉 Props */
|
||||
export interface SubtitleDrawerProps {
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onCloseSubtitleDrawer: () => void
|
||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
||||
}
|
||||
|
||||
/** 单个片段级抽屉通用 Props */
|
||||
export interface ClipLevelDrawersProps {
|
||||
clips: ClipData[]
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
onCloseTransitionDrawer: () => void
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onCloseSpeedDrawer: () => void
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onCloseTtsDrawer: () => void
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
}
|
||||
|
||||
/** 全局设置抽屉 Props */
|
||||
export interface GlobalDrawersProps {
|
||||
totalDuration: number
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onCloseWatermarkDrawer: () => void
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
onClosePipDrawer: () => void
|
||||
onPipChange: (config: PipConfig) => void
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onCloseFilterDrawer: () => void
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onCloseStickerDrawer: () => void
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
}
|
||||
|
||||
export type EditingDrawersProps = SaveModalDrawerProps &
|
||||
BgmDrawerProps &
|
||||
SubtitleDrawerProps &
|
||||
ClipLevelDrawersProps &
|
||||
GlobalDrawersProps
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 滤镜手动调节组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { FilterConfig } from "../../types"
|
||||
import { MANUAL_ADJUST_ITEMS } from "../../constants/filter"
|
||||
|
||||
type FilterKey = keyof Pick<
|
||||
FilterConfig,
|
||||
"brightness" | "contrast" | "saturation" | "temperature" | "tint" | "sharpness"
|
||||
>
|
||||
|
||||
interface FilterManualAdjustProps {
|
||||
config: FilterConfig
|
||||
onChange: (key: FilterKey, value: number) => void
|
||||
}
|
||||
|
||||
const FilterManualAdjust: React.FC<FilterManualAdjustProps> = ({ config, onChange }) => {
|
||||
return (
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">手动调节</div>
|
||||
{MANUAL_ADJUST_ITEMS.map((item) => (
|
||||
<div key={item.key} className="filter-slider-row">
|
||||
<span className="filter-slider-label">{item.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
value={config[item.key as FilterKey]}
|
||||
onChange={(e) => onChange(item.key as FilterKey, Number(e.target.value))}
|
||||
/>
|
||||
<span className="filter-slider-value">{config[item.key as FilterKey]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterManualAdjust
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 滤镜预设选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { FilterPreset } from "../../types"
|
||||
import { FILTER_PRESET_LABELS } from "../../types"
|
||||
import { PRESET_LIST, PRESET_GRADIENTS } from "../../constants/filter"
|
||||
|
||||
interface FilterPresetGridProps {
|
||||
selectedPreset: FilterPreset
|
||||
onPresetSelect: (preset: FilterPreset) => void
|
||||
}
|
||||
|
||||
const FilterPresetGrid: React.FC<FilterPresetGridProps> = ({ selectedPreset, onPresetSelect }) => {
|
||||
return (
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">预设滤镜</div>
|
||||
<div className="filter-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`filter-preset-item${selectedPreset === p ? " active" : ""}`}
|
||||
onClick={() => onPresetSelect(p)}
|
||||
>
|
||||
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
|
||||
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPresetGrid
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenCustomColorProps {
|
||||
color: string
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
export const GreenScreenCustomColor: React.FC<GreenScreenCustomColorProps> = ({
|
||||
color,
|
||||
onColorChange,
|
||||
}) => {
|
||||
const handleColorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onColorChange(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={color}
|
||||
onChange={handleColorInput}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ChromaKeyColorPreset } from "../../types"
|
||||
import { CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS } from "../../types"
|
||||
|
||||
interface GreenScreenPresetsProps {
|
||||
selectedPreset: ChromaKeyColorPreset | null
|
||||
onPresetSelect: (preset: ChromaKeyColorPreset) => void
|
||||
}
|
||||
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
export const GreenScreenPresets: React.FC<GreenScreenPresetsProps> = ({
|
||||
selectedPreset,
|
||||
onPresetSelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${selectedPreset === p ? " active" : ""}`}
|
||||
onClick={() => onPresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface GreenScreenPreviewProps {
|
||||
color: string
|
||||
blend: number
|
||||
}
|
||||
|
||||
export const GreenScreenPreview: React.FC<GreenScreenPreviewProps> = ({ color, blend }) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div className="green-preview-bg" style={{ background: color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: color,
|
||||
filter: `blur(${blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SliderConfig {
|
||||
label: string
|
||||
value: number
|
||||
description: string
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
interface GreenScreenSlidersProps {
|
||||
similarity: number
|
||||
blend: number
|
||||
spill: number
|
||||
onSimilarityChange: (value: number) => void
|
||||
onBlendChange: (value: number) => void
|
||||
onSpillChange: (value: number) => void
|
||||
}
|
||||
|
||||
const SliderRow: React.FC<SliderConfig> = ({ label, value, description, onChange }) => (
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">{label}</span>
|
||||
<span className="green-slider-value">{value}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="green-slider-desc">{description}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const GreenScreenSliders: React.FC<GreenScreenSlidersProps> = ({
|
||||
similarity,
|
||||
blend,
|
||||
spill,
|
||||
onSimilarityChange,
|
||||
onBlendChange,
|
||||
onSpillChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
<SliderRow
|
||||
label="相似度"
|
||||
value={similarity}
|
||||
description="越大容忍的色差范围越广"
|
||||
onChange={onSimilarityChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="边缘平滑"
|
||||
value={blend}
|
||||
description="越大边缘越柔和自然"
|
||||
onChange={onBlendChange}
|
||||
/>
|
||||
<SliderRow
|
||||
label="溢色抑制"
|
||||
value={spill}
|
||||
description="去除边缘颜色溢出"
|
||||
onChange={onSpillChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { IntroOutroItem, IntroOutroKind, TransitionType } from "../../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { KIND_OPTIONS } from "../../constants/introOutro"
|
||||
|
||||
interface IntroOutroBlockProps {
|
||||
title: string
|
||||
icon: string
|
||||
item: IntroOutroItem
|
||||
transitionLabel: string
|
||||
onKindChange: (kind: IntroOutroKind) => void
|
||||
onChange: (partial: Partial<IntroOutroItem>) => void
|
||||
}
|
||||
|
||||
const IntroOutroBlock: React.FC<IntroOutroBlockProps> = ({
|
||||
title,
|
||||
icon,
|
||||
item,
|
||||
transitionLabel,
|
||||
onKindChange,
|
||||
onChange,
|
||||
}) => {
|
||||
const hasContent = item.kind !== "none"
|
||||
|
||||
return (
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">{icon}</span>
|
||||
<span className="iop-block-title">{title}</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${item.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => onKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{hasContent && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">{item.kind === "video" ? "视频" : "图片"} URL</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
item.kind === "video"
|
||||
? `https://example.com/${title}.mp4`
|
||||
: `https://example.com/${title}.png`
|
||||
}
|
||||
value={item.url ?? ""}
|
||||
onChange={(e) => onChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={item.duration}
|
||||
onChange={(e) => onChange({ duration: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="iop-slider-value">{item.duration}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">{transitionLabel}</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={item.transition ?? "none"}
|
||||
onChange={(e) => onChange({ transition: e.target.value as TransitionType })}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.transition && item.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={item.transition_duration ?? 0.5}
|
||||
onChange={(e) => onChange({ transition_duration: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(item.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntroOutroBlock
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleEffectButtonsProps {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
onStrokeChange: (enabled: boolean) => void
|
||||
onShadowChange: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
|
||||
stroke,
|
||||
shadow,
|
||||
onStrokeChange,
|
||||
onShadowChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${stroke ? " active" : ""}`}
|
||||
onClick={() => onStrokeChange(!stroke)}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${shadow ? " active" : ""}`}
|
||||
onClick={() => onShadowChange(!shadow)}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleModeSwitchProps {
|
||||
mode: "manual" | "asr"
|
||||
onModeChange: (mode: "manual" | "asr") => void
|
||||
}
|
||||
|
||||
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("asr")}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
|
||||
interface SubtitlePositionSelectorProps {
|
||||
position: SubtitleStyleConfig["position"]
|
||||
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
|
||||
}
|
||||
|
||||
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
|
||||
position,
|
||||
onPositionChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
|
||||
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 字幕预览组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
|
||||
interface SubtitlePreviewProps {
|
||||
config: SubtitleStyleConfig
|
||||
previewText?: string
|
||||
}
|
||||
|
||||
const SubtitlePreview: React.FC<SubtitlePreviewProps> = ({
|
||||
config,
|
||||
previewText = "这是一段字幕预览",
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">预览</label>
|
||||
<div className="sub-preview-box">
|
||||
<span
|
||||
className="sub-preview-text"
|
||||
style={{
|
||||
fontSize: `${Math.min(config.fontSize, 28)}px`,
|
||||
color: config.fontColor,
|
||||
fontFamily: config.font,
|
||||
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
|
||||
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
|
||||
}}
|
||||
>
|
||||
{previewText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubtitlePreview
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ClipType } from "../../types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline"
|
||||
|
||||
interface AddClipPickerProps {
|
||||
pickerRef: React.RefObject<HTMLDivElement>
|
||||
position: { top: number; right: number }
|
||||
availableTypes: ClipType[]
|
||||
addType: ClipType
|
||||
onTypeChange: (type: ClipType) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
|
||||
pickerRef,
|
||||
position,
|
||||
availableTypes,
|
||||
addType,
|
||||
onTypeChange,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="ep-add-clip-picker ep-add-clip-picker--portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: position.top,
|
||||
right: position.right,
|
||||
}}
|
||||
>
|
||||
<div className="ep-add-clip-picker-title">添加片段</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="ep-add-clip-type-row">
|
||||
<span className="ep-add-clip-type-label">类型:</span>
|
||||
{availableTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
|
||||
onClick={() => onTypeChange(t)}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ClipData } from "../../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
|
||||
|
||||
interface ClipCardProps {
|
||||
clip: ClipData
|
||||
idx: number
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
isHovered: boolean
|
||||
pps: number
|
||||
trimDragActive: boolean
|
||||
showTrimHandles: boolean
|
||||
onDragStart: (e: React.DragEvent, idx: number) => void
|
||||
onDragOver: (e: React.DragEvent, idx: number) => void
|
||||
onDragEnd: () => void
|
||||
onDrop: (e: React.DragEvent, idx: number) => void
|
||||
onSelect: (clipId: string) => void
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
|
||||
onRemove: (clipId: string) => void
|
||||
}
|
||||
|
||||
export const ClipCard: React.FC<ClipCardProps> = ({
|
||||
clip,
|
||||
idx,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
isHovered,
|
||||
pps,
|
||||
trimDragActive,
|
||||
showTrimHandles,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onTrimHandleMouseDown,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none"
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined
|
||||
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
|
||||
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${isSelected ? "selected" : ""} ${isDragging ? "dragging" : ""} ${isDragOver ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
|
||||
draggable={!trimDragActive}
|
||||
onDragStart={(e) => onDragStart(e, idx)}
|
||||
onDragOver={(e) => onDragOver(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => onDrop(e, idx)}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, clip.id)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && showTrimHandles && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "left")}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && showTrimHandles && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "right")}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(clip.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ClipData } from "../../types"
|
||||
import { ClipCard } from "./ClipCard"
|
||||
|
||||
interface ClipTrackProps {
|
||||
/** 片段列表 */
|
||||
clips: ClipData[]
|
||||
/** 选中的片段ID */
|
||||
selectedClipId: string | null
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 正在拖拽的片段索引 */
|
||||
dragIdx: number | null
|
||||
/** 拖拽悬停的片段索引 */
|
||||
dragOverIdx: number | null
|
||||
/** 悬停的片段ID */
|
||||
hoveredClipId: string | null
|
||||
/** 是否正在裁剪拖拽 */
|
||||
trimDragActive: boolean
|
||||
/** 是否显示裁剪手柄 */
|
||||
showTrimHandles: boolean
|
||||
/** 轨道ref */
|
||||
trackRef: React.RefObject<HTMLDivElement>
|
||||
/** 添加卡片ref */
|
||||
addCardRef: React.RefObject<HTMLDivElement>
|
||||
/** 播放头拖拽开始 */
|
||||
onPlayheadMouseDown: (e: React.MouseEvent) => void
|
||||
/** 片段选中 */
|
||||
onClipSelect: (clipId: string) => void
|
||||
/** 拖拽开始 */
|
||||
onDragStart: (e: React.DragEvent, idx: number) => void
|
||||
/** 拖拽悬停 */
|
||||
onDragOver: (e: React.DragEvent, idx: number) => void
|
||||
/** 拖拽结束 */
|
||||
onDragEnd: () => void
|
||||
/** 放置 */
|
||||
onDrop: (e: React.DragEvent, idx: number) => void
|
||||
/** 空区域拖拽悬停 */
|
||||
onEmptyDragOver: (e: React.DragEvent) => void
|
||||
/** 右键菜单 */
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void
|
||||
/** 片段悬停进入 */
|
||||
onClipMouseEnter: (clipId: string) => void
|
||||
/** 片段悬停离开 */
|
||||
onClipMouseLeave: () => void
|
||||
/** 裁剪手柄按下 */
|
||||
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
|
||||
/** 删除片段 */
|
||||
onClipRemove: (clipId: string) => void
|
||||
/** 点击添加卡片 */
|
||||
onTogglePicker: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段轨道组件
|
||||
* 包含播放头、片段列表、空状态和添加卡片
|
||||
*/
|
||||
export const ClipTrack: React.FC<ClipTrackProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
pps,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
hoveredClipId,
|
||||
trimDragActive,
|
||||
showTrimHandles,
|
||||
trackRef,
|
||||
addCardRef,
|
||||
onPlayheadMouseDown,
|
||||
onClipSelect,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
onEmptyDragOver,
|
||||
onContextMenu,
|
||||
onClipMouseEnter,
|
||||
onClipMouseLeave,
|
||||
onTrimHandleMouseDown,
|
||||
onClipRemove,
|
||||
onTogglePicker,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-clip-track" ref={trackRef} onDragOver={onEmptyDragOver}>
|
||||
{/* 播放头 */}
|
||||
{totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={onPlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<ClipCard
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
idx={idx}
|
||||
isSelected={selectedClipId === clip.id}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
isHovered={hoveredClipId === clip.id}
|
||||
pps={pps}
|
||||
trimDragActive={trimDragActive}
|
||||
showTrimHandles={showTrimHandles}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
onSelect={onClipSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseEnter={() => onClipMouseEnter(clip.id)}
|
||||
onMouseLeave={onClipMouseLeave}
|
||||
onTrimHandleMouseDown={onTrimHandleMouseDown}
|
||||
onRemove={onClipRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
className="ep-track-add-card"
|
||||
onClick={onTogglePicker}
|
||||
title="添加片段"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
menuRef: React.RefObject<HTMLDivElement>
|
||||
hasTrim: boolean
|
||||
onSplit: () => void
|
||||
onResetTrim: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
x,
|
||||
y,
|
||||
menuRef,
|
||||
hasTrim,
|
||||
onSplit,
|
||||
onResetTrim,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: x,
|
||||
top: y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={onSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{hasTrim && (
|
||||
<div className="ep-context-menu-item" onClick={onResetTrim}>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div className="ep-context-menu-item ep-context-menu-item-danger" onClick={onDelete}>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from "react"
|
||||
import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline"
|
||||
import { generateRulerMarks } from "../../utils/timeline"
|
||||
|
||||
interface TimeRulerProps {
|
||||
totalDuration: number
|
||||
pps: number
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export const TimeRuler: React.FC<TimeRulerProps> = ({ totalDuration, pps, onClick }) => {
|
||||
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
|
||||
const step = getRulerStep(totalDuration)
|
||||
const marks = generateRulerMarks(totalDuration, step)
|
||||
|
||||
return (
|
||||
<div className="ep-time-ruler" onClick={onClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
{marks.map((t) => (
|
||||
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
|
||||
{t}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatTime } from "../../utils/timeline"
|
||||
|
||||
interface TimelineHeaderProps {
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void
|
||||
/** 撤销最后一个片段 */
|
||||
onUndoClip?: () => void
|
||||
/** 片段数量 */
|
||||
clipsCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线头部组件
|
||||
* 包含标题、总时长、缩放控件和操作按钮
|
||||
*/
|
||||
export const TimelineHeader: React.FC<TimelineHeaderProps> = ({
|
||||
totalDuration,
|
||||
pps,
|
||||
onZoomChange,
|
||||
onUndoClip,
|
||||
clipsCount,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-timeline-header">
|
||||
<div className="ep-timeline-title">
|
||||
<span>🎬 时间线</span>
|
||||
<span className="ep-timeline-duration">总时长: {formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={onUndoClip}
|
||||
title="删除最后一个片段"
|
||||
disabled={clipsCount <= 1}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button className="ep-timeline-action-btn" title="重做">
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatTrimTime } from "../../utils/timeline"
|
||||
|
||||
interface TrimPreviewProps {
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export const TrimPreview: React.FC<TrimPreviewProps> = ({ startTime, endTime, duration, x, y }) => {
|
||||
return (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: x + 12,
|
||||
top: y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(startTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(endTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
interface UsePlayheadDragOptions {
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime: number
|
||||
/** 缩放:每秒像素数 */
|
||||
pps: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放头拖拽 Hook
|
||||
* 封装播放头的 mousedown/mousemove/mouseup 拖拽逻辑
|
||||
*/
|
||||
export const usePlayheadDrag = ({ pps, totalDuration, onSeek }: UsePlayheadDragOptions) => {
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current
|
||||
if (!trackEl) return
|
||||
const rect = trackEl.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [playheadDragging, pps, totalDuration, onSeek])
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setPlayheadDragging(true)
|
||||
}, [])
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration))
|
||||
onSeek?.(Math.round(time * 10) / 10)
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
)
|
||||
|
||||
return {
|
||||
trackRef,
|
||||
playheadDragging,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerClick,
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 图片水印配置组件
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface ImageWatermarkSectionProps {
|
||||
imageUrl: string
|
||||
localImageUrl: string
|
||||
width: number | undefined
|
||||
height: number | undefined
|
||||
onImageUrlChange: (url: string) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const ImageWatermarkSection: React.FC<ImageWatermarkSectionProps> = ({
|
||||
imageUrl,
|
||||
localImageUrl,
|
||||
width,
|
||||
height,
|
||||
onImageUrlChange,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
const displayUrl = localImageUrl || imageUrl || ""
|
||||
|
||||
return (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印图片 URL</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="text"
|
||||
placeholder="https://example.com/logo.png"
|
||||
value={displayUrl}
|
||||
onChange={(e) => onImageUrlChange(e.target.value)}
|
||||
/>
|
||||
{displayUrl && (
|
||||
<div className="wp-image-preview">
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="水印预览"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">宽度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={width ?? 0}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">高度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={height ?? 0}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageWatermarkSection
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 滚动水印配置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ScrollDirection } from "../../types"
|
||||
import { SCROLL_DIRECTION_OPTIONS } from "../../constants/watermark"
|
||||
|
||||
interface ScrollWatermarkSectionProps {
|
||||
text: string | undefined
|
||||
scrollDirection: ScrollDirection | undefined
|
||||
scrollSpeed: number | undefined
|
||||
fontSize: number | undefined
|
||||
color: string | undefined
|
||||
onTextChange: (text: string) => void
|
||||
onScrollDirectionChange: (dir: ScrollDirection) => void
|
||||
onScrollSpeedChange: (speed: number) => void
|
||||
onFontSizeChange: (size: number) => void
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
const ScrollWatermarkSection: React.FC<ScrollWatermarkSectionProps> = ({
|
||||
text,
|
||||
scrollDirection,
|
||||
scrollSpeed,
|
||||
fontSize,
|
||||
color,
|
||||
onTextChange,
|
||||
onScrollDirectionChange,
|
||||
onScrollSpeedChange,
|
||||
onFontSizeChange,
|
||||
onColorChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入滚动水印文字"
|
||||
rows={2}
|
||||
value={text ?? ""}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动方向</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={scrollDirection ?? "horizontal"}
|
||||
onChange={(e) => onScrollDirectionChange(e.target.value as ScrollDirection)}
|
||||
>
|
||||
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动速度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={200}
|
||||
value={scrollSpeed ?? 50}
|
||||
onChange={(e) => onScrollSpeedChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">{scrollSpeed ?? 50}px/s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={fontSize ?? 24}
|
||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">{fontSize ?? 24}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={color ?? "#ffffff"}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">{color ?? "#ffffff"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollWatermarkSection
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 文字水印配置组件
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
interface TextWatermarkSectionProps {
|
||||
text: string | undefined
|
||||
fontSize: number | undefined
|
||||
color: string | undefined
|
||||
onTextChange: (text: string) => void
|
||||
onFontSizeChange: (size: number) => void
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
const TextWatermarkSection: React.FC<TextWatermarkSectionProps> = ({
|
||||
text,
|
||||
fontSize,
|
||||
color,
|
||||
onTextChange,
|
||||
onFontSizeChange,
|
||||
onColorChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入水印文字内容"
|
||||
rows={3}
|
||||
value={text ?? ""}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={fontSize ?? 24}
|
||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">{fontSize ?? 24}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={color ?? "#ffffff"}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">{color ?? "#ffffff"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextWatermarkSection
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 水印通用设置组件(位置 + 透明度)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { WatermarkPosition } from "../../types"
|
||||
import { POSITION_OPTIONS } from "../../constants/watermark"
|
||||
|
||||
interface WatermarkCommonSectionProps {
|
||||
position: WatermarkPosition
|
||||
opacity: number
|
||||
onPositionChange: (pos: WatermarkPosition) => void
|
||||
onOpacityChange: (opacity: number) => void
|
||||
}
|
||||
|
||||
const WatermarkCommonSection: React.FC<WatermarkCommonSectionProps> = ({
|
||||
position,
|
||||
opacity,
|
||||
onPositionChange,
|
||||
onOpacityChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="wp-section wp-common-section">
|
||||
<div className="wp-section-divider" />
|
||||
<div className="wp-common-title">通用设置</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印位置</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={position}
|
||||
onChange={(e) => onPositionChange(e.target.value as WatermarkPosition)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">不透明度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => onOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">{Math.round(opacity * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WatermarkCommonSection
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 水印类型 Tab 组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { WatermarkType } from "../../types"
|
||||
import { WATERMARK_TABS } from "../../constants/watermark"
|
||||
|
||||
interface WatermarkTypeTabsProps {
|
||||
activeTab: WatermarkType
|
||||
onTypeChange: (type: WatermarkType) => void
|
||||
}
|
||||
|
||||
const WatermarkTypeTabs: React.FC<WatermarkTypeTabsProps> = ({ activeTab, onTypeChange }) => {
|
||||
return (
|
||||
<div className="wp-tabs">
|
||||
{WATERMARK_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => onTypeChange(tab.key)}
|
||||
>
|
||||
<span className="wp-tab-icon">{tab.icon}</span>
|
||||
<span className="wp-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WatermarkTypeTabs
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
export const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||||
{ key: "pip", label: "混剪", icon: "🖼️" },
|
||||
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
|
||||
{ key: "one_take", label: "一镜到底", icon: "🎥" },
|
||||
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
|
||||
]
|
||||
|
||||
export const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* ClipPropertiesPanel 常量定义
|
||||
*/
|
||||
import type { ClipType } from "@/pages/editing-planner/types"
|
||||
|
||||
export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
// FONT_OPTIONS 统一从 generate/constants 导入,避免多处维护遗漏
|
||||
export { FONT_OPTIONS } from "@/pages/generate/constants"
|
||||
|
||||
export const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
]
|
||||
|
||||
/** 片段类型图标/标签 */
|
||||
export const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
|
||||
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* FilterPanel 相关常量
|
||||
*/
|
||||
import type { FilterPreset } from "../types"
|
||||
|
||||
/** 所有预设列表 */
|
||||
export const PRESET_LIST: FilterPreset[] = [
|
||||
"none",
|
||||
"original",
|
||||
"fresh",
|
||||
"warm",
|
||||
"cool",
|
||||
"vintage",
|
||||
"cinema",
|
||||
"bw",
|
||||
"sunshine",
|
||||
"film",
|
||||
]
|
||||
|
||||
/** 预设对应的示例渐变色(用于视觉预览) */
|
||||
export const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
|
||||
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
|
||||
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
|
||||
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
|
||||
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
|
||||
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
|
||||
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
|
||||
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
|
||||
}
|
||||
|
||||
/** 手动调节项配置 */
|
||||
export const MANUAL_ADJUST_ITEMS = [
|
||||
{ key: "brightness", label: "亮度", min: -100, max: 100 },
|
||||
{ key: "contrast", label: "对比度", min: -100, max: 100 },
|
||||
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
|
||||
{ key: "temperature", label: "色温", min: -100, max: 100 },
|
||||
{ key: "tint", label: "色调", min: -100, max: 100 },
|
||||
{ key: "sharpness", label: "锐度", min: 0, max: 100 },
|
||||
] as const
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* IntroOutroPanel 相关常量
|
||||
*/
|
||||
import type { IntroOutroKind } from "../types"
|
||||
|
||||
export const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
|
||||
{ value: "none", label: "无", icon: "🚫" },
|
||||
{ value: "video", label: "视频", icon: "🎬" },
|
||||
{ value: "image", label: "图片", icon: "🖼️" },
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* PipConfigPanel 常量定义
|
||||
*/
|
||||
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
}
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
export const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
]
|
||||
|
||||
/** 入场动画选项 */
|
||||
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
]
|
||||
|
||||
/** 滑入方向选项 */
|
||||
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
]
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
export const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user