Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce48c1b02 | |||
| 7936be3339 | |||
| d2c067e9bd | |||
| 925b365d6a | |||
| 7c7f33fbd6 | |||
| b891eeab43 | |||
| dfed224b7d | |||
| 9a849d319e | |||
| 07bbe7ee01 | |||
| af7088a549 | |||
| 7d75ee6586 | |||
| 83bf454daf | |||
| ea04bb8525 | |||
| d537dd2ed0 | |||
| 18590e22e5 | |||
| a96819ce22 | |||
| b98acefe0f | |||
| 2d67fe8631 | |||
| dc500acbf2 | |||
| 2253b7d15a | |||
| 9a25eb6642 | |||
| ad3dc06101 | |||
| dc555bc8c1 | |||
| dcab4180e5 | |||
| 8d7e13ea73 | |||
| 09a19f69c7 | |||
| ac667e60c9 | |||
| 3ec74bfc32 | |||
| c0af8e7c43 | |||
| 1399912095 | |||
| 9f153eec54 | |||
| b392bb1b78 | |||
| b852603664 | |||
| bda2170b9d | |||
| bb0d01f080 | |||
| f6a458564f | |||
| 48bf66c298 | |||
| 8e2c563b42 | |||
| b99437fd81 | |||
| 81eff29e7b | |||
| 02d60a02f2 | |||
| 5b0ba40ecd | |||
| 9b9dc243ed | |||
| 6514b8c34d | |||
| 4e8265ec5e | |||
| 23de3906c2 | |||
| 50b05db03e | |||
| 7f3c462617 | |||
| f31408baee |
@@ -36,12 +36,11 @@ jobs:
|
|||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
shell: sh
|
||||||
|
env:
|
||||||
- name: Setup Python
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
uses: actions/setup-python@v5
|
run: |
|
||||||
with:
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
||||||
- name: Get staging running images (whitelist)
|
- name: Get staging running images (whitelist)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Daily Health Check
|
name: Daily Health Check
|
||||||
|
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -23,47 +24,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Production health check & smoke test
|
- name: Production health check & smoke test
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -132,47 +95,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Run API smoke test on staging
|
- name: Run API smoke test on staging
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -284,47 +209,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
python3 - <<'PY'
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
| bash
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Run Playwright E2E on staging
|
- name: Run Playwright E2E on staging
|
||||||
id: e2e
|
id: e2e
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import random
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
||||||
|
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
||||||
|
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
|
||||||
|
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
|
||||||
from packages.shared.ai_client import get_doubao_client
|
from packages.shared.ai_client import get_doubao_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -64,85 +66,9 @@ def _generate_titles_fallback(
|
|||||||
style: str = "viral",
|
style: str = "viral",
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""本地降级:基于模板规则生成标题.
|
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
|
||||||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
|
||||||
"""
|
|
||||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||||
examples = style_info["examples"]
|
return _generate_titles_fallback_base(description, style_info, count)
|
||||||
|
|
||||||
# 从描述中提取关键词(取前几个词)
|
|
||||||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
|
||||||
keyword = keywords[0] if keywords else "精彩内容"
|
|
||||||
|
|
||||||
# 基于模板生成
|
|
||||||
templates = [
|
|
||||||
f"「{keyword}」{examples[0][:10]}...",
|
|
||||||
f"{keyword}:{examples[1]}",
|
|
||||||
f"关于{keyword},你不知道的3件事",
|
|
||||||
f"{keyword}入门指南,新手必看",
|
|
||||||
f"深度解析:{keyword}背后的秘密",
|
|
||||||
f"{keyword}怎么做?手把手教你",
|
|
||||||
f"干货分享 | {keyword}全攻略",
|
|
||||||
f"建议收藏:{keyword}实用技巧",
|
|
||||||
f"{keyword}避坑指南,别再踩雷了",
|
|
||||||
f"一分钟搞懂{keyword}",
|
|
||||||
]
|
|
||||||
|
|
||||||
random.shuffle(templates)
|
|
||||||
return templates[: min(count, len(templates))]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_titles_from_response(content: str) -> List[str]:
|
|
||||||
"""从模型返回中解析标题列表.
|
|
||||||
|
|
||||||
支持多种返回格式:
|
|
||||||
- JSON 数组: ["标题1", "标题2"]
|
|
||||||
- 编号列表: 1. 标题1 / 2. 标题2
|
|
||||||
- 换行分隔: 标题1\n标题2
|
|
||||||
- 带破折号: - 标题1
|
|
||||||
"""
|
|
||||||
if not content:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 尝试解析 JSON
|
|
||||||
try:
|
|
||||||
# 清理可能的 markdown 代码块标记
|
|
||||||
cleaned = content.strip()
|
|
||||||
if cleaned.startswith("```"):
|
|
||||||
cleaned = cleaned.strip("`")
|
|
||||||
if cleaned.lower().startswith("json"):
|
|
||||||
cleaned = cleaned[4:]
|
|
||||||
cleaned = cleaned.strip()
|
|
||||||
|
|
||||||
data = json.loads(cleaned)
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [str(item).strip() for item in data if str(item).strip()]
|
|
||||||
if isinstance(data, dict) and "titles" in data:
|
|
||||||
titles = data["titles"]
|
|
||||||
if isinstance(titles, list):
|
|
||||||
return [str(t).strip() for t in titles if str(t).strip()]
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 尝试按行解析
|
|
||||||
titles: List[str] = []
|
|
||||||
for line in content.strip().split("\n"):
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
# 去掉编号前缀 "1. " "1、" "(1)"
|
|
||||||
import re
|
|
||||||
|
|
||||||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
|
||||||
# 去掉破折号前缀 "- " "• "
|
|
||||||
line = re.sub(r"^[-•·]\s*", "", line)
|
|
||||||
# 去掉引号
|
|
||||||
line = line.strip('"').strip("'").strip("「」")
|
|
||||||
if line and len(line) < 100: # 过滤过长的行
|
|
||||||
titles.append(line)
|
|
||||||
|
|
||||||
return titles
|
|
||||||
|
|
||||||
|
|
||||||
def generate_smart_titles(
|
def generate_smart_titles(
|
||||||
@@ -241,132 +167,19 @@ def _semantic_match_fallback(
|
|||||||
description: str,
|
description: str,
|
||||||
assets: List[Dict[str, Any]],
|
assets: List[Dict[str, Any]],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""本地降级:基于关键词的简单匹配.
|
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
return _semantic_match_fallback_base(description, assets)
|
||||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
|
||||||
作为匹配度评分。0-1分。
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# 提取关键词(中文按2字以上片段,英文按单词)
|
|
||||||
desc = description.lower()
|
|
||||||
# 简单分词:提取2字以上的中文字符串和英文单词
|
|
||||||
keywords = set()
|
|
||||||
# 英文单词
|
|
||||||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
|
||||||
keywords.add(word)
|
|
||||||
# 中文2-4字片段
|
|
||||||
for i in range(len(desc)):
|
|
||||||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
|
||||||
fragment = desc[i:j]
|
|
||||||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
|
||||||
keywords.add(fragment)
|
|
||||||
|
|
||||||
if not keywords:
|
|
||||||
# 没有关键词时给所有素材中等分数
|
|
||||||
for asset in assets:
|
|
||||||
asset["match_score"] = 0.5
|
|
||||||
asset["match_reason"] = "fallback_default"
|
|
||||||
return assets
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for asset in assets:
|
|
||||||
# 组合素材的文本信息:名称 + 标签 + 描述
|
|
||||||
asset_text_parts = [
|
|
||||||
str(asset.get("name", "")).lower(),
|
|
||||||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
|
||||||
str(asset.get("description", "")).lower(),
|
|
||||||
]
|
|
||||||
asset_text = " | ".join(asset_text_parts)
|
|
||||||
|
|
||||||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
|
||||||
hit_count = 0
|
|
||||||
hit_keywords = []
|
|
||||||
for kw in keywords:
|
|
||||||
if kw in asset_text:
|
|
||||||
hit_count += 1
|
|
||||||
hit_keywords.append(kw)
|
|
||||||
|
|
||||||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
|
||||||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
|
||||||
|
|
||||||
# 名称命中加分(名称匹配更重要)
|
|
||||||
name = str(asset.get("name", "")).lower()
|
|
||||||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
|
||||||
name_bonus = min(0.2, name_hits * 0.05)
|
|
||||||
|
|
||||||
score = min(1.0, base_score * 0.8 + name_bonus)
|
|
||||||
score = round(score, 3)
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
**asset,
|
|
||||||
"match_score": score,
|
|
||||||
"match_reason": "fallback_keyword",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 按匹配度降序
|
|
||||||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_semantic_match_response(
|
def _parse_semantic_match_response(
|
||||||
content: str,
|
content: str,
|
||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
) -> Optional[Dict[str, float]]:
|
) -> Optional[Dict[str, float]]:
|
||||||
"""从模型返回中解析素材匹配度.
|
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
||||||
|
result = _parse_semantic_match_base(content, asset_ids)
|
||||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
if result is None:
|
||||||
score 范围 0-1。
|
|
||||||
"""
|
|
||||||
if not content:
|
|
||||||
return None
|
return None
|
||||||
|
return dict(result)
|
||||||
# 尝试解析 JSON
|
|
||||||
try:
|
|
||||||
cleaned = content.strip()
|
|
||||||
if cleaned.startswith("```"):
|
|
||||||
cleaned = cleaned.strip("`")
|
|
||||||
if cleaned.lower().startswith("json"):
|
|
||||||
cleaned = cleaned[4:]
|
|
||||||
cleaned = cleaned.strip()
|
|
||||||
|
|
||||||
data = json.loads(cleaned)
|
|
||||||
|
|
||||||
result: Dict[str, float] = {}
|
|
||||||
|
|
||||||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
|
||||||
if isinstance(data, dict):
|
|
||||||
if "matches" in data and isinstance(data["matches"], list):
|
|
||||||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
|
||||||
for item in data["matches"]:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
aid = item.get("asset_id") or item.get("id")
|
|
||||||
score = item.get("score", 0)
|
|
||||||
if aid and isinstance(score, (int, float)):
|
|
||||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
|
||||||
else:
|
|
||||||
for key, value in data.items():
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
|
||||||
|
|
||||||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
|
||||||
elif isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
aid = item.get("asset_id") or item.get("id")
|
|
||||||
score = item.get("score", 0)
|
|
||||||
if aid and isinstance(score, (int, float)):
|
|
||||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
|
||||||
|
|
||||||
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
|
||||||
return result
|
|
||||||
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def semantic_match_assets(
|
def semantic_match_assets(
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
SQLAlchemyGenerationTaskRepository,
|
SQLAlchemyGenerationTaskRepository,
|
||||||
)
|
)
|
||||||
|
from packages.domain.clip_operations import calculate_merge as _calc_merge
|
||||||
|
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
|
||||||
|
from packages.domain.clip_operations import calculate_split as _calc_split
|
||||||
|
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
|
||||||
|
from packages.domain.clip_operations import validate_split_time as _validate_split
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
|
|
||||||
@@ -384,36 +389,45 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(clip_id)
|
clip = self.get_clip_or_raise(clip_id)
|
||||||
plan_id = clip.plan_id
|
plan_id = clip.plan_id
|
||||||
|
|
||||||
if split_time <= 0 or split_time >= clip.duration:
|
# 纯逻辑:校验 + 计算
|
||||||
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
_validate_split(split_time, clip.duration)
|
||||||
|
split = _calc_split(
|
||||||
|
duration=clip.duration,
|
||||||
|
split_time=split_time,
|
||||||
|
start_time=clip.start_time,
|
||||||
|
)
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
self._auto_resume_editing(plan_id)
|
||||||
|
|
||||||
original_duration = clip.duration
|
|
||||||
left_duration = round(split_time, 3)
|
|
||||||
right_duration = round(original_duration - split_time, 3)
|
|
||||||
original_order = clip.order
|
original_order = clip.order
|
||||||
|
|
||||||
# 更新左半部分(原片段)
|
# 更新左半部分(原片段)
|
||||||
clip.duration = left_duration
|
clip.duration = split.left_duration
|
||||||
left_clip = self._clip_repo.update(clip)
|
left_clip = self._clip_repo.update(clip)
|
||||||
|
|
||||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
for c in all_clips:
|
shifts = _calc_shift_orders(
|
||||||
if c.order > original_order and c.id != clip_id:
|
all_clips,
|
||||||
c.order += 1
|
threshold_order=original_order,
|
||||||
self._clip_repo.update(c)
|
shift=1,
|
||||||
|
excluded_ids={clip_id},
|
||||||
|
id_attr="id",
|
||||||
|
order_attr="order",
|
||||||
|
)
|
||||||
|
for c, new_order in shifts:
|
||||||
|
c.order = new_order
|
||||||
|
self._clip_repo.update(c)
|
||||||
|
|
||||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
# 创建右半部分新片段(继承原片段的大部分属性)
|
||||||
right_config = dict(clip.config) if clip.config else {}
|
right_config = dict(clip.config) if clip.config else {}
|
||||||
# 素材裁剪信息
|
# 素材裁剪信息
|
||||||
if clip.asset_id:
|
if clip.asset_id:
|
||||||
# 右半部分从 split_time 开始播放
|
# 右半部分从 split_time 开始播放
|
||||||
right_config["trim_start"] = left_duration
|
right_config["trim_start"] = split.right_trim_start
|
||||||
# 左半部分在 split_time 处结束
|
# 左半部分在 split_time 处结束
|
||||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
left_config = dict(left_clip.config) if left_clip.config else {}
|
||||||
left_config["trim_end"] = right_duration
|
left_config["trim_end"] = split.left_trim_end
|
||||||
left_clip.config = left_config
|
left_clip.config = left_config
|
||||||
left_clip = self._clip_repo.update(left_clip)
|
left_clip = self._clip_repo.update(left_clip)
|
||||||
|
|
||||||
@@ -424,8 +438,8 @@ class EditPlanService:
|
|||||||
template_clip_config_id=clip.template_clip_config_id,
|
template_clip_config_id=clip.template_clip_config_id,
|
||||||
asset_id=clip.asset_id,
|
asset_id=clip.asset_id,
|
||||||
text_content=clip.text_content,
|
text_content=clip.text_content,
|
||||||
start_time=clip.start_time + left_duration,
|
start_time=split.right_start_time,
|
||||||
duration=right_duration,
|
duration=split.right_duration,
|
||||||
transition_effect=clip.transition_effect,
|
transition_effect=clip.transition_effect,
|
||||||
transition_duration=clip.transition_duration,
|
transition_duration=clip.transition_duration,
|
||||||
playback_speed=clip.playback_speed,
|
playback_speed=clip.playback_speed,
|
||||||
@@ -438,8 +452,8 @@ class EditPlanService:
|
|||||||
clip_id,
|
clip_id,
|
||||||
plan_id,
|
plan_id,
|
||||||
split_time,
|
split_time,
|
||||||
left_duration,
|
split.left_duration,
|
||||||
right_duration,
|
split.right_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -468,70 +482,45 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(cid)
|
clip = self.get_clip_or_raise(cid)
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
|
|
||||||
# 校验:同一计划
|
# 纯逻辑:校验 + 计算
|
||||||
plan_id = clips[0].plan_id
|
plan_id, first_order = _validate_merge(clips)
|
||||||
for c in clips[1:]:
|
merge = _calc_merge(clips)
|
||||||
if c.plan_id != plan_id:
|
|
||||||
raise ValueError("只能合并同一计划下的片段")
|
|
||||||
|
|
||||||
# 按 order 排序
|
|
||||||
clips.sort(key=lambda c: c.order)
|
|
||||||
|
|
||||||
# 校验:order 连续
|
|
||||||
for i in range(1, len(clips)):
|
|
||||||
if clips[i].order != clips[i - 1].order + 1:
|
|
||||||
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
|
||||||
|
|
||||||
# 校验:类型一致
|
|
||||||
clip_type = clips[0].clip_type
|
|
||||||
for c in clips[1:]:
|
|
||||||
if c.clip_type != clip_type:
|
|
||||||
raise ValueError("只能合并相同类型的片段")
|
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
self._auto_resume_editing(plan_id)
|
||||||
|
|
||||||
# 计算合并后的属性
|
|
||||||
first_clip = clips[0]
|
|
||||||
total_duration = round(sum(c.duration for c in clips), 3)
|
|
||||||
first_order = first_clip.order
|
|
||||||
|
|
||||||
# 合并文案(用换行连接)
|
|
||||||
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
|
||||||
|
|
||||||
# 合并 config(后面的覆盖前面的)
|
|
||||||
merged_config: Dict[str, Any] = {}
|
|
||||||
for c in clips:
|
|
||||||
if c.config:
|
|
||||||
merged_config.update(c.config)
|
|
||||||
# 清理 trim 相关字段(合并后就是完整片段了)
|
|
||||||
merged_config.pop("trim_start", None)
|
|
||||||
merged_config.pop("trim_end", None)
|
|
||||||
|
|
||||||
# 更新第一个片段(保留它作为合并结果)
|
# 更新第一个片段(保留它作为合并结果)
|
||||||
first_clip.duration = total_duration
|
first_clip = sorted(clips, key=lambda c: c.order)[0]
|
||||||
first_clip.text_content = merged_text
|
first_clip.duration = merge.total_duration
|
||||||
first_clip.config = merged_config
|
first_clip.text_content = merge.merged_text
|
||||||
|
first_clip.config = merge.merged_config
|
||||||
# 转场保留第一个的(合并后的入点转场)
|
# 转场保留第一个的(合并后的入点转场)
|
||||||
# playback_speed 取第一个的
|
# playback_speed 取第一个的
|
||||||
merged_clip = self._clip_repo.update(first_clip)
|
merged_clip = self._clip_repo.update(first_clip)
|
||||||
|
|
||||||
# 删除其余片段
|
# 删除其余片段
|
||||||
for c in clips[1:]:
|
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
|
||||||
self._clip_repo.delete(c.id)
|
for cid in rest_ids:
|
||||||
|
self._clip_repo.delete(cid)
|
||||||
|
|
||||||
# 后面的片段 order 前移 (len - 1) 位
|
# 后面的片段 order 前移 (len - 1) 位
|
||||||
shift = len(clips) - 1
|
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
for c in all_clips:
|
shifts = _calc_shift_orders(
|
||||||
if c.order > first_order and c.id != merged_clip.id:
|
all_clips,
|
||||||
c.order -= shift
|
threshold_order=first_order,
|
||||||
self._clip_repo.update(c)
|
shift=-merge.shift_amount,
|
||||||
|
excluded_ids={merged_clip.id},
|
||||||
|
id_attr="id",
|
||||||
|
order_attr="order",
|
||||||
|
)
|
||||||
|
for c, new_order in shifts:
|
||||||
|
c.order = new_order
|
||||||
|
self._clip_repo.update(c)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||||
plan_id,
|
plan_id,
|
||||||
len(clips),
|
len(clips),
|
||||||
total_duration,
|
merge.total_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return merged_clip
|
return merged_clip
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ from packages.domain.template_clip_config import (
|
|||||||
TemplateClipConfig,
|
TemplateClipConfig,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
)
|
)
|
||||||
|
from packages.domain.template_clip_converter import (
|
||||||
|
clip_configs_to_snapshots,
|
||||||
|
clips_to_template_clip_configs,
|
||||||
|
filter_plan_config_to_template,
|
||||||
|
snapshots_to_template_clip_configs,
|
||||||
|
validate_template_name,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -121,9 +128,7 @@ class EditTemplateService:
|
|||||||
ValueError: 名称为空或重复
|
ValueError: 名称为空或重复
|
||||||
"""
|
"""
|
||||||
# 名称校验
|
# 名称校验
|
||||||
clean_name = name.strip()
|
clean_name = validate_template_name(name)
|
||||||
if not clean_name:
|
|
||||||
raise ValueError("模板名称不能为空")
|
|
||||||
|
|
||||||
# 名称重复检查
|
# 名称重复检查
|
||||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||||
@@ -471,12 +476,7 @@ class EditTemplateService:
|
|||||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||||
|
|
||||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||||
plan_config = plan.config or {}
|
template_config = filter_plan_config_to_template(plan.config)
|
||||||
template_config: dict[str, Any] = {}
|
|
||||||
for key, value in plan_config.items():
|
|
||||||
# 跳过明显的运行时/实例字段,保留风格/模式类配置
|
|
||||||
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
|
|
||||||
template_config[key] = value
|
|
||||||
|
|
||||||
template = EditTemplate.create(
|
template = EditTemplate.create(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
@@ -497,40 +497,7 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 5. 转换每个片段为模板片段配置
|
# 5. 转换每个片段为模板片段配置
|
||||||
created_configs: List[TemplateClipConfig] = []
|
created_configs: List[TemplateClipConfig] = []
|
||||||
for clip in clips:
|
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
||||||
clip_config: dict[str, Any] = {}
|
|
||||||
# 播放速度存入 config
|
|
||||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
|
||||||
clip_config["playback_speed"] = clip.playback_speed
|
|
||||||
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
|
|
||||||
if clip.config:
|
|
||||||
clip_config.update(clip.config)
|
|
||||||
# 去掉素材相关字段
|
|
||||||
clip_config.pop("asset_info", None)
|
|
||||||
clip_config.pop("source_asset_id", None)
|
|
||||||
|
|
||||||
# 转场效果兼容校验
|
|
||||||
try:
|
|
||||||
transition = TransitionEffect(clip.transition_effect)
|
|
||||||
except ValueError:
|
|
||||||
transition = TransitionEffect.CUT
|
|
||||||
|
|
||||||
# 片段类型兼容校验
|
|
||||||
try:
|
|
||||||
clip_type = ClipType(clip.clip_type)
|
|
||||||
except ValueError:
|
|
||||||
clip_type = ClipType.MAIN
|
|
||||||
|
|
||||||
clip_config_obj = TemplateClipConfig.create(
|
|
||||||
template_id=created_template.id,
|
|
||||||
clip_type=clip_type,
|
|
||||||
order=clip.order,
|
|
||||||
min_duration=clip.duration,
|
|
||||||
max_duration=clip.duration,
|
|
||||||
text_template=clip.text_content or "",
|
|
||||||
transition_effect=transition,
|
|
||||||
config=clip_config,
|
|
||||||
)
|
|
||||||
created = self._clip_config_repo.create(clip_config_obj)
|
created = self._clip_config_repo.create(clip_config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -679,8 +646,6 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
||||||
"""
|
"""
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
# 1. 校验模板和草稿
|
# 1. 校验模板和草稿
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
draft = self._plan_repo.get(draft_plan_id)
|
draft = self._plan_repo.get(draft_plan_id)
|
||||||
@@ -700,39 +665,14 @@ class EditTemplateService:
|
|||||||
editing_mode = config.get("editing_mode", "one_take")
|
editing_mode = config.get("editing_mode", "one_take")
|
||||||
|
|
||||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
# 4. 提取模板配置(去掉草稿/运行时字段)
|
||||||
draft_config = draft.config or {}
|
template_config = filter_plan_config_to_template(draft.config)
|
||||||
template_config: dict[str, Any] = {}
|
|
||||||
skip_keys = {
|
|
||||||
"is_template_draft",
|
|
||||||
"asset_ids",
|
|
||||||
"source_edit_plan_id",
|
|
||||||
"generation_task_id",
|
|
||||||
}
|
|
||||||
for key, value in draft_config.items():
|
|
||||||
if key not in skip_keys:
|
|
||||||
template_config[key] = value
|
|
||||||
|
|
||||||
# 5. 事务更新
|
# 5. 事务更新
|
||||||
try:
|
try:
|
||||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
||||||
old_version = template.version or 1
|
old_version = template.version or 1
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = [
|
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||||
{
|
|
||||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
|
||||||
"order": cfg.order,
|
|
||||||
"min_duration": cfg.min_duration,
|
|
||||||
"max_duration": cfg.max_duration,
|
|
||||||
"text_template": cfg.text_template or "",
|
|
||||||
"transition_effect": (
|
|
||||||
cfg.transition_effect.value
|
|
||||||
if hasattr(cfg.transition_effect, "value")
|
|
||||||
else cfg.transition_effect
|
|
||||||
),
|
|
||||||
"config": cfg.config or {},
|
|
||||||
}
|
|
||||||
for cfg in old_clip_configs
|
|
||||||
]
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -759,46 +699,7 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 创建新的片段配置
|
# 创建新的片段配置
|
||||||
created_configs: list[TemplateClipConfig] = []
|
created_configs: list[TemplateClipConfig] = []
|
||||||
for clip in draft_clips:
|
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
|
||||||
clip_config: dict[str, Any] = {}
|
|
||||||
# 播放速度存入 config
|
|
||||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
|
||||||
clip_config["playback_speed"] = clip.playback_speed
|
|
||||||
# 片段自有 config 合并
|
|
||||||
if clip.config:
|
|
||||||
clip_config.update(clip.config)
|
|
||||||
# 去掉素材相关字段
|
|
||||||
clip_config.pop("asset_info", None)
|
|
||||||
clip_config.pop("source_asset_id", None)
|
|
||||||
|
|
||||||
# 转场效果兼容校验
|
|
||||||
try:
|
|
||||||
from packages.domain.template_clip_config import (
|
|
||||||
TransitionEffect,
|
|
||||||
)
|
|
||||||
|
|
||||||
transition = TransitionEffect(clip.transition_effect)
|
|
||||||
except (ValueError, ImportError):
|
|
||||||
transition = TransitionEffect.CUT # type: ignore
|
|
||||||
|
|
||||||
# 片段类型兼容校验
|
|
||||||
try:
|
|
||||||
from packages.domain.template_clip_config import ClipType
|
|
||||||
|
|
||||||
clip_type = ClipType(clip.clip_type)
|
|
||||||
except (ValueError, ImportError):
|
|
||||||
clip_type = ClipType.MAIN # type: ignore
|
|
||||||
|
|
||||||
config_obj = TemplateClipConfig.create(
|
|
||||||
template_id=template_id,
|
|
||||||
clip_type=clip_type,
|
|
||||||
order=clip.order,
|
|
||||||
min_duration=clip.duration,
|
|
||||||
max_duration=clip.duration,
|
|
||||||
text_template=clip.text_content or "",
|
|
||||||
transition_effect=transition,
|
|
||||||
config=clip_config,
|
|
||||||
)
|
|
||||||
created = self._clip_config_repo.create(config_obj)
|
created = self._clip_config_repo.create(config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -843,8 +744,6 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/版本不存在
|
ValueError: 模板/版本不存在
|
||||||
"""
|
"""
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
|
|
||||||
# 1. 读取目标版本快照
|
# 1. 读取目标版本快照
|
||||||
@@ -857,22 +756,7 @@ class EditTemplateService:
|
|||||||
try:
|
try:
|
||||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = [
|
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
||||||
{
|
|
||||||
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
|
||||||
"order": cfg.order,
|
|
||||||
"min_duration": cfg.min_duration,
|
|
||||||
"max_duration": cfg.max_duration,
|
|
||||||
"text_template": cfg.text_template or "",
|
|
||||||
"transition_effect": (
|
|
||||||
cfg.transition_effect.value
|
|
||||||
if hasattr(cfg.transition_effect, "value")
|
|
||||||
else cfg.transition_effect
|
|
||||||
),
|
|
||||||
"config": cfg.config or {},
|
|
||||||
}
|
|
||||||
for cfg in old_clip_configs
|
|
||||||
]
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -905,37 +789,7 @@ class EditTemplateService:
|
|||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
|
||||||
for clip_snap in target_version.clip_configs:
|
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
|
||||||
# 转场效果兼容校验
|
|
||||||
try:
|
|
||||||
from packages.domain.template_clip_config import TransitionEffect
|
|
||||||
|
|
||||||
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
|
|
||||||
except (ValueError, ImportError):
|
|
||||||
from packages.domain.template_clip_config import TransitionEffect
|
|
||||||
|
|
||||||
transition = TransitionEffect.CUT
|
|
||||||
|
|
||||||
# 片段类型兼容校验
|
|
||||||
try:
|
|
||||||
from packages.domain.template_clip_config import ClipType
|
|
||||||
|
|
||||||
clip_type = ClipType(clip_snap.get("clip_type", "main"))
|
|
||||||
except (ValueError, ImportError):
|
|
||||||
from packages.domain.template_clip_config import ClipType
|
|
||||||
|
|
||||||
clip_type = ClipType.MAIN
|
|
||||||
|
|
||||||
config_obj = TemplateClipConfig.create(
|
|
||||||
template_id=template_id,
|
|
||||||
clip_type=clip_type,
|
|
||||||
order=clip_snap.get("order", 0),
|
|
||||||
min_duration=clip_snap.get("min_duration", 0.0),
|
|
||||||
max_duration=clip_snap.get("max_duration", 0.0),
|
|
||||||
text_template=clip_snap.get("text_template", ""),
|
|
||||||
transition_effect=transition,
|
|
||||||
config=clip_snap.get("config", {}) or {},
|
|
||||||
)
|
|
||||||
self._clip_config_repo.create(config_obj)
|
self._clip_config_repo.create(config_obj)
|
||||||
|
|
||||||
self._db.commit()
|
self._db.commit()
|
||||||
|
|||||||
@@ -27,13 +27,12 @@ from packages.domain.edit_plan_clip import EditPlanClip
|
|||||||
from packages.domain.edit_template import EditTemplate
|
from packages.domain.edit_template import EditTemplate
|
||||||
from packages.domain.editing_mode import EditingMode
|
from packages.domain.editing_mode import EditingMode
|
||||||
from packages.domain.plan_generator_utils import (
|
from packages.domain.plan_generator_utils import (
|
||||||
DEFAULT_CLIP_DURATION,
|
|
||||||
create_clips_from_configs,
|
create_clips_from_configs,
|
||||||
distribute_assets,
|
distribute_assets,
|
||||||
generate_default_clips,
|
generate_default_clips,
|
||||||
map_clip_types_for_mode,
|
map_clip_types_for_mode,
|
||||||
)
|
)
|
||||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
from packages.domain.template_clip_config import TemplateClipConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -206,4 +205,3 @@ class PlanGeneratorService:
|
|||||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||||
"""
|
"""
|
||||||
distribute_assets(clips, asset_ids, editing_mode)
|
distribute_assets(clips, asset_ids, editing_mode)
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||||
|
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||||
from packages.domain.asset_scoring import (
|
from packages.domain.asset_scoring import (
|
||||||
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
|
|
||||||
MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE,
|
|
||||||
OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX,
|
|
||||||
OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN,
|
|
||||||
SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX,
|
|
||||||
TARGET_HEIGHT as _TARGET_HEIGHT,
|
|
||||||
TARGET_WIDTH as _TARGET_WIDTH,
|
|
||||||
AssetScoreDetail,
|
AssetScoreDetail,
|
||||||
SmartSelectResult,
|
SmartSelectResult,
|
||||||
diverse_selection,
|
diverse_selection,
|
||||||
|
|||||||
@@ -29,47 +29,33 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
|||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlanStatus
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.template_clip_config import TransitionEffect
|
from packages.domain.video_filter_builder import (
|
||||||
|
DEFAULT_FPS,
|
||||||
|
DEFAULT_OUTPUT_HEIGHT,
|
||||||
|
DEFAULT_OUTPUT_WIDTH,
|
||||||
|
DEFAULT_TRANSITION_DURATION,
|
||||||
|
ClipFilterChain,
|
||||||
|
build_clip_filter,
|
||||||
|
)
|
||||||
|
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
||||||
|
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
||||||
|
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
||||||
|
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
||||||
|
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
|
||||||
|
# 实际定义已迁移至 packages/domain/video_filter_builder.py
|
||||||
|
|
||||||
DEFAULT_OUTPUT_WIDTH = 1280
|
|
||||||
DEFAULT_OUTPUT_HEIGHT = 720
|
|
||||||
DEFAULT_FPS = 25
|
|
||||||
DEFAULT_CODEC = "libx264"
|
DEFAULT_CODEC = "libx264"
|
||||||
DEFAULT_CRF = 23
|
DEFAULT_CRF = 23
|
||||||
DEFAULT_PRESET = "medium"
|
DEFAULT_PRESET = "medium"
|
||||||
|
|
||||||
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
|
||||||
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
|
||||||
TransitionEffect.FADE: "fade",
|
|
||||||
TransitionEffect.SLIDE_LEFT: "slideleft",
|
|
||||||
TransitionEffect.SLIDE_RIGHT: "slideright",
|
|
||||||
TransitionEffect.DISSOLVE: "dissolve",
|
|
||||||
TransitionEffect.WIPE: "wipeleft",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 转场默认时长(秒)
|
|
||||||
DEFAULT_TRANSITION_DURATION = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ClipFilterChain:
|
|
||||||
"""单个片段的滤镜链描述。"""
|
|
||||||
|
|
||||||
clip_id: str
|
|
||||||
input_index: int
|
|
||||||
video_label: str
|
|
||||||
audio_label: str | None
|
|
||||||
filters: list[str]
|
|
||||||
duration: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ComposeCommand:
|
class ComposeCommand:
|
||||||
"""完整的 FFmpeg 合成命令描述。"""
|
"""完整的 FFmpeg 合成命令描述。"""
|
||||||
@@ -401,62 +387,8 @@ class VideoComposeService:
|
|||||||
output_height: int,
|
output_height: int,
|
||||||
fps: int,
|
fps: int,
|
||||||
) -> ClipFilterChain:
|
) -> ClipFilterChain:
|
||||||
"""为单个片段构建滤镜链。
|
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
||||||
|
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
||||||
滤镜顺序:
|
|
||||||
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
|
||||||
2. crop — 居中裁剪到目标分辨率
|
|
||||||
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
|
|
||||||
4. setpts — 重置时间戳 + 偏移
|
|
||||||
5. trim — 视频时长裁剪
|
|
||||||
6. atrim — 音频时长裁剪(如有音频流)
|
|
||||||
"""
|
|
||||||
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
|
||||||
start = clip.start_time
|
|
||||||
|
|
||||||
filters: list[str] = []
|
|
||||||
|
|
||||||
# 1. scale: 等比缩放(保持比例,不裁剪)
|
|
||||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
|
||||||
|
|
||||||
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
|
|
||||||
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
|
||||||
|
|
||||||
# 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致)
|
|
||||||
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
|
|
||||||
filters.append("format=yuv420p")
|
|
||||||
|
|
||||||
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
|
|
||||||
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
|
|
||||||
if fps and fps > 0:
|
|
||||||
filters.append(f"fps={fps}")
|
|
||||||
|
|
||||||
# 3. setpts: 重置时间戳
|
|
||||||
if start > 0:
|
|
||||||
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
|
||||||
else:
|
|
||||||
filters.append("setpts=PTS-STARTPTS")
|
|
||||||
|
|
||||||
# 4. trim: 视频时长
|
|
||||||
filters.append(f"trim=0:{duration}")
|
|
||||||
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
|
||||||
|
|
||||||
video_label = f"v{input_index}"
|
|
||||||
|
|
||||||
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
|
||||||
# title/subtitle 是纯文字/图片卡片,没有音频流
|
|
||||||
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
|
||||||
has_audio_stream = clip_type not in ("title", "subtitle")
|
|
||||||
audio_label = f"a{input_index}" if has_audio_stream else None
|
|
||||||
|
|
||||||
return ClipFilterChain(
|
|
||||||
clip_id=clip.id,
|
|
||||||
input_index=input_index,
|
|
||||||
video_label=video_label,
|
|
||||||
audio_label=audio_label,
|
|
||||||
filters=filters,
|
|
||||||
duration=duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_filter_complex(
|
def _build_filter_complex(
|
||||||
@@ -466,102 +398,30 @@ class VideoComposeService:
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建完整的 filter_complex 字符串。
|
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
||||||
|
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
||||||
策略:
|
|
||||||
- 单片段:直接输出
|
|
||||||
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
|
||||||
- 多片段 + 有转场:使用 xfade 滤镜链
|
|
||||||
|
|
||||||
返回 (filter_complex_string, estimated_total_duration)。
|
|
||||||
"""
|
|
||||||
n = len(clip_chains)
|
|
||||||
|
|
||||||
if n == 0:
|
|
||||||
return "", 0.0
|
|
||||||
|
|
||||||
# ── 单片段 ─────────────────────────────────────────────────────
|
|
||||||
if n == 1:
|
|
||||||
chain = clip_chains[0]
|
|
||||||
filter_str = _chain_filters(chain.filters, chain.video_label)
|
|
||||||
# 音频
|
|
||||||
if chain.audio_label:
|
|
||||||
filter_str += f";[0:a]{chain.audio_label}"
|
|
||||||
total_duration = chain.duration
|
|
||||||
return filter_str, total_duration
|
|
||||||
|
|
||||||
# ── 检查是否有转场 ─────────────────────────────────────────────
|
|
||||||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
|
||||||
|
|
||||||
if not has_transitions:
|
|
||||||
return _build_concat_filter(clip_chains)
|
|
||||||
|
|
||||||
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
|
||||||
return _build_xfade_filter(
|
|
||||||
clip_chains=clip_chains,
|
|
||||||
transition_duration=transition_duration,
|
|
||||||
transitions=transitions,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||||
"""是否有任何片段包含音频流。"""
|
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
||||||
return any(c.audio_label is not None for c in clip_chains)
|
return _has_audio_func(clip_chains)
|
||||||
|
|
||||||
|
|
||||||
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
||||||
|
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
||||||
|
# 保留此处别名以兼容现有测试与调用方
|
||||||
|
|
||||||
|
|
||||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
||||||
filter_body = ",".join(filters)
|
return _chain_filters_func(filters, output_label)
|
||||||
return f"[0:v]{filter_body}[{output_label}]"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_concat_filter(
|
def _build_concat_filter(
|
||||||
clip_chains: list[ClipFilterChain],
|
clip_chains: list[ClipFilterChain],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建 concat 滤镜(无转场,高效拼接)。
|
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
||||||
|
return _build_concat_filter_func(clip_chains)
|
||||||
格式:
|
|
||||||
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
|
||||||
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
|
||||||
"""
|
|
||||||
n = len(clip_chains)
|
|
||||||
parts: list[str] = []
|
|
||||||
total_duration = 0.0
|
|
||||||
|
|
||||||
# 每个片段的滤镜链
|
|
||||||
for idx, chain in enumerate(clip_chains):
|
|
||||||
filter_body = ",".join(chain.filters)
|
|
||||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
|
||||||
total_duration += chain.duration
|
|
||||||
|
|
||||||
# concat 滤镜
|
|
||||||
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
|
||||||
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
|
||||||
parts.append(concat_filter)
|
|
||||||
|
|
||||||
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
|
|
||||||
audio_parts: list[str] = []
|
|
||||||
for idx, chain in enumerate(clip_chains):
|
|
||||||
if chain.audio_label:
|
|
||||||
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
|
|
||||||
audio_filters = [
|
|
||||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
|
||||||
f"atrim=0:{chain.duration}",
|
|
||||||
"asetpts=PTS-STARTPTS",
|
|
||||||
]
|
|
||||||
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
|
|
||||||
|
|
||||||
if audio_parts:
|
|
||||||
parts.extend(audio_parts)
|
|
||||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
|
||||||
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
|
||||||
if audio_count > 0:
|
|
||||||
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
|
||||||
|
|
||||||
return ";".join(parts), total_duration
|
|
||||||
|
|
||||||
|
|
||||||
def _build_xfade_filter(
|
def _build_xfade_filter(
|
||||||
@@ -569,80 +429,5 @@ def _build_xfade_filter(
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""构建 xfade 转场滤镜链。
|
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
||||||
|
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
||||||
每两个相邻片段之间插入 xfade 转场。
|
|
||||||
offset = 前一个片段的累积时长 - 转场时长。
|
|
||||||
|
|
||||||
格式(2 片段):
|
|
||||||
[0:v]filters[v0]; [1:v]filters[v1];
|
|
||||||
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
|
||||||
|
|
||||||
格式(3+ 片段):
|
|
||||||
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
|
||||||
"""
|
|
||||||
n = len(clip_chains)
|
|
||||||
parts: list[str] = []
|
|
||||||
total_duration = 0.0
|
|
||||||
|
|
||||||
# 每个片段的滤镜链
|
|
||||||
for idx, chain in enumerate(clip_chains):
|
|
||||||
filter_body = ",".join(chain.filters)
|
|
||||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
|
||||||
total_duration += chain.duration
|
|
||||||
|
|
||||||
# xfade 链
|
|
||||||
if n == 1:
|
|
||||||
# 单片段不需要 xfade
|
|
||||||
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
|
||||||
return ";".join(parts), total_duration
|
|
||||||
|
|
||||||
# 计算每个转场的 offset
|
|
||||||
cumulative = 0.0
|
|
||||||
prev_label = clip_chains[0].video_label
|
|
||||||
|
|
||||||
for i in range(1, n):
|
|
||||||
cumulative += clip_chains[i - 1].duration
|
|
||||||
offset = max(0.0, cumulative - transition_duration * i)
|
|
||||||
|
|
||||||
# 获取转场类型
|
|
||||||
transition = transitions[i] if i < len(transitions) else "cut"
|
|
||||||
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
|
||||||
|
|
||||||
if i == n - 1:
|
|
||||||
# 最后一个转场,输出到 [outv]
|
|
||||||
out_label = "outv"
|
|
||||||
else:
|
|
||||||
out_label = f"xf{i}"
|
|
||||||
|
|
||||||
parts.append(
|
|
||||||
f"[{prev_label}][{clip_chains[i].video_label}]"
|
|
||||||
f"xfade=transition={xfade_transition}"
|
|
||||||
f":duration={transition_duration}"
|
|
||||||
f":offset={offset:.3f}"
|
|
||||||
f"[{out_label}]"
|
|
||||||
)
|
|
||||||
prev_label = out_label
|
|
||||||
|
|
||||||
# 总时长需要减去转场重叠部分
|
|
||||||
total_duration -= transition_duration * (n - 1)
|
|
||||||
|
|
||||||
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
|
|
||||||
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
|
|
||||||
if len(audio_chains_with_label) >= 2:
|
|
||||||
normalized_audio_labels: list[str] = []
|
|
||||||
for chain, _ in audio_chains_with_label:
|
|
||||||
norm_label = f"anorm_{chain.video_label}"
|
|
||||||
audio_filters = [
|
|
||||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
|
||||||
f"atrim=0:{chain.duration}",
|
|
||||||
"asetpts=PTS-STARTPTS",
|
|
||||||
]
|
|
||||||
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
|
|
||||||
normalized_audio_labels.append(norm_label)
|
|
||||||
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
|
|
||||||
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
|
|
||||||
elif len(audio_chains_with_label) == 1:
|
|
||||||
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
|
|
||||||
|
|
||||||
return ";".join(parts), max(0.0, total_duration)
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 任务相关 API — 目录化入口
|
||||||
|
* 保持与原 tasks.ts 相同导出,向后兼容
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 类型
|
||||||
|
export type {
|
||||||
|
TaskStatus,
|
||||||
|
TaskType,
|
||||||
|
TaskErrorInfo,
|
||||||
|
TaskItem,
|
||||||
|
TaskListParams,
|
||||||
|
TaskListResponse,
|
||||||
|
CreateGenerationTaskRequest,
|
||||||
|
CreateGenerationTaskResponse,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
// API 函数
|
||||||
|
export { createGenerationTask, getTasks, getUserTasks, getTask, retryTask } from "./tasks"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 任务相关 API 函数
|
||||||
|
* 对接后端任务中心 API
|
||||||
|
*/
|
||||||
|
import apiClient from "../client"
|
||||||
|
import type {
|
||||||
|
CreateGenerationTaskRequest,
|
||||||
|
CreateGenerationTaskResponse,
|
||||||
|
TaskItem,
|
||||||
|
TaskListParams,
|
||||||
|
TaskListResponse,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
/** 创建生成任务(智能剪辑) */
|
||||||
|
export const createGenerationTask = async (
|
||||||
|
params: CreateGenerationTaskRequest,
|
||||||
|
): Promise<CreateGenerationTaskResponse> => {
|
||||||
|
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取任务列表(支持分页和筛选) */
|
||||||
|
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||||
|
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||||
|
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||||
|
const { data } = await apiClient.get("/tasks")
|
||||||
|
return data.items || data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个任务详情(含 error_info) */
|
||||||
|
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||||
|
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重试失败的任务 */
|
||||||
|
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||||
|
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 任务相关 API
|
* 任务相关类型定义
|
||||||
* 对接后端任务中心 API:
|
|
||||||
* - POST /api/v1/generation/tasks — 创建生成任务
|
|
||||||
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
|
||||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
|
||||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client"
|
|
||||||
|
|
||||||
/* ──────────── 类型定义 ──────────── */
|
|
||||||
|
|
||||||
/** 任务状态 */
|
/** 任务状态 */
|
||||||
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||||
@@ -85,39 +77,3 @@ export interface CreateGenerationTaskResponse {
|
|||||||
result_count: number
|
result_count: number
|
||||||
error_message: string
|
error_message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ──────────── API 函数 ──────────── */
|
|
||||||
|
|
||||||
/** 创建生成任务(智能剪辑) */
|
|
||||||
export const createGenerationTask = async (
|
|
||||||
params: CreateGenerationTaskRequest,
|
|
||||||
): Promise<CreateGenerationTaskResponse> => {
|
|
||||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取任务列表(支持分页和筛选) */
|
|
||||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
|
||||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
|
||||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
|
||||||
const { data } = await apiClient.get("/tasks")
|
|
||||||
return data.items || data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取单个任务详情(含 error_info) */
|
|
||||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
|
||||||
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重试失败的任务 */
|
|
||||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
|
||||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* 模板编辑器 — 制作/编辑剪辑模板
|
* 模板编辑器 — 制作/编辑剪辑模板
|
||||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||||
|
*
|
||||||
|
* 主组件仅保留 Hook 组装与整体布局
|
||||||
|
* 全局配置 → hooks/useGlobalSettings
|
||||||
|
* 配音素材 → hooks/useVoiceMaterials
|
||||||
|
* 撤销重做 → hooks/useUndoRedo
|
||||||
|
* 抽屉管理 → hooks/useEditorDrawers
|
||||||
|
* 播放控制 → hooks/usePlaybackControl
|
||||||
|
* 片段操作 → hooks/useClipOperations
|
||||||
|
* 模板管理 → hooks/useTemplateManagement
|
||||||
*/
|
*/
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { MODE_LABELS } from "@/api/editing-planner"
|
import { MODE_LABELS } from "@/api/editing-planner"
|
||||||
import { MODE_LIST } from "./constants"
|
import { MODE_LIST } from "./constants"
|
||||||
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
|
import type { MediaAsset } from "@/api/template-editor"
|
||||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
|
||||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
|
||||||
|
|
||||||
import MediaPanel from "./components/MediaPanel"
|
import MediaPanel from "./components/MediaPanel"
|
||||||
import PreviewPlayer from "./components/PreviewPlayer"
|
import PreviewPlayer from "./components/PreviewPlayer"
|
||||||
@@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers"
|
|||||||
import { usePlaybackControl } from "./hooks/usePlaybackControl"
|
import { usePlaybackControl } from "./hooks/usePlaybackControl"
|
||||||
import { useClipOperations } from "./hooks/useClipOperations"
|
import { useClipOperations } from "./hooks/useClipOperations"
|
||||||
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
|
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
|
||||||
|
import { useGlobalSettings } from "./hooks/useGlobalSettings"
|
||||||
|
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||||
|
|
||||||
import type {
|
import type { ClipData } from "./types"
|
||||||
ClipData,
|
|
||||||
WatermarkConfig,
|
|
||||||
IntroOutroConfig,
|
|
||||||
PipConfig,
|
|
||||||
FilterConfig,
|
|
||||||
ChromaKeyConfig,
|
|
||||||
StickerConfig,
|
|
||||||
CoverConfig,
|
|
||||||
} from "./types"
|
|
||||||
import {
|
|
||||||
DEFAULT_WATERMARK,
|
|
||||||
DEFAULT_INTRO_OUTRO,
|
|
||||||
DEFAULT_PIP_CONFIG,
|
|
||||||
DEFAULT_FILTER_CONFIG,
|
|
||||||
DEFAULT_CHROMA_KEY_CONFIG,
|
|
||||||
DEFAULT_STICKER_CONFIG,
|
|
||||||
DEFAULT_COVER_CONFIG,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
import type { SubtitleStyleConfig } from "./types/subtitle"
|
|
||||||
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle"
|
|
||||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
|
||||||
import "./EditingPlanner.css"
|
import "./EditingPlanner.css"
|
||||||
|
|
||||||
/* ──────────── 常量 ──────────── */
|
|
||||||
|
|
||||||
/* ──────────── 组件 ──────────── */
|
|
||||||
|
|
||||||
const EditingPlanner: React.FC = () => {
|
const EditingPlanner: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const urlTemplateId = searchParams.get("templateId") || ""
|
const urlTemplateId = searchParams.get("templateId") || ""
|
||||||
@@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => {
|
|||||||
reset: resetClips,
|
reset: resetClips,
|
||||||
} = useUndoRedo<ClipData[]>([])
|
} = useUndoRedo<ClipData[]>([])
|
||||||
|
|
||||||
/* ── 全局配置 state ── */
|
/* ── 全局配置 ── */
|
||||||
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
|
const {
|
||||||
ai_auto_select: false,
|
titleConfig,
|
||||||
content: "",
|
setTitleConfig,
|
||||||
position: "bottom",
|
subtitleSettings,
|
||||||
font_preset: "思源黑体",
|
setSubtitleSettings,
|
||||||
font_size: 28,
|
bgmSettings,
|
||||||
font_color: "#ffffff",
|
setBgmSettings,
|
||||||
})
|
watermarkSettings,
|
||||||
|
setWatermarkSettings,
|
||||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
|
introOutroSettings,
|
||||||
...DEFAULT_SUBTITLE_STYLE,
|
setIntroOutroSettings,
|
||||||
})
|
pipSettings,
|
||||||
|
setPipSettings,
|
||||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
filterSettings,
|
||||||
...DEFAULT_BGM_MIX_CONFIG,
|
setFilterSettings,
|
||||||
})
|
chromaKeySettings,
|
||||||
|
setChromaKeySettings,
|
||||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
stickerSettings,
|
||||||
...DEFAULT_WATERMARK,
|
setStickerSettings,
|
||||||
})
|
coverConfig,
|
||||||
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({
|
setCoverConfig,
|
||||||
...DEFAULT_INTRO_OUTRO,
|
} = useGlobalSettings()
|
||||||
})
|
|
||||||
|
|
||||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
|
||||||
...DEFAULT_PIP_CONFIG,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
|
||||||
...DEFAULT_FILTER_CONFIG,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
|
||||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
|
||||||
...DEFAULT_STICKER_CONFIG,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
|
||||||
...DEFAULT_COVER_CONFIG,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 右侧栏 Tab ── */
|
/* ── 右侧栏 Tab ── */
|
||||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||||
@@ -128,18 +89,12 @@ const EditingPlanner: React.FC = () => {
|
|||||||
setSelectedAssetIds(ids)
|
setSelectedAssetIds(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
/* ── 配音素材 ── */
|
||||||
const voiceMaterialsQuery = useQuery({
|
const {
|
||||||
queryKey: ["assets", "voice"],
|
voiceMaterials,
|
||||||
queryFn: async () => {
|
loading: voiceMaterialsLoading,
|
||||||
const project = await getOrCreateDefaultProject()
|
refetch: refetchVoiceMaterials,
|
||||||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
} = useVoiceMaterials()
|
||||||
const assets = await getAssetsByKind("voice")
|
|
||||||
return assets
|
|
||||||
},
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? []
|
|
||||||
|
|
||||||
/* ── 派生计算 ── */
|
/* ── 派生计算 ── */
|
||||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
|
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
|
||||||
@@ -179,31 +134,6 @@ const EditingPlanner: React.FC = () => {
|
|||||||
coverConfig,
|
coverConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ── 配置变更 handlers ── */
|
|
||||||
const handleWatermarkChange = (config: WatermarkConfig) => {
|
|
||||||
setWatermarkSettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIntroOutroChange = (config: IntroOutroConfig) => {
|
|
||||||
setIntroOutroSettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePipChange = (config: PipConfig) => {
|
|
||||||
setPipSettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterChange = (config: FilterConfig) => {
|
|
||||||
setFilterSettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChromaKeyChange = (config: ChromaKeyConfig) => {
|
|
||||||
setChromaKeySettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStickerChange = (config: StickerConfig) => {
|
|
||||||
setStickerSettings(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────── 渲染 ──────────── */
|
/* ──────────── 渲染 ──────────── */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -294,15 +224,15 @@ const EditingPlanner: React.FC = () => {
|
|||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
currentMode={tpl.currentMode}
|
currentMode={tpl.currentMode}
|
||||||
onSubtitleSettingsChange={(partial) =>
|
onSubtitleSettingsChange={(partial) =>
|
||||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
|
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||||
}
|
}
|
||||||
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
|
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
|
||||||
onClipUpdate={clipOps.handleClipUpdate}
|
onClipUpdate={clipOps.handleClipUpdate}
|
||||||
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
|
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
|
||||||
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
|
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
|
||||||
voiceMaterials={voiceMaterials}
|
voiceMaterials={voiceMaterials}
|
||||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
onRefreshVoiceMaterials={refetchVoiceMaterials}
|
||||||
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
|
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
|
||||||
onOpenTransitionDrawer={drawers.openTransitionDrawer}
|
onOpenTransitionDrawer={drawers.openTransitionDrawer}
|
||||||
onOpenSpeedDrawer={drawers.openSpeedDrawer}
|
onOpenSpeedDrawer={drawers.openSpeedDrawer}
|
||||||
@@ -382,28 +312,28 @@ const EditingPlanner: React.FC = () => {
|
|||||||
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
|
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
|
||||||
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
|
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
|
||||||
watermarkSettings={watermarkSettings}
|
watermarkSettings={watermarkSettings}
|
||||||
onWatermarkChange={handleWatermarkChange}
|
onWatermarkChange={setWatermarkSettings}
|
||||||
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
|
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
|
||||||
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
|
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
|
||||||
introOutroSettings={introOutroSettings}
|
introOutroSettings={introOutroSettings}
|
||||||
onIntroOutroChange={handleIntroOutroChange}
|
onIntroOutroChange={setIntroOutroSettings}
|
||||||
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
|
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
|
||||||
pipDrawerOpen={drawers.pipDrawerOpen}
|
pipDrawerOpen={drawers.pipDrawerOpen}
|
||||||
pipSettings={pipSettings}
|
pipSettings={pipSettings}
|
||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
onPipChange={handlePipChange}
|
onPipChange={setPipSettings}
|
||||||
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
|
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
|
||||||
filterDrawerOpen={drawers.filterDrawerOpen}
|
filterDrawerOpen={drawers.filterDrawerOpen}
|
||||||
filterSettings={filterSettings}
|
filterSettings={filterSettings}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={setFilterSettings}
|
||||||
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
|
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
|
||||||
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
|
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
|
||||||
chromaKeySettings={chromaKeySettings}
|
chromaKeySettings={chromaKeySettings}
|
||||||
onChromaKeyChange={handleChromaKeyChange}
|
onChromaKeyChange={setChromaKeySettings}
|
||||||
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
|
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
|
||||||
stickerDrawerOpen={drawers.stickerDrawerOpen}
|
stickerDrawerOpen={drawers.stickerDrawerOpen}
|
||||||
stickerSettings={stickerSettings}
|
stickerSettings={stickerSettings}
|
||||||
onStickerChange={handleStickerChange}
|
onStickerChange={setStickerSettings}
|
||||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,189 +3,32 @@
|
|||||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||||
*/
|
*/
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import type { TemplateCategory } from "@/api/editing-planner"
|
|
||||||
import type {
|
|
||||||
ClipData,
|
|
||||||
TransitionConfig,
|
|
||||||
SpeedConfig,
|
|
||||||
TtsConfig,
|
|
||||||
WatermarkConfig,
|
|
||||||
IntroOutroConfig,
|
|
||||||
PipConfig,
|
|
||||||
FilterConfig,
|
|
||||||
ChromaKeyConfig,
|
|
||||||
StickerConfig,
|
|
||||||
} from "../types"
|
|
||||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
|
||||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
|
||||||
import type { BgmMixConfig } from "@/api/bgm"
|
|
||||||
import SaveModal from "./SaveModal"
|
import SaveModal from "./SaveModal"
|
||||||
import BgmSelector from "./BgmSelector"
|
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
|
||||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
|
||||||
import TransitionSelector from "./TransitionSelector"
|
import type { EditingDrawersProps } from "./editing-drawers/types"
|
||||||
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"
|
|
||||||
|
|
||||||
interface EditingDrawersProps {
|
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
|
||||||
/* 保存弹窗 */
|
const {
|
||||||
saveModalOpen: boolean
|
saveModalOpen,
|
||||||
saveLoading: boolean
|
saveLoading,
|
||||||
isUpdate: boolean
|
isUpdate,
|
||||||
draftName: string
|
draftName,
|
||||||
draftCategory: string
|
draftCategory,
|
||||||
draftTags: string
|
draftTags,
|
||||||
categories: TemplateCategory[]
|
categories,
|
||||||
estimatedDuration: number
|
estimatedDuration,
|
||||||
onNameChange: (name: string) => void
|
onNameChange,
|
||||||
onCategoryChange: (cat: string) => void
|
onCategoryChange,
|
||||||
onTagsChange: (tags: string) => void
|
onTagsChange,
|
||||||
onSave: () => Promise<void>
|
onSave,
|
||||||
onCancelSave: () => void
|
onCancelSave,
|
||||||
/* BGM */
|
clips,
|
||||||
bgmDrawerOpen: boolean
|
} = props
|
||||||
bgmSettings: BgmMixConfig
|
|
||||||
onCloseBgmDrawer: () => void
|
|
||||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
|
||||||
/* 字幕 */
|
|
||||||
subtitleDrawerOpen: boolean
|
|
||||||
subtitleSettings: SubtitleStyleConfig
|
|
||||||
onCloseSubtitleDrawer: () => void
|
|
||||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
|
||||||
/* 转场 */
|
|
||||||
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
|
|
||||||
/* TTS 配音 */
|
|
||||||
ttsDrawerOpen: boolean
|
|
||||||
ttsTargetClipId: string | null
|
|
||||||
onCloseTtsDrawer: () => void
|
|
||||||
onTtsChange: (config: TtsConfig) => void
|
|
||||||
/* 水印 */
|
|
||||||
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
|
|
||||||
/* 共享数据 */
|
|
||||||
clips: ClipData[]
|
|
||||||
totalDuration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
|
||||||
saveModalOpen,
|
|
||||||
saveLoading,
|
|
||||||
isUpdate,
|
|
||||||
draftName,
|
|
||||||
draftCategory,
|
|
||||||
draftTags,
|
|
||||||
categories,
|
|
||||||
estimatedDuration,
|
|
||||||
onNameChange,
|
|
||||||
onCategoryChange,
|
|
||||||
onTagsChange,
|
|
||||||
onSave,
|
|
||||||
onCancelSave,
|
|
||||||
bgmDrawerOpen,
|
|
||||||
bgmSettings,
|
|
||||||
onCloseBgmDrawer,
|
|
||||||
onChangeBgmSettings,
|
|
||||||
subtitleDrawerOpen,
|
|
||||||
subtitleSettings,
|
|
||||||
onCloseSubtitleDrawer,
|
|
||||||
onChangeSubtitleSettings,
|
|
||||||
transitionDrawerOpen,
|
|
||||||
transitionTargetClipId,
|
|
||||||
onCloseTransitionDrawer,
|
|
||||||
onTransitionChange,
|
|
||||||
speedDrawerOpen,
|
|
||||||
speedTargetClipId,
|
|
||||||
onCloseSpeedDrawer,
|
|
||||||
onSpeedChange,
|
|
||||||
onApplySpeedAll,
|
|
||||||
ttsDrawerOpen,
|
|
||||||
ttsTargetClipId,
|
|
||||||
onCloseTtsDrawer,
|
|
||||||
onTtsChange,
|
|
||||||
watermarkDrawerOpen,
|
|
||||||
watermarkSettings,
|
|
||||||
onCloseWatermarkDrawer,
|
|
||||||
onWatermarkChange,
|
|
||||||
introOutroDrawerOpen,
|
|
||||||
introOutroSettings,
|
|
||||||
onCloseIntroOutroDrawer,
|
|
||||||
onIntroOutroChange,
|
|
||||||
pipDrawerOpen,
|
|
||||||
pipSettings,
|
|
||||||
onClosePipDrawer,
|
|
||||||
onPipChange,
|
|
||||||
filterDrawerOpen,
|
|
||||||
filterSettings,
|
|
||||||
onCloseFilterDrawer,
|
|
||||||
onFilterChange,
|
|
||||||
chromaKeyDrawerOpen,
|
|
||||||
chromaKeySettings,
|
|
||||||
onCloseChromaKeyDrawer,
|
|
||||||
onChromaKeyChange,
|
|
||||||
stickerDrawerOpen,
|
|
||||||
stickerSettings,
|
|
||||||
onCloseStickerDrawer,
|
|
||||||
onStickerChange,
|
|
||||||
clips,
|
|
||||||
totalDuration,
|
|
||||||
}) => {
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ═══ 保存弹窗 ═══ */}
|
{/* 保存弹窗 */}
|
||||||
<SaveModal
|
<SaveModal
|
||||||
open={saveModalOpen}
|
open={saveModalOpen}
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
@@ -202,100 +45,59 @@ const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
|||||||
onCancel={onCancelSave}
|
onCancel={onCancelSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
{/* 片段级抽屉(转场/调速/TTS) */}
|
||||||
<BgmSelector
|
<ClipLevelDrawers
|
||||||
open={bgmDrawerOpen}
|
clips={clips}
|
||||||
onClose={onCloseBgmDrawer}
|
transitionDrawerOpen={props.transitionDrawerOpen}
|
||||||
config={bgmSettings}
|
transitionTargetClipId={props.transitionTargetClipId}
|
||||||
onChange={onChangeBgmSettings}
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
|
||||||
<SubtitleStylePanel
|
<GlobalDrawers
|
||||||
open={subtitleDrawerOpen}
|
bgmDrawerOpen={props.bgmDrawerOpen}
|
||||||
onClose={onCloseSubtitleDrawer}
|
bgmSettings={props.bgmSettings}
|
||||||
config={subtitleSettings}
|
onCloseBgmDrawer={props.onCloseBgmDrawer}
|
||||||
onChange={onChangeSubtitleSettings}
|
onChangeBgmSettings={props.onChangeBgmSettings}
|
||||||
/>
|
subtitleDrawerOpen={props.subtitleDrawerOpen}
|
||||||
|
subtitleSettings={props.subtitleSettings}
|
||||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
|
||||||
<TransitionSelector
|
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
|
||||||
open={transitionDrawerOpen}
|
totalDuration={props.totalDuration}
|
||||||
onClose={onCloseTransitionDrawer}
|
watermarkDrawerOpen={props.watermarkDrawerOpen}
|
||||||
config={transitionConfig}
|
watermarkSettings={props.watermarkSettings}
|
||||||
onChange={onTransitionChange}
|
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
|
||||||
title={transitionTitle}
|
onWatermarkChange={props.onWatermarkChange}
|
||||||
/>
|
introOutroDrawerOpen={props.introOutroDrawerOpen}
|
||||||
|
introOutroSettings={props.introOutroSettings}
|
||||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
|
||||||
{speedTargetClipId && (
|
onIntroOutroChange={props.onIntroOutroChange}
|
||||||
<SpeedPanel
|
pipDrawerOpen={props.pipDrawerOpen}
|
||||||
open={speedDrawerOpen}
|
pipSettings={props.pipSettings}
|
||||||
onClose={onCloseSpeedDrawer}
|
onClosePipDrawer={props.onClosePipDrawer}
|
||||||
config={speedConfig}
|
onPipChange={props.onPipChange}
|
||||||
onChange={onSpeedChange}
|
filterDrawerOpen={props.filterDrawerOpen}
|
||||||
onApplyAll={onApplySpeedAll}
|
filterSettings={props.filterSettings}
|
||||||
/>
|
onCloseFilterDrawer={props.onCloseFilterDrawer}
|
||||||
)}
|
onFilterChange={props.onFilterChange}
|
||||||
|
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
|
||||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
chromaKeySettings={props.chromaKeySettings}
|
||||||
{ttsTargetClipId && (
|
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
|
||||||
<TtsPanel
|
onChromaKeyChange={props.onChromaKeyChange}
|
||||||
open={ttsDrawerOpen}
|
stickerDrawerOpen={props.stickerDrawerOpen}
|
||||||
onClose={onCloseTtsDrawer}
|
stickerSettings={props.stickerSettings}
|
||||||
config={ttsConfig}
|
onCloseStickerDrawer={props.onCloseStickerDrawer}
|
||||||
onChange={onTtsChange}
|
onStickerChange={props.onStickerChange}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══ 水印配置面板 ═══ */}
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
* 混剪单图层配置区
|
* 混剪单图层配置区
|
||||||
*/
|
*/
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import type { PipLayer, PipAnimType, PipSlideDirection, PipGridPosition } from "@/pages/editing-planner/types"
|
import type {
|
||||||
|
PipLayer,
|
||||||
|
PipAnimType,
|
||||||
|
PipSlideDirection,
|
||||||
|
PipGridPosition,
|
||||||
|
} from "@/pages/editing-planner/types"
|
||||||
import {
|
import {
|
||||||
GRID_POSITIONS,
|
GRID_POSITIONS,
|
||||||
ANIM_OPTIONS,
|
ANIM_OPTIONS,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useUndoRedo } from "../useUndoRedo"
|
||||||
|
import type { EditPlanClip } from "@/api/template-editor"
|
||||||
|
import { useEditPlanClipList } from "./useEditPlanClipList"
|
||||||
|
import { useEditPlanClipMutations } from "./useEditPlanClipMutations"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板片段管理 Hook
|
||||||
|
* 对接后端 PR#389 片段 CRUD API
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 加载/刷新片段列表
|
||||||
|
* - 单个增删改查
|
||||||
|
* - 批量删除
|
||||||
|
* - 拖拽重排序
|
||||||
|
* - 从素材批量导入
|
||||||
|
* - 乐观更新 + 撤销重做
|
||||||
|
*/
|
||||||
|
export function useEditPlanClips(planId: string | undefined) {
|
||||||
|
// 列表数据 + 选中状态
|
||||||
|
const {
|
||||||
|
clips,
|
||||||
|
clipsTotal,
|
||||||
|
clipsLoading,
|
||||||
|
refetchClips,
|
||||||
|
selectedClipId,
|
||||||
|
setSelectedClipId,
|
||||||
|
selectedClip,
|
||||||
|
} = useEditPlanClipList(planId)
|
||||||
|
|
||||||
|
// CRUD 操作
|
||||||
|
const mutations = useEditPlanClipMutations({
|
||||||
|
planId,
|
||||||
|
selectedClipId,
|
||||||
|
setSelectedClipId,
|
||||||
|
clipsLength: clips.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 本地撤销重做(供拖拽等即时操作使用)
|
||||||
|
const {
|
||||||
|
state: localClips,
|
||||||
|
set: setLocalClips,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
reset: resetLocalClips,
|
||||||
|
} = useUndoRedo<EditPlanClip[]>([])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
clips,
|
||||||
|
clipsTotal,
|
||||||
|
clipsLoading,
|
||||||
|
selectedClipId,
|
||||||
|
selectedClip,
|
||||||
|
// 选中
|
||||||
|
setSelectedClipId,
|
||||||
|
// 操作
|
||||||
|
addClip: mutations.addClip,
|
||||||
|
updateClip: mutations.updateClip,
|
||||||
|
removeClip: mutations.removeClip,
|
||||||
|
batchRemoveClips: mutations.batchRemoveClips,
|
||||||
|
reorderClips: mutations.reorderClips,
|
||||||
|
importFromAssets: mutations.importFromAssets,
|
||||||
|
refetchClips,
|
||||||
|
// 状态
|
||||||
|
isCreating: mutations.isCreating,
|
||||||
|
isUpdating: mutations.isUpdating,
|
||||||
|
isDeleting: mutations.isDeleting,
|
||||||
|
isReordering: mutations.isReordering,
|
||||||
|
isImporting: mutations.isImporting,
|
||||||
|
// 本地撤销重做
|
||||||
|
localClips,
|
||||||
|
setLocalClips,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
resetLocalClips,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useEditPlanClips
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import type { EditPlanClip } from "@/api/template-editor"
|
||||||
|
import { getEditPlanClips } from "@/api/template-editor"
|
||||||
|
|
||||||
|
const QUERY_KEY = "editPlanClips"
|
||||||
|
|
||||||
|
interface UseEditPlanClipListResult {
|
||||||
|
clips: EditPlanClip[]
|
||||||
|
clipsTotal: number
|
||||||
|
clipsLoading: boolean
|
||||||
|
refetchClips: () => void
|
||||||
|
selectedClipId: string | null
|
||||||
|
setSelectedClipId: (id: string | null) => void
|
||||||
|
selectedClip: EditPlanClip | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑计划片段列表 Hook
|
||||||
|
* 封装片段列表查询、选中状态
|
||||||
|
*/
|
||||||
|
export function useEditPlanClipList(planId: string | undefined): UseEditPlanClipListResult {
|
||||||
|
const {
|
||||||
|
data: clipListData,
|
||||||
|
isLoading: clipsLoading,
|
||||||
|
refetch: refetchClips,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [QUERY_KEY, planId],
|
||||||
|
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||||
|
enabled: !!planId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clips: EditPlanClip[] = clipListData?.items ?? []
|
||||||
|
const clipsTotal = clipListData?.total ?? 0
|
||||||
|
|
||||||
|
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||||
|
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
clips,
|
||||||
|
clipsTotal,
|
||||||
|
clipsLoading,
|
||||||
|
refetchClips,
|
||||||
|
selectedClipId,
|
||||||
|
setSelectedClipId,
|
||||||
|
selectedClip,
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-84
@@ -1,26 +1,12 @@
|
|||||||
/**
|
import { useCallback } from "react"
|
||||||
* 模板片段管理 Hook
|
|
||||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 加载/刷新片段列表
|
|
||||||
* - 单个增删改查
|
|
||||||
* - 批量删除
|
|
||||||
* - 拖拽重排序
|
|
||||||
* - 从素材批量导入
|
|
||||||
* - 乐观更新 + 撤销重做
|
|
||||||
*/
|
|
||||||
import { useCallback, useState } from "react"
|
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
import type {
|
import type {
|
||||||
EditPlanClip,
|
|
||||||
CreateEditPlanClipRequest,
|
CreateEditPlanClipRequest,
|
||||||
UpdateEditPlanClipRequest,
|
UpdateEditPlanClipRequest,
|
||||||
ClipReorderItem,
|
ClipReorderItem,
|
||||||
} from "@/api/template-editor"
|
} from "@/api/template-editor"
|
||||||
import {
|
import {
|
||||||
getEditPlanClips,
|
|
||||||
createEditPlanClip,
|
createEditPlanClip,
|
||||||
updateEditPlanClip,
|
updateEditPlanClip,
|
||||||
deleteEditPlanClip,
|
deleteEditPlanClip,
|
||||||
@@ -28,51 +14,37 @@ import {
|
|||||||
batchDeleteEditPlanClips,
|
batchDeleteEditPlanClips,
|
||||||
createClipsFromAssets,
|
createClipsFromAssets,
|
||||||
} from "@/api/template-editor"
|
} from "@/api/template-editor"
|
||||||
import { useUndoRedo } from "./useUndoRedo"
|
|
||||||
|
|
||||||
const QUERY_KEY = "editPlanClips"
|
const QUERY_KEY = "editPlanClips"
|
||||||
|
|
||||||
export function useEditPlanClips(planId: string | undefined) {
|
interface UseEditPlanClipMutationsOptions {
|
||||||
|
planId: string | undefined
|
||||||
|
selectedClipId: string | null
|
||||||
|
setSelectedClipId: (id: string | null) => void
|
||||||
|
clipsLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑计划片段 CRUD Hook
|
||||||
|
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
|
||||||
|
*/
|
||||||
|
export function useEditPlanClipMutations({
|
||||||
|
planId,
|
||||||
|
selectedClipId,
|
||||||
|
setSelectedClipId,
|
||||||
|
clipsLength,
|
||||||
|
}: UseEditPlanClipMutationsOptions) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
/* ── 片段列表查询 ── */
|
const invalidate = () => {
|
||||||
const {
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||||
data: clipListData,
|
}
|
||||||
isLoading: clipsLoading,
|
|
||||||
refetch: refetchClips,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [QUERY_KEY, planId],
|
|
||||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
|
||||||
enabled: !!planId,
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const clips: EditPlanClip[] = clipListData?.items ?? []
|
|
||||||
const clipsTotal = clipListData?.total ?? 0
|
|
||||||
|
|
||||||
/* ── 选中片段 ── */
|
|
||||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
|
||||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
|
|
||||||
|
|
||||||
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
|
|
||||||
const {
|
|
||||||
state: localClips,
|
|
||||||
set: setLocalClips,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
reset: resetLocalClips,
|
|
||||||
} = useUndoRedo<EditPlanClip[]>([])
|
|
||||||
|
|
||||||
// 当服务端数据变化时同步本地
|
|
||||||
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
|
|
||||||
|
|
||||||
/* ── 创建片段 ── */
|
/* ── 创建片段 ── */
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
message.success("片段已添加")
|
message.success("片段已添加")
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -83,10 +55,10 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
const addClip = useCallback(
|
const addClip = useCallback(
|
||||||
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
||||||
if (!planId) return
|
if (!planId) return
|
||||||
const order = data.order ?? clips.length
|
const order = data.order ?? clipsLength
|
||||||
createMutation.mutate({ ...data, order })
|
createMutation.mutate({ ...data, order })
|
||||||
},
|
},
|
||||||
[planId, clips.length, createMutation],
|
[planId, clipsLength, createMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 更新片段 ── */
|
/* ── 更新片段 ── */
|
||||||
@@ -94,7 +66,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
||||||
updateEditPlanClip(planId!, clipId, data),
|
updateEditPlanClip(planId!, clipId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error("更新片段失败")
|
message.error("更新片段失败")
|
||||||
@@ -113,7 +85,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
message.success("片段已删除")
|
message.success("片段已删除")
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -129,14 +101,14 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
}
|
}
|
||||||
deleteMutation.mutate(clipId)
|
deleteMutation.mutate(clipId)
|
||||||
},
|
},
|
||||||
[planId, selectedClipId, deleteMutation],
|
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 批量删除 ── */
|
/* ── 批量删除 ── */
|
||||||
const batchDeleteMutation = useMutation({
|
const batchDeleteMutation = useMutation({
|
||||||
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
message.success(`已删除 ${res.deleted_count} 个片段`)
|
message.success(`已删除 ${res.deleted_count} 个片段`)
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -152,19 +124,18 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
}
|
}
|
||||||
batchDeleteMutation.mutate(clipIds)
|
batchDeleteMutation.mutate(clipIds)
|
||||||
},
|
},
|
||||||
[planId, selectedClipId, batchDeleteMutation],
|
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 重排序(拖拽结束后一次性提交) ── */
|
/* ── 重排序 ── */
|
||||||
const reorderMutation = useMutation({
|
const reorderMutation = useMutation({
|
||||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error("排序失败")
|
message.error("排序失败")
|
||||||
// 失败后刷新回服务端状态
|
invalidate()
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,7 +151,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
const importFromAssetsMutation = useMutation({
|
const importFromAssetsMutation = useMutation({
|
||||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
invalidate()
|
||||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -197,37 +168,16 @@ export function useEditPlanClips(planId: string | undefined) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 数据
|
|
||||||
clips,
|
|
||||||
clipsTotal,
|
|
||||||
clipsLoading,
|
|
||||||
selectedClipId,
|
|
||||||
selectedClip,
|
|
||||||
// 选中
|
|
||||||
setSelectedClipId,
|
|
||||||
// 操作
|
|
||||||
addClip,
|
addClip,
|
||||||
updateClip,
|
updateClip,
|
||||||
removeClip,
|
removeClip,
|
||||||
batchRemoveClips,
|
batchRemoveClips,
|
||||||
reorderClips,
|
reorderClips,
|
||||||
importFromAssets,
|
importFromAssets,
|
||||||
refetchClips,
|
|
||||||
// 状态
|
|
||||||
isCreating: createMutation.isPending,
|
isCreating: createMutation.isPending,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
isDeleting: deleteMutation.isPending,
|
isDeleting: deleteMutation.isPending,
|
||||||
isReordering: reorderMutation.isPending,
|
isReordering: reorderMutation.isPending,
|
||||||
isImporting: importFromAssetsMutation.isPending,
|
isImporting: importFromAssetsMutation.isPending,
|
||||||
// 本地撤销重做(供拖拽等场景使用)
|
|
||||||
localClips,
|
|
||||||
setLocalClips,
|
|
||||||
undo,
|
|
||||||
redo,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
resetLocalClips,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useEditPlanClips
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* EditingPlanner 全局配置状态管理
|
||||||
|
* 集中管理 9 个全局配置:标题/字幕/BGM/水印/片头片尾/画中画/滤镜/绿幕/贴纸/封面
|
||||||
|
*/
|
||||||
|
import { useState } from "react"
|
||||||
|
import type { TitleConfig } from "@/api/template-editor"
|
||||||
|
import type {
|
||||||
|
WatermarkConfig,
|
||||||
|
IntroOutroConfig,
|
||||||
|
PipConfig,
|
||||||
|
FilterConfig,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
StickerConfig,
|
||||||
|
CoverConfig,
|
||||||
|
} from "../types"
|
||||||
|
import {
|
||||||
|
DEFAULT_WATERMARK,
|
||||||
|
DEFAULT_INTRO_OUTRO,
|
||||||
|
DEFAULT_PIP_CONFIG,
|
||||||
|
DEFAULT_FILTER_CONFIG,
|
||||||
|
DEFAULT_CHROMA_KEY_CONFIG,
|
||||||
|
DEFAULT_STICKER_CONFIG,
|
||||||
|
DEFAULT_COVER_CONFIG,
|
||||||
|
} from "../types"
|
||||||
|
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||||
|
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
|
||||||
|
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||||
|
|
||||||
|
export interface GlobalSettings {
|
||||||
|
titleConfig: TitleConfig
|
||||||
|
setTitleConfig: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||||
|
subtitleSettings: SubtitleStyleConfig
|
||||||
|
setSubtitleSettings: (
|
||||||
|
settings: SubtitleStyleConfig | ((prev: SubtitleStyleConfig) => SubtitleStyleConfig),
|
||||||
|
) => void
|
||||||
|
bgmSettings: BgmMixConfig
|
||||||
|
setBgmSettings: (settings: BgmMixConfig | ((prev: BgmMixConfig) => BgmMixConfig)) => void
|
||||||
|
watermarkSettings: WatermarkConfig
|
||||||
|
setWatermarkSettings: (config: WatermarkConfig) => void
|
||||||
|
introOutroSettings: IntroOutroConfig
|
||||||
|
setIntroOutroSettings: (config: IntroOutroConfig) => void
|
||||||
|
pipSettings: PipConfig
|
||||||
|
setPipSettings: (config: PipConfig) => void
|
||||||
|
filterSettings: FilterConfig
|
||||||
|
setFilterSettings: (config: FilterConfig) => void
|
||||||
|
chromaKeySettings: ChromaKeyConfig
|
||||||
|
setChromaKeySettings: (config: ChromaKeyConfig) => void
|
||||||
|
stickerSettings: StickerConfig
|
||||||
|
setStickerSettings: (config: StickerConfig) => void
|
||||||
|
coverConfig: CoverConfig
|
||||||
|
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGlobalSettings = (): GlobalSettings => {
|
||||||
|
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
|
||||||
|
ai_auto_select: false,
|
||||||
|
content: "",
|
||||||
|
position: "bottom",
|
||||||
|
font_preset: "思源黑体",
|
||||||
|
font_size: 28,
|
||||||
|
font_color: "#ffffff",
|
||||||
|
})
|
||||||
|
|
||||||
|
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
|
||||||
|
...DEFAULT_SUBTITLE_STYLE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||||
|
...DEFAULT_BGM_MIX_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||||
|
...DEFAULT_WATERMARK,
|
||||||
|
})
|
||||||
|
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({
|
||||||
|
...DEFAULT_INTRO_OUTRO,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||||
|
...DEFAULT_PIP_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||||
|
...DEFAULT_FILTER_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||||
|
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||||
|
...DEFAULT_STICKER_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||||
|
...DEFAULT_COVER_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleConfig,
|
||||||
|
setTitleConfig,
|
||||||
|
subtitleSettings,
|
||||||
|
setSubtitleSettings,
|
||||||
|
bgmSettings,
|
||||||
|
setBgmSettings,
|
||||||
|
watermarkSettings,
|
||||||
|
setWatermarkSettings,
|
||||||
|
introOutroSettings,
|
||||||
|
setIntroOutroSettings,
|
||||||
|
pipSettings,
|
||||||
|
setPipSettings,
|
||||||
|
filterSettings,
|
||||||
|
setFilterSettings,
|
||||||
|
chromaKeySettings,
|
||||||
|
setChromaKeySettings,
|
||||||
|
stickerSettings,
|
||||||
|
setStickerSettings,
|
||||||
|
coverConfig,
|
||||||
|
setCoverConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* EditingPlanner 配音素材数据加载
|
||||||
|
* queryKey 与 VoiceMaterialLibrary 共享缓存
|
||||||
|
*/
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||||
|
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||||
|
|
||||||
|
export interface UseVoiceMaterialsReturn {
|
||||||
|
voiceMaterials: AssetItem[]
|
||||||
|
loading: boolean
|
||||||
|
refetch: () => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVoiceMaterials = (): UseVoiceMaterialsReturn => {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["assets", "voice"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const project = await getOrCreateDefaultProject()
|
||||||
|
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||||
|
const assets = await getAssetsByKind("voice")
|
||||||
|
return assets
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
voiceMaterials: query.data ?? [],
|
||||||
|
loading: query.isLoading,
|
||||||
|
refetch: query.refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
/**
|
|
||||||
* 片段(Clip)统一类型定义
|
|
||||||
* 片段 = 时间规划 + 类型标记,不绑定任何素材
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ClipType = "voice" | "pip"
|
|
||||||
|
|
||||||
/* ──────── 转场特效 ──────── */
|
|
||||||
|
|
||||||
/** 14 种转场类型 */
|
|
||||||
export type TransitionType =
|
|
||||||
| "none"
|
|
||||||
| "cut"
|
|
||||||
| "fade"
|
|
||||||
| "dissolve"
|
|
||||||
| "zoom"
|
|
||||||
| "slide_left"
|
|
||||||
| "slide_right"
|
|
||||||
| "slide_up"
|
|
||||||
| "slide_down"
|
|
||||||
| "wipe_left"
|
|
||||||
| "wipe_right"
|
|
||||||
| "wipe_up"
|
|
||||||
| "wipe_down"
|
|
||||||
| "circlecrop"
|
|
||||||
| "rectcrop"
|
|
||||||
|
|
||||||
/** 片段间转场配置 */
|
|
||||||
export interface TransitionConfig {
|
|
||||||
/** 转场类型 */
|
|
||||||
type: TransitionType
|
|
||||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
|
||||||
duration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认转场配置 */
|
|
||||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
|
||||||
type: "none",
|
|
||||||
duration: 0.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 片段调速 ──────── */
|
|
||||||
|
|
||||||
/** 片段调速配置 */
|
|
||||||
export interface SpeedConfig {
|
|
||||||
/** 播放速度,0.25 ~ 4.0 */
|
|
||||||
rate: number
|
|
||||||
/** 音调修正(变速不变调) */
|
|
||||||
pitchCorrection: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认调速配置 */
|
|
||||||
export const DEFAULT_SPEED: SpeedConfig = {
|
|
||||||
rate: 1.0,
|
|
||||||
pitchCorrection: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── TTS 配音 ──────── */
|
|
||||||
|
|
||||||
/** 配音模式 */
|
|
||||||
export type TtsMode = "none" | "upload" | "tts"
|
|
||||||
|
|
||||||
/** TTS 配音配置 */
|
|
||||||
export interface TtsConfig {
|
|
||||||
/** 配音模式 */
|
|
||||||
mode: TtsMode
|
|
||||||
/** TTS 合成文本 */
|
|
||||||
text: string
|
|
||||||
/** 音色 ID */
|
|
||||||
voice_id: string
|
|
||||||
/** 语速 0.5 ~ 2.0 */
|
|
||||||
speed: number
|
|
||||||
/** 语调(半音)-12 ~ +12 */
|
|
||||||
pitch: number
|
|
||||||
/** 音量 0 ~ 100 */
|
|
||||||
volume: number
|
|
||||||
/** 字幕联动 */
|
|
||||||
subtitle_sync: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认 TTS 配置 */
|
|
||||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
|
||||||
mode: "none",
|
|
||||||
text: "",
|
|
||||||
voice_id: "",
|
|
||||||
speed: 1.0,
|
|
||||||
pitch: 0,
|
|
||||||
volume: 100,
|
|
||||||
subtitle_sync: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 裁剪配置 ──────── */
|
|
||||||
|
|
||||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
|
||||||
export interface TrimConfig {
|
|
||||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
|
||||||
start_time: number
|
|
||||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
|
||||||
end_time: number
|
|
||||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
|
||||||
original_duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 水印配置 ──────── */
|
|
||||||
|
|
||||||
/** 水印类型 */
|
|
||||||
export type WatermarkType = "none" | "image" | "text" | "scroll"
|
|
||||||
|
|
||||||
/** 水印位置 */
|
|
||||||
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
|
|
||||||
|
|
||||||
/** 滚动水印方向 */
|
|
||||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
|
|
||||||
|
|
||||||
/** 水印配置 */
|
|
||||||
export interface WatermarkConfig {
|
|
||||||
/** 水印类型 */
|
|
||||||
type: WatermarkType
|
|
||||||
/** 图片水印 URL */
|
|
||||||
image_url?: string
|
|
||||||
/** 水印宽度(像素或百分比 0~1) */
|
|
||||||
width?: number
|
|
||||||
/** 水印高度(像素或百分比 0~1) */
|
|
||||||
height?: number
|
|
||||||
/** 水印位置 */
|
|
||||||
position: WatermarkPosition
|
|
||||||
/** 水印不透明度 0~1 */
|
|
||||||
opacity: number
|
|
||||||
/** 文字水印内容 */
|
|
||||||
text?: string
|
|
||||||
/** 文字水印字号 */
|
|
||||||
font_size?: number
|
|
||||||
/** 文字水印颜色 */
|
|
||||||
color?: string
|
|
||||||
/** 滚动水印方向 */
|
|
||||||
scroll_direction?: ScrollDirection
|
|
||||||
/** 滚动水印速度(像素/秒) */
|
|
||||||
scroll_speed?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认水印配置 */
|
|
||||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
|
||||||
type: "none",
|
|
||||||
position: "bottom_right",
|
|
||||||
opacity: 0.7,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 片头片尾配置 ──────── */
|
|
||||||
|
|
||||||
/** 片头片尾素材类型 */
|
|
||||||
export type IntroOutroKind = "none" | "video" | "image"
|
|
||||||
|
|
||||||
/** 片头/片尾单项配置 */
|
|
||||||
export interface IntroOutroItem {
|
|
||||||
/** 素材类型 */
|
|
||||||
kind: IntroOutroKind
|
|
||||||
/** 素材 URL */
|
|
||||||
url?: string
|
|
||||||
/** 显示时长(秒) */
|
|
||||||
duration: number
|
|
||||||
/** 过渡动画 */
|
|
||||||
transition?: TransitionType
|
|
||||||
/** 过渡时长(秒) */
|
|
||||||
transition_duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 片头片尾完整配置 */
|
|
||||||
export interface IntroOutroConfig {
|
|
||||||
intro: IntroOutroItem
|
|
||||||
outro: IntroOutroItem
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认片头片尾配置 */
|
|
||||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
|
||||||
intro: { kind: "none", duration: 3 },
|
|
||||||
outro: { kind: "none", duration: 3 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 混剪配置 ──────── */
|
|
||||||
|
|
||||||
/** 九宫格位置 */
|
|
||||||
export type PipGridPosition =
|
|
||||||
| "top_left"
|
|
||||||
| "top_center"
|
|
||||||
| "top_right"
|
|
||||||
| "center_left"
|
|
||||||
| "center"
|
|
||||||
| "center_right"
|
|
||||||
| "bottom_left"
|
|
||||||
| "bottom_center"
|
|
||||||
| "bottom_right"
|
|
||||||
|
|
||||||
/** 入场动画类型 */
|
|
||||||
export type PipAnimType = "none" | "fade_in" | "slide_in"
|
|
||||||
|
|
||||||
/** 入场方向 */
|
|
||||||
export type PipSlideDirection = "left" | "right" | "up" | "down"
|
|
||||||
|
|
||||||
/** 混剪图层 */
|
|
||||||
export interface PipLayer {
|
|
||||||
id: string
|
|
||||||
/** 图层名称(用户可编辑) */
|
|
||||||
name: string
|
|
||||||
/** 素材类型 */
|
|
||||||
material_type: "image" | "video"
|
|
||||||
/** 素材 URL */
|
|
||||||
material_url: string
|
|
||||||
/** 素材缩略图 */
|
|
||||||
thumbnail_url?: string
|
|
||||||
/** 九宫格快捷位置 */
|
|
||||||
grid_position: PipGridPosition
|
|
||||||
/** 精确 X 坐标(百分比 0~100) */
|
|
||||||
x: number
|
|
||||||
/** 精确 Y 坐标(百分比 0~100) */
|
|
||||||
y: number
|
|
||||||
/** 宽度(百分比 0~100,相对主画面) */
|
|
||||||
width: number
|
|
||||||
/** 高度(百分比 0~100,相对主画面) */
|
|
||||||
height: number
|
|
||||||
/** 锁定宽高比 */
|
|
||||||
aspect_lock: boolean
|
|
||||||
/** 圆角(百分比 0~50) */
|
|
||||||
border_radius: number
|
|
||||||
/** 不透明度(0~100) */
|
|
||||||
opacity: number
|
|
||||||
/** 开始时间(秒) */
|
|
||||||
start_time: number
|
|
||||||
/** 持续时长(秒) */
|
|
||||||
duration: number
|
|
||||||
/** 入场动画 */
|
|
||||||
animation: PipAnimType
|
|
||||||
/** 入场方向 */
|
|
||||||
slide_direction: PipSlideDirection
|
|
||||||
/** 图层顺序(z-index) */
|
|
||||||
z_index: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 混剪配置 */
|
|
||||||
export interface PipConfig {
|
|
||||||
/** 是否启用混剪 */
|
|
||||||
enabled: boolean
|
|
||||||
/** 图层列表 */
|
|
||||||
layers: PipLayer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认 PiP 图层 */
|
|
||||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
|
||||||
id: "",
|
|
||||||
name: "图层",
|
|
||||||
material_type: "image",
|
|
||||||
material_url: "",
|
|
||||||
grid_position: "top_right",
|
|
||||||
x: 70,
|
|
||||||
y: 5,
|
|
||||||
width: 25,
|
|
||||||
height: 25,
|
|
||||||
aspect_lock: true,
|
|
||||||
border_radius: 0,
|
|
||||||
opacity: 100,
|
|
||||||
start_time: 0,
|
|
||||||
duration: 5,
|
|
||||||
animation: "none",
|
|
||||||
slide_direction: "right",
|
|
||||||
z_index: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认 PiP 配置 */
|
|
||||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
|
||||||
enabled: false,
|
|
||||||
layers: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 滤镜调色 ──────── */
|
|
||||||
|
|
||||||
/** 预设滤镜 */
|
|
||||||
export type FilterPreset =
|
|
||||||
| "none"
|
|
||||||
| "original"
|
|
||||||
| "fresh"
|
|
||||||
| "warm"
|
|
||||||
| "cool"
|
|
||||||
| "vintage"
|
|
||||||
| "cinema"
|
|
||||||
| "bw"
|
|
||||||
| "sunshine"
|
|
||||||
| "film"
|
|
||||||
|
|
||||||
/** 预设滤镜标签 */
|
|
||||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
|
||||||
none: "无",
|
|
||||||
original: "原片",
|
|
||||||
fresh: "清新",
|
|
||||||
warm: "暖调",
|
|
||||||
cool: "冷色",
|
|
||||||
vintage: "复古",
|
|
||||||
cinema: "电影",
|
|
||||||
bw: "黑白",
|
|
||||||
sunshine: "暖阳",
|
|
||||||
film: "胶片",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 滤镜调色配置 */
|
|
||||||
export interface FilterConfig {
|
|
||||||
/** 是否启用滤镜 */
|
|
||||||
enabled: boolean
|
|
||||||
/** 预设滤镜 */
|
|
||||||
preset: FilterPreset
|
|
||||||
/** 亮度(-100 ~ 100) */
|
|
||||||
brightness: number
|
|
||||||
/** 对比度(-100 ~ 100) */
|
|
||||||
contrast: number
|
|
||||||
/** 饱和度(-100 ~ 100) */
|
|
||||||
saturation: number
|
|
||||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
|
||||||
temperature: number
|
|
||||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
|
||||||
tint: number
|
|
||||||
/** 锐度(0 ~ 100) */
|
|
||||||
sharpness: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认滤镜调色配置 */
|
|
||||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
|
||||||
enabled: false,
|
|
||||||
preset: "none",
|
|
||||||
brightness: 0,
|
|
||||||
contrast: 0,
|
|
||||||
saturation: 0,
|
|
||||||
temperature: 0,
|
|
||||||
tint: 0,
|
|
||||||
sharpness: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 绿幕抠像 ──────── */
|
|
||||||
|
|
||||||
/** 绿幕抠像颜色预设 */
|
|
||||||
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
|
|
||||||
|
|
||||||
/** 颜色预设标签 */
|
|
||||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
|
||||||
green: "绿",
|
|
||||||
blue: "蓝",
|
|
||||||
red: "红",
|
|
||||||
pure_green: "精绿",
|
|
||||||
soft_green: "柔绿",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 颜色预设对应的默认色值 */
|
|
||||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
|
||||||
green: "#00FF00",
|
|
||||||
blue: "#0000FF",
|
|
||||||
red: "#FF0000",
|
|
||||||
pure_green: "#00C800",
|
|
||||||
soft_green: "#40E040",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 绿幕抠像配置 */
|
|
||||||
export interface ChromaKeyConfig {
|
|
||||||
/** 是否启用绿幕抠像 */
|
|
||||||
enabled: boolean
|
|
||||||
/** 颜色预设 */
|
|
||||||
color_preset: ChromaKeyColorPreset
|
|
||||||
/** 抠像目标颜色(HEX) */
|
|
||||||
color: string
|
|
||||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
|
||||||
similarity: number
|
|
||||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
|
||||||
blend: number
|
|
||||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
|
||||||
spill: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认绿幕抠像配置 */
|
|
||||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
|
||||||
enabled: false,
|
|
||||||
color_preset: "green",
|
|
||||||
color: "#00FF00",
|
|
||||||
similarity: 30,
|
|
||||||
blend: 10,
|
|
||||||
spill: 20,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 贴纸配置 ──────── */
|
|
||||||
|
|
||||||
/** 贴纸类型 */
|
|
||||||
export type StickerType = "emoji" | "image" | "text"
|
|
||||||
|
|
||||||
/** 文字花字预设 */
|
|
||||||
export type TextStickerPreset =
|
|
||||||
| "normal" // 普通
|
|
||||||
| "highlight" // 高亮
|
|
||||||
| "bubble" // 气泡
|
|
||||||
| "neon" // 霓虹
|
|
||||||
| "shadow" // 投影
|
|
||||||
| "outline" // 描边
|
|
||||||
| "gradient" // 渐变
|
|
||||||
| "handwrite" // 手写
|
|
||||||
|
|
||||||
/** 贴纸项 */
|
|
||||||
export interface StickerItem {
|
|
||||||
id: string
|
|
||||||
/** 贴纸类型 */
|
|
||||||
type: StickerType
|
|
||||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
|
||||||
content: string
|
|
||||||
/** X 坐标(百分比 0~100) */
|
|
||||||
x: number
|
|
||||||
/** Y 坐标(百分比 0~100) */
|
|
||||||
y: number
|
|
||||||
/** 宽度(百分比 0~100) */
|
|
||||||
width: number
|
|
||||||
/** 高度(百分比 0~100) */
|
|
||||||
height: number
|
|
||||||
/** 旋转角度(度 -180~180) */
|
|
||||||
rotation: number
|
|
||||||
/** 不透明度(0~100) */
|
|
||||||
opacity: number
|
|
||||||
/** 开始时间(秒) */
|
|
||||||
start_time: number
|
|
||||||
/** 持续时长(秒,0 表示全程显示) */
|
|
||||||
duration: number
|
|
||||||
/** 图层顺序 */
|
|
||||||
z_index: number
|
|
||||||
/** 文字花字预设(仅 type=text 时有效) */
|
|
||||||
text_preset: TextStickerPreset
|
|
||||||
/** 文字颜色(仅 type=text 时有效) */
|
|
||||||
text_color: string
|
|
||||||
/** 文字大小(px,仅 type=text 时有效) */
|
|
||||||
font_size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 贴纸配置 */
|
|
||||||
export interface StickerConfig {
|
|
||||||
enabled: boolean
|
|
||||||
items: StickerItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认贴纸项 */
|
|
||||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
|
||||||
id: "",
|
|
||||||
type: "emoji",
|
|
||||||
content: "😀",
|
|
||||||
x: 50,
|
|
||||||
y: 50,
|
|
||||||
width: 15,
|
|
||||||
height: 15,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 100,
|
|
||||||
start_time: 0,
|
|
||||||
duration: 0,
|
|
||||||
z_index: 1,
|
|
||||||
text_preset: "normal",
|
|
||||||
text_color: "#FFFFFF",
|
|
||||||
font_size: 24,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认贴纸配置 */
|
|
||||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
|
||||||
enabled: false,
|
|
||||||
items: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文字花字预设标签 */
|
|
||||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
|
||||||
normal: "普通",
|
|
||||||
highlight: "高亮",
|
|
||||||
bubble: "气泡",
|
|
||||||
neon: "霓虹",
|
|
||||||
shadow: "投影",
|
|
||||||
outline: "描边",
|
|
||||||
gradient: "渐变",
|
|
||||||
handwrite: "手写",
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 封面配置 ──────── */
|
|
||||||
|
|
||||||
/** 封面来源模式 */
|
|
||||||
export type CoverMode = "auto" | "frame" | "upload"
|
|
||||||
|
|
||||||
/** 封面配置 */
|
|
||||||
export interface CoverConfig {
|
|
||||||
/** 是否启用自定义封面 */
|
|
||||||
enabled: boolean
|
|
||||||
/** 封面来源模式 */
|
|
||||||
mode: CoverMode
|
|
||||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
|
||||||
frame_time: number
|
|
||||||
/** 上传的封面 URL(mode=upload 时使用) */
|
|
||||||
upload_url: string
|
|
||||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
|
||||||
ai_suggested_time: number | null
|
|
||||||
/** 封面缩略图 URL */
|
|
||||||
thumbnail_url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 默认封面配置 */
|
|
||||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
|
||||||
enabled: false,
|
|
||||||
mode: "auto",
|
|
||||||
frame_time: 0,
|
|
||||||
upload_url: "",
|
|
||||||
ai_suggested_time: null,
|
|
||||||
thumbnail_url: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 片段数据 ──────── */
|
|
||||||
|
|
||||||
export interface ClipData {
|
|
||||||
id: string
|
|
||||||
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
|
|
||||||
duration: number // 时长(秒)
|
|
||||||
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
|
|
||||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
|
||||||
media_asset_id?: string
|
|
||||||
// 保留兼容字段(后端序列化需要)
|
|
||||||
template_segment_id?: string
|
|
||||||
script_text?: string
|
|
||||||
order?: number
|
|
||||||
/** 配音素材 ID(voice 类型片段使用) */
|
|
||||||
voice_asset_id?: string
|
|
||||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
|
||||||
voice_file_url?: string
|
|
||||||
/** 与前一片段之间的转场效果 */
|
|
||||||
transition?: TransitionConfig
|
|
||||||
/** 播放速度配置 */
|
|
||||||
speed?: SpeedConfig
|
|
||||||
/** TTS 配音配置 */
|
|
||||||
tts_config?: TtsConfig
|
|
||||||
/** 裁剪配置 — 定义素材入点/出点 */
|
|
||||||
trim_config?: TrimConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── 标题设置 ──────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标题设置 — 对齐后端 title_config 字段
|
|
||||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
|
||||||
*/
|
|
||||||
export interface TitleSettings {
|
|
||||||
aiAutoSelect: boolean
|
|
||||||
title: string
|
|
||||||
position: string
|
|
||||||
font: string
|
|
||||||
size: number
|
|
||||||
bold: boolean
|
|
||||||
italic: boolean
|
|
||||||
stroke: boolean
|
|
||||||
shadow: boolean
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 绿幕抠像类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 绿幕抠像颜色预设 */
|
||||||
|
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
|
||||||
|
|
||||||
|
/** 颜色预设标签 */
|
||||||
|
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||||
|
green: "绿",
|
||||||
|
blue: "蓝",
|
||||||
|
red: "红",
|
||||||
|
pure_green: "精绿",
|
||||||
|
soft_green: "柔绿",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 颜色预设对应的默认色值 */
|
||||||
|
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||||
|
green: "#00FF00",
|
||||||
|
blue: "#0000FF",
|
||||||
|
red: "#FF0000",
|
||||||
|
pure_green: "#00C800",
|
||||||
|
soft_green: "#40E040",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绿幕抠像配置 */
|
||||||
|
export interface ChromaKeyConfig {
|
||||||
|
/** 是否启用绿幕抠像 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 颜色预设 */
|
||||||
|
color_preset: ChromaKeyColorPreset
|
||||||
|
/** 抠像目标颜色(HEX) */
|
||||||
|
color: string
|
||||||
|
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||||
|
similarity: number
|
||||||
|
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||||
|
blend: number
|
||||||
|
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||||
|
spill: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认绿幕抠像配置 */
|
||||||
|
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||||
|
enabled: false,
|
||||||
|
color_preset: "green",
|
||||||
|
color: "#00FF00",
|
||||||
|
similarity: 30,
|
||||||
|
blend: 10,
|
||||||
|
spill: 20,
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 片段数据类型
|
||||||
|
*/
|
||||||
|
import type { TransitionConfig } from "./transition"
|
||||||
|
import type { SpeedConfig } from "./speed"
|
||||||
|
import type { TtsConfig } from "./tts"
|
||||||
|
import type { TrimConfig } from "./trim"
|
||||||
|
|
||||||
|
/** 片段类型 */
|
||||||
|
export type ClipType = "voice" | "pip"
|
||||||
|
|
||||||
|
/** 片段数据 — 时间规划 + 类型标记,不绑定任何素材 */
|
||||||
|
export interface ClipData {
|
||||||
|
id: string
|
||||||
|
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
|
||||||
|
duration: number // 时长(秒)
|
||||||
|
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||||
|
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||||
|
media_asset_id?: string
|
||||||
|
// 保留兼容字段(后端序列化需要)
|
||||||
|
template_segment_id?: string
|
||||||
|
script_text?: string
|
||||||
|
order?: number
|
||||||
|
/** 配音素材 ID(voice 类型片段使用) */
|
||||||
|
voice_asset_id?: string
|
||||||
|
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||||
|
voice_file_url?: string
|
||||||
|
/** 与前一片段之间的转场效果 */
|
||||||
|
transition?: TransitionConfig
|
||||||
|
/** 播放速度配置 */
|
||||||
|
speed?: SpeedConfig
|
||||||
|
/** TTS 配音配置 */
|
||||||
|
tts_config?: TtsConfig
|
||||||
|
/** 裁剪配置 — 定义素材入点/出点 */
|
||||||
|
trim_config?: TrimConfig
|
||||||
|
}
|
||||||
Executable → Regular
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ClipPropertiesPanel 相关类型定义
|
* ClipPropertiesPanel 相关类型定义
|
||||||
*/
|
*/
|
||||||
import type { ClipData } from "@/pages/editing-planner/types"
|
import type { ClipData } from "./clip"
|
||||||
import type { TemplateMode } from "@/api/editing-planner"
|
import type { TemplateMode } from "@/api/editing-planner"
|
||||||
import type { AssetItem } from "@/api/assets"
|
import type { AssetItem } from "@/api/assets"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 封面配置类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 封面来源模式 */
|
||||||
|
export type CoverMode = "auto" | "frame" | "upload"
|
||||||
|
|
||||||
|
/** 封面配置 */
|
||||||
|
export interface CoverConfig {
|
||||||
|
/** 是否启用自定义封面 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 封面来源模式 */
|
||||||
|
mode: CoverMode
|
||||||
|
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||||
|
frame_time: number
|
||||||
|
/** 上传的封面 URL(mode=upload 时使用) */
|
||||||
|
upload_url: string
|
||||||
|
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||||
|
ai_suggested_time: number | null
|
||||||
|
/** 封面缩略图 URL */
|
||||||
|
thumbnail_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认封面配置 */
|
||||||
|
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||||
|
enabled: false,
|
||||||
|
mode: "auto",
|
||||||
|
frame_time: 0,
|
||||||
|
upload_url: "",
|
||||||
|
ai_suggested_time: null,
|
||||||
|
thumbnail_url: "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 滤镜调色类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 预设滤镜 */
|
||||||
|
export type FilterPreset =
|
||||||
|
| "none"
|
||||||
|
| "original"
|
||||||
|
| "fresh"
|
||||||
|
| "warm"
|
||||||
|
| "cool"
|
||||||
|
| "vintage"
|
||||||
|
| "cinema"
|
||||||
|
| "bw"
|
||||||
|
| "sunshine"
|
||||||
|
| "film"
|
||||||
|
|
||||||
|
/** 预设滤镜标签 */
|
||||||
|
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||||
|
none: "无",
|
||||||
|
original: "原片",
|
||||||
|
fresh: "清新",
|
||||||
|
warm: "暖调",
|
||||||
|
cool: "冷色",
|
||||||
|
vintage: "复古",
|
||||||
|
cinema: "电影",
|
||||||
|
bw: "黑白",
|
||||||
|
sunshine: "暖阳",
|
||||||
|
film: "胶片",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滤镜调色配置 */
|
||||||
|
export interface FilterConfig {
|
||||||
|
/** 是否启用滤镜 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 预设滤镜 */
|
||||||
|
preset: FilterPreset
|
||||||
|
/** 亮度(-100 ~ 100) */
|
||||||
|
brightness: number
|
||||||
|
/** 对比度(-100 ~ 100) */
|
||||||
|
contrast: number
|
||||||
|
/** 饱和度(-100 ~ 100) */
|
||||||
|
saturation: number
|
||||||
|
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||||
|
temperature: number
|
||||||
|
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||||
|
tint: number
|
||||||
|
/** 锐度(0 ~ 100) */
|
||||||
|
sharpness: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认滤镜调色配置 */
|
||||||
|
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||||
|
enabled: false,
|
||||||
|
preset: "none",
|
||||||
|
brightness: 0,
|
||||||
|
contrast: 0,
|
||||||
|
saturation: 0,
|
||||||
|
temperature: 0,
|
||||||
|
tint: 0,
|
||||||
|
sharpness: 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* EditingPlanner 类型定义入口
|
||||||
|
* 按功能模块拆分,统一从这里导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 转场 */
|
||||||
|
export { type TransitionType, type TransitionConfig, DEFAULT_TRANSITION } from "./transition"
|
||||||
|
|
||||||
|
/* 调速 */
|
||||||
|
export { type SpeedConfig, DEFAULT_SPEED } from "./speed"
|
||||||
|
|
||||||
|
/* TTS 配音 */
|
||||||
|
export { type TtsMode, type TtsConfig, DEFAULT_TTS_CONFIG } from "./tts"
|
||||||
|
|
||||||
|
/* 裁剪 */
|
||||||
|
export { type TrimConfig } from "./trim"
|
||||||
|
|
||||||
|
/* 水印 */
|
||||||
|
export {
|
||||||
|
type WatermarkType,
|
||||||
|
type WatermarkPosition,
|
||||||
|
type ScrollDirection,
|
||||||
|
type WatermarkConfig,
|
||||||
|
DEFAULT_WATERMARK,
|
||||||
|
} from "./watermark"
|
||||||
|
|
||||||
|
/* 片头片尾 */
|
||||||
|
export {
|
||||||
|
type IntroOutroKind,
|
||||||
|
type IntroOutroItem,
|
||||||
|
type IntroOutroConfig,
|
||||||
|
DEFAULT_INTRO_OUTRO,
|
||||||
|
} from "./intro-outro"
|
||||||
|
|
||||||
|
/* 混剪 PiP */
|
||||||
|
export {
|
||||||
|
type PipGridPosition,
|
||||||
|
type PipAnimType,
|
||||||
|
type PipSlideDirection,
|
||||||
|
type PipLayer,
|
||||||
|
type PipConfig,
|
||||||
|
DEFAULT_PIP_LAYER,
|
||||||
|
DEFAULT_PIP_CONFIG,
|
||||||
|
} from "./pip"
|
||||||
|
|
||||||
|
/* 滤镜调色 */
|
||||||
|
export {
|
||||||
|
type FilterPreset,
|
||||||
|
FILTER_PRESET_LABELS,
|
||||||
|
type FilterConfig,
|
||||||
|
DEFAULT_FILTER_CONFIG,
|
||||||
|
} from "./filter"
|
||||||
|
|
||||||
|
/* 绿幕抠像 */
|
||||||
|
export {
|
||||||
|
type ChromaKeyColorPreset,
|
||||||
|
CHROMA_KEY_PRESET_LABELS,
|
||||||
|
CHROMA_KEY_PRESET_COLORS,
|
||||||
|
type ChromaKeyConfig,
|
||||||
|
DEFAULT_CHROMA_KEY_CONFIG,
|
||||||
|
} from "./chroma-key"
|
||||||
|
|
||||||
|
/* 贴纸 */
|
||||||
|
export {
|
||||||
|
type StickerType,
|
||||||
|
type TextStickerPreset,
|
||||||
|
type StickerItem,
|
||||||
|
type StickerConfig,
|
||||||
|
DEFAULT_STICKER_ITEM,
|
||||||
|
DEFAULT_STICKER_CONFIG,
|
||||||
|
TEXT_STICKER_PRESET_LABELS,
|
||||||
|
} from "./sticker"
|
||||||
|
|
||||||
|
/* 封面 */
|
||||||
|
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover"
|
||||||
|
|
||||||
|
/* 片段数据 */
|
||||||
|
export { type ClipType, type ClipData } from "./clip"
|
||||||
|
|
||||||
|
/* 标题设置 */
|
||||||
|
export { type TitleSettings } from "./title"
|
||||||
|
|
||||||
|
/* 字幕样式 */
|
||||||
|
export { type SubtitleStyleConfig, DEFAULT_SUBTITLE_STYLE } from "./subtitle"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 片头片尾配置类型
|
||||||
|
*/
|
||||||
|
import type { TransitionType } from "./transition"
|
||||||
|
|
||||||
|
/** 片头片尾素材类型 */
|
||||||
|
export type IntroOutroKind = "none" | "video" | "image"
|
||||||
|
|
||||||
|
/** 片头/片尾单项配置 */
|
||||||
|
export interface IntroOutroItem {
|
||||||
|
/** 素材类型 */
|
||||||
|
kind: IntroOutroKind
|
||||||
|
/** 素材 URL */
|
||||||
|
url?: string
|
||||||
|
/** 显示时长(秒) */
|
||||||
|
duration: number
|
||||||
|
/** 过渡动画 */
|
||||||
|
transition?: TransitionType
|
||||||
|
/** 过渡时长(秒) */
|
||||||
|
transition_duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片头片尾完整配置 */
|
||||||
|
export interface IntroOutroConfig {
|
||||||
|
intro: IntroOutroItem
|
||||||
|
outro: IntroOutroItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认片头片尾配置 */
|
||||||
|
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||||
|
intro: { kind: "none", duration: 3 },
|
||||||
|
outro: { kind: "none", duration: 3 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 混剪(PiP)配置类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 九宫格位置 */
|
||||||
|
export type PipGridPosition =
|
||||||
|
| "top_left"
|
||||||
|
| "top_center"
|
||||||
|
| "top_right"
|
||||||
|
| "center_left"
|
||||||
|
| "center"
|
||||||
|
| "center_right"
|
||||||
|
| "bottom_left"
|
||||||
|
| "bottom_center"
|
||||||
|
| "bottom_right"
|
||||||
|
|
||||||
|
/** 入场动画类型 */
|
||||||
|
export type PipAnimType = "none" | "fade_in" | "slide_in"
|
||||||
|
|
||||||
|
/** 入场方向 */
|
||||||
|
export type PipSlideDirection = "left" | "right" | "up" | "down"
|
||||||
|
|
||||||
|
/** 混剪图层 */
|
||||||
|
export interface PipLayer {
|
||||||
|
id: string
|
||||||
|
/** 图层名称(用户可编辑) */
|
||||||
|
name: string
|
||||||
|
/** 素材类型 */
|
||||||
|
material_type: "image" | "video"
|
||||||
|
/** 素材 URL */
|
||||||
|
material_url: string
|
||||||
|
/** 素材缩略图 */
|
||||||
|
thumbnail_url?: string
|
||||||
|
/** 九宫格快捷位置 */
|
||||||
|
grid_position: PipGridPosition
|
||||||
|
/** 精确 X 坐标(百分比 0~100) */
|
||||||
|
x: number
|
||||||
|
/** 精确 Y 坐标(百分比 0~100) */
|
||||||
|
y: number
|
||||||
|
/** 宽度(百分比 0~100,相对主画面) */
|
||||||
|
width: number
|
||||||
|
/** 高度(百分比 0~100,相对主画面) */
|
||||||
|
height: number
|
||||||
|
/** 锁定宽高比 */
|
||||||
|
aspect_lock: boolean
|
||||||
|
/** 圆角(百分比 0~50) */
|
||||||
|
border_radius: number
|
||||||
|
/** 不透明度(0~100) */
|
||||||
|
opacity: number
|
||||||
|
/** 开始时间(秒) */
|
||||||
|
start_time: number
|
||||||
|
/** 持续时长(秒) */
|
||||||
|
duration: number
|
||||||
|
/** 入场动画 */
|
||||||
|
animation: PipAnimType
|
||||||
|
/** 入场方向 */
|
||||||
|
slide_direction: PipSlideDirection
|
||||||
|
/** 图层顺序(z-index) */
|
||||||
|
z_index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 混剪配置 */
|
||||||
|
export interface PipConfig {
|
||||||
|
/** 是否启用混剪 */
|
||||||
|
enabled: boolean
|
||||||
|
/** 图层列表 */
|
||||||
|
layers: PipLayer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认 PiP 图层 */
|
||||||
|
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||||
|
id: "",
|
||||||
|
name: "图层",
|
||||||
|
material_type: "image",
|
||||||
|
material_url: "",
|
||||||
|
grid_position: "top_right",
|
||||||
|
x: 70,
|
||||||
|
y: 5,
|
||||||
|
width: 25,
|
||||||
|
height: 25,
|
||||||
|
aspect_lock: true,
|
||||||
|
border_radius: 0,
|
||||||
|
opacity: 100,
|
||||||
|
start_time: 0,
|
||||||
|
duration: 5,
|
||||||
|
animation: "none",
|
||||||
|
slide_direction: "right",
|
||||||
|
z_index: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认 PiP 配置 */
|
||||||
|
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||||
|
enabled: false,
|
||||||
|
layers: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 片段调速类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 片段调速配置 */
|
||||||
|
export interface SpeedConfig {
|
||||||
|
/** 播放速度,0.25 ~ 4.0 */
|
||||||
|
rate: number
|
||||||
|
/** 音调修正(变速不变调) */
|
||||||
|
pitchCorrection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认调速配置 */
|
||||||
|
export const DEFAULT_SPEED: SpeedConfig = {
|
||||||
|
rate: 1.0,
|
||||||
|
pitchCorrection: true,
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 贴纸配置类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 贴纸类型 */
|
||||||
|
export type StickerType = "emoji" | "image" | "text"
|
||||||
|
|
||||||
|
/** 文字花字预设 */
|
||||||
|
export type TextStickerPreset =
|
||||||
|
| "normal" // 普通
|
||||||
|
| "highlight" // 高亮
|
||||||
|
| "bubble" // 气泡
|
||||||
|
| "neon" // 霓虹
|
||||||
|
| "shadow" // 投影
|
||||||
|
| "outline" // 描边
|
||||||
|
| "gradient" // 渐变
|
||||||
|
| "handwrite" // 手写
|
||||||
|
|
||||||
|
/** 贴纸项 */
|
||||||
|
export interface StickerItem {
|
||||||
|
id: string
|
||||||
|
/** 贴纸类型 */
|
||||||
|
type: StickerType
|
||||||
|
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||||
|
content: string
|
||||||
|
/** X 坐标(百分比 0~100) */
|
||||||
|
x: number
|
||||||
|
/** Y 坐标(百分比 0~100) */
|
||||||
|
y: number
|
||||||
|
/** 宽度(百分比 0~100) */
|
||||||
|
width: number
|
||||||
|
/** 高度(百分比 0~100) */
|
||||||
|
height: number
|
||||||
|
/** 旋转角度(度 -180~180) */
|
||||||
|
rotation: number
|
||||||
|
/** 不透明度(0~100) */
|
||||||
|
opacity: number
|
||||||
|
/** 开始时间(秒) */
|
||||||
|
start_time: number
|
||||||
|
/** 持续时长(秒,0 表示全程显示) */
|
||||||
|
duration: number
|
||||||
|
/** 图层顺序 */
|
||||||
|
z_index: number
|
||||||
|
/** 文字花字预设(仅 type=text 时有效) */
|
||||||
|
text_preset: TextStickerPreset
|
||||||
|
/** 文字颜色(仅 type=text 时有效) */
|
||||||
|
text_color: string
|
||||||
|
/** 文字大小(px,仅 type=text 时有效) */
|
||||||
|
font_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 贴纸配置 */
|
||||||
|
export interface StickerConfig {
|
||||||
|
enabled: boolean
|
||||||
|
items: StickerItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认贴纸项 */
|
||||||
|
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||||
|
id: "",
|
||||||
|
type: "emoji",
|
||||||
|
content: "😀",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 100,
|
||||||
|
start_time: 0,
|
||||||
|
duration: 0,
|
||||||
|
z_index: 1,
|
||||||
|
text_preset: "normal",
|
||||||
|
text_color: "#FFFFFF",
|
||||||
|
font_size: 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认贴纸配置 */
|
||||||
|
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||||
|
enabled: false,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文字花字预设标签 */
|
||||||
|
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||||
|
normal: "普通",
|
||||||
|
highlight: "高亮",
|
||||||
|
bubble: "气泡",
|
||||||
|
neon: "霓虹",
|
||||||
|
shadow: "投影",
|
||||||
|
outline: "描边",
|
||||||
|
gradient: "渐变",
|
||||||
|
handwrite: "手写",
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 标题设置类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题设置 — 对齐后端 title_config 字段
|
||||||
|
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||||
|
*/
|
||||||
|
export interface TitleSettings {
|
||||||
|
aiAutoSelect: boolean
|
||||||
|
title: string
|
||||||
|
position: string
|
||||||
|
font: string
|
||||||
|
size: number
|
||||||
|
bold: boolean
|
||||||
|
italic: boolean
|
||||||
|
stroke: boolean
|
||||||
|
shadow: boolean
|
||||||
|
color: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 转场特效类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 14 种转场类型 */
|
||||||
|
export type TransitionType =
|
||||||
|
| "none"
|
||||||
|
| "cut"
|
||||||
|
| "fade"
|
||||||
|
| "dissolve"
|
||||||
|
| "zoom"
|
||||||
|
| "slide_left"
|
||||||
|
| "slide_right"
|
||||||
|
| "slide_up"
|
||||||
|
| "slide_down"
|
||||||
|
| "wipe_left"
|
||||||
|
| "wipe_right"
|
||||||
|
| "wipe_up"
|
||||||
|
| "wipe_down"
|
||||||
|
| "circlecrop"
|
||||||
|
| "rectcrop"
|
||||||
|
|
||||||
|
/** 片段间转场配置 */
|
||||||
|
export interface TransitionConfig {
|
||||||
|
/** 转场类型 */
|
||||||
|
type: TransitionType
|
||||||
|
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认转场配置 */
|
||||||
|
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||||
|
type: "none",
|
||||||
|
duration: 0.5,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 片段裁剪类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||||
|
export interface TrimConfig {
|
||||||
|
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||||
|
start_time: number
|
||||||
|
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||||
|
end_time: number
|
||||||
|
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||||
|
original_duration?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* TTS 配音类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 配音模式 */
|
||||||
|
export type TtsMode = "none" | "upload" | "tts"
|
||||||
|
|
||||||
|
/** TTS 配音配置 */
|
||||||
|
export interface TtsConfig {
|
||||||
|
/** 配音模式 */
|
||||||
|
mode: TtsMode
|
||||||
|
/** TTS 合成文本 */
|
||||||
|
text: string
|
||||||
|
/** 音色 ID */
|
||||||
|
voice_id: string
|
||||||
|
/** 语速 0.5 ~ 2.0 */
|
||||||
|
speed: number
|
||||||
|
/** 语调(半音)-12 ~ +12 */
|
||||||
|
pitch: number
|
||||||
|
/** 音量 0 ~ 100 */
|
||||||
|
volume: number
|
||||||
|
/** 字幕联动 */
|
||||||
|
subtitle_sync: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认 TTS 配置 */
|
||||||
|
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||||
|
mode: "none",
|
||||||
|
text: "",
|
||||||
|
voice_id: "",
|
||||||
|
speed: 1.0,
|
||||||
|
pitch: 0,
|
||||||
|
volume: 100,
|
||||||
|
subtitle_sync: true,
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 水印配置类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 水印类型 */
|
||||||
|
export type WatermarkType = "none" | "image" | "text" | "scroll"
|
||||||
|
|
||||||
|
/** 水印位置 */
|
||||||
|
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
|
||||||
|
|
||||||
|
/** 滚动水印方向 */
|
||||||
|
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
|
||||||
|
|
||||||
|
/** 水印配置 */
|
||||||
|
export interface WatermarkConfig {
|
||||||
|
/** 水印类型 */
|
||||||
|
type: WatermarkType
|
||||||
|
/** 图片水印 URL */
|
||||||
|
image_url?: string
|
||||||
|
/** 水印宽度(像素或百分比 0~1) */
|
||||||
|
width?: number
|
||||||
|
/** 水印高度(像素或百分比 0~1) */
|
||||||
|
height?: number
|
||||||
|
/** 水印位置 */
|
||||||
|
position: WatermarkPosition
|
||||||
|
/** 水印不透明度 0~1 */
|
||||||
|
opacity: number
|
||||||
|
/** 文字水印内容 */
|
||||||
|
text?: string
|
||||||
|
/** 文字水印字号 */
|
||||||
|
font_size?: number
|
||||||
|
/** 文字水印颜色 */
|
||||||
|
color?: string
|
||||||
|
/** 滚动水印方向 */
|
||||||
|
scroll_direction?: ScrollDirection
|
||||||
|
/** 滚动水印速度(像素/秒) */
|
||||||
|
scroll_speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认水印配置 */
|
||||||
|
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||||
|
type: "none",
|
||||||
|
position: "bottom_right",
|
||||||
|
opacity: 0.7,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||||
|
import { saveTtsToLibrary } from "@/api/tts"
|
||||||
|
import { getTags, createTag } from "@/api/tags"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存为素材(配音库)弹窗逻辑
|
||||||
|
*/
|
||||||
|
export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||||
|
const [saveName, setSaveName] = useState("")
|
||||||
|
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||||
|
const [saveNewTag, setSaveNewTag] = useState("")
|
||||||
|
|
||||||
|
const { data: allTags = [] } = useQuery({
|
||||||
|
queryKey: ["generate-save-tags"],
|
||||||
|
queryFn: getTags,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleGoToLibrary = useCallback(() => {
|
||||||
|
navigate("/app/voice-materials")
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const saveToLibraryMutation = useMutation({
|
||||||
|
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||||
|
saveTtsToLibrary(completedTtsJobId!, params),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
已保存到配音库!{" "}
|
||||||
|
<a
|
||||||
|
onClick={handleGoToLibrary}
|
||||||
|
style={{
|
||||||
|
color: "var(--primary-500, #6366f1)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去视频库查看
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
duration: 5,
|
||||||
|
})
|
||||||
|
setSaveModalOpen(false)
|
||||||
|
setSaveName("")
|
||||||
|
setSaveTagIds([])
|
||||||
|
setSaveNewTag("")
|
||||||
|
resetTtsState()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
message.error(`保存失败:${err.message || "请重试"}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenSaveModal = useCallback(() => {
|
||||||
|
setSaveName("")
|
||||||
|
setSaveTagIds([])
|
||||||
|
setSaveNewTag("")
|
||||||
|
setSaveModalOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirmSave = useCallback(() => {
|
||||||
|
if (!completedTtsJobId) return
|
||||||
|
saveToLibraryMutation.mutate({
|
||||||
|
name: saveName.trim() || undefined,
|
||||||
|
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||||
|
})
|
||||||
|
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||||
|
|
||||||
|
const handleAddTagInModal = useCallback(
|
||||||
|
async (tagName: string) => {
|
||||||
|
const trimmed = tagName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const existing = allTags.find((t) => t.name === trimmed)
|
||||||
|
if (existing) {
|
||||||
|
if (!saveTagIds.includes(existing.id)) {
|
||||||
|
setSaveTagIds((prev) => [...prev, existing.id])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createTag(trimmed)
|
||||||
|
setSaveTagIds((prev) => [...prev, created.id])
|
||||||
|
setSaveNewTag("")
|
||||||
|
} catch {
|
||||||
|
message.error(`创建标签"${trimmed}"失败`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTags, saveTagIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveModalOpen,
|
||||||
|
setSaveModalOpen,
|
||||||
|
saveName,
|
||||||
|
setSaveName,
|
||||||
|
saveTagIds,
|
||||||
|
setSaveTagIds,
|
||||||
|
saveNewTag,
|
||||||
|
setSaveNewTag,
|
||||||
|
allTags,
|
||||||
|
saveToLibraryMutation,
|
||||||
|
handleOpenSaveModal,
|
||||||
|
handleConfirmSave,
|
||||||
|
handleAddTagInModal,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTS 自定义合成 + 轮询状态
|
||||||
|
*/
|
||||||
|
export function useTtsSynthesis(selectedVoice: string) {
|
||||||
|
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||||
|
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||||
|
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||||
|
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||||
|
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const synthesizeMutation = useMutation({
|
||||||
|
mutationFn: synthesizeSpeech,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTtsJobId(data.job_id)
|
||||||
|
message.info("语音合成已提交,等待处理…")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setTtsError("语音合成请求失败,请重试")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 轮询 TTS 任务状态 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ttsJobId) return
|
||||||
|
let cancelled = false
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const status = await getTTSJobStatus(ttsJobId)
|
||||||
|
if (cancelled) return
|
||||||
|
if (status.status === "completed") {
|
||||||
|
setCustomAudioUrl(status.output_audio_url)
|
||||||
|
setCompletedTtsJobId(ttsJobId)
|
||||||
|
setTtsJobId(null)
|
||||||
|
setTtsError(null)
|
||||||
|
message.success("语音合成完成!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status.status === "failed" || status.status === "cancelled") {
|
||||||
|
setTtsError(status.error_message || "语音合成失败")
|
||||||
|
setTtsJobId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setTimeout(poll, 2000)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTtsError("查询合成状态失败")
|
||||||
|
setTtsJobId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(poll, 2000)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [ttsJobId])
|
||||||
|
|
||||||
|
const handleSynthesizeVoice = useCallback(() => {
|
||||||
|
if (!customVoiceText.trim()) {
|
||||||
|
message.warning("请先输入配音文案")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTtsError(null)
|
||||||
|
setCustomAudioUrl(null)
|
||||||
|
synthesizeMutation.mutate({
|
||||||
|
text: customVoiceText.trim(),
|
||||||
|
voice_id: selectedVoice || undefined,
|
||||||
|
language: "zh-CN",
|
||||||
|
})
|
||||||
|
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||||
|
|
||||||
|
const resetTtsState = useCallback(() => {
|
||||||
|
setCompletedTtsJobId(null)
|
||||||
|
setCustomAudioUrl(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
customVoiceText,
|
||||||
|
setCustomVoiceText,
|
||||||
|
customAudioUrl,
|
||||||
|
ttsError,
|
||||||
|
ttsJobId,
|
||||||
|
completedTtsJobId,
|
||||||
|
synthesizeMutation,
|
||||||
|
handleSynthesizeVoice,
|
||||||
|
resetTtsState,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音色试听播放控制
|
||||||
|
*/
|
||||||
|
export function useVoiceAudio() {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const toggleVoicePlay = useCallback(
|
||||||
|
(voiceId: string, previewUrl: string | null) => {
|
||||||
|
if (playingVoice === voiceId) {
|
||||||
|
audioRef.current?.pause()
|
||||||
|
audioRef.current = null
|
||||||
|
setPlayingVoice(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioRef.current?.pause()
|
||||||
|
if (!previewUrl) {
|
||||||
|
message.warning("该音色暂无试听音频")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const audio = new Audio(previewUrl)
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.play().catch(() => {
|
||||||
|
message.error("播放失败,请检查网络")
|
||||||
|
})
|
||||||
|
audio.onended = () => {
|
||||||
|
setPlayingVoice(null)
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
setPlayingVoice(voiceId)
|
||||||
|
},
|
||||||
|
[playingVoice],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { playingVoice, toggleVoicePlay }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { fetchPresetVoices } from "@/api/voices"
|
||||||
|
import type { PresetVoiceItem } from "@/api/voices"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能配音推荐
|
||||||
|
* 根据标题内容风格模拟推荐音色
|
||||||
|
*/
|
||||||
|
export function useVoiceRecommend(titleText: string) {
|
||||||
|
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||||
|
queryKey: ["preset-voices"],
|
||||||
|
queryFn: fetchPresetVoices,
|
||||||
|
})
|
||||||
|
|
||||||
|
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||||
|
() => presetVoicesData?.items ?? [],
|
||||||
|
[presetVoicesData],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||||
|
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||||
|
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||||
|
|
||||||
|
const handleVoiceRecommend = useCallback(async () => {
|
||||||
|
if (presetVoices.length === 0) return
|
||||||
|
setVoiceRecommendLoading(true)
|
||||||
|
setHasVoiceRecommend(true)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const title = titleText.toLowerCase()
|
||||||
|
let recommended: string[] = []
|
||||||
|
|
||||||
|
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||||
|
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||||
|
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||||
|
|
||||||
|
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||||
|
recommended = femaleVoices.slice(0, 3)
|
||||||
|
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||||
|
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||||
|
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||||
|
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||||
|
} else {
|
||||||
|
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommended.length < 3) {
|
||||||
|
const others = presetVoices
|
||||||
|
.filter((v) => !recommended.includes(v.voice_id))
|
||||||
|
.map((v) => v.voice_id)
|
||||||
|
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoiceRecommendations(recommended)
|
||||||
|
setVoiceRecommendLoading(false)
|
||||||
|
}, [presetVoices, titleText])
|
||||||
|
|
||||||
|
return {
|
||||||
|
presetVoices,
|
||||||
|
presetVoicesLoading,
|
||||||
|
voiceRecommendLoading,
|
||||||
|
voiceRecommendations,
|
||||||
|
hasVoiceRecommend,
|
||||||
|
handleVoiceRecommend,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* Step 4 标题设置 Hook
|
|
||||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
|
||||||
*/
|
|
||||||
import { useState, useCallback, useMemo } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { getTitles } from "@/api/titles"
|
|
||||||
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
|
|
||||||
import type { TitleSettings } from "../types"
|
|
||||||
|
|
||||||
interface UseStep4TitleProps {
|
|
||||||
titleSettings: TitleSettings
|
|
||||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AiTitleItem {
|
|
||||||
title: string
|
|
||||||
highlight: string
|
|
||||||
style: "catchy" | "emotional" | "informative"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
|
||||||
/* ── 标题库 API ── */
|
|
||||||
const { data: userTitles = [] } = useQuery({
|
|
||||||
queryKey: ["titles"],
|
|
||||||
queryFn: () => getTitles(),
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── AI 标题生成状态 ── */
|
|
||||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
|
||||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
|
||||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
|
||||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
|
||||||
|
|
||||||
/* ── 辅助函数 ── */
|
|
||||||
const extractTopic = (text: string): string => {
|
|
||||||
const keywords = text
|
|
||||||
.replace(/[,。!?、,.!?]/g, " ")
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
if (keywords.length === 0) return "这个话题"
|
|
||||||
// 取前3个关键词组合
|
|
||||||
return keywords.slice(0, 3).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
|
||||||
for (const p of TITLE_PRESETS) {
|
|
||||||
if (
|
|
||||||
settings.size === p.style.size &&
|
|
||||||
settings.color === p.style.color &&
|
|
||||||
settings.bold === p.style.bold &&
|
|
||||||
settings.italic === p.style.italic &&
|
|
||||||
settings.stroke === p.style.stroke &&
|
|
||||||
settings.shadow === p.style.shadow
|
|
||||||
) {
|
|
||||||
return p.key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
|
||||||
|
|
||||||
/* ── AI 标题生成 ── */
|
|
||||||
const handleGenerateAiTitles = useCallback(async () => {
|
|
||||||
if (!aiTitleInput.trim()) {
|
|
||||||
message.warning("请先输入视频描述或关键词")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAiTitleGenerating(true)
|
|
||||||
setHasGeneratedTitles(true)
|
|
||||||
|
|
||||||
// 模拟 AI 生成延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
|
||||||
|
|
||||||
const topic = extractTopic(aiTitleInput)
|
|
||||||
const results: AiTitleItem[] = []
|
|
||||||
|
|
||||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
|
||||||
"catchy",
|
|
||||||
"emotional",
|
|
||||||
"informative",
|
|
||||||
]
|
|
||||||
styles.forEach((style) => {
|
|
||||||
const templates = AI_TITLE_TEMPLATES[style]
|
|
||||||
// 每种风格随机选2个
|
|
||||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
|
||||||
shuffled.forEach((tpl) => {
|
|
||||||
const title = tpl.replace(/\{topic\}/g, topic)
|
|
||||||
const highlights = {
|
|
||||||
catchy: "吸睛标题",
|
|
||||||
emotional: "情感共鸣",
|
|
||||||
informative: "知识干货",
|
|
||||||
}
|
|
||||||
results.push({
|
|
||||||
title,
|
|
||||||
highlight: highlights[style],
|
|
||||||
style,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打乱顺序
|
|
||||||
results.sort(() => Math.random() - 0.5)
|
|
||||||
setAiTitleResults(results)
|
|
||||||
setAiTitleGenerating(false)
|
|
||||||
}, [aiTitleInput])
|
|
||||||
|
|
||||||
const handleSelectAiTitle = useCallback(
|
|
||||||
(title: string) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
|
||||||
message.success("已选用此标题")
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleRefreshAiTitles = useCallback(async () => {
|
|
||||||
if (!aiTitleInput.trim()) return
|
|
||||||
setAiTitleGenerating(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
||||||
// 重新生成一批
|
|
||||||
const topic = extractTopic(aiTitleInput)
|
|
||||||
const results: AiTitleItem[] = []
|
|
||||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
|
||||||
"catchy",
|
|
||||||
"emotional",
|
|
||||||
"informative",
|
|
||||||
]
|
|
||||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
|
||||||
styles.forEach((style) => {
|
|
||||||
const templates = AI_TITLE_TEMPLATES[style]
|
|
||||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
|
||||||
shuffled.forEach((tpl) => {
|
|
||||||
results.push({
|
|
||||||
title: tpl.replace(/\{topic\}/g, topic),
|
|
||||||
highlight: highlights[style],
|
|
||||||
style,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
results.sort(() => Math.random() - 0.5)
|
|
||||||
setAiTitleResults(results)
|
|
||||||
setAiTitleGenerating(false)
|
|
||||||
}, [aiTitleInput])
|
|
||||||
|
|
||||||
/* ── 标题设置更新 ── */
|
|
||||||
const updateTitle = useCallback(
|
|
||||||
(title: string) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, title })
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleAiAutoSelect = useCallback(() => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
|
||||||
}, [titleSettings, onTitleSettingsChange])
|
|
||||||
|
|
||||||
const updatePosition = useCallback(
|
|
||||||
(position: string) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, position })
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateFont = useCallback(
|
|
||||||
(font: string) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, font })
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateSize = useCallback(
|
|
||||||
(size: number) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, size })
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateColor = useCallback(
|
|
||||||
(color: string) => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, color })
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleBold = useCallback(() => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
|
||||||
}, [titleSettings, onTitleSettingsChange])
|
|
||||||
|
|
||||||
const toggleItalic = useCallback(() => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
|
||||||
}, [titleSettings, onTitleSettingsChange])
|
|
||||||
|
|
||||||
const toggleStroke = useCallback(() => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
|
||||||
}, [titleSettings, onTitleSettingsChange])
|
|
||||||
|
|
||||||
const toggleShadow = useCallback(() => {
|
|
||||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
|
||||||
}, [titleSettings, onTitleSettingsChange])
|
|
||||||
|
|
||||||
const applyPreset = useCallback(
|
|
||||||
(presetKey: string) => {
|
|
||||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
|
||||||
if (!preset) return
|
|
||||||
onTitleSettingsChange({
|
|
||||||
...titleSettings,
|
|
||||||
size: preset.style.size,
|
|
||||||
color: preset.style.color,
|
|
||||||
bold: preset.style.bold,
|
|
||||||
italic: preset.style.italic,
|
|
||||||
stroke: preset.style.stroke,
|
|
||||||
shadow: preset.style.shadow,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[titleSettings, onTitleSettingsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 数据
|
|
||||||
userTitles,
|
|
||||||
titleSettings,
|
|
||||||
aiTitleInput,
|
|
||||||
setAiTitleInput,
|
|
||||||
aiTitleGenerating,
|
|
||||||
aiTitleResults,
|
|
||||||
hasGeneratedTitles,
|
|
||||||
activePreset,
|
|
||||||
titlePresets: TITLE_PRESETS,
|
|
||||||
// AI 标题操作
|
|
||||||
handleGenerateAiTitles,
|
|
||||||
handleSelectAiTitle,
|
|
||||||
handleRefreshAiTitles,
|
|
||||||
// 标题设置操作
|
|
||||||
updateTitle,
|
|
||||||
toggleAiAutoSelect,
|
|
||||||
updatePosition,
|
|
||||||
updateFont,
|
|
||||||
updateSize,
|
|
||||||
updateColor,
|
|
||||||
toggleBold,
|
|
||||||
toggleItalic,
|
|
||||||
toggleStroke,
|
|
||||||
toggleShadow,
|
|
||||||
applyPreset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useStep4Title
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { getTitles } from "@/api/titles"
|
||||||
|
import type { TitleSettings } from "../../types"
|
||||||
|
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||||
|
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||||
|
|
||||||
|
interface UseStep4TitleProps {
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 标题设置 Hook
|
||||||
|
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||||
|
*/
|
||||||
|
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||||
|
// 标题库数据
|
||||||
|
const { data: userTitles = [] } = useQuery({
|
||||||
|
queryKey: ["titles"],
|
||||||
|
queryFn: () => getTitles(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI 标题生成
|
||||||
|
const aiGenerator = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||||
|
|
||||||
|
// 样式更新
|
||||||
|
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
userTitles,
|
||||||
|
titleSettings,
|
||||||
|
// AI 标题状态
|
||||||
|
aiTitleInput: aiGenerator.aiTitleInput,
|
||||||
|
setAiTitleInput: aiGenerator.setAiTitleInput,
|
||||||
|
aiTitleGenerating: aiGenerator.aiTitleGenerating,
|
||||||
|
aiTitleResults: aiGenerator.aiTitleResults,
|
||||||
|
hasGeneratedTitles: aiGenerator.hasGeneratedTitles,
|
||||||
|
activePreset: styleUpdaters.activePreset,
|
||||||
|
titlePresets: styleUpdaters.titlePresets,
|
||||||
|
// AI 标题操作
|
||||||
|
handleGenerateAiTitles: aiGenerator.handleGenerateAiTitles,
|
||||||
|
handleSelectAiTitle: aiGenerator.handleSelectAiTitle,
|
||||||
|
handleRefreshAiTitles: aiGenerator.handleRefreshAiTitles,
|
||||||
|
// 标题设置操作
|
||||||
|
updateTitle: styleUpdaters.updateTitle,
|
||||||
|
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
|
||||||
|
updatePosition: styleUpdaters.updatePosition,
|
||||||
|
updateFont: styleUpdaters.updateFont,
|
||||||
|
updateSize: styleUpdaters.updateSize,
|
||||||
|
updateColor: styleUpdaters.updateColor,
|
||||||
|
toggleBold: styleUpdaters.toggleBold,
|
||||||
|
toggleItalic: styleUpdaters.toggleItalic,
|
||||||
|
toggleStroke: styleUpdaters.toggleStroke,
|
||||||
|
toggleShadow: styleUpdaters.toggleShadow,
|
||||||
|
applyPreset: styleUpdaters.applyPreset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useStep4Title
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { AI_TITLE_TEMPLATES } from "../../constants"
|
||||||
|
import type { TitleSettings } from "../../types"
|
||||||
|
|
||||||
|
export interface AiTitleItem {
|
||||||
|
title: string
|
||||||
|
highlight: string
|
||||||
|
style: "catchy" | "emotional" | "informative"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAiTitleGeneratorOptions {
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 标题生成 Hook
|
||||||
|
* 封装 AI 标题生成、刷新、选择等逻辑
|
||||||
|
*/
|
||||||
|
export function useAiTitleGenerator({
|
||||||
|
titleSettings,
|
||||||
|
onTitleSettingsChange,
|
||||||
|
}: UseAiTitleGeneratorOptions) {
|
||||||
|
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||||
|
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||||
|
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||||
|
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||||
|
|
||||||
|
const extractTopic = (text: string): string => {
|
||||||
|
const keywords = text
|
||||||
|
.replace(/[,。!?、,.!?]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (keywords.length === 0) return "这个话题"
|
||||||
|
return keywords.slice(0, 3).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
|
||||||
|
const results: AiTitleItem[] = []
|
||||||
|
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||||
|
"catchy",
|
||||||
|
"emotional",
|
||||||
|
"informative",
|
||||||
|
]
|
||||||
|
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||||
|
styles.forEach((style) => {
|
||||||
|
const templates = AI_TITLE_TEMPLATES[style]
|
||||||
|
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||||
|
shuffled.forEach((tpl) => {
|
||||||
|
results.push({
|
||||||
|
title: tpl.replace(/\{topic\}/g, topic),
|
||||||
|
highlight: highlights[style],
|
||||||
|
style,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
results.sort(() => Math.random() - 0.5)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateAiTitles = useCallback(async () => {
|
||||||
|
if (!aiTitleInput.trim()) {
|
||||||
|
message.warning("请先输入视频描述或关键词")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAiTitleGenerating(true)
|
||||||
|
setHasGeneratedTitles(true)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||||
|
const topic = extractTopic(aiTitleInput)
|
||||||
|
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||||
|
setAiTitleGenerating(false)
|
||||||
|
}, [aiTitleInput])
|
||||||
|
|
||||||
|
const handleSelectAiTitle = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||||
|
message.success("已选用此标题")
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRefreshAiTitles = useCallback(async () => {
|
||||||
|
if (!aiTitleInput.trim()) return
|
||||||
|
setAiTitleGenerating(true)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||||
|
const topic = extractTopic(aiTitleInput)
|
||||||
|
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||||
|
setAiTitleGenerating(false)
|
||||||
|
}, [aiTitleInput])
|
||||||
|
|
||||||
|
return {
|
||||||
|
aiTitleInput,
|
||||||
|
setAiTitleInput,
|
||||||
|
aiTitleGenerating,
|
||||||
|
aiTitleResults,
|
||||||
|
hasGeneratedTitles,
|
||||||
|
handleGenerateAiTitles,
|
||||||
|
handleSelectAiTitle,
|
||||||
|
handleRefreshAiTitles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useCallback, useMemo } from "react"
|
||||||
|
import { TITLE_PRESETS } from "../../constants"
|
||||||
|
import type { TitleSettings } from "../../types"
|
||||||
|
|
||||||
|
interface UseTitleStyleUpdatersOptions {
|
||||||
|
titleSettings: TitleSettings
|
||||||
|
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题样式更新 Hook
|
||||||
|
* 封装标题文字、位置、字体、样式等所有设置更新函数
|
||||||
|
*/
|
||||||
|
export function useTitleStyleUpdaters({
|
||||||
|
titleSettings,
|
||||||
|
onTitleSettingsChange,
|
||||||
|
}: UseTitleStyleUpdatersOptions) {
|
||||||
|
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||||
|
for (const p of TITLE_PRESETS) {
|
||||||
|
if (
|
||||||
|
settings.size === p.style.size &&
|
||||||
|
settings.color === p.style.color &&
|
||||||
|
settings.bold === p.style.bold &&
|
||||||
|
settings.italic === p.style.italic &&
|
||||||
|
settings.stroke === p.style.stroke &&
|
||||||
|
settings.shadow === p.style.shadow
|
||||||
|
) {
|
||||||
|
return p.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||||
|
|
||||||
|
const updateTitle = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, title })
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleAiAutoSelect = useCallback(() => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||||
|
}, [titleSettings, onTitleSettingsChange])
|
||||||
|
|
||||||
|
const updatePosition = useCallback(
|
||||||
|
(position: string) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, position })
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateFont = useCallback(
|
||||||
|
(font: string) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, font })
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateSize = useCallback(
|
||||||
|
(size: number) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, size })
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateColor = useCallback(
|
||||||
|
(color: string) => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, color })
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleBold = useCallback(() => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||||
|
}, [titleSettings, onTitleSettingsChange])
|
||||||
|
|
||||||
|
const toggleItalic = useCallback(() => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||||
|
}, [titleSettings, onTitleSettingsChange])
|
||||||
|
|
||||||
|
const toggleStroke = useCallback(() => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||||
|
}, [titleSettings, onTitleSettingsChange])
|
||||||
|
|
||||||
|
const toggleShadow = useCallback(() => {
|
||||||
|
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||||
|
}, [titleSettings, onTitleSettingsChange])
|
||||||
|
|
||||||
|
const applyPreset = useCallback(
|
||||||
|
(presetKey: string) => {
|
||||||
|
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||||
|
if (!preset) return
|
||||||
|
onTitleSettingsChange({
|
||||||
|
...titleSettings,
|
||||||
|
size: preset.style.size,
|
||||||
|
color: preset.style.color,
|
||||||
|
bold: preset.style.bold,
|
||||||
|
italic: preset.style.italic,
|
||||||
|
stroke: preset.style.stroke,
|
||||||
|
shadow: preset.style.shadow,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[titleSettings, onTitleSettingsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
activePreset,
|
||||||
|
titlePresets: TITLE_PRESETS,
|
||||||
|
updateTitle,
|
||||||
|
toggleAiAutoSelect,
|
||||||
|
updatePosition,
|
||||||
|
updateFont,
|
||||||
|
updateSize,
|
||||||
|
updateColor,
|
||||||
|
toggleBold,
|
||||||
|
toggleItalic,
|
||||||
|
toggleStroke,
|
||||||
|
toggleShadow,
|
||||||
|
applyPreset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,15 @@
|
|||||||
* Step 5 配音选择 Hook
|
* Step 5 配音选择 Hook
|
||||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||||
*/
|
*/
|
||||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
import { useCallback } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
|
||||||
import type { PresetVoiceItem } from "@/api/voices"
|
|
||||||
import { fetchPresetVoices } from "@/api/voices"
|
|
||||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
|
||||||
import { getTags, createTag } from "@/api/tags"
|
|
||||||
import { formatDuration } from "../utils/formatDuration"
|
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
import { message } from "antd"
|
||||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||||
|
import { formatDuration } from "../utils/formatDuration"
|
||||||
|
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
|
||||||
|
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
|
||||||
|
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
|
||||||
|
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
|
||||||
|
|
||||||
interface UseStep5VoiceProps {
|
interface UseStep5VoiceProps {
|
||||||
selectedVoice: string
|
selectedVoice: string
|
||||||
@@ -43,91 +41,47 @@ export function useStep5Voice({
|
|||||||
onCloneModalOpenChange,
|
onCloneModalOpenChange,
|
||||||
titleText,
|
titleText,
|
||||||
}: UseStep5VoiceProps) {
|
}: UseStep5VoiceProps) {
|
||||||
const navigate = useNavigate()
|
/* ── 子模块 ── */
|
||||||
/* ── 预置音色 API ── */
|
const { playingVoice, toggleVoicePlay } = useVoiceAudio()
|
||||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
|
||||||
queryKey: ["preset-voices"],
|
|
||||||
queryFn: fetchPresetVoices,
|
|
||||||
})
|
|
||||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
|
||||||
() => presetVoicesData?.items ?? [],
|
|
||||||
[presetVoicesData],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 音频播放 ── */
|
const {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
presetVoices,
|
||||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
presetVoicesLoading,
|
||||||
|
voiceRecommendLoading,
|
||||||
|
voiceRecommendations,
|
||||||
|
hasVoiceRecommend,
|
||||||
|
handleVoiceRecommend,
|
||||||
|
} = useVoiceRecommend(titleText)
|
||||||
|
|
||||||
const toggleVoicePlay = useCallback(
|
const {
|
||||||
(voiceId: string, previewUrl: string | null) => {
|
customVoiceText,
|
||||||
if (playingVoice === voiceId) {
|
setCustomVoiceText,
|
||||||
audioRef.current?.pause()
|
customAudioUrl,
|
||||||
audioRef.current = null
|
ttsError,
|
||||||
setPlayingVoice(null)
|
ttsJobId,
|
||||||
return
|
completedTtsJobId,
|
||||||
}
|
synthesizeMutation,
|
||||||
audioRef.current?.pause()
|
handleSynthesizeVoice,
|
||||||
if (!previewUrl) {
|
resetTtsState,
|
||||||
message.warning("该音色暂无试听音频")
|
} = useTtsSynthesis(selectedVoice)
|
||||||
return
|
|
||||||
}
|
|
||||||
const audio = new Audio(previewUrl)
|
|
||||||
audioRef.current = audio
|
|
||||||
audio.play().catch(() => {
|
|
||||||
message.error("播放失败,请检查网络")
|
|
||||||
})
|
|
||||||
audio.onended = () => {
|
|
||||||
setPlayingVoice(null)
|
|
||||||
audioRef.current = null
|
|
||||||
}
|
|
||||||
setPlayingVoice(voiceId)
|
|
||||||
},
|
|
||||||
[playingVoice],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 智能配音推荐 ── */
|
const {
|
||||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
saveModalOpen,
|
||||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
setSaveModalOpen,
|
||||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
saveName,
|
||||||
|
setSaveName,
|
||||||
const handleVoiceRecommend = useCallback(async () => {
|
saveTagIds,
|
||||||
if (presetVoices.length === 0) return
|
setSaveTagIds,
|
||||||
setVoiceRecommendLoading(true)
|
saveNewTag,
|
||||||
setHasVoiceRecommend(true)
|
setSaveNewTag,
|
||||||
|
allTags,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
saveToLibraryMutation,
|
||||||
|
handleOpenSaveModal,
|
||||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
handleConfirmSave,
|
||||||
const title = titleText.toLowerCase()
|
handleAddTagInModal,
|
||||||
let recommended: string[] = []
|
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
|
||||||
|
|
||||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
|
||||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
|
||||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
|
||||||
|
|
||||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
|
||||||
recommended = femaleVoices.slice(0, 3)
|
|
||||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
|
||||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
|
||||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
|
||||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
|
||||||
} else {
|
|
||||||
// 默认推荐前3个
|
|
||||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不足3个时补足
|
|
||||||
if (recommended.length < 3) {
|
|
||||||
const others = presetVoices
|
|
||||||
.filter((v) => !recommended.includes(v.voice_id))
|
|
||||||
.map((v) => v.voice_id)
|
|
||||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
setVoiceRecommendations(recommended)
|
|
||||||
setVoiceRecommendLoading(false)
|
|
||||||
}, [presetVoices, titleText])
|
|
||||||
|
|
||||||
|
/* ── 推荐音色选择 ── */
|
||||||
const handleSelectRecommendedVoice = useCallback(
|
const handleSelectRecommendedVoice = useCallback(
|
||||||
(voiceId: string) => {
|
(voiceId: string) => {
|
||||||
onVoiceModeChange("preset")
|
onVoiceModeChange("preset")
|
||||||
@@ -136,173 +90,6 @@ export function useStep5Voice({
|
|||||||
[onVoiceModeChange, onSelectedVoiceChange],
|
[onVoiceModeChange, onSelectedVoiceChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── TTS 自定义合成状态 ── */
|
|
||||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
|
||||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
|
||||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
|
||||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
|
||||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
|
||||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
/* ── TTS mutation ── */
|
|
||||||
const synthesizeMutation = useMutation({
|
|
||||||
mutationFn: synthesizeSpeech,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setTtsJobId(data.job_id)
|
|
||||||
message.info("语音合成已提交,等待处理…")
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setTtsError("语音合成请求失败,请重试")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 轮询 TTS 任务状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ttsJobId) return
|
|
||||||
let cancelled = false
|
|
||||||
let timer: ReturnType<typeof setTimeout>
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const status = await getTTSJobStatus(ttsJobId)
|
|
||||||
if (cancelled) return
|
|
||||||
if (status.status === "completed") {
|
|
||||||
setCustomAudioUrl(status.output_audio_url)
|
|
||||||
setCompletedTtsJobId(ttsJobId)
|
|
||||||
setTtsJobId(null)
|
|
||||||
setTtsError(null)
|
|
||||||
message.success("语音合成完成!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status.status === "failed" || status.status === "cancelled") {
|
|
||||||
setTtsError(status.error_message || "语音合成失败")
|
|
||||||
setTtsJobId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer = setTimeout(poll, 2000)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTtsError("查询合成状态失败")
|
|
||||||
setTtsJobId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(poll, 2000)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [ttsJobId])
|
|
||||||
|
|
||||||
/** 触发自定义文本 TTS 合成 */
|
|
||||||
const handleSynthesizeVoice = useCallback(() => {
|
|
||||||
if (!customVoiceText.trim()) {
|
|
||||||
message.warning("请先输入配音文案")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTtsError(null)
|
|
||||||
setCustomAudioUrl(null)
|
|
||||||
synthesizeMutation.mutate({
|
|
||||||
text: customVoiceText.trim(),
|
|
||||||
voice_id: selectedVoice || undefined,
|
|
||||||
language: "zh-CN",
|
|
||||||
})
|
|
||||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
|
||||||
|
|
||||||
/* ── 存为素材弹窗状态 ── */
|
|
||||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
|
||||||
const [saveName, setSaveName] = useState("")
|
|
||||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
|
||||||
const [saveNewTag, setSaveNewTag] = useState("")
|
|
||||||
|
|
||||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
|
||||||
const { data: allTags = [] } = useQuery({
|
|
||||||
queryKey: ["generate-save-tags"],
|
|
||||||
queryFn: getTags,
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 存为素材 mutation ── */
|
|
||||||
const saveToLibraryMutation = useMutation({
|
|
||||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
|
||||||
saveTtsToLibrary(completedTtsJobId!, params),
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success({
|
|
||||||
content: (
|
|
||||||
<span>
|
|
||||||
已保存到配音库!{" "}
|
|
||||||
<a
|
|
||||||
onClick={handleGoToLibrary}
|
|
||||||
style={{
|
|
||||||
color: "var(--primary-500, #6366f1)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
去视频库查看
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
duration: 5,
|
|
||||||
})
|
|
||||||
setSaveModalOpen(false)
|
|
||||||
setSaveName("")
|
|
||||||
setSaveTagIds([])
|
|
||||||
setSaveNewTag("")
|
|
||||||
setCompletedTtsJobId(null)
|
|
||||||
setCustomAudioUrl(null)
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
message.error(`保存失败:${err.message || "请重试"}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 打开存为素材弹窗 */
|
|
||||||
const handleOpenSaveModal = useCallback(() => {
|
|
||||||
setSaveName("")
|
|
||||||
setSaveTagIds([])
|
|
||||||
setSaveNewTag("")
|
|
||||||
setSaveModalOpen(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 确认保存 */
|
|
||||||
const handleConfirmSave = useCallback(() => {
|
|
||||||
if (!completedTtsJobId) return
|
|
||||||
saveToLibraryMutation.mutate({
|
|
||||||
name: saveName.trim() || undefined,
|
|
||||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
|
||||||
})
|
|
||||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
|
||||||
|
|
||||||
/** 在弹窗中新增标签(先创建再选中) */
|
|
||||||
const handleAddTagInModal = useCallback(
|
|
||||||
async (tagName: string) => {
|
|
||||||
const trimmed = tagName.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
/* 已在选中列表则跳过 */
|
|
||||||
const existing = allTags.find((t) => t.name === trimmed)
|
|
||||||
if (existing) {
|
|
||||||
if (!saveTagIds.includes(existing.id)) {
|
|
||||||
setSaveTagIds((prev) => [...prev, existing.id])
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const created = await createTag(trimmed)
|
|
||||||
setSaveTagIds((prev) => [...prev, created.id])
|
|
||||||
setSaveNewTag("")
|
|
||||||
} catch {
|
|
||||||
message.error(`创建标签"${trimmed}"失败`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[allTags, saveTagIds],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 保存成功后跳转到视频库 */
|
|
||||||
const handleGoToLibrary = useCallback(() => {
|
|
||||||
navigate("/app/voice-materials")
|
|
||||||
}, [navigate])
|
|
||||||
|
|
||||||
/* ── 克隆成功回调 ── */
|
/* ── 克隆成功回调 ── */
|
||||||
const handleCloneSuccess = useCallback(
|
const handleCloneSuccess = useCallback(
|
||||||
(voice: VoiceClone) => {
|
(voice: VoiceClone) => {
|
||||||
|
|||||||
@@ -3,126 +3,39 @@
|
|||||||
* 卡片视图展示用户已保存的剪辑模板
|
* 卡片视图展示用户已保存的剪辑模板
|
||||||
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
|
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
|
||||||
*/
|
*/
|
||||||
import React, { useState } from "react"
|
import React from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd"
|
||||||
import {
|
import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons"
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Tag,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Empty,
|
|
||||||
Spin,
|
|
||||||
Tooltip,
|
|
||||||
message,
|
|
||||||
Popconfirm,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
} from "antd"
|
|
||||||
import {
|
|
||||||
SearchOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import type { EditingTemplate } from "@/api/editing-planner"
|
||||||
getEditingTemplates,
|
import { useMyTemplates } from "./hooks/useMyTemplates"
|
||||||
getTemplateCategories,
|
import { TemplateCard } from "./components/TemplateCard"
|
||||||
deleteEditingTemplate,
|
|
||||||
createEditingTemplate,
|
|
||||||
MODE_LABELS,
|
|
||||||
MODE_COLORS,
|
|
||||||
type EditingTemplate,
|
|
||||||
type TemplateMode,
|
|
||||||
} from "@/api/editing-planner"
|
|
||||||
import "./MyTemplates.css"
|
import "./MyTemplates.css"
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const MyTemplates: React.FC = () => {
|
const MyTemplates: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const {
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
filterCategory,
|
||||||
|
setFilterCategory,
|
||||||
|
templates,
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
handleCopy,
|
||||||
|
handleDelete,
|
||||||
|
} = useMyTemplates()
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [filterCategory, setFilterCategory] = useState("")
|
|
||||||
|
|
||||||
/* ── 数据查询 ── */
|
|
||||||
const { data: templates = [], isLoading } = useQuery({
|
|
||||||
queryKey: ["editing-templates", filterCategory, searchText],
|
|
||||||
queryFn: () =>
|
|
||||||
getEditingTemplates({
|
|
||||||
category: filterCategory || undefined,
|
|
||||||
tag: searchText || undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: categories = [] } = useQuery({
|
|
||||||
queryKey: ["template-categories"],
|
|
||||||
queryFn: getTemplateCategories,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── Mutations ── */
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: deleteEditingTemplate,
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success("模板已删除")
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const copyMutation = useMutation({
|
|
||||||
mutationFn: (tpl: EditingTemplate) =>
|
|
||||||
createEditingTemplate({
|
|
||||||
name: `${tpl.name}(副本)`,
|
|
||||||
mode: tpl.mode,
|
|
||||||
category: tpl.category,
|
|
||||||
tags: tpl.tags,
|
|
||||||
title_config: tpl.title_config,
|
|
||||||
subtitle_config: tpl.subtitle_config,
|
|
||||||
bgm_config: tpl.bgm_config,
|
|
||||||
estimated_duration:
|
|
||||||
tpl.estimated_duration ??
|
|
||||||
Math.round(
|
|
||||||
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
|
||||||
),
|
|
||||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success("模板已复制")
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 操作 ── */
|
|
||||||
const handleEdit = (tpl: EditingTemplate) => {
|
const handleEdit = (tpl: EditingTemplate) => {
|
||||||
navigate(`/editing-planner?template=${tpl.id}`)
|
navigate(`/editing-planner?template=${tpl.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerate = (tpl: EditingTemplate) => {
|
const handleGenerate = (tpl: EditingTemplate) => {
|
||||||
// 跳转到智能剪辑页面,统一从智能剪辑出片
|
|
||||||
navigate(`/generate?templateId=${tpl.id}`)
|
navigate(`/generate?templateId=${tpl.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = (tpl: EditingTemplate) => {
|
|
||||||
copyMutation.mutate(tpl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
deleteMutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-page">
|
<div className="mt-page">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
@@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => {
|
|||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{templates.map((tpl) => (
|
{templates.map((tpl) => (
|
||||||
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
|
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
<Card
|
<TemplateCard
|
||||||
className="mt-card"
|
tpl={tpl}
|
||||||
hoverable
|
onEdit={handleEdit}
|
||||||
actions={[
|
onCopy={handleCopy}
|
||||||
<Tooltip title="编辑" key="edit">
|
onGenerate={handleGenerate}
|
||||||
<EditOutlined onClick={() => handleEdit(tpl)} />
|
onDelete={handleDelete}
|
||||||
</Tooltip>,
|
/>
|
||||||
<Tooltip title="复制" key="copy">
|
|
||||||
<CopyOutlined onClick={() => handleCopy(tpl)} />
|
|
||||||
</Tooltip>,
|
|
||||||
<Tooltip title="使用模板生成" key="generate">
|
|
||||||
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
|
|
||||||
</Tooltip>,
|
|
||||||
<Popconfirm
|
|
||||||
key="delete"
|
|
||||||
title="确定删除此模板?"
|
|
||||||
onConfirm={() => handleDelete(tpl.id)}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<DeleteOutlined style={{ color: "#ff4d4f" }} />
|
|
||||||
</Tooltip>
|
|
||||||
</Popconfirm>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className="mt-card-head">
|
|
||||||
<Text strong ellipsis style={{ fontSize: 15 }}>
|
|
||||||
{tpl.name}
|
|
||||||
</Text>
|
|
||||||
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
|
|
||||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
|
||||||
</Tag>
|
|
||||||
<Tag color="green">用户自制</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-card-meta">
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
|
||||||
</Text>
|
|
||||||
{tpl.category && (
|
|
||||||
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tpl.tags.length > 0 && (
|
|
||||||
<div className="mt-card-tags">
|
|
||||||
{tpl.tags.map((tag) => (
|
|
||||||
<Tag key={tag} style={{ fontSize: 11 }}>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-card-config">
|
|
||||||
<Space size={4} wrap>
|
|
||||||
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
|
|
||||||
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
|
|
||||||
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Card, Tag, Tooltip, Popconfirm, Space, Typography } from "antd"
|
||||||
|
import { EditOutlined, CopyOutlined, DeleteOutlined, VideoCameraOutlined } from "@ant-design/icons"
|
||||||
|
import {
|
||||||
|
MODE_LABELS,
|
||||||
|
MODE_COLORS,
|
||||||
|
type EditingTemplate,
|
||||||
|
type TemplateMode,
|
||||||
|
} from "@/api/editing-planner"
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface TemplateCardProps {
|
||||||
|
tpl: EditingTemplate
|
||||||
|
onEdit: (tpl: EditingTemplate) => void
|
||||||
|
onCopy: (tpl: EditingTemplate) => void
|
||||||
|
onGenerate: (tpl: EditingTemplate) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个模板卡片组件
|
||||||
|
*/
|
||||||
|
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||||
|
tpl,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onGenerate,
|
||||||
|
onDelete,
|
||||||
|
}) => (
|
||||||
|
<Card
|
||||||
|
className="mt-card"
|
||||||
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Tooltip title="编辑" key="edit">
|
||||||
|
<EditOutlined onClick={() => onEdit(tpl)} />
|
||||||
|
</Tooltip>,
|
||||||
|
<Tooltip title="复制" key="copy">
|
||||||
|
<CopyOutlined onClick={() => onCopy(tpl)} />
|
||||||
|
</Tooltip>,
|
||||||
|
<Tooltip title="使用模板生成" key="generate">
|
||||||
|
<VideoCameraOutlined onClick={() => onGenerate(tpl)} />
|
||||||
|
</Tooltip>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确定删除此模板?"
|
||||||
|
onConfirm={() => onDelete(tpl.id)}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<DeleteOutlined style={{ color: "#ff4d4f" }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="mt-card-head">
|
||||||
|
<Text strong ellipsis style={{ fontSize: 15 }}>
|
||||||
|
{tpl.name}
|
||||||
|
</Text>
|
||||||
|
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
|
||||||
|
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="green">用户自制</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-card-meta">
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||||
|
</Text>
|
||||||
|
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tpl.tags.length > 0 && (
|
||||||
|
<div className="mt-card-tags">
|
||||||
|
{tpl.tags.map((tag) => (
|
||||||
|
<Tag key={tag} style={{ fontSize: 11 }}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-card-config">
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
|
||||||
|
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
|
||||||
|
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
getEditingTemplates,
|
||||||
|
getTemplateCategories,
|
||||||
|
deleteEditingTemplate,
|
||||||
|
createEditingTemplate,
|
||||||
|
type EditingTemplate,
|
||||||
|
} from "@/api/editing-planner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的模板数据 Hook
|
||||||
|
* 封装模板列表查询、筛选、删除、复制等数据操作
|
||||||
|
*/
|
||||||
|
export function useMyTemplates() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [filterCategory, setFilterCategory] = useState("")
|
||||||
|
|
||||||
|
/* 模板列表 */
|
||||||
|
const { data: templates = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["editing-templates", filterCategory, searchText],
|
||||||
|
queryFn: () =>
|
||||||
|
getEditingTemplates({
|
||||||
|
category: filterCategory || undefined,
|
||||||
|
tag: searchText || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 分类列表 */
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ["template-categories"],
|
||||||
|
queryFn: getTemplateCategories,
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 删除 mutation */
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteEditingTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("模板已删除")
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 复制 mutation */
|
||||||
|
const copyMutation = useMutation({
|
||||||
|
mutationFn: (tpl: EditingTemplate) =>
|
||||||
|
createEditingTemplate({
|
||||||
|
name: `${tpl.name}(副本)`,
|
||||||
|
mode: tpl.mode,
|
||||||
|
category: tpl.category,
|
||||||
|
tags: tpl.tags,
|
||||||
|
title_config: tpl.title_config,
|
||||||
|
subtitle_config: tpl.subtitle_config,
|
||||||
|
bgm_config: tpl.bgm_config,
|
||||||
|
estimated_duration:
|
||||||
|
tpl.estimated_duration ??
|
||||||
|
Math.round(
|
||||||
|
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
||||||
|
),
|
||||||
|
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("模板已复制")
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCopy = (tpl: EditingTemplate) => {
|
||||||
|
copyMutation.mutate(tpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
deleteMutation.mutate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
filterCategory,
|
||||||
|
setFilterCategory,
|
||||||
|
// 数据
|
||||||
|
templates,
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
// 操作
|
||||||
|
handleCopy,
|
||||||
|
handleDelete,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
isCopying: copyMutation.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable → Regular
+44
-197
@@ -1,38 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 成片库页面 — V21 设计系统
|
* 成片库页面 — V21 设计系统
|
||||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||||
* 使用 useQuery 对接后端真实 API(api/products.ts)
|
|
||||||
*
|
*
|
||||||
* 代码结构(三阶段重构后):
|
* 主组件仅保留 Hook 组装与整体布局
|
||||||
* - types.ts: 类型定义
|
* 列表查询 → hooks/useProductList
|
||||||
* - constants.ts: 常量配置
|
* 操作逻辑 → hooks/useProductActions
|
||||||
* - utils/index.ts: 工具函数
|
* 筛选栏 → components/ProductFilterBar
|
||||||
* - components/ProductCard.tsx: 产品卡片组件
|
* 批量操作栏 → components/ProductBatchBar
|
||||||
* - components/VideoPlayer.tsx: 视频播放器组件
|
* 空状态 → components/ProductEmptyState
|
||||||
* - hooks/useProductList.ts: 列表查询与筛选
|
* 产品卡片 → components/ProductCard
|
||||||
* - hooks/useProductActions.ts: 单个/批量操作
|
* 视频播放 → components/VideoPlayer
|
||||||
*/
|
*/
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { Popconfirm, message } from "antd"
|
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||||
import {
|
import { Button } from "@/components/ui"
|
||||||
SearchOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
CloudUploadOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button, Input, Select } from "@/components/ui"
|
|
||||||
import type { ProductItem } from "./types"
|
import type { ProductItem } from "./types"
|
||||||
import { ProductCard } from "./components/ProductCard"
|
import { ProductCard } from "./components/ProductCard"
|
||||||
import { VideoPlayer } from "./components/VideoPlayer"
|
import { VideoPlayer } from "./components/VideoPlayer"
|
||||||
|
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||||
|
import { ProductBatchBar } from "./components/ProductBatchBar"
|
||||||
|
import { ProductEmptyState } from "./components/ProductEmptyState"
|
||||||
import { useProductList } from "./hooks/useProductList"
|
import { useProductList } from "./hooks/useProductList"
|
||||||
import { useProductActions } from "./hooks/useProductActions"
|
import { useProductActions } from "./hooks/useProductActions"
|
||||||
import "./products.css"
|
import "./products.css"
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 主组件
|
|
||||||
* ============================================================ */
|
|
||||||
const ProductLibrary: React.FC = () => {
|
const ProductLibrary: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
products,
|
products,
|
||||||
@@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => {
|
|||||||
setPlayingProduct,
|
setPlayingProduct,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Loading 状态 ──
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <ProductEmptyState type="loading" />
|
||||||
<div className="xx-products-page">
|
|
||||||
<div className="xx-products-empty">
|
|
||||||
<div className="xx-products-empty-icon">⏳</div>
|
|
||||||
<p>加载中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error 状态 ──
|
// ── Error 状态 ──
|
||||||
if (isError) {
|
if (isError) {
|
||||||
console.error("[ProductLibrary] 加载失败:", error)
|
console.error("[ProductLibrary] 加载失败:", error)
|
||||||
const errorMsg = error?.message || "加载失败"
|
const errorMsg = error?.message || "加载失败"
|
||||||
// 404 视为空数据(API 尚未就绪或无数据)
|
|
||||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
|
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
|
||||||
if (is404) {
|
if (is404) {
|
||||||
return (
|
return <ProductEmptyState type="404" />
|
||||||
<div className="xx-products-page">
|
|
||||||
<div className="xx-products-header">
|
|
||||||
<h2>
|
|
||||||
<VideoCameraOutlined /> 成片库
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="xx-products-empty">
|
|
||||||
<div className="xx-products-empty-icon">🎬</div>
|
|
||||||
<p>暂无成片数据</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
完成视频生成后,成片将自动保存到这里
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (
|
return <ProductEmptyState type="error" errorMessage={errorMsg} onRetry={refetch} />
|
||||||
<div className="xx-products-page">
|
|
||||||
<div className="xx-products-empty">
|
|
||||||
<div className="xx-products-empty-icon">❌</div>
|
|
||||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
|
||||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetch()}>
|
|
||||||
重新加载
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => {
|
|||||||
|
|
||||||
{/* 批量操作栏 */}
|
{/* 批量操作栏 */}
|
||||||
{batchMode && (
|
{batchMode && (
|
||||||
<div className="xx-products-batch-bar">
|
<ProductBatchBar
|
||||||
<div className="xx-products-batch-bar-left">
|
allSelected={allSelected}
|
||||||
<div
|
selectedCount={selectedIds.size}
|
||||||
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
|
batchDownloading={batchDownloading}
|
||||||
onClick={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
>
|
onBatchDownload={handleBatchDownload}
|
||||||
{allSelected && <CheckOutlined />}
|
onBatchPublish={handleBatchPublish}
|
||||||
</div>
|
onBatchDelete={handleBatchDelete}
|
||||||
<span className="xx-products-select-all" onClick={handleSelectAll}>
|
onClearSelection={clearSelection}
|
||||||
{allSelected ? "取消全选" : "全选"}
|
/>
|
||||||
</span>
|
|
||||||
<span className="xx-products-batch-count">已选择 {selectedIds.size} 项</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-products-batch-bar-right">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
onClick={handleBatchDownload}
|
|
||||||
disabled={batchDownloading}
|
|
||||||
>
|
|
||||||
{batchDownloading ? "打包中..." : "批量下载"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<CloudUploadOutlined />}
|
|
||||||
onClick={handleBatchPublish}
|
|
||||||
>
|
|
||||||
批量发布
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title={`确定删除选中的 ${selectedIds.size} 个视频?`}
|
|
||||||
onConfirm={handleBatchDelete}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
|
||||||
批量删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
|
||||||
取消选择
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
<div className="xx-products-filters">
|
<ProductFilterBar
|
||||||
<div className="xx-products-filters-left">
|
searchText={searchText}
|
||||||
<Input
|
onSearchChange={setSearchText}
|
||||||
placeholder="搜索成片名称..."
|
filterStatus={filterStatus}
|
||||||
prefix={<SearchOutlined />}
|
onFilterStatusChange={setFilterStatus}
|
||||||
value={searchText}
|
filterTime={filterTime}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onFilterTimeChange={setFilterTime}
|
||||||
allowClear
|
filterDuration={filterDuration}
|
||||||
style={{ width: 220 }}
|
onFilterDurationChange={setFilterDuration}
|
||||||
/>
|
filterProject={filterProject}
|
||||||
<Select
|
onFilterProjectChange={setFilterProject}
|
||||||
value={filterStatus}
|
filterReviewStatus={filterReviewStatus}
|
||||||
onChange={setFilterStatus}
|
onFilterReviewStatusChange={setFilterReviewStatus}
|
||||||
style={{ width: 120 }}
|
projectOptions={projectOptions}
|
||||||
options={[
|
resultCount={filteredProducts.length}
|
||||||
{ value: "all", label: "全部状态" },
|
/>
|
||||||
{ value: "completed", label: "已完成" },
|
|
||||||
{ value: "processing", label: "处理中" },
|
|
||||||
{ value: "review", label: "待复核" },
|
|
||||||
{ value: "failed", label: "失败" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterTime}
|
|
||||||
onChange={setFilterTime}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部时间" },
|
|
||||||
{ value: "today", label: "今天" },
|
|
||||||
{ value: "week", label: "近一周" },
|
|
||||||
{ value: "month", label: "近一月" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterDuration}
|
|
||||||
onChange={setFilterDuration}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部时长" },
|
|
||||||
{ value: "short", label: "≤1分钟" },
|
|
||||||
{ value: "medium", label: "1-3分钟" },
|
|
||||||
{ value: "long", label: ">3分钟" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterProject}
|
|
||||||
onChange={setFilterProject}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterReviewStatus}
|
|
||||||
onChange={setFilterReviewStatus}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部复核" },
|
|
||||||
{ value: "none", label: "未设置" },
|
|
||||||
{ value: "pending_review", label: "待复核" },
|
|
||||||
{ value: "approved", label: "已通过" },
|
|
||||||
{ value: "rejected", label: "需修改" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="xx-products-filters-right">
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
共 {filteredProducts.length} 个成片
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 卡片网格 */}
|
{/* 卡片网格 */}
|
||||||
{filteredProducts.length > 0 ? (
|
{filteredProducts.length > 0 ? (
|
||||||
@@ -297,19 +156,7 @@ const ProductLibrary: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="xx-products-empty">
|
<ProductEmptyState type="empty" />
|
||||||
<div className="xx-products-empty-icon">
|
|
||||||
<VideoCameraOutlined />
|
|
||||||
</div>
|
|
||||||
<p>暂无成片,去智能剪辑吧</p>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => message.info("跳转到生成页面")}
|
|
||||||
>
|
|
||||||
去生成
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 视频播放弹窗 */}
|
{/* 视频播放弹窗 */}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* ProductLibrary 批量操作栏
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import { Popconfirm } from "antd"
|
||||||
|
|
||||||
|
export interface ProductBatchBarProps {
|
||||||
|
allSelected: boolean
|
||||||
|
selectedCount: number
|
||||||
|
batchDownloading: boolean
|
||||||
|
onSelectAll: () => void
|
||||||
|
onBatchDownload: () => void
|
||||||
|
onBatchPublish: () => void
|
||||||
|
onBatchDelete: () => void
|
||||||
|
onClearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductBatchBar: React.FC<ProductBatchBarProps> = ({
|
||||||
|
allSelected,
|
||||||
|
selectedCount,
|
||||||
|
batchDownloading,
|
||||||
|
onSelectAll,
|
||||||
|
onBatchDownload,
|
||||||
|
onBatchPublish,
|
||||||
|
onBatchDelete,
|
||||||
|
onClearSelection,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-products-batch-bar">
|
||||||
|
<div className="xx-products-batch-bar-left">
|
||||||
|
<div
|
||||||
|
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
|
||||||
|
onClick={onSelectAll}
|
||||||
|
>
|
||||||
|
{allSelected && <CheckOutlined />}
|
||||||
|
</div>
|
||||||
|
<span className="xx-products-select-all" onClick={onSelectAll}>
|
||||||
|
{allSelected ? "取消全选" : "全选"}
|
||||||
|
</span>
|
||||||
|
<span className="xx-products-batch-count">已选择 {selectedCount} 项</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-products-batch-bar-right">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
buttonSize="sm"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={onBatchDownload}
|
||||||
|
disabled={batchDownloading}
|
||||||
|
>
|
||||||
|
{batchDownloading ? "打包中..." : "批量下载"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="sm"
|
||||||
|
icon={<CloudUploadOutlined />}
|
||||||
|
onClick={onBatchPublish}
|
||||||
|
>
|
||||||
|
批量发布
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定删除选中的 ${selectedCount} 个视频?`}
|
||||||
|
onConfirm={onBatchDelete}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button buttonType="ghost" buttonSize="sm" onClick={onClearSelection}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductBatchBar
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* ProductLibrary 空状态/加载/错误页面
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { VideoCameraOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import { message } from "antd"
|
||||||
|
|
||||||
|
export type ProductEmptyStateType = "loading" | "empty" | "404" | "error"
|
||||||
|
|
||||||
|
export interface ProductEmptyStateProps {
|
||||||
|
type: ProductEmptyStateType
|
||||||
|
errorMessage?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductEmptyState: React.FC<ProductEmptyStateProps> = ({
|
||||||
|
type,
|
||||||
|
errorMessage,
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
if (type === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="xx-products-page">
|
||||||
|
<div className="xx-products-empty">
|
||||||
|
<div className="xx-products-empty-icon">⏳</div>
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "404") {
|
||||||
|
return (
|
||||||
|
<div className="xx-products-page">
|
||||||
|
<div className="xx-products-header">
|
||||||
|
<h2>
|
||||||
|
<VideoCameraOutlined /> 成片库
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="xx-products-empty">
|
||||||
|
<div className="xx-products-empty-icon">🎬</div>
|
||||||
|
<p>暂无成片数据</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
完成视频生成后,成片将自动保存到这里
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
return (
|
||||||
|
<div className="xx-products-page">
|
||||||
|
<div className="xx-products-empty">
|
||||||
|
<div className="xx-products-empty-icon">❌</div>
|
||||||
|
<p>{errorMessage || "加载失败,请稍后重试"}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty
|
||||||
|
return (
|
||||||
|
<div className="xx-products-empty">
|
||||||
|
<div className="xx-products-empty-icon">
|
||||||
|
<VideoCameraOutlined />
|
||||||
|
</div>
|
||||||
|
<p>暂无成片,去智能剪辑吧</p>
|
||||||
|
<Button buttonType="primary" buttonSize="sm" onClick={() => message.info("跳转到生成页面")}>
|
||||||
|
去生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductEmptyState
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* ProductLibrary 筛选栏
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { SearchOutlined } from "@ant-design/icons"
|
||||||
|
import { Input, Select } from "@/components/ui"
|
||||||
|
|
||||||
|
export interface ProductFilterBarProps {
|
||||||
|
searchText: string
|
||||||
|
onSearchChange: (text: string) => void
|
||||||
|
filterStatus: string
|
||||||
|
onFilterStatusChange: (val: string) => void
|
||||||
|
filterTime: string
|
||||||
|
onFilterTimeChange: (val: string) => void
|
||||||
|
filterDuration: string
|
||||||
|
onFilterDurationChange: (val: string) => void
|
||||||
|
filterProject: string
|
||||||
|
onFilterProjectChange: (val: string) => void
|
||||||
|
filterReviewStatus: string
|
||||||
|
onFilterReviewStatusChange: (val: string) => void
|
||||||
|
projectOptions: Array<{ value: string; label: string }>
|
||||||
|
resultCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductFilterBar: React.FC<ProductFilterBarProps> = ({
|
||||||
|
searchText,
|
||||||
|
onSearchChange,
|
||||||
|
filterStatus,
|
||||||
|
onFilterStatusChange,
|
||||||
|
filterTime,
|
||||||
|
onFilterTimeChange,
|
||||||
|
filterDuration,
|
||||||
|
onFilterDurationChange,
|
||||||
|
filterProject,
|
||||||
|
onFilterProjectChange,
|
||||||
|
filterReviewStatus,
|
||||||
|
onFilterReviewStatusChange,
|
||||||
|
projectOptions,
|
||||||
|
resultCount,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-products-filters">
|
||||||
|
<div className="xx-products-filters-left">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索成片名称..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={onFilterStatusChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
{ value: "completed", label: "已完成" },
|
||||||
|
{ value: "processing", label: "处理中" },
|
||||||
|
{ value: "review", label: "待复核" },
|
||||||
|
{ value: "failed", label: "失败" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterTime}
|
||||||
|
onChange={onFilterTimeChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部时间" },
|
||||||
|
{ value: "today", label: "今天" },
|
||||||
|
{ value: "week", label: "近一周" },
|
||||||
|
{ value: "month", label: "近一月" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterDuration}
|
||||||
|
onChange={onFilterDurationChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部时长" },
|
||||||
|
{ value: "short", label: "≤1分钟" },
|
||||||
|
{ value: "medium", label: "1-3分钟" },
|
||||||
|
{ value: "long", label: ">3分钟" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterProject}
|
||||||
|
onChange={onFilterProjectChange}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterReviewStatus}
|
||||||
|
onChange={onFilterReviewStatusChange}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部复核" },
|
||||||
|
{ value: "none", label: "未设置" },
|
||||||
|
{ value: "pending_review", label: "待复核" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "rejected", label: "需修改" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="xx-products-filters-right">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--font-size-sm)",
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
共 {resultCount} 个成片
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductFilterBar
|
||||||
@@ -2,151 +2,59 @@
|
|||||||
* 升级/降级/续费页面
|
* 升级/降级/续费页面
|
||||||
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
|
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from "react"
|
import React from "react"
|
||||||
import { message } from "antd"
|
import { Modal } from "@/components/ui"
|
||||||
import { Button, Modal } from "@/components/ui"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import type { PlanType } from "@/api/subscription"
|
||||||
getCurrentSubscription,
|
|
||||||
changePlan,
|
|
||||||
toggleAutoRenew,
|
|
||||||
cancelSubscription,
|
|
||||||
} from "@/api/subscription"
|
|
||||||
import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
|
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
|
||||||
|
import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
|
||||||
|
import { useSubscription } from "./hooks/useSubscription"
|
||||||
import "./UpgradeSubscription.css"
|
import "./UpgradeSubscription.css"
|
||||||
|
|
||||||
const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
|
|
||||||
free: { name: "体验版", price: 0, yearlyPrice: 0 },
|
|
||||||
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
|
|
||||||
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
|
|
||||||
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 自定义计费周期切换组件 */
|
|
||||||
const BillingCycleSwitch: React.FC<{
|
|
||||||
value: BillingCycle
|
|
||||||
onChange: (cycle: BillingCycle) => void
|
|
||||||
monthlyPrice: number
|
|
||||||
yearlyPrice: number
|
|
||||||
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
|
|
||||||
<div className="xx-billing-cycle-switch">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
|
|
||||||
onClick={() => onChange("monthly")}
|
|
||||||
>
|
|
||||||
¥{monthlyPrice}/月
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
|
|
||||||
onClick={() => onChange("yearly")}
|
|
||||||
>
|
|
||||||
¥{yearlyPrice}/年
|
|
||||||
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
|
||||||
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 自定义 Spinner 组件 */
|
|
||||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
|
|
||||||
<div className={`xx-spinner xx-spinner--${size}`}>
|
|
||||||
<div className="xx-spinner-dot" />
|
|
||||||
<div className="xx-spinner-dot" />
|
|
||||||
<div className="xx-spinner-dot" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const UpgradeSubscription: React.FC = () => {
|
const UpgradeSubscription: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
|
const {
|
||||||
const [loading, setLoading] = useState(true)
|
subscription,
|
||||||
const [submitting, setSubmitting] = useState(false)
|
loading,
|
||||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
|
submitting,
|
||||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
|
selectedPlan,
|
||||||
|
billingCycle,
|
||||||
|
setSelectedPlan,
|
||||||
|
setBillingCycle,
|
||||||
|
executeChangePlan,
|
||||||
|
handleToggleAutoRenew,
|
||||||
|
handleCancel,
|
||||||
|
} = useSubscription()
|
||||||
|
|
||||||
useEffect(() => {
|
const handleUpgradeClick = () => {
|
||||||
loadSubscription()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadSubscription = async () => {
|
|
||||||
try {
|
|
||||||
const data = await getCurrentSubscription()
|
|
||||||
setSubscription(data)
|
|
||||||
setSelectedPlan(data.plan_id)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
|
||||||
if (!subscription) return
|
if (!subscription) return
|
||||||
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
||||||
message.info("当前已是该套餐")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = PLANS_META[selectedPlan]
|
const plan = PLANS_META[selectedPlan]
|
||||||
const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price
|
const price = getPlanPrice(selectedPlan, billingCycle)
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: "确认变更套餐",
|
title: "确认变更套餐",
|
||||||
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
|
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
|
||||||
okText: "确认变更",
|
okText: "确认变更",
|
||||||
cancelText: "取消",
|
cancelText: "取消",
|
||||||
onOk: async () => {
|
onOk: executeChangePlan,
|
||||||
try {
|
|
||||||
setSubmitting(true)
|
|
||||||
const res = await changePlan({
|
|
||||||
target_plan_id: selectedPlan,
|
|
||||||
billing_cycle: billingCycle,
|
|
||||||
})
|
|
||||||
if (res.success) {
|
|
||||||
message.success(res.message)
|
|
||||||
setSubscription(res.new_subscription ?? null)
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleAutoRenew = async (enabled: boolean) => {
|
const handleCancelClick = () => {
|
||||||
try {
|
|
||||||
const res = await toggleAutoRenew(enabled)
|
|
||||||
message.success(res.message)
|
|
||||||
if (subscription) {
|
|
||||||
setSubscription({ ...subscription, auto_renew: enabled })
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: "确认取消订阅",
|
title: "确认取消订阅",
|
||||||
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
|
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
|
||||||
okText: "确认取消",
|
okText: "确认取消",
|
||||||
cancelText: "再想想",
|
cancelText: "再想想",
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
const ok = await handleCancel()
|
||||||
const res = await cancelSubscription()
|
if (ok) navigate("/app/subscription")
|
||||||
message.success(res.message)
|
|
||||||
navigate("/app/subscription")
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -163,10 +71,7 @@ const UpgradeSubscription: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-upgrade-page">
|
<div className="xx-upgrade-page">
|
||||||
<PageHead
|
<PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
|
||||||
title="变更订阅方案"
|
|
||||||
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="xx-upgrade-plans">
|
<div className="xx-upgrade-plans">
|
||||||
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
|
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
|
||||||
@@ -198,7 +103,7 @@ const UpgradeSubscription: React.FC = () => {
|
|||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
buttonSize="lg"
|
buttonSize="lg"
|
||||||
disabled={submitting || selectedPlan === currentPlan}
|
disabled={submitting || selectedPlan === currentPlan}
|
||||||
onClick={handleUpgrade}
|
onClick={handleUpgradeClick}
|
||||||
>
|
>
|
||||||
{submitting ? "处理中..." : "确认变更"}
|
{submitting ? "处理中..." : "确认变更"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,7 +125,7 @@ const UpgradeSubscription: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
buttonSize="sm"
|
buttonSize="sm"
|
||||||
onClick={handleCancel}
|
onClick={handleCancelClick}
|
||||||
className="xx-cancel-btn"
|
className="xx-cancel-btn"
|
||||||
>
|
>
|
||||||
取消订阅
|
取消订阅
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { BillingCycle } from "@/api/subscription"
|
||||||
|
|
||||||
|
interface BillingCycleSwitchProps {
|
||||||
|
value: BillingCycle
|
||||||
|
onChange: (cycle: BillingCycle) => void
|
||||||
|
monthlyPrice: number
|
||||||
|
yearlyPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自定义计费周期切换组件 */
|
||||||
|
export const BillingCycleSwitch: React.FC<BillingCycleSwitchProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
monthlyPrice,
|
||||||
|
yearlyPrice,
|
||||||
|
}) => (
|
||||||
|
<div className="xx-billing-cycle-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
|
||||||
|
onClick={() => onChange("monthly")}
|
||||||
|
>
|
||||||
|
¥{monthlyPrice}/月
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
|
||||||
|
onClick={() => onChange("yearly")}
|
||||||
|
>
|
||||||
|
¥{yearlyPrice}/年
|
||||||
|
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
||||||
|
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
size?: "small" | "large"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自定义 Spinner 组件 */
|
||||||
|
export const Spinner: React.FC<SpinnerProps> = ({ size = "large" }) => (
|
||||||
|
<div className={`xx-spinner xx-spinner--${size}`}>
|
||||||
|
<div className="xx-spinner-dot" />
|
||||||
|
<div className="xx-spinner-dot" />
|
||||||
|
<div className="xx-spinner-dot" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PlanType, BillingCycle } from "@/api/subscription"
|
||||||
|
|
||||||
|
export const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
|
||||||
|
free: { name: "体验版", price: 0, yearlyPrice: 0 },
|
||||||
|
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
|
||||||
|
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
|
||||||
|
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版"
|
||||||
|
|
||||||
|
export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => {
|
||||||
|
const plan = PLANS_META[planId]
|
||||||
|
if (!plan) return 0
|
||||||
|
return cycle === "yearly" ? plan.yearlyPrice : plan.price
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
getCurrentSubscription,
|
||||||
|
changePlan,
|
||||||
|
toggleAutoRenew,
|
||||||
|
cancelSubscription,
|
||||||
|
type SubscriptionInfo,
|
||||||
|
type PlanType,
|
||||||
|
type BillingCycle,
|
||||||
|
} from "@/api/subscription"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅管理 Hook
|
||||||
|
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
|
||||||
|
*/
|
||||||
|
export function useSubscription() {
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
|
||||||
|
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
|
||||||
|
|
||||||
|
const loadSubscription = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getCurrentSubscription()
|
||||||
|
setSubscription(data)
|
||||||
|
setSelectedPlan(data.plan_id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubscription()
|
||||||
|
}, [loadSubscription])
|
||||||
|
|
||||||
|
const handleUpgrade = useCallback(async () => {
|
||||||
|
if (!subscription) return
|
||||||
|
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
||||||
|
message.info("当前已是该套餐")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 由调用方决定是否弹确认框
|
||||||
|
}, [subscription, selectedPlan, billingCycle])
|
||||||
|
|
||||||
|
const executeChangePlan = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
const res = await changePlan({
|
||||||
|
target_plan_id: selectedPlan,
|
||||||
|
billing_cycle: billingCycle,
|
||||||
|
})
|
||||||
|
if (res.success) {
|
||||||
|
message.success(res.message)
|
||||||
|
setSubscription(res.new_subscription ?? null)
|
||||||
|
} else {
|
||||||
|
message.error(res.message)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [selectedPlan, billingCycle])
|
||||||
|
|
||||||
|
const handleToggleAutoRenew = useCallback(
|
||||||
|
async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await toggleAutoRenew(enabled)
|
||||||
|
message.success(res.message)
|
||||||
|
if (subscription) {
|
||||||
|
setSubscription({ ...subscription, auto_renew: enabled })
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[subscription],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCancel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await cancelSubscription()
|
||||||
|
message.success(res.message)
|
||||||
|
return true
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
subscription,
|
||||||
|
loading,
|
||||||
|
submitting,
|
||||||
|
selectedPlan,
|
||||||
|
billingCycle,
|
||||||
|
setSelectedPlan,
|
||||||
|
setBillingCycle,
|
||||||
|
// 操作
|
||||||
|
loadSubscription,
|
||||||
|
handleUpgrade,
|
||||||
|
executeChangePlan,
|
||||||
|
handleToggleAutoRenew,
|
||||||
|
handleCancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,708 +6,99 @@
|
|||||||
* - 复制模板 / 从模板生成
|
* - 复制模板 / 从模板生成
|
||||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||||
*/
|
*/
|
||||||
import React, { useState, useMemo, useCallback } from "react"
|
import React from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useTemplateLibrary } from "./hooks/useTemplateLibrary"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useTemplateDetail } from "./hooks/useTemplateDetail"
|
||||||
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd"
|
import { TemplateHeader } from "./components/template-library/TemplateHeader"
|
||||||
import {
|
import { TemplateToolbar } from "./components/template-library/TemplateToolbar"
|
||||||
LoadingOutlined,
|
import { TemplateGrid } from "./components/template-library/TemplateGrid"
|
||||||
ExclamationCircleOutlined,
|
import { TemplateDetailModal } from "./components/template-library/TemplateDetailModal"
|
||||||
InboxOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import {
|
|
||||||
getTemplates,
|
|
||||||
getTemplate,
|
|
||||||
toggleFavoriteTemplate,
|
|
||||||
copyTemplate,
|
|
||||||
type TemplateItem,
|
|
||||||
type TemplateListParams,
|
|
||||||
type TemplateSegment,
|
|
||||||
} from "@/api/templates"
|
|
||||||
import "./templates.css"
|
import "./templates.css"
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 类型定义
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/** 模板类型 */
|
|
||||||
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 模板类型配置
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
const TEMPLATE_TYPES: Array<{
|
|
||||||
type: EditTemplateType | "全部"
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
color: string
|
|
||||||
}> = [
|
|
||||||
{ type: "全部", label: "全部", icon: "📋", color: "#6366f1" },
|
|
||||||
{ type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" },
|
|
||||||
{ type: "种草", label: "种草", icon: "🌱", color: "#10b981" },
|
|
||||||
{ type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" },
|
|
||||||
{ type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" },
|
|
||||||
{ type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" },
|
|
||||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 时长筛选选项 */
|
|
||||||
const DURATION_OPTIONS: Array<{
|
|
||||||
value: "" | "short" | "medium" | "long"
|
|
||||||
label: string
|
|
||||||
}> = [
|
|
||||||
{ value: "", label: "全部时长" },
|
|
||||||
{ value: "short", label: "30秒以内" },
|
|
||||||
{ value: "medium", label: "30秒-2分钟" },
|
|
||||||
{ value: "long", label: "2分钟以上" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 辅助函数
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/** 获取类型对应颜色 */
|
|
||||||
const getTypeColor = (type: string): string => {
|
|
||||||
const found = TEMPLATE_TYPES.find((t) => t.type === type)
|
|
||||||
return found?.color ?? "#6366f1"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 category 生成占位渐变色 */
|
|
||||||
const gradientForCategory = (category: string): string => {
|
|
||||||
const gradients: Record<string, string> = {
|
|
||||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
|
||||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
|
||||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
|
||||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
|
||||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
|
||||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
|
||||||
}
|
|
||||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化时长 */
|
|
||||||
const formatDuration = (seconds: number | undefined | null): string => {
|
|
||||||
if (!seconds || seconds <= 0) return "0秒"
|
|
||||||
const totalSec = Math.round(seconds)
|
|
||||||
const m = Math.floor(totalSec / 60)
|
|
||||||
const s = totalSec % 60
|
|
||||||
if (m === 0) return `${s}秒`
|
|
||||||
return `${m}分${s > 0 ? `${s}秒` : ""}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
|
|
||||||
interface ConfigDisplayFields {
|
|
||||||
font_size?: string | number
|
|
||||||
font_family?: string
|
|
||||||
color?: string
|
|
||||||
position?: string
|
|
||||||
volume?: string | number
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化配置对象为可读文本 */
|
|
||||||
const formatConfig = (config?: object): string => {
|
|
||||||
if (!config || Object.keys(config).length === 0) return "默认"
|
|
||||||
const c = config as ConfigDisplayFields
|
|
||||||
const parts: string[] = []
|
|
||||||
if (c.font_size) parts.push(`字号: ${c.font_size}`)
|
|
||||||
if (c.font_family) parts.push(`字体: ${c.font_family}`)
|
|
||||||
if (c.color) parts.push(`颜色: ${c.color}`)
|
|
||||||
if (c.position) parts.push(`位置: ${c.position}`)
|
|
||||||
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`)
|
|
||||||
if (c.name) parts.push(String(c.name))
|
|
||||||
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 素材类型标签 */
|
|
||||||
const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
|
||||||
video: "视频",
|
|
||||||
image: "图片",
|
|
||||||
audio: "音频",
|
|
||||||
voiceover: "配音",
|
|
||||||
subtitle: "字幕",
|
|
||||||
null: "不限",
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 模板详情弹窗组件
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
interface TemplateDetailModalProps {
|
|
||||||
template: TemplateItem
|
|
||||||
isFavorite: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onToggleFavorite: (id: string) => void
|
|
||||||
onUse: (template: TemplateItem) => void
|
|
||||||
onCopy: (template: TemplateItem) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|
||||||
template,
|
|
||||||
isFavorite,
|
|
||||||
onClose,
|
|
||||||
onToggleFavorite,
|
|
||||||
onUse,
|
|
||||||
onCopy,
|
|
||||||
}) => {
|
|
||||||
const segments = template.segments ?? []
|
|
||||||
const totalSegmentDuration = segments.reduce(
|
|
||||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className="xx-template-modal xx-template-modal-wide"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 关闭按钮 */}
|
|
||||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 预览区域 */}
|
|
||||||
<div
|
|
||||||
className="xx-template-modal-preview"
|
|
||||||
style={{ background: gradientForCategory(template.category) }}
|
|
||||||
>
|
|
||||||
{template.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={template.thumbnail_url}
|
|
||||||
alt={template.name}
|
|
||||||
className="xx-template-modal-thumb-img"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="xx-template-modal-preview-content">
|
|
||||||
<span className="xx-template-preview-icon">
|
|
||||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
|
||||||
</span>
|
|
||||||
<span className="xx-template-preview-title">{template.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className="xx-template-modal-content">
|
|
||||||
{/* 标题行 */}
|
|
||||||
<div className="xx-template-modal-title-row">
|
|
||||||
<h3>{template.name}</h3>
|
|
||||||
<span
|
|
||||||
className="xx-template-modal-type-badge"
|
|
||||||
style={{
|
|
||||||
color: getTypeColor(template.category),
|
|
||||||
background: `${getTypeColor(template.category)}18`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="xx-template-modal-desc">{template.description}</p>
|
|
||||||
|
|
||||||
{/* 标签 */}
|
|
||||||
{(template.tags?.length ?? 0) > 0 && (
|
|
||||||
<div className="xx-template-modal-tags">
|
|
||||||
{template.tags!.map((tag) => (
|
|
||||||
<span key={tag} className="xx-template-modal-tag">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 基本信息 */}
|
|
||||||
<Descriptions
|
|
||||||
column={2}
|
|
||||||
size="small"
|
|
||||||
className="xx-template-modal-desc-table"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: "duration",
|
|
||||||
label: "目标时长",
|
|
||||||
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "clips",
|
|
||||||
label: "片段数量",
|
|
||||||
children: `${template.clip_count} 个`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ratio",
|
|
||||||
label: "视频比例",
|
|
||||||
children: template.aspect_ratio ?? "16:9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "usage",
|
|
||||||
label: "使用次数",
|
|
||||||
children: `${template.usage_count ?? 0} 次`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 素材规则(片段配置) */}
|
|
||||||
{segments.length > 0 && (
|
|
||||||
<div className="xx-template-modal-section">
|
|
||||||
<h4>🎬 素材规则</h4>
|
|
||||||
<div className="xx-template-modal-clip-list">
|
|
||||||
{segments
|
|
||||||
.sort((a, b) => a.segment_order - b.segment_order)
|
|
||||||
.map((seg: TemplateSegment, idx: number) => (
|
|
||||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
|
||||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
|
||||||
<span
|
|
||||||
className="xx-template-modal-clip-badge"
|
|
||||||
style={{
|
|
||||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
|
||||||
background: seg.material_type
|
|
||||||
? `${getTypeColor(seg.material_type)}18`
|
|
||||||
: "#f1f5f9",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
|
|
||||||
seg.material_type ??
|
|
||||||
"不限"}
|
|
||||||
</span>
|
|
||||||
<span className="xx-template-modal-clip-desc">
|
|
||||||
{seg.description || `片段 ${seg.segment_order}`}
|
|
||||||
</span>
|
|
||||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
|
||||||
<span className="xx-template-modal-clip-duration">
|
|
||||||
{seg.duration_min}-{seg.duration_max}秒
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-total-duration">
|
|
||||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 样式配置 */}
|
|
||||||
<div className="xx-template-modal-section">
|
|
||||||
<h4>🎨 样式配置</h4>
|
|
||||||
<div className="xx-template-modal-style-grid">
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.subtitle_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">标题样式</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.title_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.bgm_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">视频比例</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{template.aspect_ratio ?? "16:9"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 统计信息 */}
|
|
||||||
<div className="xx-template-modal-stats">
|
|
||||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
|
||||||
<button
|
|
||||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
|
||||||
onClick={() => onToggleFavorite(template.id)}
|
|
||||||
>
|
|
||||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="xx-template-modal-actions">
|
|
||||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
|
||||||
复制模板
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
|
||||||
使用此模板生成
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 模板卡片组件
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
interface TemplateCardProps {
|
|
||||||
template: TemplateItem
|
|
||||||
isFavorite: boolean
|
|
||||||
onPreview: (template: TemplateItem) => void
|
|
||||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void
|
|
||||||
onUse: (template: TemplateItem) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
|
||||||
template,
|
|
||||||
isFavorite,
|
|
||||||
onPreview,
|
|
||||||
onToggleFavorite,
|
|
||||||
onUse,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
|
||||||
{/* 缩略图 */}
|
|
||||||
<div className="xx-template-thumb">
|
|
||||||
{template.thumbnail_url ? (
|
|
||||||
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="xx-template-thumb-bg"
|
|
||||||
style={{ background: gradientForCategory(template.category) }}
|
|
||||||
>
|
|
||||||
{(template.description ?? "").slice(0, 80)}
|
|
||||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="xx-template-thumb-overlay" />
|
|
||||||
<div className="xx-template-thumb-name">{template.name}</div>
|
|
||||||
<div className="xx-template-thumb-meta">
|
|
||||||
<span className="xx-template-thumb-duration">
|
|
||||||
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
|
||||||
<button
|
|
||||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
|
||||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
|
||||||
title={isFavorite ? "取消收藏" : "收藏"}
|
|
||||||
>
|
|
||||||
{isFavorite ? "★" : "☆"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 信息区 */}
|
|
||||||
<div className="xx-template-info">
|
|
||||||
<div className="xx-template-info-top">
|
|
||||||
<span
|
|
||||||
className="xx-template-category-pill"
|
|
||||||
style={{
|
|
||||||
color: getTypeColor(template.category),
|
|
||||||
background: `${getTypeColor(template.category)}18`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{template.category}
|
|
||||||
</span>
|
|
||||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
|
||||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
|
||||||
<div className="xx-template-meta">
|
|
||||||
<span className="xx-template-usage">已使用 {template.usage_count ?? 0} 次</span>
|
|
||||||
<button
|
|
||||||
className="xx-template-use-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onUse(template)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
使用此模板
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 主组件
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
const TemplateLibrary: React.FC = () => {
|
const TemplateLibrary: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// 筛选状态
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
|
|
||||||
const [durationRange, setDurationRange] = useState<"" | "short" | "medium" | "long">("")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize] = useState(12)
|
|
||||||
|
|
||||||
// 弹窗状态
|
|
||||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(null)
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
|
||||||
|
|
||||||
// ── 构建查询参数 ──
|
|
||||||
const queryParams: TemplateListParams = useMemo(() => {
|
|
||||||
const params: TemplateListParams = {
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
}
|
|
||||||
if (activeType !== "全部") params.category = activeType
|
|
||||||
if (searchText.trim()) params.keyword = searchText.trim()
|
|
||||||
if (durationRange) params.duration_range = durationRange
|
|
||||||
return params
|
|
||||||
}, [page, pageSize, activeType, searchText, durationRange])
|
|
||||||
|
|
||||||
// ── 获取模板列表(后端分页 + 筛选) ──
|
|
||||||
const {
|
const {
|
||||||
data: templateData,
|
templates,
|
||||||
|
totalTemplates,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
searchText,
|
||||||
queryKey: ["templates", queryParams],
|
activeType,
|
||||||
queryFn: () => getTemplates(queryParams),
|
durationRange,
|
||||||
staleTime: 30_000,
|
page,
|
||||||
|
pageSize,
|
||||||
|
setPage,
|
||||||
|
toggleFavorite,
|
||||||
|
handleCopy,
|
||||||
|
handleUse,
|
||||||
|
handleCreate,
|
||||||
|
handleSearchChange,
|
||||||
|
handleCategoryChange,
|
||||||
|
handleDurationChange,
|
||||||
|
} = useTemplateLibrary()
|
||||||
|
|
||||||
|
const {
|
||||||
|
previewTemplate,
|
||||||
|
detailLoading,
|
||||||
|
handlePreview,
|
||||||
|
handleClose,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleUse: handleUseFromDetail,
|
||||||
|
handleCopy: handleCopyFromDetail,
|
||||||
|
} = useTemplateDetail({
|
||||||
|
onToggleFavorite: (id) => toggleFavorite(id),
|
||||||
|
onUse: handleUse,
|
||||||
|
onCopy: handleCopy,
|
||||||
})
|
})
|
||||||
|
|
||||||
const templates = templateData?.items ?? []
|
|
||||||
const totalTemplates = templateData?.total ?? 0
|
|
||||||
|
|
||||||
// ── 收藏 mutation ──
|
|
||||||
const favMutation = useMutation({
|
|
||||||
mutationFn: toggleFavoriteTemplate,
|
|
||||||
onSuccess: (_data, templateId) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["templates"] })
|
|
||||||
if (previewTemplate && previewTemplate.id === templateId) {
|
|
||||||
setPreviewTemplate((prev) => (prev ? { ...prev, is_favorite: !prev.is_favorite } : prev))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 复制模板 mutation ──
|
|
||||||
const copyMutation = useMutation({
|
|
||||||
mutationFn: copyTemplate,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
message.success(`模板「${data.name}」已复制到「我的模板」`)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["templates"] })
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("复制模板失败,请稍后重试")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 切换收藏 */
|
|
||||||
const toggleFavorite = useCallback(
|
|
||||||
(id: string, e?: React.MouseEvent) => {
|
|
||||||
e?.stopPropagation()
|
|
||||||
favMutation.mutate(id)
|
|
||||||
},
|
|
||||||
[favMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 点击卡片 → 获取详情并展示弹窗 */
|
|
||||||
const handlePreview = useCallback(async (template: TemplateItem) => {
|
|
||||||
setDetailLoading(true)
|
|
||||||
setPreviewTemplate(template)
|
|
||||||
try {
|
|
||||||
const detail = await getTemplate(template.id)
|
|
||||||
setPreviewTemplate(detail)
|
|
||||||
} catch {
|
|
||||||
// 详情加载失败时使用列表数据
|
|
||||||
message.warning("模板详情加载失败,显示摘要信息")
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 复制模板 */
|
|
||||||
const handleCopy = useCallback(
|
|
||||||
(template: TemplateItem) => {
|
|
||||||
copyMutation.mutate(template.id)
|
|
||||||
},
|
|
||||||
[copyMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 使用模板 → 进入剪辑编辑器配置 */
|
|
||||||
const handleUse = useCallback(
|
|
||||||
(template: TemplateItem) => {
|
|
||||||
navigate(`/app/editing-planner?templateId=${template.id}`)
|
|
||||||
},
|
|
||||||
[navigate],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 搜索防抖处理 */
|
|
||||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchText(e.target.value)
|
|
||||||
setPage(1)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 切换分类 */
|
|
||||||
const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => {
|
|
||||||
setActiveType(type)
|
|
||||||
setPage(1)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 切换时长筛选 */
|
|
||||||
const handleDurationChange = useCallback((value: "" | "short" | "medium" | "long") => {
|
|
||||||
setDurationRange(value)
|
|
||||||
setPage(1)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ── Loading 状态 ──
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="xx-templates-page">
|
|
||||||
<div className="xx-templates-empty">
|
|
||||||
<div className="xx-templates-empty-icon">
|
|
||||||
<LoadingOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>加载模板中...</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error 状态 ──
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="xx-templates-page">
|
|
||||||
<div className="xx-templates-empty">
|
|
||||||
<div className="xx-templates-empty-icon">
|
|
||||||
<ExclamationCircleOutlined />
|
|
||||||
</div>
|
|
||||||
<h3>加载失败</h3>
|
|
||||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-templates-page">
|
<div className="xx-templates-page">
|
||||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
{/* 页面头部 */}
|
||||||
<div className="xx-templates-header">
|
<TemplateHeader onCreateClick={handleCreate} />
|
||||||
<div className="xx-templates-header-text">
|
|
||||||
<h2>模板库</h2>
|
|
||||||
<p>选择模板快速创建,支持自定义修改</p>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
|
||||||
+ 创建模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
{/* 工具栏:搜索 + 类型按钮组 + 时长筛选 */}
|
||||||
<div className="xx-templates-toolbar">
|
<TemplateToolbar
|
||||||
<div className="xx-templates-search">
|
searchText={searchText}
|
||||||
<span className="xx-templates-search-icon">
|
onSearchChange={handleSearchChange}
|
||||||
<SearchOutlined />
|
activeType={activeType}
|
||||||
</span>
|
onTypeChange={handleCategoryChange}
|
||||||
<input
|
durationRange={durationRange}
|
||||||
className="xx-templates-search-input"
|
onDurationChange={handleDurationChange}
|
||||||
type="text"
|
/>
|
||||||
placeholder="搜索模板名称、描述或标签..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="xx-templates-categories">
|
|
||||||
{TEMPLATE_TYPES.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat.type}
|
|
||||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
|
||||||
onClick={() => handleCategoryChange(cat.type)}
|
|
||||||
>
|
|
||||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
|
||||||
{cat.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* 时长筛选 */}
|
|
||||||
<div className="xx-templates-duration-filter">
|
|
||||||
{DURATION_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
|
||||||
onClick={() => handleDurationChange(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
{/* 模板展示区 */}
|
||||||
{templates.length === 0 ? (
|
<TemplateGrid
|
||||||
<div className="xx-templates-empty">
|
templates={templates}
|
||||||
<div className="xx-templates-empty-icon">
|
total={totalTemplates}
|
||||||
<InboxOutlined />
|
page={page}
|
||||||
</div>
|
pageSize={pageSize}
|
||||||
<h3>
|
isLoading={isLoading}
|
||||||
{searchText || activeType !== "全部" || durationRange ? "未找到匹配的模板" : "暂无模板"}
|
isError={isError}
|
||||||
</h3>
|
errorMessage={error?.message}
|
||||||
<p>
|
searchText={searchText}
|
||||||
{searchText || activeType !== "全部" || durationRange
|
activeType={activeType}
|
||||||
? "试试调整搜索条件或切换类型"
|
durationRange={durationRange}
|
||||||
: "点击上方「创建模板」开始创作"}
|
onPageChange={setPage}
|
||||||
</p>
|
onPreview={handlePreview}
|
||||||
</div>
|
onToggleFavorite={toggleFavorite}
|
||||||
) : (
|
onUse={handleUse}
|
||||||
<>
|
/>
|
||||||
<div className="xx-templates-grid">
|
|
||||||
{templates.map((tpl) => (
|
|
||||||
<TemplateCard
|
|
||||||
key={tpl.id}
|
|
||||||
template={tpl}
|
|
||||||
isFavorite={tpl.is_favorite ?? false}
|
|
||||||
onPreview={handlePreview}
|
|
||||||
onToggleFavorite={toggleFavorite}
|
|
||||||
onUse={handleUse}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 详情弹窗 */}
|
||||||
{totalTemplates > pageSize && (
|
|
||||||
<div className="xx-templates-pagination">
|
|
||||||
<Pagination
|
|
||||||
current={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
total={totalTemplates}
|
|
||||||
showSizeChanger={false}
|
|
||||||
showQuickJumper
|
|
||||||
showTotal={(total) => `共 ${total} 个模板`}
|
|
||||||
onChange={(p) => setPage(p)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
|
|
||||||
{previewTemplate && (
|
{previewTemplate && (
|
||||||
<TemplateDetailModal
|
<TemplateDetailModal
|
||||||
template={previewTemplate}
|
template={previewTemplate}
|
||||||
isFavorite={previewTemplate.is_favorite ?? false}
|
isFavorite={previewTemplate.is_favorite ?? false}
|
||||||
onClose={() => setPreviewTemplate(null)}
|
onClose={handleClose}
|
||||||
onToggleFavorite={toggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onUse={handleUse}
|
onUse={handleUseFromDetail}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopyFromDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 详情加载中的提示(可选覆盖层) */}
|
{/* 详情加载中的提示 */}
|
||||||
{detailLoading && previewTemplate && (
|
{detailLoading && previewTemplate && (
|
||||||
<div className="xx-template-detail-loading">
|
<div className="xx-template-detail-loading">加载中...</div>
|
||||||
<LoadingOutlined /> 加载中...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag } from "antd"
|
||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||||
|
|
||||||
|
interface TemplateCardProps {
|
||||||
|
template: TemplateItem
|
||||||
|
isFavorite: boolean
|
||||||
|
onPreview: (template: TemplateItem) => void
|
||||||
|
onToggleFavorite: (id: string, e: React.MouseEvent) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||||
|
template,
|
||||||
|
isFavorite,
|
||||||
|
onPreview,
|
||||||
|
onToggleFavorite,
|
||||||
|
onUse,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||||
|
{/* 缩略图 */}
|
||||||
|
<div className="xx-template-thumb">
|
||||||
|
{template.thumbnail_url ? (
|
||||||
|
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="xx-template-thumb-bg"
|
||||||
|
style={{ background: gradientForCategory(template.category) }}
|
||||||
|
>
|
||||||
|
{(template.description ?? "").slice(0, 80)}
|
||||||
|
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="xx-template-thumb-overlay" />
|
||||||
|
<div className="xx-template-thumb-name">{template.name}</div>
|
||||||
|
<div className="xx-template-thumb-meta">
|
||||||
|
<span className="xx-template-thumb-duration">
|
||||||
|
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||||
|
<button
|
||||||
|
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||||
|
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||||
|
title={isFavorite ? "取消收藏" : "收藏"}
|
||||||
|
>
|
||||||
|
{isFavorite ? "★" : "☆"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 信息区 */}
|
||||||
|
<div className="xx-template-info">
|
||||||
|
<div className="xx-template-info-top">
|
||||||
|
<span
|
||||||
|
className="xx-template-category-pill"
|
||||||
|
style={{
|
||||||
|
color: getTypeColor(template.category),
|
||||||
|
background: `${getTypeColor(template.category)}18`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{template.category}
|
||||||
|
</span>
|
||||||
|
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||||
|
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||||
|
<div className="xx-template-meta">
|
||||||
|
<span className="xx-template-usage">已使用 {template.usage_count ?? 0} 次</span>
|
||||||
|
<button
|
||||||
|
className="xx-template-use-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onUse(template)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
使用此模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button, Descriptions, Tooltip } from "antd"
|
||||||
|
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||||
|
import type { TemplateItem, TemplateSegment } from "@/api/templates"
|
||||||
|
import {
|
||||||
|
gradientForCategory,
|
||||||
|
getTypeColor,
|
||||||
|
formatDuration,
|
||||||
|
formatConfig,
|
||||||
|
getMaterialTypeLabel,
|
||||||
|
calcTotalSegmentDuration,
|
||||||
|
} from "../../utils/templateLibrary"
|
||||||
|
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||||
|
|
||||||
|
interface TemplateDetailModalProps {
|
||||||
|
template: TemplateItem
|
||||||
|
isFavorite: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToggleFavorite: (id: string) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
onCopy: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||||
|
template,
|
||||||
|
isFavorite,
|
||||||
|
onClose,
|
||||||
|
onToggleFavorite,
|
||||||
|
onUse,
|
||||||
|
onCopy,
|
||||||
|
}) => {
|
||||||
|
const segments = template.segments ?? []
|
||||||
|
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="xx-template-modal xx-template-modal-wide"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 预览区域 */}
|
||||||
|
<div
|
||||||
|
className="xx-template-modal-preview"
|
||||||
|
style={{ background: gradientForCategory(template.category) }}
|
||||||
|
>
|
||||||
|
{template.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={template.thumbnail_url}
|
||||||
|
alt={template.name}
|
||||||
|
className="xx-template-modal-thumb-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="xx-template-modal-preview-content">
|
||||||
|
<span className="xx-template-preview-icon">
|
||||||
|
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
||||||
|
</span>
|
||||||
|
<span className="xx-template-preview-title">{template.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="xx-template-modal-content">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="xx-template-modal-title-row">
|
||||||
|
<h3>{template.name}</h3>
|
||||||
|
<span
|
||||||
|
className="xx-template-modal-type-badge"
|
||||||
|
style={{
|
||||||
|
color: getTypeColor(template.category),
|
||||||
|
background: `${getTypeColor(template.category)}18`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="xx-template-modal-desc">{template.description}</p>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{(template.tags?.length ?? 0) > 0 && (
|
||||||
|
<div className="xx-template-modal-tags">
|
||||||
|
{template.tags!.map((tag) => (
|
||||||
|
<span key={tag} className="xx-template-modal-tag">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<Descriptions
|
||||||
|
column={2}
|
||||||
|
size="small"
|
||||||
|
className="xx-template-modal-desc-table"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: "目标时长",
|
||||||
|
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "clips",
|
||||||
|
label: "片段数量",
|
||||||
|
children: `${template.clip_count} 个`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ratio",
|
||||||
|
label: "视频比例",
|
||||||
|
children: template.aspect_ratio ?? "16:9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "usage",
|
||||||
|
label: "使用次数",
|
||||||
|
children: `${template.usage_count ?? 0} 次`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 素材规则(片段配置) */}
|
||||||
|
{segments.length > 0 && (
|
||||||
|
<div className="xx-template-modal-section">
|
||||||
|
<h4>🎬 素材规则</h4>
|
||||||
|
<div className="xx-template-modal-clip-list">
|
||||||
|
{segments
|
||||||
|
.sort((a, b) => a.segment_order - b.segment_order)
|
||||||
|
.map((seg: TemplateSegment, idx: number) => (
|
||||||
|
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||||
|
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||||
|
<span
|
||||||
|
className="xx-template-modal-clip-badge"
|
||||||
|
style={{
|
||||||
|
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||||
|
background: seg.material_type
|
||||||
|
? `${getTypeColor(seg.material_type)}18`
|
||||||
|
: "#f1f5f9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getMaterialTypeLabel(seg.material_type)}
|
||||||
|
</span>
|
||||||
|
<span className="xx-template-modal-clip-desc">
|
||||||
|
{seg.description || `片段 ${seg.segment_order}`}
|
||||||
|
</span>
|
||||||
|
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||||
|
<span className="xx-template-modal-clip-duration">
|
||||||
|
{seg.duration_min}-{seg.duration_max}秒
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-modal-total-duration">
|
||||||
|
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 样式配置 */}
|
||||||
|
<div className="xx-template-modal-section">
|
||||||
|
<h4>🎨 样式配置</h4>
|
||||||
|
<div className="xx-template-modal-style-grid">
|
||||||
|
<div className="xx-template-modal-style-item">
|
||||||
|
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||||
|
<span className="xx-template-modal-style-value">
|
||||||
|
{formatConfig(template.subtitle_config)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-modal-style-item">
|
||||||
|
<span className="xx-template-modal-style-label">标题样式</span>
|
||||||
|
<span className="xx-template-modal-style-value">
|
||||||
|
{formatConfig(template.title_config)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-modal-style-item">
|
||||||
|
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||||
|
<span className="xx-template-modal-style-value">
|
||||||
|
{formatConfig(template.bgm_config)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-modal-style-item">
|
||||||
|
<span className="xx-template-modal-style-label">视频比例</span>
|
||||||
|
<span className="xx-template-modal-style-value">
|
||||||
|
{template.aspect_ratio ?? "16:9"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="xx-template-modal-stats">
|
||||||
|
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||||
|
<button
|
||||||
|
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||||
|
onClick={() => onToggleFavorite(template.id)}
|
||||||
|
>
|
||||||
|
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="xx-template-modal-actions">
|
||||||
|
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||||
|
复制模板
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||||
|
使用此模板生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Pagination } from "antd"
|
||||||
|
import { InboxOutlined, LoadingOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||||
|
import { TemplateCard } from "./TemplateCard"
|
||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
|
||||||
|
interface TemplateGridProps {
|
||||||
|
templates: TemplateItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
searchText: string
|
||||||
|
activeType: string
|
||||||
|
durationRange: string
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
onPreview: (template: TemplateItem) => void
|
||||||
|
onToggleFavorite: (id: string, e: React.MouseEvent) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateGrid: React.FC<TemplateGridProps> = ({
|
||||||
|
templates,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
errorMessage,
|
||||||
|
searchText,
|
||||||
|
activeType,
|
||||||
|
durationRange,
|
||||||
|
onPageChange,
|
||||||
|
onPreview,
|
||||||
|
onToggleFavorite,
|
||||||
|
onUse,
|
||||||
|
}) => {
|
||||||
|
// Loading 状态
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="xx-templates-empty">
|
||||||
|
<div className="xx-templates-empty-icon">
|
||||||
|
<LoadingOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>加载模板中...</h3>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 状态
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="xx-templates-empty">
|
||||||
|
<div className="xx-templates-empty-icon">
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>加载失败</h3>
|
||||||
|
<p>{errorMessage || "网络异常,请稍后重试"}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
if (templates.length === 0) {
|
||||||
|
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
|
||||||
|
return (
|
||||||
|
<div className="xx-templates-empty">
|
||||||
|
<div className="xx-templates-empty-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</div>
|
||||||
|
<h3>{hasFilter ? "未找到匹配的模板" : "暂无模板"}</h3>
|
||||||
|
<p>{hasFilter ? "试试调整搜索条件或切换类型" : "点击上方「创建模板」开始创作"}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="xx-templates-grid">
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<TemplateCard
|
||||||
|
key={tpl.id}
|
||||||
|
template={tpl}
|
||||||
|
isFavorite={tpl.is_favorite ?? false}
|
||||||
|
onPreview={onPreview}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onUse={onUse}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{total > pageSize && (
|
||||||
|
<div className="xx-templates-pagination">
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showQuickJumper
|
||||||
|
showTotal={(t) => `共 ${t} 个模板`}
|
||||||
|
onChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "antd"
|
||||||
|
|
||||||
|
interface TemplateHeaderProps {
|
||||||
|
onCreateClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateHeader: React.FC<TemplateHeaderProps> = ({ onCreateClick }) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-templates-header">
|
||||||
|
<div className="xx-templates-header-text">
|
||||||
|
<h2>模板库</h2>
|
||||||
|
<p>选择模板快速创建,支持自定义修改</p>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" onClick={onCreateClick}>
|
||||||
|
+ 创建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { SearchOutlined } from "@ant-design/icons"
|
||||||
|
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
|
||||||
|
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
|
||||||
|
|
||||||
|
interface TemplateToolbarProps {
|
||||||
|
searchText: string
|
||||||
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
activeType: EditTemplateType | "全部"
|
||||||
|
onTypeChange: (type: EditTemplateType | "全部") => void
|
||||||
|
durationRange: DurationRange
|
||||||
|
onDurationChange: (value: DurationRange) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||||
|
searchText,
|
||||||
|
onSearchChange,
|
||||||
|
activeType,
|
||||||
|
onTypeChange,
|
||||||
|
durationRange,
|
||||||
|
onDurationChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-templates-toolbar">
|
||||||
|
<div className="xx-templates-search">
|
||||||
|
<span className="xx-templates-search-icon">
|
||||||
|
<SearchOutlined />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="xx-templates-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索模板名称、描述或标签..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="xx-templates-categories">
|
||||||
|
{TEMPLATE_TYPES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.type}
|
||||||
|
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||||
|
onClick={() => onTypeChange(cat.type)}
|
||||||
|
>
|
||||||
|
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||||
|
{cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 时长筛选 */}
|
||||||
|
<div className="xx-templates-duration-filter">
|
||||||
|
{DURATION_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||||
|
onClick={() => onDurationChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
|
||||||
|
|
||||||
|
export const TEMPLATE_TYPES: Array<{
|
||||||
|
type: EditTemplateType | "全部"
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
}> = [
|
||||||
|
{ type: "全部", label: "全部", icon: "📋", color: "#6366f1" },
|
||||||
|
{ type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" },
|
||||||
|
{ type: "种草", label: "种草", icon: "🌱", color: "#10b981" },
|
||||||
|
{ type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" },
|
||||||
|
{ type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" },
|
||||||
|
{ type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" },
|
||||||
|
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DURATION_OPTIONS: Array<{
|
||||||
|
value: DurationRange
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: "", label: "全部时长" },
|
||||||
|
{ value: "short", label: "30秒以内" },
|
||||||
|
{ value: "medium", label: "30秒-2分钟" },
|
||||||
|
{ value: "long", label: "2分钟以上" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
video: "视频",
|
||||||
|
image: "图片",
|
||||||
|
audio: "音频",
|
||||||
|
voiceover: "配音",
|
||||||
|
subtitle: "字幕",
|
||||||
|
null: "不限",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 12
|
||||||
|
export const CATEGORY_GRADIENT_MAP: Record<string, string> = {
|
||||||
|
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||||
|
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||||
|
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||||
|
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||||
|
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||||
|
}
|
||||||
|
export const DEFAULT_GRADIENT = "linear-gradient(135deg, #6366f1, #8b5cf6)"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { getTemplate, type TemplateItem } from "@/api/templates"
|
||||||
|
|
||||||
|
interface UseTemplateDetailProps {
|
||||||
|
onToggleFavorite: (id: string) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
onCopy: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTemplateDetail = ({ onToggleFavorite, onUse, onCopy }: UseTemplateDetailProps) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
/* 弹窗状态 */
|
||||||
|
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(null)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
|
||||||
|
/* 点击卡片 → 获取详情并展示弹窗 */
|
||||||
|
const handlePreview = useCallback(async (template: TemplateItem) => {
|
||||||
|
setDetailLoading(true)
|
||||||
|
setPreviewTemplate(template)
|
||||||
|
try {
|
||||||
|
const detail = await getTemplate(template.id)
|
||||||
|
setPreviewTemplate(detail)
|
||||||
|
} catch {
|
||||||
|
message.warning("模板详情加载失败,显示摘要信息")
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 关闭弹窗 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setPreviewTemplate(null)
|
||||||
|
setDetailLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 收藏切换(同时更新预览模板的状态) */
|
||||||
|
const handleToggleFavorite = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onToggleFavorite(id)
|
||||||
|
/* 乐观更新详情弹窗的收藏状态 */
|
||||||
|
setPreviewTemplate((prev) =>
|
||||||
|
prev && prev.id === id ? { ...prev, is_favorite: !prev.is_favorite } : prev,
|
||||||
|
)
|
||||||
|
/* 刷新列表缓存 */
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["templates"] })
|
||||||
|
},
|
||||||
|
[onToggleFavorite, queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewTemplate,
|
||||||
|
detailLoading,
|
||||||
|
handlePreview,
|
||||||
|
handleClose,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleUse: onUse,
|
||||||
|
handleCopy: onCopy,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useMemo, useCallback } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
getTemplates,
|
||||||
|
toggleFavoriteTemplate,
|
||||||
|
copyTemplate,
|
||||||
|
type TemplateItem,
|
||||||
|
type TemplateListParams,
|
||||||
|
} from "@/api/templates"
|
||||||
|
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
|
||||||
|
import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary"
|
||||||
|
|
||||||
|
export const useTemplateLibrary = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
/* 筛选状态 */
|
||||||
|
const [searchText, setSearchText] = useState("")
|
||||||
|
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
|
||||||
|
const [durationRange, setDurationRange] = useState<DurationRange>("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
|
|
||||||
|
/* 构建查询参数 */
|
||||||
|
const queryParams: TemplateListParams = useMemo(() => {
|
||||||
|
const params: TemplateListParams = {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}
|
||||||
|
if (activeType !== "全部") params.category = activeType
|
||||||
|
if (searchText.trim()) params.keyword = searchText.trim()
|
||||||
|
if (durationRange) params.duration_range = durationRange
|
||||||
|
return params
|
||||||
|
}, [page, pageSize, activeType, searchText, durationRange])
|
||||||
|
|
||||||
|
/* 获取模板列表 */
|
||||||
|
const {
|
||||||
|
data: templateData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["templates", queryParams],
|
||||||
|
queryFn: () => getTemplates(queryParams),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const templates = templateData?.items ?? []
|
||||||
|
const totalTemplates = templateData?.total ?? 0
|
||||||
|
|
||||||
|
/* 收藏 mutation */
|
||||||
|
const favMutation = useMutation({
|
||||||
|
mutationFn: toggleFavoriteTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["templates"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 复制模板 mutation */
|
||||||
|
const copyMutation = useMutation({
|
||||||
|
mutationFn: copyTemplate,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
message.success(`模板「${data.name}」已复制到「我的模板」`)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["templates"] })
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("复制模板失败,请稍后重试")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 操作:切换收藏 */
|
||||||
|
const toggleFavorite = useCallback(
|
||||||
|
(id: string, e?: React.MouseEvent) => {
|
||||||
|
e?.stopPropagation()
|
||||||
|
favMutation.mutate(id)
|
||||||
|
},
|
||||||
|
[favMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* 操作:复制模板 */
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
(template: TemplateItem) => {
|
||||||
|
copyMutation.mutate(template.id)
|
||||||
|
},
|
||||||
|
[copyMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* 操作:使用模板 → 跳转剪辑编辑器 */
|
||||||
|
const handleUse = useCallback(
|
||||||
|
(template: TemplateItem) => {
|
||||||
|
navigate(`/app/editing-planner?templateId=${template.id}`)
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* 操作:创建模板 */
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
navigate("/app/editing-planner")
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
/* 搜索 */
|
||||||
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchText(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 切换分类 */
|
||||||
|
const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => {
|
||||||
|
setActiveType(type)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* 切换时长筛选 */
|
||||||
|
const handleDurationChange = useCallback((value: DurationRange) => {
|
||||||
|
setDurationRange(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
/* 状态 */
|
||||||
|
templates,
|
||||||
|
totalTemplates,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
searchText,
|
||||||
|
activeType,
|
||||||
|
durationRange,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
/* mutations */
|
||||||
|
favMutation,
|
||||||
|
copyMutation,
|
||||||
|
/* setters */
|
||||||
|
setPage,
|
||||||
|
/* handlers */
|
||||||
|
toggleFavorite,
|
||||||
|
handleCopy,
|
||||||
|
handleUse,
|
||||||
|
handleCreate,
|
||||||
|
handleSearchChange,
|
||||||
|
handleCategoryChange,
|
||||||
|
handleDurationChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** 模板类型 */
|
||||||
|
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
|
||||||
|
|
||||||
|
/** 时长筛选值 */
|
||||||
|
export type DurationRange = "" | "short" | "medium" | "long"
|
||||||
|
|
||||||
|
/** 配置展示字段 */
|
||||||
|
export interface ConfigDisplayFields {
|
||||||
|
font_size?: string | number
|
||||||
|
font_family?: string
|
||||||
|
color?: string
|
||||||
|
position?: string
|
||||||
|
volume?: string | number
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ConfigDisplayFields } from "../types/templateLibrary"
|
||||||
|
import {
|
||||||
|
TEMPLATE_TYPES,
|
||||||
|
CATEGORY_GRADIENT_MAP,
|
||||||
|
DEFAULT_GRADIENT,
|
||||||
|
MATERIAL_TYPE_LABELS,
|
||||||
|
} from "../constants/templateLibrary"
|
||||||
|
import type { TemplateSegment } from "@/api/templates"
|
||||||
|
|
||||||
|
/** 获取类型对应颜色 */
|
||||||
|
export const getTypeColor = (type: string): string => {
|
||||||
|
const found = TEMPLATE_TYPES.find((t) => t.type === type)
|
||||||
|
return found?.color ?? "#6366f1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 category 生成占位渐变色 */
|
||||||
|
export const gradientForCategory = (category: string): string => {
|
||||||
|
return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长 */
|
||||||
|
export const formatDuration = (seconds: number | undefined | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return "0秒"
|
||||||
|
const totalSec = Math.round(seconds)
|
||||||
|
const m = Math.floor(totalSec / 60)
|
||||||
|
const s = totalSec % 60
|
||||||
|
if (m === 0) return `${s}秒`
|
||||||
|
return `${m}分${s > 0 ? `${s}秒` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化配置对象为可读文本 */
|
||||||
|
export const formatConfig = (config?: object): string => {
|
||||||
|
if (!config || Object.keys(config).length === 0) return "默认"
|
||||||
|
const c = config as ConfigDisplayFields
|
||||||
|
const parts: string[] = []
|
||||||
|
if (c.font_size) parts.push(`字号: ${c.font_size}`)
|
||||||
|
if (c.font_family) parts.push(`字体: ${c.font_family}`)
|
||||||
|
if (c.color) parts.push(`颜色: ${c.color}`)
|
||||||
|
if (c.position) parts.push(`位置: ${c.position}`)
|
||||||
|
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`)
|
||||||
|
if (c.name) parts.push(String(c.name))
|
||||||
|
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取素材类型标签文本 */
|
||||||
|
export const getMaterialTypeLabel = (materialType: string | null | undefined): string => {
|
||||||
|
if (!materialType) return "不限"
|
||||||
|
return MATERIAL_TYPE_LABELS[materialType] ?? materialType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算片段总时长(取每个片段 min/max 的平均值) */
|
||||||
|
export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => {
|
||||||
|
return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0)
|
||||||
|
}
|
||||||
@@ -115,6 +115,16 @@ vi.mock("@/api/templates", () => ({
|
|||||||
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
|
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
|
||||||
|
|
||||||
import TemplateLibrary from "@/pages/templates/TemplateLibrary"
|
import TemplateLibrary from "@/pages/templates/TemplateLibrary"
|
||||||
|
import "@/pages/templates/types/templateLibrary"
|
||||||
|
import "@/pages/templates/constants/templateLibrary"
|
||||||
|
import "@/pages/templates/utils/templateLibrary"
|
||||||
|
import "@/pages/templates/hooks/useTemplateLibrary"
|
||||||
|
import "@/pages/templates/hooks/useTemplateDetail"
|
||||||
|
import "@/pages/templates/components/template-library/TemplateCard"
|
||||||
|
import "@/pages/templates/components/template-library/TemplateDetailModal"
|
||||||
|
import "@/pages/templates/components/template-library/TemplateHeader"
|
||||||
|
import "@/pages/templates/components/template-library/TemplateToolbar"
|
||||||
|
import "@/pages/templates/components/template-library/TemplateGrid"
|
||||||
|
|
||||||
describe("TemplateLibrary Page", () => {
|
describe("TemplateLibrary Page", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
|
|||||||
@@ -2,129 +2,33 @@
|
|||||||
|
|
||||||
支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。
|
支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。
|
||||||
|
|
||||||
使用方式:
|
注:核心领域模型已抽离到 packages/domain/chroma_key_config.py,
|
||||||
config = ChromaKeyConfig(key_color="#00FF00", similarity=0.3, blend=0.1)
|
本模块保留薄包装层,确保向后兼容。
|
||||||
engine = ChromaKeyEngine(config)
|
|
||||||
filter_str = engine.build_filter(input_label, output_label)
|
|
||||||
# 结果: [in]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[out]
|
|
||||||
|
|
||||||
降级策略:
|
|
||||||
- 参数越界自动钳制
|
|
||||||
- 素材格式不支持时跳过(调用方捕获异常)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from packages.domain.chroma_key_config import (
|
||||||
|
CHROMA_KEY_PRESETS,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
apply_chroma_key_if_needed,
|
||||||
|
)
|
||||||
|
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
|
||||||
|
build_chromakey_filter as _build_chromakey_filter_base,
|
||||||
|
)
|
||||||
|
from packages.domain.chroma_key_config import build_colorkey_filter as _build_colorkey_filter_base
|
||||||
|
from packages.domain.chroma_key_config import normalize_color as _normalize_color_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChromaKeyConfig:
|
|
||||||
"""绿幕抠像配置。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
enabled: 是否启用抠像
|
|
||||||
key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名
|
|
||||||
similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大
|
|
||||||
blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和
|
|
||||||
spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
key_color: str = "#00FF00"
|
|
||||||
similarity: float = 0.3
|
|
||||||
blend: float = 0.1
|
|
||||||
spill_suppress: float = 0.0
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict | None) -> "ChromaKeyConfig":
|
|
||||||
"""从字典解析配置,参数越界自动钳制。"""
|
|
||||||
if not data or not data.get("enabled", False):
|
|
||||||
return cls(enabled=False)
|
|
||||||
|
|
||||||
key_color = str(data.get("key_color", "#00FF00")).strip()
|
|
||||||
|
|
||||||
def _safe_float(val, default):
|
|
||||||
try:
|
|
||||||
return float(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
similarity = _safe_float(data.get("similarity", 0.3), 0.3)
|
|
||||||
blend = _safe_float(data.get("blend", 0.1), 0.1)
|
|
||||||
spill_suppress = _safe_float(data.get("spill_suppress", 0.0), 0.0)
|
|
||||||
|
|
||||||
# 钳制到合法范围
|
|
||||||
similarity = max(0.01, min(1.0, similarity))
|
|
||||||
blend = max(0.0, min(1.0, blend))
|
|
||||||
spill_suppress = max(0.0, min(1.0, spill_suppress))
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
enabled=True,
|
|
||||||
key_color=key_color,
|
|
||||||
similarity=similarity,
|
|
||||||
blend=blend,
|
|
||||||
spill_suppress=spill_suppress,
|
|
||||||
)
|
|
||||||
|
|
||||||
def has_effect(self) -> bool:
|
|
||||||
"""判断是否有实际抠像效果。"""
|
|
||||||
return self.enabled and self.similarity > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── 预设配置 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# 常见绿幕/蓝幕预设
|
|
||||||
CHROMA_KEY_PRESETS = {
|
|
||||||
"green_screen": {
|
|
||||||
"key_color": "#00FF00",
|
|
||||||
"similarity": 0.3,
|
|
||||||
"blend": 0.1,
|
|
||||||
"spill_suppress": 0.5,
|
|
||||||
},
|
|
||||||
"blue_screen": {
|
|
||||||
"key_color": "#0000FF",
|
|
||||||
"similarity": 0.3,
|
|
||||||
"blend": 0.1,
|
|
||||||
"spill_suppress": 0.5,
|
|
||||||
},
|
|
||||||
"red_screen": {
|
|
||||||
"key_color": "#FF0000",
|
|
||||||
"similarity": 0.3,
|
|
||||||
"blend": 0.1,
|
|
||||||
"spill_suppress": 0.0,
|
|
||||||
},
|
|
||||||
"precise_green": {
|
|
||||||
"key_color": "#00FF00",
|
|
||||||
"similarity": 0.2,
|
|
||||||
"blend": 0.05,
|
|
||||||
"spill_suppress": 0.3,
|
|
||||||
},
|
|
||||||
"soft_green": {
|
|
||||||
"key_color": "#00FF00",
|
|
||||||
"similarity": 0.45,
|
|
||||||
"blend": 0.2,
|
|
||||||
"spill_suppress": 0.5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ChromaKeyEngine:
|
class ChromaKeyEngine:
|
||||||
"""绿幕抠像引擎。
|
"""绿幕抠像引擎.
|
||||||
|
|
||||||
基于 FFmpeg colorkey 滤镜实现,将指定颜色变为透明。
|
薄包装层,实际逻辑委托给 packages.domain.chroma_key_config。
|
||||||
适用于绿幕/蓝幕视频的背景去除,配合画中画或 overlay 实现虚拟背景。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: ChromaKeyConfig):
|
def __init__(self, config: ChromaKeyConfig):
|
||||||
@@ -132,117 +36,13 @@ class ChromaKeyEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_color(color_str: str) -> str:
|
def _normalize_color(color_str: str) -> str:
|
||||||
"""将颜色字符串转为 FFmpeg colorkey 接受的格式。
|
"""将颜色字符串转为 FFmpeg colorkey 接受的格式."""
|
||||||
|
return _normalize_color_base(color_str)
|
||||||
支持:
|
|
||||||
- "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB
|
|
||||||
- "0xRRGGBB" → 直接使用
|
|
||||||
- 颜色名(green/blue/red/black/white 等)→ 直接透传
|
|
||||||
"""
|
|
||||||
color = color_str.strip()
|
|
||||||
|
|
||||||
# hex 格式
|
|
||||||
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
|
|
||||||
if hex_match:
|
|
||||||
return f"0x{hex_match.group(1).upper()}"
|
|
||||||
|
|
||||||
# 已经是 0x 格式
|
|
||||||
if color.lower().startswith("0x"):
|
|
||||||
return color.upper()
|
|
||||||
|
|
||||||
# 颜色名直接透传(FFmpeg 支持常见颜色名)
|
|
||||||
return color
|
|
||||||
|
|
||||||
def build_filter(self, input_label: str, output_label: str) -> str:
|
def build_filter(self, input_label: str, output_label: str) -> str:
|
||||||
"""构建 colorkey 滤镜字符串。
|
"""构建 colorkey 滤镜字符串."""
|
||||||
|
return _build_colorkey_filter_base(self.config, input_label, output_label)
|
||||||
Args:
|
|
||||||
input_label: 输入标签,如 "[0:v]" 或 "[v0]"
|
|
||||||
output_label: 输出标签,如 "[ck0]"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FFmpeg 滤镜字符串,如 "[v0]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[ck0]"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 配置无效时抛出(调用方应捕获并降级)
|
|
||||||
"""
|
|
||||||
if not self.config.has_effect():
|
|
||||||
# 无效果,直接直通
|
|
||||||
return f"{input_label}copy{output_label}"
|
|
||||||
|
|
||||||
color = self._normalize_color(self.config.key_color)
|
|
||||||
similarity = self.config.similarity
|
|
||||||
blend = self.config.blend
|
|
||||||
|
|
||||||
# 基础 colorkey 滤镜
|
|
||||||
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
|
|
||||||
|
|
||||||
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
|
|
||||||
if self.config.spill_suppress > 0:
|
|
||||||
# 降低绿通道增益,减少绿幕反光溢出
|
|
||||||
spill = self.config.spill_suppress
|
|
||||||
# 绿通道增益 = 1 - spill_factor
|
|
||||||
g_gain = max(0.3, 1.0 - spill * 0.7)
|
|
||||||
# 同时稍微提升红和蓝来补偿色偏
|
|
||||||
r_gain = 1.0 + spill * 0.15
|
|
||||||
b_gain = 1.0 + spill * 0.15
|
|
||||||
parts.append(f"colorchannelmixer=" f"rr={r_gain}:" f"gg={g_gain}:" f"bb={b_gain}:" f"aa=1")
|
|
||||||
|
|
||||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
|
||||||
return filter_str
|
|
||||||
|
|
||||||
def build_filter_chromakey(self, input_label: str, output_label: str) -> str:
|
def build_filter_chromakey(self, input_label: str, output_label: str) -> str:
|
||||||
"""使用 chromakey 滤镜(更高级的版本,支持更多参数)。
|
"""使用 chromakey 滤镜(更高级的版本,支持更多参数)."""
|
||||||
|
return _build_chromakey_filter_base(self.config, input_label, output_label)
|
||||||
注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜,
|
|
||||||
优先使用 colorkey(兼容性更好)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_label: 输入标签
|
|
||||||
output_label: 输出标签
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FFmpeg 滤镜字符串
|
|
||||||
"""
|
|
||||||
if not self.config.has_effect():
|
|
||||||
return f"{input_label}copy{output_label}"
|
|
||||||
|
|
||||||
color = self._normalize_color(self.config.key_color)
|
|
||||||
similarity = self.config.similarity
|
|
||||||
blend = self.config.blend
|
|
||||||
|
|
||||||
return f"{input_label}" f"chromakey=color={color}:similarity={similarity}:blend={blend}" f"{output_label}"
|
|
||||||
|
|
||||||
|
|
||||||
def apply_chroma_key_if_needed(
|
|
||||||
clip_config: dict | None,
|
|
||||||
input_label: str,
|
|
||||||
output_label: str,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clip_config: clip 的 config 字典
|
|
||||||
input_label: 输入标签
|
|
||||||
output_label: 输出标签
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
滤镜字符串,不需要抠像时返回 None
|
|
||||||
"""
|
|
||||||
if not clip_config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
chroma_key_data = clip_config.get("chroma_key")
|
|
||||||
if not chroma_key_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = ChromaKeyConfig.from_dict(chroma_key_data)
|
|
||||||
if not config.has_effect():
|
|
||||||
return None
|
|
||||||
|
|
||||||
engine = ChromaKeyEngine(config)
|
|
||||||
return engine.build_filter(input_label, output_label)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -10,233 +10,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from packages.domain.color_grade_config import ( # noqa: F401 — 向后兼容
|
||||||
|
DEFAULT_PARAMS,
|
||||||
|
PARAM_RANGES,
|
||||||
# ── 预设滤镜包 ────────────────────────────────────────────────────────────────
|
PRESET_BW,
|
||||||
|
PRESET_CINEMA,
|
||||||
# 预设名称常量
|
PRESET_COOL,
|
||||||
PRESET_FRESH = "fresh" # 清新
|
PRESET_DISPLAY_NAMES,
|
||||||
PRESET_JAPANESE = "japanese" # 日系
|
PRESET_FILM,
|
||||||
PRESET_VINTAGE = "vintage" # 复古
|
|
||||||
PRESET_CINEMA = "cinema" # 电影
|
|
||||||
PRESET_FILM = "film" # 胶片
|
|
||||||
PRESET_BW = "black_white" # 黑白
|
|
||||||
PRESET_WARM = "warm" # 暖色
|
|
||||||
PRESET_COOL = "cool" # 冷色
|
|
||||||
|
|
||||||
VALID_PRESETS = {
|
|
||||||
PRESET_FRESH,
|
PRESET_FRESH,
|
||||||
PRESET_JAPANESE,
|
PRESET_JAPANESE,
|
||||||
|
PRESET_PARAMS,
|
||||||
PRESET_VINTAGE,
|
PRESET_VINTAGE,
|
||||||
PRESET_CINEMA,
|
|
||||||
PRESET_FILM,
|
|
||||||
PRESET_BW,
|
|
||||||
PRESET_WARM,
|
PRESET_WARM,
|
||||||
PRESET_COOL,
|
VALID_PRESETS,
|
||||||
}
|
ColorGradeConfig,
|
||||||
|
clamp_param,
|
||||||
|
get_preset_names,
|
||||||
|
get_preset_params,
|
||||||
|
)
|
||||||
|
|
||||||
# 预设名称 → 中文显示名
|
logger = logging.getLogger(__name__)
|
||||||
PRESET_DISPLAY_NAMES = {
|
|
||||||
PRESET_FRESH: "清新",
|
|
||||||
PRESET_JAPANESE: "日系",
|
|
||||||
PRESET_VINTAGE: "复古",
|
|
||||||
PRESET_CINEMA: "电影",
|
|
||||||
PRESET_FILM: "胶片",
|
|
||||||
PRESET_BW: "黑白",
|
|
||||||
PRESET_WARM: "暖色",
|
|
||||||
PRESET_COOL: "冷色",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 预设参数配置
|
|
||||||
# 每个预设包含:brightness, contrast, saturation, temperature, hue
|
|
||||||
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
|
|
||||||
PRESET_PARAMS: dict[str, dict[str, float]] = {
|
|
||||||
PRESET_FRESH: {
|
|
||||||
# 清新:提亮、高饱和、偏冷、微微调
|
|
||||||
"brightness": 8,
|
|
||||||
"contrast": 10,
|
|
||||||
"saturation": 120,
|
|
||||||
"temperature": -8,
|
|
||||||
"hue": 5,
|
|
||||||
},
|
|
||||||
PRESET_JAPANESE: {
|
|
||||||
# 日系:低对比、低饱和、偏暖、偏黄绿
|
|
||||||
"brightness": 12,
|
|
||||||
"contrast": -15,
|
|
||||||
"saturation": 70,
|
|
||||||
"temperature": 10,
|
|
||||||
"hue": -5,
|
|
||||||
},
|
|
||||||
PRESET_VINTAGE: {
|
|
||||||
# 复古:低饱和、偏黄、对比度适中、偏暖
|
|
||||||
"brightness": -5,
|
|
||||||
"contrast": 5,
|
|
||||||
"saturation": 60,
|
|
||||||
"temperature": 25,
|
|
||||||
"hue": -8,
|
|
||||||
},
|
|
||||||
PRESET_CINEMA: {
|
|
||||||
# 电影:高对比、低饱和、偏冷蓝、暗角感
|
|
||||||
"brightness": -8,
|
|
||||||
"contrast": 20,
|
|
||||||
"saturation": 75,
|
|
||||||
"temperature": -15,
|
|
||||||
"hue": -3,
|
|
||||||
},
|
|
||||||
PRESET_FILM: {
|
|
||||||
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
|
|
||||||
"brightness": -3,
|
|
||||||
"contrast": 12,
|
|
||||||
"saturation": 95,
|
|
||||||
"temperature": 15,
|
|
||||||
"hue": -2,
|
|
||||||
},
|
|
||||||
PRESET_BW: {
|
|
||||||
# 黑白:饱和度为0,对比度略高
|
|
||||||
"brightness": 0,
|
|
||||||
"contrast": 15,
|
|
||||||
"saturation": 0,
|
|
||||||
"temperature": 0,
|
|
||||||
"hue": 0,
|
|
||||||
},
|
|
||||||
PRESET_WARM: {
|
|
||||||
# 暖色:高色温、偏红黄
|
|
||||||
"brightness": 5,
|
|
||||||
"contrast": 8,
|
|
||||||
"saturation": 110,
|
|
||||||
"temperature": 30,
|
|
||||||
"hue": -5,
|
|
||||||
},
|
|
||||||
PRESET_COOL: {
|
|
||||||
# 冷色:低色温、偏蓝青
|
|
||||||
"brightness": 3,
|
|
||||||
"contrast": 8,
|
|
||||||
"saturation": 105,
|
|
||||||
"temperature": -25,
|
|
||||||
"hue": 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 参数范围 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
PARAM_RANGES = {
|
|
||||||
"brightness": (-100.0, 100.0),
|
|
||||||
"contrast": (-100.0, 100.0),
|
|
||||||
"saturation": (0.0, 200.0),
|
|
||||||
"temperature": (-100.0, 100.0),
|
|
||||||
"hue": (-180.0, 180.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 默认值(零调整)
|
|
||||||
DEFAULT_PARAMS = {
|
|
||||||
"brightness": 0.0,
|
|
||||||
"contrast": 0.0,
|
|
||||||
"saturation": 100.0,
|
|
||||||
"temperature": 0.0,
|
|
||||||
"hue": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorGradeConfig:
|
|
||||||
"""色彩调色配置.
|
|
||||||
|
|
||||||
优先级:自定义参数 > 预设参数
|
|
||||||
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
preset: str = "" # 预设名称,空表示不使用预设
|
|
||||||
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
|
|
||||||
brightness: float | None = None
|
|
||||||
contrast: float | None = None
|
|
||||||
saturation: float | None = None
|
|
||||||
temperature: float | None = None
|
|
||||||
hue: float | None = None
|
|
||||||
|
|
||||||
def resolve_params(self) -> dict[str, float]:
|
|
||||||
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
包含 brightness, contrast, saturation, temperature, hue 的参数字典
|
|
||||||
"""
|
|
||||||
# 1. 从默认值开始
|
|
||||||
params = dict(DEFAULT_PARAMS)
|
|
||||||
|
|
||||||
# 2. 应用预设
|
|
||||||
if self.preset and self.preset in PRESET_PARAMS:
|
|
||||||
params.update(PRESET_PARAMS[self.preset])
|
|
||||||
|
|
||||||
# 3. 应用自定义覆盖
|
|
||||||
if self.brightness is not None:
|
|
||||||
params["brightness"] = self.brightness
|
|
||||||
if self.contrast is not None:
|
|
||||||
params["contrast"] = self.contrast
|
|
||||||
if self.saturation is not None:
|
|
||||||
params["saturation"] = self.saturation
|
|
||||||
if self.temperature is not None:
|
|
||||||
params["temperature"] = self.temperature
|
|
||||||
if self.hue is not None:
|
|
||||||
params["hue"] = self.hue
|
|
||||||
|
|
||||||
# 4. 边界钳制
|
|
||||||
for key, (min_val, max_val) in PARAM_RANGES.items():
|
|
||||||
params[key] = max(min_val, min(max_val, params[key]))
|
|
||||||
|
|
||||||
return params
|
|
||||||
|
|
||||||
def has_effect(self) -> bool:
|
|
||||||
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
|
|
||||||
|
|
||||||
用于优化:无效果时跳过滤镜,不浪费性能。
|
|
||||||
"""
|
|
||||||
params = self.resolve_params()
|
|
||||||
for key, default in DEFAULT_PARAMS.items():
|
|
||||||
if abs(params[key] - default) > 0.001:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
|
|
||||||
"""从字典解析配置."""
|
|
||||||
if not data or not data.get("enabled", False):
|
|
||||||
return cls(enabled=False)
|
|
||||||
|
|
||||||
preset = data.get("preset", "")
|
|
||||||
if preset and preset not in VALID_PRESETS:
|
|
||||||
logger.warning("未知的调色预设: %s,忽略预设", preset)
|
|
||||||
preset = ""
|
|
||||||
|
|
||||||
def _get_float(key: str) -> float | None:
|
|
||||||
val = data.get(key)
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(val)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return cls(
|
|
||||||
enabled=True,
|
|
||||||
preset=preset,
|
|
||||||
brightness=_get_float("brightness"),
|
|
||||||
contrast=_get_float("contrast"),
|
|
||||||
saturation=_get_float("saturation"),
|
|
||||||
temperature=_get_float("temperature"),
|
|
||||||
hue=_get_float("hue"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("调色配置解析失败: %s,使用默认配置", e)
|
|
||||||
return cls(enabled=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
|
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,135 +18,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
||||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||||
|
|
||||||
|
from packages.domain.video_concat import ( # noqa: F401 向后兼容导出
|
||||||
|
ALLOWED_VIDEO_EXTENSIONS,
|
||||||
|
CONCAT_DEMUXER_REQUIRED_PARAMS,
|
||||||
|
MAX_CONCAT_SEGMENTS,
|
||||||
|
ConcatConfig,
|
||||||
|
ConcatSegment,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
|
|
||||||
|
|
||||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
|
|
||||||
|
|
||||||
# concat demuxer 要求一致的参数列表
|
|
||||||
CONCAT_DEMUXER_REQUIRED_PARAMS = [
|
|
||||||
"codec_name", # 视频编码
|
|
||||||
"width", # 宽度
|
|
||||||
"height", # 高度
|
|
||||||
"r_frame_rate", # 帧率
|
|
||||||
"pix_fmt", # 像素格式
|
|
||||||
"sample_rate", # 音频采样率
|
|
||||||
"channels", # 音频声道数
|
|
||||||
"audio_codec", # 音频编码
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConcatSegment:
|
|
||||||
"""单个拼接片段."""
|
|
||||||
|
|
||||||
video_path: str # 视频文件路径
|
|
||||||
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
|
|
||||||
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
|
|
||||||
has_audio: bool = True # 是否包含音频
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, seg: dict) -> "ConcatSegment":
|
|
||||||
"""从字典创建拼接片段,带安全类型转换."""
|
|
||||||
try:
|
|
||||||
start_time = max(0.0, float(seg.get("start_time", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
start_time = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
duration = max(0.0, float(seg.get("duration", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
duration = 0.0
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
video_path=str(seg.get("video_path", "")),
|
|
||||||
start_time=start_time,
|
|
||||||
duration=duration,
|
|
||||||
has_audio=bool(seg.get("has_audio", True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConcatConfig:
|
|
||||||
"""视频拼接配置."""
|
|
||||||
|
|
||||||
segments: list[ConcatSegment] = field(default_factory=list)
|
|
||||||
output_width: int = 0 # 输出宽度(0=自动取第一段)
|
|
||||||
output_height: int = 0 # 输出高度(0=自动取第一段)
|
|
||||||
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
|
|
||||||
force_reencode: bool = False # 强制重新编码(不用 stream copy)
|
|
||||||
transition: str = "none" # 转场效果(none/crossfade)- 预留
|
|
||||||
transition_duration: float = 0.3 # 转场时长
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
|
|
||||||
"""从配置字典创建 ConcatConfig."""
|
|
||||||
if not config or not isinstance(config, dict):
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
segments_raw = config.get("segments", [])
|
|
||||||
segments: list[ConcatSegment] = []
|
|
||||||
|
|
||||||
if isinstance(segments_raw, list):
|
|
||||||
for s in segments_raw:
|
|
||||||
if isinstance(s, dict) and s.get("video_path"):
|
|
||||||
try:
|
|
||||||
seg = ConcatSegment.from_dict(s)
|
|
||||||
if seg.video_path:
|
|
||||||
segments.append(seg)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("[concat] skip invalid segment: %s", s)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_width = max(0, int(config.get("output_width", 0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
output_width = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_height = max(0, int(config.get("output_height", 0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
output_height = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
output_fps = 0.0
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
segments=segments,
|
|
||||||
output_width=output_width,
|
|
||||||
output_height=output_height,
|
|
||||||
output_fps=output_fps,
|
|
||||||
force_reencode=bool(config.get("force_reencode", False)),
|
|
||||||
transition=str(config.get("transition", "none")),
|
|
||||||
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_effect(self) -> bool:
|
|
||||||
"""是否有有效片段需要拼接."""
|
|
||||||
return len([s for s in self.segments if s.video_path]) >= 2
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_segments(self) -> int:
|
|
||||||
"""有效片段数量."""
|
|
||||||
return len([s for s in self.segments if s.video_path])
|
|
||||||
|
|
||||||
|
|
||||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ from shared.ffmpeg_utils import ( # noqa: F401
|
|||||||
run_ffmpeg,
|
run_ffmpeg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
|
||||||
|
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
|
||||||
|
from packages.domain.xfade_builder import (
|
||||||
|
SUPPORTED_TRANSITIONS,
|
||||||
|
XFADE_TRANSITION_MAP,
|
||||||
|
XFade_TRANSITION_NAMES,
|
||||||
|
)
|
||||||
|
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
|
||||||
|
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
|
||||||
|
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
|
# ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
|
||||||
@@ -28,43 +39,34 @@ DEFAULT_OUTPUT_WIDTH = 1280
|
|||||||
DEFAULT_OUTPUT_HEIGHT = 720
|
DEFAULT_OUTPUT_HEIGHT = 720
|
||||||
DEFAULT_FPS = 25
|
DEFAULT_FPS = 25
|
||||||
|
|
||||||
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
|
# 向后兼容:DEFAULT_TRANSITION_DURATION 从 domain 层导出
|
||||||
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
|
DEFAULT_TRANSITION_DURATION = _default_transition_duration_base
|
||||||
# "cut" 为特殊值:硬切,不使用 xfade(由调用方特殊处理)
|
|
||||||
XFADE_TRANSITION_MAP: dict[str, str] = {
|
|
||||||
# 基础
|
|
||||||
"fade": "fade",
|
|
||||||
"dissolve": "dissolve",
|
|
||||||
"crossfade": "dissolve",
|
|
||||||
"crossdissolve": "dissolve",
|
|
||||||
# 滑入系列
|
|
||||||
"slideleft": "slideleft",
|
|
||||||
"slide_left": "slideleft",
|
|
||||||
"slideright": "slideright",
|
|
||||||
"slide_right": "slideright",
|
|
||||||
"slideup": "slideup",
|
|
||||||
"slide_up": "slideup",
|
|
||||||
"slidedown": "slidedown",
|
|
||||||
"slide_down": "slidedown",
|
|
||||||
"slide": "slideleft", # 默认向左滑
|
|
||||||
# 缩放
|
|
||||||
"zoom": "zoomin",
|
|
||||||
"zoomin": "zoomin",
|
|
||||||
"zoomout": "zoomout",
|
|
||||||
# 擦除系列
|
|
||||||
"wipe": "wipeleft", # 默认向左擦
|
|
||||||
"wipeleft": "wipeleft",
|
|
||||||
"wiperight": "wiperight",
|
|
||||||
"wipeup": "wipeup",
|
|
||||||
"wipedown": "wipedown",
|
|
||||||
# 特殊效果
|
|
||||||
"circlecrop": "circlecrop",
|
|
||||||
"circle": "circlecrop",
|
|
||||||
"rectcrop": "rectcrop",
|
|
||||||
"rect": "rectcrop",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_TRANSITION_DURATION = 0.5
|
|
||||||
|
# 向后兼容:薄包装函数
|
||||||
|
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
|
||||||
|
return _chain_filters_base(filters, output_label, input_label=input_label)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_xfade_transition(transition_name: Any) -> str:
|
||||||
|
return _resolve_xfade_transition_base(transition_name)
|
||||||
|
|
||||||
|
|
||||||
|
def build_xfade_filter_chain(
|
||||||
|
clip_durations: list[float],
|
||||||
|
clip_video_labels: list[str],
|
||||||
|
transitions: list[str],
|
||||||
|
*,
|
||||||
|
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||||
|
output_label: str = "outv",
|
||||||
|
) -> tuple[str, float]:
|
||||||
|
return _build_xfade_filter_chain_base(
|
||||||
|
clip_durations,
|
||||||
|
clip_video_labels,
|
||||||
|
transitions,
|
||||||
|
transition_duration=transition_duration,
|
||||||
|
output_label=output_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
|
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
|
||||||
@@ -304,111 +306,3 @@ def normalize_video(
|
|||||||
]
|
]
|
||||||
run_ffmpeg(command)
|
run_ffmpeg(command)
|
||||||
return {"width": width, "height": height, "path": output_path}
|
return {"width": width, "height": height, "path": output_path}
|
||||||
|
|
||||||
|
|
||||||
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
|
|
||||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
|
|
||||||
|
|
||||||
例:chain_filters(["scale=1280:720", "fps=25"], "v0")
|
|
||||||
→ "[0:v]scale=1280:720,fps=25[v0]"
|
|
||||||
"""
|
|
||||||
filter_body = ",".join(filters)
|
|
||||||
return f"[{input_label}]{filter_body}[{output_label}]"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_xfade_transition(transition_name: str) -> str:
|
|
||||||
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
|
|
||||||
|
|
||||||
支持 TransitionEffect 枚举值和字符串名称,未知值回退到 "fade"。
|
|
||||||
"""
|
|
||||||
# 兼容 TransitionEffect 枚举(有 .value 属性)
|
|
||||||
if hasattr(transition_name, "value"):
|
|
||||||
transition_name = transition_name.value
|
|
||||||
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
|
|
||||||
|
|
||||||
|
|
||||||
def build_xfade_filter_chain(
|
|
||||||
clip_durations: list[float],
|
|
||||||
clip_video_labels: list[str],
|
|
||||||
transitions: list[str],
|
|
||||||
*,
|
|
||||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
|
||||||
output_label: str = "outv",
|
|
||||||
) -> tuple[str, float]:
|
|
||||||
"""构建 xfade 转场滤镜链。
|
|
||||||
|
|
||||||
对每步 xfade 自动钳制 transition duration,确保
|
|
||||||
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
|
|
||||||
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1")
|
|
||||||
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
|
|
||||||
transition_duration: 转场时长(秒)
|
|
||||||
output_label: 最终输出标签
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(filter_string, estimated_total_duration)
|
|
||||||
"""
|
|
||||||
n = len(clip_durations)
|
|
||||||
parts: list[str] = []
|
|
||||||
|
|
||||||
if n == 0:
|
|
||||||
return "", 0.0
|
|
||||||
|
|
||||||
if n == 1:
|
|
||||||
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
|
|
||||||
return ";".join(parts), clip_durations[0]
|
|
||||||
|
|
||||||
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
|
|
||||||
cumulative = 0.0
|
|
||||||
prev_label = clip_video_labels[0]
|
|
||||||
total_transition = 0.0 # 累计已使用的转场时长
|
|
||||||
|
|
||||||
for i in range(1, n):
|
|
||||||
cumulative += clip_durations[i - 1]
|
|
||||||
|
|
||||||
# 当前 xfade 的第一个输入时长
|
|
||||||
if i == 1:
|
|
||||||
first_input_dur = clip_durations[0]
|
|
||||||
else:
|
|
||||||
first_input_dur = cumulative - total_transition
|
|
||||||
|
|
||||||
# 原始 offset 计算
|
|
||||||
offset = max(0.0, cumulative - transition_duration * i)
|
|
||||||
|
|
||||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
|
||||||
available = max(0.0, first_input_dur - offset)
|
|
||||||
safe_td = min(transition_duration, available)
|
|
||||||
|
|
||||||
# 同时不能超过剩余总时长
|
|
||||||
remaining = max(0.0, sum(clip_durations) - cumulative)
|
|
||||||
safe_td = min(safe_td, remaining)
|
|
||||||
# 同时不能超过当前第二个输入(单个片段)的时长
|
|
||||||
safe_td = min(safe_td, clip_durations[i])
|
|
||||||
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
|
|
||||||
|
|
||||||
transition = transitions[i] if i < len(transitions) else "cut"
|
|
||||||
xfade_transition = resolve_xfade_transition(transition)
|
|
||||||
|
|
||||||
if i == n - 1:
|
|
||||||
out_label = output_label
|
|
||||||
else:
|
|
||||||
out_label = f"xf{i}"
|
|
||||||
|
|
||||||
parts.append(
|
|
||||||
f"[{prev_label}][{clip_video_labels[i]}]"
|
|
||||||
f"xfade=transition={xfade_transition}"
|
|
||||||
f":duration={safe_td:.3f}"
|
|
||||||
f":offset={offset:.3f}"
|
|
||||||
f"[{out_label}]"
|
|
||||||
)
|
|
||||||
prev_label = out_label
|
|
||||||
total_transition += safe_td
|
|
||||||
|
|
||||||
# 总时长减去转场重叠部分
|
|
||||||
total_duration = sum(clip_durations) - total_transition
|
|
||||||
return ";".join(parts), max(0.0, total_duration)
|
|
||||||
|
|||||||
@@ -11,129 +11,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||||
|
|
||||||
|
from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容
|
||||||
|
INTRO_OUTRO_TYPE_FOLLOW,
|
||||||
|
INTRO_OUTRO_TYPE_NONE,
|
||||||
|
INTRO_OUTRO_TYPE_TEXT,
|
||||||
|
INTRO_OUTRO_TYPE_VIDEO,
|
||||||
|
TRANSITION_FADE,
|
||||||
|
IntroOutroConfig,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# ── 片头片尾引擎 ──────────────────────────────────────────────────────────────
|
||||||
class IntroOutroConfig:
|
|
||||||
"""片头片尾配置.
|
|
||||||
|
|
||||||
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
|
|
||||||
# 片头
|
|
||||||
intro_type: str = "none" # none | video | text
|
|
||||||
intro_video_path: str = "" # 视频片段路径
|
|
||||||
intro_duration: float = 3.0 # 片头时长(秒)
|
|
||||||
|
|
||||||
# 文字片头配置
|
|
||||||
intro_background: str = "#000000" # 背景色
|
|
||||||
intro_title: str = ""
|
|
||||||
intro_subtitle: str = ""
|
|
||||||
intro_title_color: str = "white"
|
|
||||||
intro_title_size: int = 48
|
|
||||||
intro_subtitle_color: str = "gray"
|
|
||||||
intro_subtitle_size: int = 24
|
|
||||||
|
|
||||||
# 片尾
|
|
||||||
outro_type: str = "none" # none | video | text | follow
|
|
||||||
outro_video_path: str = "" # 视频片段路径
|
|
||||||
outro_duration: float = 3.0 # 片尾时长(秒)
|
|
||||||
|
|
||||||
# 文字片尾配置
|
|
||||||
outro_background: str = "#000000"
|
|
||||||
outro_title: str = "感谢观看"
|
|
||||||
outro_subtitle: str = "点赞关注不迷路"
|
|
||||||
outro_title_color: str = "white"
|
|
||||||
outro_title_size: int = 48
|
|
||||||
outro_subtitle_color: str = "gray"
|
|
||||||
outro_subtitle_size: int = 24
|
|
||||||
|
|
||||||
# 转场
|
|
||||||
transition_effect: str = "fade"
|
|
||||||
transition_duration: float = 0.5
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
|
|
||||||
"""从字典构造."""
|
|
||||||
if not data:
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
enabled = data.get("enabled", False)
|
|
||||||
if not enabled:
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
intro = data.get("intro", {}) or {}
|
|
||||||
outro = data.get("outro", {}) or {}
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
enabled=True,
|
|
||||||
# 片头
|
|
||||||
intro_type=str(intro.get("type", "none")),
|
|
||||||
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
|
|
||||||
intro_duration=float(intro.get("duration", 3.0)),
|
|
||||||
intro_background=str(intro.get("background", "#000000")),
|
|
||||||
intro_title=str(intro.get("title", "") or ""),
|
|
||||||
intro_subtitle=str(intro.get("subtitle", "") or ""),
|
|
||||||
intro_title_color=str(intro.get("title_color", "white")),
|
|
||||||
intro_title_size=int(intro.get("title_size", 48)),
|
|
||||||
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
|
|
||||||
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
|
|
||||||
# 片尾
|
|
||||||
outro_type=str(outro.get("type", "none")),
|
|
||||||
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
|
|
||||||
outro_duration=float(outro.get("duration", 3.0)),
|
|
||||||
outro_background=str(outro.get("background", "#000000")),
|
|
||||||
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
|
|
||||||
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
|
|
||||||
outro_title_color=str(outro.get("title_color", "white")),
|
|
||||||
outro_title_size=int(outro.get("title_size", 48)),
|
|
||||||
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
|
|
||||||
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
|
|
||||||
# 转场
|
|
||||||
transition_effect=str(data.get("transition", "fade")),
|
|
||||||
transition_duration=float(data.get("transition_duration", 0.5)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_intro(self) -> bool:
|
|
||||||
"""是否有片头."""
|
|
||||||
return self.enabled and self.intro_type in ("video", "text")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_outro(self) -> bool:
|
|
||||||
"""是否有片尾."""
|
|
||||||
return self.enabled and self.outro_type in ("video", "text", "follow")
|
|
||||||
|
|
||||||
def validate(self) -> tuple[bool, str]:
|
|
||||||
"""校验配置."""
|
|
||||||
if not self.enabled:
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
if self.intro_type == "video" and not self.intro_video_path:
|
|
||||||
return False, "视频片头缺少 video_path"
|
|
||||||
if self.intro_type == "text" and not self.intro_title:
|
|
||||||
return False, "文字片头缺少 title"
|
|
||||||
|
|
||||||
if self.outro_type == "video" and not self.outro_video_path:
|
|
||||||
return False, "视频片尾缺少 video_path"
|
|
||||||
if self.outro_type in ("text", "follow") and not self.outro_title:
|
|
||||||
return False, "文字片尾缺少 title"
|
|
||||||
|
|
||||||
if self.intro_duration <= 0:
|
|
||||||
return False, "片头时长必须大于 0"
|
|
||||||
if self.outro_duration <= 0:
|
|
||||||
return False, "片尾时长必须大于 0"
|
|
||||||
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
class IntroOutroEngine:
|
class IntroOutroEngine:
|
||||||
|
|||||||
@@ -16,152 +16,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||||
|
|
||||||
|
from packages.domain.audio_track_config import ( # noqa: F401 — 向后兼容
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS,
|
||||||
|
DEFAULT_VOLUMES,
|
||||||
|
MAX_AUDIO_TRACKS,
|
||||||
|
TRACK_TYPE_AMBIENT,
|
||||||
|
TRACK_TYPE_BGM,
|
||||||
|
TRACK_TYPE_MAIN,
|
||||||
|
TRACK_TYPE_SFX,
|
||||||
|
TRACK_TYPE_VOICEOVER,
|
||||||
|
AudioTrack,
|
||||||
|
MultiTrackMixConfig,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from video_processing.render_audio import RenderContext
|
from video_processing.render_audio import RenderContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
|
|
||||||
TRACK_TYPE_BGM = "bgm" # 背景音乐
|
|
||||||
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
|
|
||||||
TRACK_TYPE_SFX = "sfx" # 音效
|
|
||||||
TRACK_TYPE_AMBIENT = "ambient" # 环境音
|
|
||||||
|
|
||||||
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
|
|
||||||
|
|
||||||
# 各轨道默认音量(相对主音频)
|
|
||||||
DEFAULT_VOLUMES = {
|
|
||||||
TRACK_TYPE_MAIN: 1.0,
|
|
||||||
TRACK_TYPE_BGM: 0.3,
|
|
||||||
TRACK_TYPE_VOICEOVER: 1.0,
|
|
||||||
TRACK_TYPE_SFX: 0.7,
|
|
||||||
TRACK_TYPE_AMBIENT: 0.2,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AudioTrack:
|
|
||||||
"""单条音频轨道配置."""
|
|
||||||
|
|
||||||
track_id: str # 轨道唯一标识
|
|
||||||
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient)
|
|
||||||
audio_path: str # 音频文件路径
|
|
||||||
volume: float = 1.0 # 音量 0.0 ~ 2.0
|
|
||||||
fade_in: float = 0.0 # 淡入时长(秒)
|
|
||||||
fade_out: float = 0.0 # 淡出时长(秒)
|
|
||||||
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
|
|
||||||
duration: float = 0.0 # 持续时长(0表示到文件末尾)
|
|
||||||
enabled: bool = True # 是否启用
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, track: dict) -> "AudioTrack":
|
|
||||||
"""从字典创建 AudioTrack,带安全类型转换."""
|
|
||||||
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
|
|
||||||
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
|
|
||||||
|
|
||||||
try:
|
|
||||||
volume = float(track.get("volume", default_vol))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
volume = default_vol
|
|
||||||
volume = max(0.0, min(2.0, volume))
|
|
||||||
|
|
||||||
try:
|
|
||||||
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
fade_in = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
fade_out = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
start_time = max(0.0, float(track.get("start_time", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
start_time = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
duration = max(0.0, float(track.get("duration", 0.0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
duration = 0.0
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
track_id=str(track.get("track_id", "")),
|
|
||||||
track_type=track_type,
|
|
||||||
audio_path=str(track.get("audio_path", "")),
|
|
||||||
volume=volume,
|
|
||||||
fade_in=fade_in,
|
|
||||||
fade_out=fade_out,
|
|
||||||
start_time=start_time,
|
|
||||||
duration=duration,
|
|
||||||
enabled=bool(track.get("enabled", True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MultiTrackMixConfig:
|
|
||||||
"""多轨道混音配置."""
|
|
||||||
|
|
||||||
tracks: list[AudioTrack] = field(default_factory=list)
|
|
||||||
master_volume: float = 1.0 # 主输出音量
|
|
||||||
normalize: bool = True # 是否自动归一化补偿
|
|
||||||
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
|
|
||||||
"""从 plan.config.audio_tracks 字典创建配置."""
|
|
||||||
if not config or not isinstance(config, dict):
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
tracks_raw = config.get("tracks", [])
|
|
||||||
tracks: list[AudioTrack] = []
|
|
||||||
|
|
||||||
if isinstance(tracks_raw, list):
|
|
||||||
for t in tracks_raw:
|
|
||||||
if isinstance(t, dict) and t.get("audio_path"):
|
|
||||||
try:
|
|
||||||
track = AudioTrack.from_dict(t)
|
|
||||||
if track.enabled and track.audio_path:
|
|
||||||
tracks.append(track)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("[multi-track] skip invalid track config: %s", t)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
master_volume = float(config.get("master_volume", 1.0))
|
|
||||||
master_volume = max(0.0, min(2.0, master_volume))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
master_volume = 1.0
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
tracks=tracks,
|
|
||||||
master_volume=master_volume,
|
|
||||||
normalize=bool(config.get("normalize", True)),
|
|
||||||
max_output_volume=float(config.get("max_output_volume", 1.5)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_effect(self) -> bool:
|
|
||||||
"""是否有有效轨道需要混音."""
|
|
||||||
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
||||||
"""校验音频文件路径安全性.
|
"""校验音频文件路径安全性.
|
||||||
|
|
||||||
|
|||||||
@@ -2,126 +2,28 @@
|
|||||||
|
|
||||||
支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。
|
支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。
|
||||||
|
|
||||||
使用方式:
|
领域模型已抽离至 packages/domain/noise_reduction_config.py,本模块保留薄包装以维持向后兼容。
|
||||||
config = NoiseReductionConfig(level="medium")
|
|
||||||
engine = NoiseReductionEngine(config)
|
|
||||||
filter_str = engine.build_filter(input_label, output_label)
|
|
||||||
# 结果: [0:a]afftdn=nf=-25[out]
|
|
||||||
|
|
||||||
降级策略:
|
|
||||||
- 参数越界自动钳制
|
|
||||||
- FFmpeg 不支持 afftdn 时,调用方可捕获异常并跳过
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
from packages.domain.noise_reduction_config import (
|
||||||
from typing import Optional
|
NoiseReductionConfig,
|
||||||
|
NoiseReductionLevel,
|
||||||
|
)
|
||||||
|
from packages.domain.noise_reduction_config import (
|
||||||
|
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
|
||||||
|
)
|
||||||
|
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
|
||||||
|
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class NoiseReductionLevel(str, Enum):
|
|
||||||
"""降噪等级预设。"""
|
|
||||||
|
|
||||||
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
|
|
||||||
MEDIUM = "medium" # 中度降噪,平衡效果和音质
|
|
||||||
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
|
|
||||||
CUSTOM = "custom" # 自定义参数
|
|
||||||
|
|
||||||
|
|
||||||
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB)
|
|
||||||
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
|
|
||||||
_LEVEL_PARAMS = {
|
|
||||||
NoiseReductionLevel.LOW: {
|
|
||||||
"nf": -35, # 噪音阈值(dB),越负越保守
|
|
||||||
"tn": -10, # 噪音频谱平滑度
|
|
||||||
"tr": 50, # 时间分辨率(ms)
|
|
||||||
},
|
|
||||||
NoiseReductionLevel.MEDIUM: {
|
|
||||||
"nf": -25,
|
|
||||||
"tn": -10,
|
|
||||||
"tr": 50,
|
|
||||||
},
|
|
||||||
NoiseReductionLevel.HIGH: {
|
|
||||||
"nf": -15,
|
|
||||||
"tn": -5,
|
|
||||||
"tr": 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NoiseReductionConfig:
|
|
||||||
"""音频降噪配置。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
enabled: 是否启用降噪
|
|
||||||
level: 降噪等级 low/medium/high/custom
|
|
||||||
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
|
|
||||||
voice_enhance: 是否启用人声增强
|
|
||||||
output_format: 输出格式描述(内部使用)
|
|
||||||
"""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
level: NoiseReductionLevel = NoiseReductionLevel.MEDIUM
|
|
||||||
noise_floor: float = -25.0 # dB
|
|
||||||
voice_enhance: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict | None) -> "NoiseReductionConfig":
|
|
||||||
"""从字典解析配置,参数越界自动钳制。"""
|
|
||||||
if not data or not data.get("enabled", False):
|
|
||||||
return cls(enabled=False)
|
|
||||||
|
|
||||||
level_str = str(data.get("level", "medium")).lower()
|
|
||||||
try:
|
|
||||||
level = NoiseReductionLevel(level_str)
|
|
||||||
except ValueError:
|
|
||||||
level = NoiseReductionLevel.MEDIUM
|
|
||||||
|
|
||||||
try:
|
|
||||||
noise_floor = float(data.get("noise_floor", -25.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
noise_floor = -25.0
|
|
||||||
|
|
||||||
voice_enhance = bool(data.get("voice_enhance", False))
|
|
||||||
|
|
||||||
# 钳制到合法范围
|
|
||||||
noise_floor = max(-60.0, min(-5.0, noise_floor))
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
enabled=True,
|
|
||||||
level=level,
|
|
||||||
noise_floor=noise_floor,
|
|
||||||
voice_enhance=voice_enhance,
|
|
||||||
)
|
|
||||||
|
|
||||||
def has_effect(self) -> bool:
|
|
||||||
"""判断是否有实际降噪效果。"""
|
|
||||||
return self.enabled
|
|
||||||
|
|
||||||
def get_effective_noise_floor(self) -> float:
|
|
||||||
"""获取实际生效的噪音阈值(dB)。"""
|
|
||||||
if self.level == NoiseReductionLevel.CUSTOM:
|
|
||||||
return self.noise_floor
|
|
||||||
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[NoiseReductionLevel.MEDIUM])
|
|
||||||
return float(params["nf"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class NoiseReductionEngine:
|
class NoiseReductionEngine:
|
||||||
"""音频降噪引擎。
|
"""音频降噪引擎 — 薄包装,实际逻辑在 domain.noise_reduction_config.
|
||||||
|
|
||||||
基于 FFmpeg afftdn(Audio FFt Denoiser)滤镜实现:
|
基于 FFmpeg afftdn(Audio FFt Denoiser)滤镜实现:
|
||||||
- 使用短时傅里叶变换分析音频频谱
|
- 使用短时傅里叶变换分析音频频谱
|
||||||
@@ -133,97 +35,40 @@ class NoiseReductionEngine:
|
|||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
def build_filter(self, input_label: str, output_label: str) -> str:
|
def build_filter(self, input_label: str, output_label: str) -> str:
|
||||||
"""构建音频降噪滤镜字符串。
|
"""构建音频降噪滤镜字符串.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_label: 输入标签,如 "[0:a]" 或 "[a0]"
|
input_label: 输入标签,如 "[0:a]" 或 "[a0]"
|
||||||
output_label: 输出标签,如 "[nr0]"
|
output_label: 输出标签,如 "[nr0]"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FFmpeg 滤镜字符串,如 "[a0]afftdn=nf=-25:tn=-10:tr=50[nr0]"
|
FFmpeg 滤镜字符串
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 配置无效时抛出(调用方应捕获并降级)
|
|
||||||
"""
|
"""
|
||||||
if not self.config.has_effect():
|
return _build_afftdn_filter_base(self.config, input_label, output_label)
|
||||||
return f"{input_label}anull{output_label}"
|
|
||||||
|
|
||||||
# 获取参数
|
|
||||||
if self.config.level == NoiseReductionLevel.CUSTOM:
|
|
||||||
nf = self.config.noise_floor
|
|
||||||
tn = -10 # 默认频谱平滑度
|
|
||||||
tr = 50 # 默认时间分辨率
|
|
||||||
else:
|
|
||||||
params = _LEVEL_PARAMS.get(
|
|
||||||
self.config.level,
|
|
||||||
_LEVEL_PARAMS[NoiseReductionLevel.MEDIUM],
|
|
||||||
)
|
|
||||||
nf = float(params["nf"])
|
|
||||||
tn = float(params["tn"])
|
|
||||||
tr = float(params["tr"])
|
|
||||||
|
|
||||||
# 构建 afftdn 滤镜
|
|
||||||
# nf: noise floor (dB)
|
|
||||||
# tn: temporal noise floor smoothing (dB)
|
|
||||||
# tr: time resolution (ms)
|
|
||||||
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
|
|
||||||
|
|
||||||
# 人声增强:通过 highpass + 轻微压缩实现
|
|
||||||
if self.config.voice_enhance:
|
|
||||||
# 1. 高通滤波,去除低频噪音
|
|
||||||
filter_parts.append("highpass=f=80")
|
|
||||||
# 2. 轻微压缩,提升人声清晰度
|
|
||||||
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
|
|
||||||
# 3. 响度归一化
|
|
||||||
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
|
|
||||||
|
|
||||||
filter_str = f"{input_label}{','.join(filter_parts)}{output_label}"
|
|
||||||
return filter_str
|
|
||||||
|
|
||||||
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
|
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
|
||||||
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件)。
|
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件).
|
||||||
|
|
||||||
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_label: 输入标签
|
input_label: 输入标签
|
||||||
output_label: 输出标签
|
output_label: 输出标签
|
||||||
model_file: RNNNoise 模型文件路径(.rnnn 格式)
|
model_file: RNNNoise 模型文件路径
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FFmpeg 滤镜字符串
|
FFmpeg 滤镜字符串
|
||||||
"""
|
"""
|
||||||
if not self.config.has_effect():
|
return _build_arnndn_filter_base(self.config, input_label, output_label, model_file)
|
||||||
return f"{input_label}anull{output_label}"
|
|
||||||
|
|
||||||
return f"{input_label}arnndn=m={model_file}{output_label}"
|
|
||||||
|
|
||||||
|
|
||||||
def apply_noise_reduction_if_needed(
|
def apply_noise_reduction_if_needed(config_data, input_label: str, output_label: str):
|
||||||
config_data: dict | None,
|
"""便捷函数:根据配置判断是否需要应用音频降噪.
|
||||||
input_label: str,
|
|
||||||
output_label: str,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""便捷函数:根据配置判断是否需要应用音频降噪。
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_data: 降噪配置字典(从 plan.config.audio_noise_reduction 或 clip.config.noise_reduction 读取)
|
config_data: 降噪配置字典
|
||||||
input_label: 输入标签
|
input_label: 输入标签
|
||||||
output_label: 输出标签
|
output_label: 输出标签
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
滤镜字符串,不需要降噪时返回 None
|
滤镜字符串,不需要降噪时返回 None
|
||||||
"""
|
"""
|
||||||
if not config_data:
|
return _apply_noise_reduction_if_needed_base(config_data, input_label, output_label)
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = NoiseReductionConfig.from_dict(config_data)
|
|
||||||
if not config.has_effect():
|
|
||||||
return None
|
|
||||||
|
|
||||||
engine = NoiseReductionEngine(config)
|
|
||||||
return engine.build_filter(input_label, output_label)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -14,174 +14,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
|
||||||
|
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
|
||||||
|
from packages.domain.pip_config import (
|
||||||
# ── 位置常量 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# 9宫格位置枚举
|
|
||||||
POSITION_TOP_LEFT = "top_left"
|
|
||||||
POSITION_TOP_CENTER = "top_center"
|
|
||||||
POSITION_TOP_RIGHT = "top_right"
|
|
||||||
POSITION_CENTER_LEFT = "center_left"
|
|
||||||
POSITION_CENTER = "center"
|
|
||||||
POSITION_CENTER_RIGHT = "center_right"
|
|
||||||
POSITION_BOTTOM_LEFT = "bottom_left"
|
|
||||||
POSITION_BOTTOM_CENTER = "bottom_center"
|
|
||||||
POSITION_BOTTOM_RIGHT = "bottom_right"
|
|
||||||
|
|
||||||
_VALID_POSITIONS = {
|
|
||||||
POSITION_TOP_LEFT,
|
|
||||||
POSITION_TOP_CENTER,
|
|
||||||
POSITION_TOP_RIGHT,
|
|
||||||
POSITION_CENTER_LEFT,
|
|
||||||
POSITION_CENTER,
|
|
||||||
POSITION_CENTER_RIGHT,
|
|
||||||
POSITION_BOTTOM_LEFT,
|
|
||||||
POSITION_BOTTOM_CENTER,
|
|
||||||
POSITION_BOTTOM_RIGHT,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 动画类型
|
|
||||||
ANIMATION_FADE = "fade" # 淡入淡出
|
|
||||||
ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入
|
|
||||||
ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入
|
|
||||||
ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入
|
|
||||||
ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入
|
|
||||||
|
|
||||||
_VALID_ANIMATIONS = {
|
|
||||||
ANIMATION_FADE,
|
ANIMATION_FADE,
|
||||||
|
ANIMATION_SCALE,
|
||||||
|
ANIMATION_SLIDE_BOTTOM,
|
||||||
ANIMATION_SLIDE_LEFT,
|
ANIMATION_SLIDE_LEFT,
|
||||||
ANIMATION_SLIDE_RIGHT,
|
ANIMATION_SLIDE_RIGHT,
|
||||||
ANIMATION_SLIDE_TOP,
|
ANIMATION_SLIDE_TOP,
|
||||||
ANIMATION_SLIDE_BOTTOM,
|
POSITION_BOTTOM_LEFT,
|
||||||
}
|
POSITION_BOTTOM_RIGHT,
|
||||||
|
POSITION_CENTER,
|
||||||
|
POSITION_CENTER_LEFT,
|
||||||
|
POSITION_CENTER_RIGHT,
|
||||||
|
POSITION_TOP_CENTER,
|
||||||
|
POSITION_TOP_LEFT,
|
||||||
|
POSITION_TOP_RIGHT,
|
||||||
|
PiPConfig,
|
||||||
|
PiPLayerConfig,
|
||||||
|
)
|
||||||
|
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
|
||||||
|
calculate_pip_position as _calculate_pip_position_base,
|
||||||
|
)
|
||||||
|
from packages.domain.pip_config import parse_size_value as _parse_size_value_base
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PiPLayerConfig:
|
|
||||||
"""单个画中画图层配置."""
|
|
||||||
|
|
||||||
# 素材来源
|
|
||||||
source: str = "" # 素材ID或视频URL
|
|
||||||
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
|
|
||||||
|
|
||||||
# 位置配置
|
|
||||||
position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom"
|
|
||||||
x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%")
|
|
||||||
y: int | str = 0 # 自定义y坐标
|
|
||||||
margin: int = 20 # 9宫格模式下的边距(像素)
|
|
||||||
|
|
||||||
# 大小配置
|
|
||||||
width: int | str = "25%" # 宽度(像素或百分比)
|
|
||||||
height: int | str = "" # 高度(空则按比例自适应)
|
|
||||||
|
|
||||||
# 样式
|
|
||||||
opacity: float = 1.0 # 透明度 0.0-1.0
|
|
||||||
corner_radius: int = 0 # 圆角半径(像素),0表示无圆角
|
|
||||||
border_width: int = 0 # 边框宽度
|
|
||||||
border_color: str = "white" # 边框颜色
|
|
||||||
|
|
||||||
# 时间控制
|
|
||||||
start_time: float = 0.0 # 开始显示时间(秒)
|
|
||||||
duration: float = 0.0 # 持续时长(秒),0表示全程显示
|
|
||||||
|
|
||||||
# 动画
|
|
||||||
animation_in: str = "" # 入场动画类型
|
|
||||||
animation_out: str = "" # 出场动画类型
|
|
||||||
animation_duration: float = 0.5 # 动画时长(秒)
|
|
||||||
|
|
||||||
# 层级
|
|
||||||
z_index: int = 1 # 图层顺序,数字越大越在上层
|
|
||||||
|
|
||||||
def validate(self) -> tuple[bool, str]:
|
|
||||||
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
|
||||||
if not self.source:
|
|
||||||
return False, "source不能为空"
|
|
||||||
|
|
||||||
if self.position != "custom" and self.position not in _VALID_POSITIONS:
|
|
||||||
return False, f"无效的position: {self.position}"
|
|
||||||
|
|
||||||
if self.opacity < 0 or self.opacity > 1:
|
|
||||||
return False, "opacity必须在0-1之间"
|
|
||||||
|
|
||||||
if self.corner_radius < 0:
|
|
||||||
return False, "corner_radius不能为负数"
|
|
||||||
|
|
||||||
if self.start_time < 0:
|
|
||||||
return False, "start_time不能为负数"
|
|
||||||
|
|
||||||
if self.duration < 0:
|
|
||||||
return False, "duration不能为负数"
|
|
||||||
|
|
||||||
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
|
|
||||||
return False, f"无效的入场动画: {self.animation_in}"
|
|
||||||
|
|
||||||
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
|
|
||||||
return False, f"无效的出场动画: {self.animation_out}"
|
|
||||||
|
|
||||||
if self.animation_duration < 0:
|
|
||||||
return False, "animation_duration不能为负数"
|
|
||||||
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PiPConfig:
|
|
||||||
"""画中画整体配置."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
layers: list[PiPLayerConfig] = field(default_factory=list)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
|
|
||||||
"""从字典解析配置."""
|
|
||||||
if not data or not data.get("enabled", False):
|
|
||||||
return cls(enabled=False)
|
|
||||||
|
|
||||||
layers_data = data.get("layers", [])
|
|
||||||
layers = []
|
|
||||||
for layer_data in layers_data:
|
|
||||||
try:
|
|
||||||
layer = PiPLayerConfig(
|
|
||||||
source=layer_data.get("source", ""),
|
|
||||||
source_type=layer_data.get("source_type", "asset_id"),
|
|
||||||
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
|
|
||||||
x=layer_data.get("x", 0),
|
|
||||||
y=layer_data.get("y", 0),
|
|
||||||
margin=int(layer_data.get("margin", 20)),
|
|
||||||
width=layer_data.get("width", "25%"),
|
|
||||||
height=layer_data.get("height", ""),
|
|
||||||
opacity=float(layer_data.get("opacity", 1.0)),
|
|
||||||
corner_radius=int(layer_data.get("corner_radius", 0)),
|
|
||||||
border_width=int(layer_data.get("border_width", 0)),
|
|
||||||
border_color=layer_data.get("border_color", "white"),
|
|
||||||
start_time=float(layer_data.get("start_time", 0.0)),
|
|
||||||
duration=float(layer_data.get("duration", 0.0)),
|
|
||||||
animation_in=layer_data.get("animation_in", ""),
|
|
||||||
animation_out=layer_data.get("animation_out", ""),
|
|
||||||
animation_duration=float(layer_data.get("animation_duration", 0.5)),
|
|
||||||
z_index=int(layer_data.get("z_index", 1)),
|
|
||||||
)
|
|
||||||
valid, err = layer.validate()
|
|
||||||
if valid:
|
|
||||||
layers.append(layer)
|
|
||||||
else:
|
|
||||||
logger.warning("PiP图层配置无效,跳过: %s", err)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logger.warning("PiP图层解析失败,跳过: %s", e)
|
|
||||||
|
|
||||||
# 按 z_index 排序
|
|
||||||
layers.sort(key=lambda layer: layer.z_index)
|
|
||||||
|
|
||||||
return cls(enabled=bool(layers), layers=layers)
|
|
||||||
|
|
||||||
|
|
||||||
# ── PiP 引擎 ──────────────────────────────────────────────────────────────────
|
# ── PiP 引擎 ──────────────────────────────────────────────────────────────────
|
||||||
@@ -201,16 +61,12 @@ class PiPEngine:
|
|||||||
self.output_fps = output_fps
|
self.output_fps = output_fps
|
||||||
|
|
||||||
def _parse_size(self, value: int | str, base: int) -> int:
|
def _parse_size(self, value: int | str, base: int) -> int:
|
||||||
"""解析尺寸值(像素或百分比)."""
|
"""解析尺寸值(像素或百分比).
|
||||||
if isinstance(value, int):
|
|
||||||
return max(1, value)
|
委托给 packages.domain.pip_config.parse_size_value 纯逻辑函数,
|
||||||
if isinstance(value, str) and value.endswith("%"):
|
薄包装保留在类内以维持向后兼容。
|
||||||
pct = float(value.rstrip("%")) / 100.0
|
"""
|
||||||
return max(1, int(base * pct))
|
return _parse_size_value_base(value, base)
|
||||||
try:
|
|
||||||
return max(1, int(value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return int(base * 0.25) # 默认25%
|
|
||||||
|
|
||||||
def _parse_position(
|
def _parse_position(
|
||||||
self,
|
self,
|
||||||
@@ -218,28 +74,21 @@ class PiPEngine:
|
|||||||
pip_width: int,
|
pip_width: int,
|
||||||
pip_height: int,
|
pip_height: int,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""计算画中画的实际位置 (x, y)."""
|
"""计算画中画的实际位置 (x, y).
|
||||||
W = self.output_width
|
|
||||||
H = self.output_height
|
|
||||||
m = layer.margin
|
|
||||||
|
|
||||||
if layer.position == "custom":
|
委托给 packages.domain.pip_config.calculate_pip_position 纯逻辑函数,
|
||||||
x = self._parse_size(layer.x, W)
|
薄包装保留在类内以维持向后兼容。
|
||||||
y = self._parse_size(layer.y, H)
|
"""
|
||||||
return (x, y)
|
return _calculate_pip_position_base(
|
||||||
|
position=layer.position,
|
||||||
pos_map = {
|
output_width=self.output_width,
|
||||||
POSITION_TOP_LEFT: (m, m),
|
output_height=self.output_height,
|
||||||
POSITION_TOP_CENTER: ((W - pip_width) // 2, m),
|
pip_width=pip_width,
|
||||||
POSITION_TOP_RIGHT: (W - pip_width - m, m),
|
pip_height=pip_height,
|
||||||
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2),
|
margin=layer.margin,
|
||||||
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2),
|
custom_x=layer.x,
|
||||||
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2),
|
custom_y=layer.y,
|
||||||
POSITION_BOTTOM_LEFT: (m, H - pip_height - m),
|
)
|
||||||
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
|
|
||||||
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
|
|
||||||
}
|
|
||||||
return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT])
|
|
||||||
|
|
||||||
def _build_pip_pre_filter(
|
def _build_pip_pre_filter(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Regular → Executable
+37
-203
@@ -1,8 +1,8 @@
|
|||||||
"""ASS 字幕生成模块 — 从 unified_render_service.py 拆分.
|
"""ASS 字幕生成模块 — 薄包装,实际逻辑在 packages/domain/ass_subtitle_builder.py.
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
- 将 title / subtitle 配置转换为 ASS 字幕文件
|
- 将 title / subtitle 配置转换为 ASS 字幕文件
|
||||||
- 提供样式计算(颜色、对齐、描边/阴影)
|
- 文件IO 在此模块,纯逻辑已抽离到 domain
|
||||||
- 供 UnifiedRenderService._maybe_generate_ass 调用
|
- 供 UnifiedRenderService._maybe_generate_ass 调用
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,107 +12,40 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from packages.domain.ass_subtitle_builder import (
|
||||||
|
TITLE_MARGIN_BOTTOM,
|
||||||
|
TITLE_MARGIN_SIDE,
|
||||||
|
TITLE_MARGIN_TOP,
|
||||||
|
build_ass_content,
|
||||||
|
)
|
||||||
|
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
|
||||||
|
from packages.domain.ass_subtitle_builder import escape_ass_text as _escape_ass_text_base
|
||||||
|
from packages.domain.ass_subtitle_builder import format_ass_time as _format_ass_time_base
|
||||||
|
from packages.domain.ass_subtitle_builder import hex_to_ass_color as _hex_to_ass_color_base
|
||||||
|
from packages.domain.ass_subtitle_builder import position_to_ass_alignment as _position_to_ass_alignment_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
# 向后兼容:模块级函数保留为薄包装
|
||||||
|
|
||||||
# Title/Subtitle 默认边距(像素)
|
|
||||||
TITLE_MARGIN_TOP = 60
|
|
||||||
TITLE_MARGIN_BOTTOM = 60
|
|
||||||
TITLE_MARGIN_SIDE = 40
|
|
||||||
|
|
||||||
|
|
||||||
# ── ASS 字幕工具 ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_ass_color(hex_color: str) -> str:
|
def _hex_to_ass_color(hex_color: str) -> str:
|
||||||
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
|
return _hex_to_ass_color_base(hex_color)
|
||||||
hex_color = hex_color.lstrip("#")
|
|
||||||
if len(hex_color) != 6:
|
|
||||||
return "&H000000"
|
|
||||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
|
||||||
return f"&H{b.upper()}{g.upper()}{r.upper()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _position_to_ass_alignment(position: str) -> int:
|
def _position_to_ass_alignment(position: str) -> int:
|
||||||
"""将文字位置映射为 ASS \\an 对齐编号。
|
return _position_to_ass_alignment_base(position)
|
||||||
|
|
||||||
ASS 对齐编号(数字小键盘布局):
|
|
||||||
7 8 9
|
|
||||||
4 5 6
|
|
||||||
1 2 3
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
"top": 8, # 顶部居中
|
|
||||||
"center": 5, # 居中
|
|
||||||
"bottom": 2, # 底部居中
|
|
||||||
}
|
|
||||||
return mapping.get(position, 8)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_ass_style(
|
def _build_ass_style(*args, **kwargs) -> str:
|
||||||
style_name: str,
|
return _build_ass_style_base(*args, **kwargs)
|
||||||
*,
|
|
||||||
font_name: str = "思源黑体",
|
|
||||||
font_size: int = 48,
|
|
||||||
primary_color: str = "&H00FFFFFF",
|
|
||||||
outline_color: str = "&H00000000",
|
|
||||||
outline_width: float = 1.0,
|
|
||||||
shadow_blur: float = 0.0,
|
|
||||||
shadow_offset: tuple[int, int] = (0, 0),
|
|
||||||
bold: bool = False,
|
|
||||||
italic: bool = False,
|
|
||||||
alignment: int = 8,
|
|
||||||
margin_v: int = 60,
|
|
||||||
margin_l: int = 40,
|
|
||||||
margin_r: int = 40,
|
|
||||||
) -> str:
|
|
||||||
"""构建 ASS Style 行。
|
|
||||||
|
|
||||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
|
|
||||||
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
|
|
||||||
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
||||||
"""
|
|
||||||
bold_val = -1 if bold else 0
|
|
||||||
italic_val = -1 if italic else 0
|
|
||||||
|
|
||||||
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow)
|
|
||||||
back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制)
|
|
||||||
|
|
||||||
# Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素),
|
|
||||||
# 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现
|
|
||||||
# 简化:BorderStyle=1(outline + drop shadow),Shadow 字段表示阴影深度
|
|
||||||
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"Style: {style_name},{font_name},{font_size},{primary_color},"
|
|
||||||
f"&H000000FF,{outline_color},{back_color},"
|
|
||||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
|
||||||
f"1,{outline_width},{shadow_depth},{alignment},"
|
|
||||||
f"{margin_l},{margin_r},{margin_v},1"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_ass_text(text: str) -> str:
|
def _escape_ass_text(text: str) -> str:
|
||||||
r"""转义 ASS 文本中的特殊字符。
|
return _escape_ass_text_base(text)
|
||||||
|
|
||||||
ASS 中换行用 \N(硬换行)或 \n(软换行),
|
|
||||||
大括号 {} 用于覆盖样式,需要转义。
|
|
||||||
"""
|
|
||||||
# 将实际换行转为 ASS 硬换行
|
|
||||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
|
||||||
# 转义大括号(ASS 用它做样式覆盖标签)
|
|
||||||
text = text.replace("{", "(").replace("}", ")")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _format_ass_time(seconds: float) -> str:
|
def _format_ass_time(seconds: float) -> str:
|
||||||
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
|
return _format_ass_time_base(seconds)
|
||||||
hours = int(seconds // 3600)
|
|
||||||
minutes = int((seconds % 3600) // 60)
|
|
||||||
secs = seconds % 60
|
|
||||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ass_subtitles(
|
def generate_ass_subtitles(
|
||||||
@@ -126,130 +59,31 @@ def generate_ass_subtitles(
|
|||||||
subtitle_text: str = "",
|
subtitle_text: str = "",
|
||||||
subtitle_config: dict[str, Any] | None = None,
|
subtitle_config: dict[str, Any] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""生成 ASS 字幕文件。
|
"""生成 ASS 字幕文件.
|
||||||
|
|
||||||
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
|
|
||||||
各自可独立配置样式、位置和内容。
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
output_path: 输出 ASS 文件路径
|
output_path: 输出 ASS 文件路径
|
||||||
video_width: 视频宽度(用于 ASS PlayResX)
|
video_width: 视频宽度
|
||||||
video_height: 视频高度(用于 ASS PlayResY)
|
video_height: 视频高度
|
||||||
video_duration: 视频总时长(秒),字幕显示整个时长
|
video_duration: 视频总时长(秒)
|
||||||
title_text: 标题文本
|
title_text: 标题文本
|
||||||
title_config: 标题样式配置(TitleConfig dict)
|
title_config: 标题样式配置
|
||||||
subtitle_text: 字幕文本
|
subtitle_text: 字幕文本
|
||||||
subtitle_config: 字幕样式配置(SubtitleConfig dict)
|
subtitle_config: 字幕样式配置
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
生成的 ASS 文件路径
|
生成的 ASS 文件路径
|
||||||
"""
|
"""
|
||||||
title_config = title_config or {}
|
content = build_ass_content(
|
||||||
subtitle_config = subtitle_config or {}
|
video_width=video_width,
|
||||||
|
video_height=video_height,
|
||||||
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
video_duration=video_duration,
|
||||||
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
title_text=title_text,
|
||||||
|
title_config=title_config,
|
||||||
if not title_enabled and not subtitle_enabled:
|
subtitle_text=subtitle_text,
|
||||||
# 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用)
|
subtitle_config=subtitle_config,
|
||||||
output_path.write_text("", encoding="utf-8")
|
)
|
||||||
return output_path
|
|
||||||
|
|
||||||
styles: list[str] = []
|
|
||||||
events: list[str] = []
|
|
||||||
|
|
||||||
# ── Title 样式与事件 ──────────────────────────────────────────────────
|
|
||||||
if title_enabled:
|
|
||||||
title_color = _hex_to_ass_color(title_config.get("color", "#ffffff"))
|
|
||||||
title_stroke = title_config.get("stroke", {}) or {}
|
|
||||||
title_shadow = title_config.get("shadow", {}) or {}
|
|
||||||
stroke_color = _hex_to_ass_color(title_stroke.get("color", "#000000"))
|
|
||||||
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
|
|
||||||
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
|
|
||||||
shadow_offset = (
|
|
||||||
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
|
|
||||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
title_alignment = _position_to_ass_alignment(title_config.get("position", "top"))
|
|
||||||
|
|
||||||
styles.append(
|
|
||||||
_build_ass_style(
|
|
||||||
"TitleStyle",
|
|
||||||
font_name=title_config.get("font", "思源黑体"),
|
|
||||||
font_size=int(title_config.get("size", 48)),
|
|
||||||
primary_color=title_color,
|
|
||||||
outline_color=stroke_color,
|
|
||||||
outline_width=stroke_width,
|
|
||||||
shadow_blur=shadow_blur,
|
|
||||||
shadow_offset=shadow_offset,
|
|
||||||
bold=bool(title_config.get("bold", True)),
|
|
||||||
italic=bool(title_config.get("italic", False)),
|
|
||||||
alignment=title_alignment,
|
|
||||||
margin_v=TITLE_MARGIN_TOP,
|
|
||||||
margin_l=TITLE_MARGIN_SIDE,
|
|
||||||
margin_r=TITLE_MARGIN_SIDE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 转义 ASS 特殊字符
|
|
||||||
safe_title_text = _escape_ass_text(title_text)
|
|
||||||
|
|
||||||
events.append(
|
|
||||||
"Dialogue: 0,0:00:00.00," f"{_format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
|
|
||||||
if subtitle_enabled:
|
|
||||||
sub_color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
|
|
||||||
sub_alignment = _position_to_ass_alignment(subtitle_config.get("position", "bottom"))
|
|
||||||
|
|
||||||
styles.append(
|
|
||||||
_build_ass_style(
|
|
||||||
"SubtitleStyle",
|
|
||||||
font_name=subtitle_config.get("font", "思源黑体"),
|
|
||||||
font_size=int(subtitle_config.get("size", 24)),
|
|
||||||
primary_color=sub_color,
|
|
||||||
outline_color="&H00000000",
|
|
||||||
outline_width=1.0,
|
|
||||||
shadow_blur=0.0,
|
|
||||||
shadow_offset=(0, 0),
|
|
||||||
bold=False,
|
|
||||||
italic=False,
|
|
||||||
alignment=sub_alignment,
|
|
||||||
margin_v=TITLE_MARGIN_BOTTOM,
|
|
||||||
margin_l=TITLE_MARGIN_SIDE,
|
|
||||||
margin_r=TITLE_MARGIN_SIDE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
safe_subtitle_text = _escape_ass_text(subtitle_text)
|
|
||||||
|
|
||||||
events.append(
|
|
||||||
"Dialogue: 0,0:00:00.00,"
|
|
||||||
f"{_format_ass_time(video_duration)},"
|
|
||||||
"SubtitleStyle,,0,0,0,,"
|
|
||||||
f"{safe_subtitle_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
|
|
||||||
ass_content = f"""[Script Info]
|
|
||||||
ScriptType: v4.00+
|
|
||||||
PlayResX: {video_width}
|
|
||||||
PlayResY: {video_height}
|
|
||||||
ScaledBorderAndShadow: yes
|
|
||||||
WrapStyle: 2
|
|
||||||
Encoding: UTF-8
|
|
||||||
|
|
||||||
[V4+ Styles]
|
|
||||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
|
|
||||||
{chr(10).join(styles)}
|
|
||||||
|
|
||||||
[Events]
|
|
||||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
||||||
{chr(10).join(events)}
|
|
||||||
"""
|
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_path.write_text(ass_content, encoding="utf-8")
|
output_path.write_text(content, encoding="utf-8")
|
||||||
return output_path
|
return output_path
|
||||||
|
|||||||
@@ -11,121 +11,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from packages.domain.sticker_config import (
|
||||||
|
POSITION_PRESETS,
|
||||||
|
STICKER_CATEGORIES,
|
||||||
|
ImageStickerConfig,
|
||||||
|
StickerOverlayResult,
|
||||||
|
TextStickerConfig,
|
||||||
|
)
|
||||||
|
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
|
||||||
|
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
|
||||||
|
from packages.domain.sticker_config import (
|
||||||
|
resolve_sticker_position,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材)
|
|
||||||
STICKER_CATEGORIES = [
|
|
||||||
("emoji", "表情包"),
|
|
||||||
("text", "文字花字"),
|
|
||||||
("decoration", "装饰"),
|
|
||||||
("arrow", "箭头指示"),
|
|
||||||
("frame", "边框"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 9宫格位置映射
|
|
||||||
POSITION_PRESETS = {
|
|
||||||
"top_left": (0.05, 0.05),
|
|
||||||
"top_center": (0.5, 0.05),
|
|
||||||
"top_right": (0.95, 0.05),
|
|
||||||
"center_left": (0.05, 0.5),
|
|
||||||
"center": (0.5, 0.5),
|
|
||||||
"center_right": (0.95, 0.5),
|
|
||||||
"bottom_left": (0.05, 0.95),
|
|
||||||
"bottom_center": (0.5, 0.95),
|
|
||||||
"bottom_right": (0.95, 0.95),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ImageStickerConfig:
|
|
||||||
"""图片贴纸配置."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
type: str = "image" # image / text
|
|
||||||
# 位置
|
|
||||||
position: str = "top_right" # 9宫格预设
|
|
||||||
x: float | None = None # 自定义x(像素或百分比)
|
|
||||||
y: float | None = None # 自定义y
|
|
||||||
x_unit: str = "percent" # pixel / percent
|
|
||||||
y_unit: str = "percent"
|
|
||||||
# 大小
|
|
||||||
scale: float = 1.0 # 缩放比例(相对于原始大小)
|
|
||||||
width: int | None = None # 指定宽度(像素)
|
|
||||||
height: int | None = None # 指定高度(像素)
|
|
||||||
# 透明度
|
|
||||||
opacity: float = 1.0 # 0.0~1.0
|
|
||||||
# 时间范围
|
|
||||||
start_time: float = 0.0
|
|
||||||
duration: float = 0.0 # 0 表示持续到结束
|
|
||||||
# 动画
|
|
||||||
fade_in: float = 0.0 # 淡入时长(秒)
|
|
||||||
fade_out: float = 0.0 # 淡出时长
|
|
||||||
# 层级
|
|
||||||
z_index: int = 10
|
|
||||||
# 素材
|
|
||||||
image_url: str = "" # 图片URL或本地路径
|
|
||||||
preset_id: str = "" # 预设贴纸ID
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TextStickerConfig:
|
|
||||||
"""文字贴纸配置."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
type: str = "text"
|
|
||||||
text: str = ""
|
|
||||||
# 字体
|
|
||||||
font_size: int = 36
|
|
||||||
font_color: str = "#FFFFFF"
|
|
||||||
font_family: str = "sans"
|
|
||||||
# 描边
|
|
||||||
stroke_color: str = "#000000"
|
|
||||||
stroke_width: int = 2
|
|
||||||
# 阴影
|
|
||||||
shadow_color: str = "#000000"
|
|
||||||
shadow_x: int = 2
|
|
||||||
shadow_y: int = 2
|
|
||||||
shadow_alpha: float = 0.5
|
|
||||||
# 位置
|
|
||||||
position: str = "center"
|
|
||||||
x: float | None = None
|
|
||||||
y: float | None = None
|
|
||||||
x_unit: str = "percent"
|
|
||||||
y_unit: str = "percent"
|
|
||||||
# 时间范围
|
|
||||||
start_time: float = 0.0
|
|
||||||
duration: float = 0.0
|
|
||||||
# 动画
|
|
||||||
fade_in: float = 0.0
|
|
||||||
fade_out: float = 0.0
|
|
||||||
# 层级
|
|
||||||
z_index: int = 10
|
|
||||||
# 背景框
|
|
||||||
bg_color: str = "" # 空表示无背景
|
|
||||||
bg_padding: int = 8
|
|
||||||
bg_alpha: float = 0.8
|
|
||||||
bg_corner_radius: int = 8
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StickerOverlayResult:
|
|
||||||
"""贴纸叠加结果."""
|
|
||||||
|
|
||||||
filter_str: str # 滤镜字符串
|
|
||||||
output_label: str # 输出标签
|
|
||||||
extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径
|
|
||||||
|
|
||||||
|
|
||||||
# ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
|
# ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -144,38 +48,18 @@ class StickerEngine:
|
|||||||
sticker_w: int = 0,
|
sticker_w: int = 0,
|
||||||
sticker_h: int = 0,
|
sticker_h: int = 0,
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
"""解析贴纸位置(像素坐标).
|
"""解析贴纸位置(像素坐标)(转发到 sticker_config 模块)."""
|
||||||
|
return resolve_sticker_position(
|
||||||
优先级:自定义坐标 > 9宫格预设
|
config.position,
|
||||||
"""
|
config.x,
|
||||||
# 先取预设的基准位置
|
config.y,
|
||||||
if config.position in POSITION_PRESETS:
|
config.x_unit,
|
||||||
px, py = POSITION_PRESETS[config.position]
|
config.y_unit,
|
||||||
else:
|
canvas_w,
|
||||||
px, py = 0.5, 0.5 # 默认居中
|
canvas_h,
|
||||||
|
sticker_w,
|
||||||
# 自定义坐标覆盖
|
sticker_h,
|
||||||
if config.x is not None:
|
)
|
||||||
if config.x_unit == "percent":
|
|
||||||
px = config.x / 100.0
|
|
||||||
else:
|
|
||||||
px = config.x / canvas_w if canvas_w > 0 else 0.5
|
|
||||||
|
|
||||||
if config.y is not None:
|
|
||||||
if config.y_unit == "percent":
|
|
||||||
py = config.y / 100.0
|
|
||||||
else:
|
|
||||||
py = config.y / canvas_h if canvas_h > 0 else 0.5
|
|
||||||
|
|
||||||
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
|
|
||||||
x = px * canvas_w - sticker_w / 2
|
|
||||||
y = py * canvas_h - sticker_h / 2
|
|
||||||
|
|
||||||
# 钳制在画布内
|
|
||||||
x = max(0, min(x, canvas_w - sticker_w))
|
|
||||||
y = max(0, min(y, canvas_h - sticker_h))
|
|
||||||
|
|
||||||
return x, y
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_overlay_filter(
|
def _build_overlay_filter(
|
||||||
@@ -594,19 +478,14 @@ class StickerEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
# ── 便捷函数(薄包装,转发到 sticker_config 模块) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
"""从 plan.config.stickers 解析贴纸列表."""
|
"""从 plan.config.stickers 解析贴纸列表(薄包装)."""
|
||||||
if not config:
|
return _parse_stickers_base(config)
|
||||||
return []
|
|
||||||
stickers = config.get("stickers", [])
|
|
||||||
if not isinstance(stickers, list):
|
|
||||||
return []
|
|
||||||
return stickers
|
|
||||||
|
|
||||||
|
|
||||||
def get_sticker_categories() -> list[tuple[str, str]]:
|
def get_sticker_categories() -> list[tuple[str, str]]:
|
||||||
"""获取贴纸分类列表."""
|
"""获取贴纸分类列表(薄包装)."""
|
||||||
return list(STICKER_CATEGORIES)
|
return _get_sticker_categories_base()
|
||||||
|
|||||||
@@ -24,264 +24,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||||
|
|
||||||
|
from packages.domain.subtitle_style import (
|
||||||
|
ALLOWED_SUBTITLE_EXTENSIONS,
|
||||||
|
DEFAULT_COLOR,
|
||||||
|
DEFAULT_FONT,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_MAX_CHARS_PER_LINE,
|
||||||
|
DEFAULT_POSITION,
|
||||||
|
DEFAULT_STROKE_COLOR,
|
||||||
|
DEFAULT_STROKE_WIDTH,
|
||||||
|
POSITION_ALIASES,
|
||||||
|
POSITION_ALIGNMENT,
|
||||||
|
SubtitleSegment,
|
||||||
|
SubtitleStyle,
|
||||||
|
)
|
||||||
|
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
|
||||||
|
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
|
||||||
|
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
|
||||||
|
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
|
||||||
|
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
|
||||||
|
from packages.domain.subtitle_style import wrap_text as _wrap_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
|
|
||||||
|
|
||||||
# 9宫格位置映射(ASS alignment 编号)
|
|
||||||
POSITION_ALIGNMENT = {
|
|
||||||
"top_left": 7,
|
|
||||||
"top_center": 8,
|
|
||||||
"top_right": 9,
|
|
||||||
"middle_left": 4,
|
|
||||||
"center": 5,
|
|
||||||
"middle_right": 6,
|
|
||||||
"bottom_left": 1,
|
|
||||||
"bottom_center": 2,
|
|
||||||
"bottom_right": 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 位置简称兼容
|
|
||||||
POSITION_ALIASES = {
|
|
||||||
"top": "top_center",
|
|
||||||
"bottom": "bottom_center",
|
|
||||||
"middle": "center",
|
|
||||||
"left": "middle_left",
|
|
||||||
"right": "middle_right",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_FONT = "思源黑体"
|
|
||||||
DEFAULT_FONT_SIZE = 24
|
|
||||||
DEFAULT_COLOR = "#FFFFFF"
|
|
||||||
DEFAULT_STROKE_COLOR = "#000000"
|
|
||||||
DEFAULT_STROKE_WIDTH = 1.5
|
|
||||||
DEFAULT_POSITION = "bottom_center"
|
|
||||||
DEFAULT_MAX_CHARS_PER_LINE = 20
|
|
||||||
|
|
||||||
|
|
||||||
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SubtitleStyle:
|
|
||||||
"""字幕样式配置."""
|
|
||||||
|
|
||||||
font_name: str = DEFAULT_FONT
|
|
||||||
font_size: int = DEFAULT_FONT_SIZE
|
|
||||||
font_color: str = DEFAULT_COLOR
|
|
||||||
bold: bool = False
|
|
||||||
italic: bool = False
|
|
||||||
|
|
||||||
# 描边
|
|
||||||
stroke_enabled: bool = True
|
|
||||||
stroke_color: str = DEFAULT_STROKE_COLOR
|
|
||||||
stroke_width: float = DEFAULT_STROKE_WIDTH
|
|
||||||
|
|
||||||
# 阴影
|
|
||||||
shadow_enabled: bool = False
|
|
||||||
shadow_color: str = "#000000"
|
|
||||||
shadow_offset_x: int = 2
|
|
||||||
shadow_offset_y: int = 2
|
|
||||||
shadow_blur: float = 0.0
|
|
||||||
|
|
||||||
# 背景框
|
|
||||||
background_enabled: bool = False
|
|
||||||
background_color: str = "#000000"
|
|
||||||
background_opacity: float = 0.5 # 0.0 ~ 1.0
|
|
||||||
background_padding: int = 8
|
|
||||||
background_radius: int = 4
|
|
||||||
|
|
||||||
# 位置
|
|
||||||
position: str = DEFAULT_POSITION # 9宫格位置名
|
|
||||||
margin_v: int = 60 # 垂直边距
|
|
||||||
margin_l: int = 40 # 左边距
|
|
||||||
margin_r: int = 40 # 右边距
|
|
||||||
|
|
||||||
# 多行
|
|
||||||
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
|
|
||||||
line_spacing: int = 0 # 行间距
|
|
||||||
|
|
||||||
# 动画
|
|
||||||
fade_in: float = 0.0 # 淡入时长(秒)
|
|
||||||
fade_out: float = 0.0 # 淡出时长(秒)
|
|
||||||
animation_type: str = "none" # none/fade/slide/typewriter
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
|
|
||||||
"""从字典创建样式配置,带安全类型转换."""
|
|
||||||
if not config or not isinstance(config, dict):
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
def safe_str(key: str, default: str) -> str:
|
|
||||||
val = config.get(key, default)
|
|
||||||
return str(val) if val is not None else default
|
|
||||||
|
|
||||||
def safe_int(key: str, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(config.get(key, default))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
def safe_float(key: str, default: float) -> float:
|
|
||||||
try:
|
|
||||||
return float(config.get(key, default))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
def safe_bool(key: str, default: bool) -> bool:
|
|
||||||
return bool(config.get(key, default))
|
|
||||||
|
|
||||||
position = safe_str("position", DEFAULT_POSITION)
|
|
||||||
position = POSITION_ALIASES.get(position, position)
|
|
||||||
if position not in POSITION_ALIGNMENT:
|
|
||||||
position = DEFAULT_POSITION
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
font_name=safe_str("font", DEFAULT_FONT),
|
|
||||||
font_size=safe_int("size", DEFAULT_FONT_SIZE),
|
|
||||||
font_color=safe_str("color", DEFAULT_COLOR),
|
|
||||||
bold=safe_bool("bold", False),
|
|
||||||
italic=safe_bool("italic", False),
|
|
||||||
stroke_enabled=safe_bool("stroke_enabled", True),
|
|
||||||
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
|
|
||||||
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
|
|
||||||
shadow_enabled=safe_bool("shadow_enabled", False),
|
|
||||||
shadow_color=safe_str("shadow_color", "#000000"),
|
|
||||||
shadow_offset_x=safe_int("shadow_offset_x", 2),
|
|
||||||
shadow_offset_y=safe_int("shadow_offset_y", 2),
|
|
||||||
shadow_blur=safe_float("shadow_blur", 0.0),
|
|
||||||
background_enabled=safe_bool("background_enabled", False),
|
|
||||||
background_color=safe_str("background_color", "#000000"),
|
|
||||||
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
|
|
||||||
background_padding=safe_int("background_padding", 8),
|
|
||||||
background_radius=safe_int("background_radius", 4),
|
|
||||||
position=position,
|
|
||||||
margin_v=safe_int("margin_v", 60),
|
|
||||||
margin_l=safe_int("margin_l", 40),
|
|
||||||
margin_r=safe_int("margin_r", 40),
|
|
||||||
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
|
|
||||||
line_spacing=safe_int("line_spacing", 0),
|
|
||||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
|
||||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
|
||||||
animation_type=safe_str("animation_type", "none"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def alignment(self) -> int:
|
|
||||||
"""获取 ASS alignment 编号."""
|
|
||||||
return POSITION_ALIGNMENT.get(self.position, 2)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ass_font_color(self) -> str:
|
|
||||||
"""ASS 格式颜色 &HAABBGGRR."""
|
|
||||||
return _hex_to_ass_color(self.font_color)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ass_stroke_color(self) -> str:
|
|
||||||
return _hex_to_ass_color(self.stroke_color)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ass_shadow_color(self) -> str:
|
|
||||||
return _hex_to_ass_color(self.shadow_color)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ass_background_color(self) -> str:
|
|
||||||
"""背景框颜色(ASS BackColour),带透明度."""
|
|
||||||
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
|
|
||||||
color_bgr = _hex_to_ass_bgr(self.background_color)
|
|
||||||
return f"&H{alpha_hex}{color_bgr}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_ass_color(hex_color: str) -> str:
|
|
||||||
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
|
|
||||||
hex_color = hex_color.lstrip("#")
|
|
||||||
if len(hex_color) != 6:
|
|
||||||
return "&H00FFFFFF"
|
|
||||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
|
||||||
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_ass_bgr(hex_color: str) -> str:
|
|
||||||
"""HEX → ASS BGR 部分(不含 alpha)."""
|
|
||||||
hex_color = hex_color.lstrip("#")
|
|
||||||
if len(hex_color) != 6:
|
|
||||||
return "FFFFFF"
|
|
||||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
|
||||||
return f"{b.upper()}{g.upper()}{r.upper()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _opacity_to_ass_alpha(opacity: float) -> str:
|
|
||||||
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
|
|
||||||
alpha = 255 - int(opacity * 255)
|
|
||||||
return f"{alpha:02X}"
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_ass_text(text: str) -> str:
|
|
||||||
"""转义 ASS 文本特殊字符."""
|
|
||||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
|
||||||
text = text.replace("{", "(").replace("}", ")")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _format_ass_time(seconds: float) -> str:
|
|
||||||
"""秒 → ASS 时间格式 H:MM:SS.cc."""
|
|
||||||
hours = int(seconds // 3600)
|
|
||||||
minutes = int((seconds % 3600) // 60)
|
|
||||||
secs = seconds % 60
|
|
||||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_text(text: str, max_chars: int) -> list[str]:
|
|
||||||
"""按字数换行,优先标点断开."""
|
|
||||||
if len(text) <= max_chars:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
remaining = text
|
|
||||||
|
|
||||||
while len(remaining) > max_chars:
|
|
||||||
break_point = max_chars
|
|
||||||
punctuations = ",。!?、;:,.;:!?"
|
|
||||||
|
|
||||||
for i in range(max_chars, max_chars // 2, -1):
|
|
||||||
if i < len(remaining) and remaining[i] in punctuations:
|
|
||||||
break_point = i + 1
|
|
||||||
break
|
|
||||||
|
|
||||||
lines.append(remaining[:break_point])
|
|
||||||
remaining = remaining[break_point:]
|
|
||||||
|
|
||||||
if remaining:
|
|
||||||
lines.append(remaining)
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SubtitleSegment:
|
|
||||||
"""单个字幕片段."""
|
|
||||||
|
|
||||||
start: float # 开始时间(秒)
|
|
||||||
end: float # 结束时间(秒)
|
|
||||||
text: str # 字幕文本
|
|
||||||
style_name: str = "Default" # 使用的样式名
|
|
||||||
|
|
||||||
|
|
||||||
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,229 +12,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
from enum import StrEnum
|
|
||||||
else:
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
class StrEnum(str, Enum):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||||
|
|
||||||
|
from packages.domain.transition_config import ( # noqa: F401 — 向后兼容
|
||||||
|
CUT_TRANSITION,
|
||||||
|
DEFAULT_TRANSITION_DURATION,
|
||||||
|
MAX_TRANSITION_DURATION,
|
||||||
|
MIN_TRANSITION_DURATION,
|
||||||
|
TransitionConfig,
|
||||||
|
TransitionType,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# 转场时长范围(秒)
|
|
||||||
MIN_TRANSITION_DURATION = 0.3
|
|
||||||
MAX_TRANSITION_DURATION = 2.0
|
|
||||||
DEFAULT_TRANSITION_DURATION = 0.5
|
|
||||||
|
|
||||||
# 硬切(无转场)
|
|
||||||
CUT_TRANSITION = "cut"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionType(StrEnum):
|
|
||||||
"""支持的转场效果类型.
|
|
||||||
|
|
||||||
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
|
|
||||||
新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 硬切(无转场效果,直接拼接)
|
|
||||||
CUT = "cut"
|
|
||||||
|
|
||||||
# 淡入淡出(最常用,默认 fallback)
|
|
||||||
FADE = "fade"
|
|
||||||
|
|
||||||
# 溶解(交叉溶解)
|
|
||||||
DISSOLVE = "dissolve"
|
|
||||||
|
|
||||||
# 滑入系列
|
|
||||||
SLIDE_LEFT = "slideleft"
|
|
||||||
SLIDE_RIGHT = "slideright"
|
|
||||||
SLIDE_UP = "slideup"
|
|
||||||
SLIDE_DOWN = "slidedown"
|
|
||||||
|
|
||||||
# 缩放
|
|
||||||
ZOOM = "zoom"
|
|
||||||
|
|
||||||
# 擦除系列
|
|
||||||
WIPE_LEFT = "wipeleft"
|
|
||||||
WIPE_RIGHT = "wiperight"
|
|
||||||
WIPE_UP = "wipeup"
|
|
||||||
WIPE_DOWN = "wipedown"
|
|
||||||
|
|
||||||
# 圆形扩散
|
|
||||||
CIRCLE_CROP = "circlecrop"
|
|
||||||
|
|
||||||
# 矩形覆盖
|
|
||||||
RECT_CROP = "rectcrop"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def all_supported(cls) -> list[str]:
|
|
||||||
"""返回所有支持的转场类型名称列表."""
|
|
||||||
return [t.value for t in cls if t != cls.CUT]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_supported(cls, name: str) -> bool:
|
|
||||||
"""检查转场类型是否支持(不区分大小写和下划线)."""
|
|
||||||
normalized = _normalize_transition_name(name)
|
|
||||||
return normalized in _NAME_TO_ENUM_MAP
|
|
||||||
|
|
||||||
|
|
||||||
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_transition_name(name: str) -> str:
|
|
||||||
"""标准化转场名称:小写 + 去下划线."""
|
|
||||||
return name.lower().replace("_", "").replace("-", "")
|
|
||||||
|
|
||||||
|
|
||||||
# 构建别名映射
|
|
||||||
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
|
|
||||||
for _t in TransitionType:
|
|
||||||
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
|
|
||||||
|
|
||||||
# 额外的别名
|
|
||||||
_ALIASES: dict[str, TransitionType] = {
|
|
||||||
"dissolve": TransitionType.DISSOLVE,
|
|
||||||
"crossfade": TransitionType.DISSOLVE,
|
|
||||||
"crossdissolve": TransitionType.DISSOLVE,
|
|
||||||
"fadein": TransitionType.FADE,
|
|
||||||
"fadeout": TransitionType.FADE,
|
|
||||||
"fadeblack": TransitionType.FADE,
|
|
||||||
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
|
|
||||||
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
|
|
||||||
"zoomin": TransitionType.ZOOM,
|
|
||||||
"zoomout": TransitionType.ZOOM,
|
|
||||||
"circle": TransitionType.CIRCLE_CROP,
|
|
||||||
"rect": TransitionType.RECT_CROP,
|
|
||||||
}
|
|
||||||
for _alias, _type in _ALIASES.items():
|
|
||||||
_key = _normalize_transition_name(_alias)
|
|
||||||
if _key not in _NAME_TO_ENUM_MAP:
|
|
||||||
_NAME_TO_ENUM_MAP[_key] = _type
|
|
||||||
|
|
||||||
|
|
||||||
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
|
|
||||||
TransitionType.FADE: "fade",
|
|
||||||
TransitionType.DISSOLVE: "dissolve",
|
|
||||||
TransitionType.SLIDE_LEFT: "slideleft",
|
|
||||||
TransitionType.SLIDE_RIGHT: "slideright",
|
|
||||||
TransitionType.SLIDE_UP: "slideup",
|
|
||||||
TransitionType.SLIDE_DOWN: "slidedown",
|
|
||||||
TransitionType.ZOOM: "zoomin",
|
|
||||||
TransitionType.WIPE_LEFT: "wipeleft",
|
|
||||||
TransitionType.WIPE_RIGHT: "wiperight",
|
|
||||||
TransitionType.WIPE_UP: "wipeup",
|
|
||||||
TransitionType.WIPE_DOWN: "wipedown",
|
|
||||||
TransitionType.CIRCLE_CROP: "circlecrop",
|
|
||||||
TransitionType.RECT_CROP: "rectcrop",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 转场配置 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class TransitionConfig:
|
|
||||||
"""转场效果配置.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
effect: 转场效果名称(见 TransitionType)
|
|
||||||
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
|
|
||||||
"""
|
|
||||||
|
|
||||||
effect: str = CUT_TRANSITION
|
|
||||||
duration: float = DEFAULT_TRANSITION_DURATION
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
|
|
||||||
"""解析并验证转场配置,自动处理边界和降级.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
effect: 转场效果名称(None 或空则使用默认 cut)
|
|
||||||
duration: 转场时长(None 则使用默认值)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
验证后的 TransitionConfig
|
|
||||||
"""
|
|
||||||
# 处理 effect
|
|
||||||
final_effect = CUT_TRANSITION
|
|
||||||
if effect and effect.strip():
|
|
||||||
effect_clean = effect.strip()
|
|
||||||
if TransitionType.is_supported(effect_clean):
|
|
||||||
final_effect = _resolve_transition_enum(effect_clean).value
|
|
||||||
elif effect_clean.lower() == CUT_TRANSITION:
|
|
||||||
final_effect = CUT_TRANSITION
|
|
||||||
else:
|
|
||||||
# 降级:不支持的转场 → 硬切,不阻断渲染
|
|
||||||
logger.warning(
|
|
||||||
"不支持的转场效果 '%s',已降级为硬切(cut)",
|
|
||||||
effect_clean,
|
|
||||||
)
|
|
||||||
final_effect = CUT_TRANSITION
|
|
||||||
|
|
||||||
# 处理 duration:边界钳制
|
|
||||||
final_duration = DEFAULT_TRANSITION_DURATION
|
|
||||||
if duration is not None:
|
|
||||||
try:
|
|
||||||
d = float(duration)
|
|
||||||
if d < MIN_TRANSITION_DURATION:
|
|
||||||
logger.warning(
|
|
||||||
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
|
|
||||||
d,
|
|
||||||
MIN_TRANSITION_DURATION,
|
|
||||||
)
|
|
||||||
final_duration = MIN_TRANSITION_DURATION
|
|
||||||
elif d > MAX_TRANSITION_DURATION:
|
|
||||||
logger.warning(
|
|
||||||
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
|
|
||||||
d,
|
|
||||||
MAX_TRANSITION_DURATION,
|
|
||||||
)
|
|
||||||
final_duration = MAX_TRANSITION_DURATION
|
|
||||||
else:
|
|
||||||
final_duration = d
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
|
|
||||||
final_duration = DEFAULT_TRANSITION_DURATION
|
|
||||||
|
|
||||||
return cls(effect=final_effect, duration=final_duration)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_cut(self) -> bool:
|
|
||||||
"""是否为硬切(无转场效果)."""
|
|
||||||
return self.effect == CUT_TRANSITION
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ffmpeg_transition(self) -> str:
|
|
||||||
"""获取对应的 FFmpeg xfade transition 名称."""
|
|
||||||
if self.is_cut:
|
|
||||||
return ""
|
|
||||||
enum_type = _resolve_transition_enum(self.effect)
|
|
||||||
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_transition_enum(name: str) -> TransitionType:
|
|
||||||
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
|
|
||||||
normalized = _normalize_transition_name(name)
|
|
||||||
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
|
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,169 +5,37 @@
|
|||||||
- 边界自动钳制(超出素材时长自动修正,不阻断渲染)
|
- 边界自动钳制(超出素材时长自动修正,不阻断渲染)
|
||||||
- 多段裁剪(一个素材裁剪出多段)
|
- 多段裁剪(一个素材裁剪出多段)
|
||||||
- 音画同步(视频 + 音频同步裁剪)
|
- 音画同步(视频 + 音频同步裁剪)
|
||||||
|
|
||||||
|
注:核心领域模型已抽离到 packages/domain/trim_config.py,
|
||||||
|
本模块保留薄包装层,确保向后兼容。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from packages.domain.trim_config import (
|
||||||
|
MIN_TRIM_DURATION,
|
||||||
|
TrimConfig,
|
||||||
|
TrimSegment,
|
||||||
|
)
|
||||||
|
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
|
||||||
|
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
|
||||||
|
from packages.domain.trim_config import (
|
||||||
|
extract_trim_from_clip_config,
|
||||||
|
)
|
||||||
|
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
|
||||||
|
from packages.domain.trim_config import resolve_segments as _resolve_segments
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 最小裁剪时长(秒),低于此值视为无效
|
|
||||||
MIN_TRIM_DURATION = 0.1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TrimConfig:
|
|
||||||
"""裁剪配置.
|
|
||||||
|
|
||||||
三选二规则:start_time / end_time / duration 中必须至少给出两个,
|
|
||||||
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
|
|
||||||
|
|
||||||
边界保护:
|
|
||||||
- start_time < 0 → 钳制到 0
|
|
||||||
- end_time > 素材时长 → 钳制到素材时长
|
|
||||||
- 计算出的 duration < 最小阈值 → 标记为无效
|
|
||||||
"""
|
|
||||||
|
|
||||||
start_time: float = 0.0 # 入点(素材内时间,秒)
|
|
||||||
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
|
|
||||||
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
|
|
||||||
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
start = float(data.get("start_time", 0) or 0)
|
|
||||||
end = float(data.get("end_time", 0) or 0)
|
|
||||||
dur = float(data.get("duration", 0) or 0)
|
|
||||||
|
|
||||||
# 三个参数都没有 → 不裁剪
|
|
||||||
if start <= 0 and end <= 0 and dur <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 至少有两个参数(或一个合理的 start/duration)
|
|
||||||
# 兼容:只传了 start_time → 从 start 开始取到末尾
|
|
||||||
# 兼容:只传了 duration → 从 0 开始取 duration
|
|
||||||
if start > 0 and end <= 0 and dur <= 0:
|
|
||||||
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
|
|
||||||
pass
|
|
||||||
elif dur > 0 and start <= 0 and end <= 0:
|
|
||||||
# 只有 duration → 从开头取 duration,算有效
|
|
||||||
pass
|
|
||||||
elif start <= 0 and end <= 0 and dur <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return cls(start_time=start, end_time=end, duration=dur)
|
|
||||||
|
|
||||||
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
|
|
||||||
"""根据素材实际时长,解析并钳制裁剪参数.
|
|
||||||
|
|
||||||
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
|
|
||||||
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
|
|
||||||
"""
|
|
||||||
start = self.start_time
|
|
||||||
end = self.end_time
|
|
||||||
dur = self.duration
|
|
||||||
|
|
||||||
# 边界:start 不能为负
|
|
||||||
if start < 0:
|
|
||||||
start = 0.0
|
|
||||||
|
|
||||||
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
|
|
||||||
if asset_duration <= 0:
|
|
||||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
|
||||||
|
|
||||||
# 三选二推导
|
|
||||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
|
||||||
# 情况1:start + end 都有显式值
|
|
||||||
if start > 0 and end > 0:
|
|
||||||
if end <= start:
|
|
||||||
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
|
|
||||||
return TrimConfig(start_time=start, end_time=start, duration=0.0)
|
|
||||||
dur = end - start
|
|
||||||
# 情况2:end + duration 都有显式值
|
|
||||||
elif end > 0 and dur > 0:
|
|
||||||
start = end - dur
|
|
||||||
if start < 0:
|
|
||||||
start = 0.0
|
|
||||||
dur = end # 重新计算
|
|
||||||
# 情况3:start + duration 都有值(start 可以是 0)
|
|
||||||
elif dur > 0:
|
|
||||||
end = start + dur
|
|
||||||
# 情况4:只有 start → 取到素材末尾
|
|
||||||
elif start > 0 and end <= 0 and dur <= 0:
|
|
||||||
end = asset_duration
|
|
||||||
dur = end - start
|
|
||||||
# 情况5:只有 end → 从开头取到 end
|
|
||||||
elif end > 0 and start <= 0 and dur <= 0:
|
|
||||||
start = 0.0
|
|
||||||
dur = end
|
|
||||||
else:
|
|
||||||
# 都没有 → 不裁剪
|
|
||||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
|
||||||
|
|
||||||
# 边界钳制:end 不能超过素材时长
|
|
||||||
if end > asset_duration:
|
|
||||||
end = asset_duration
|
|
||||||
dur = end - start
|
|
||||||
|
|
||||||
# 边界钳制:start 不能超过素材时长
|
|
||||||
if start >= asset_duration:
|
|
||||||
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
|
|
||||||
dur = asset_duration - start
|
|
||||||
end = asset_duration
|
|
||||||
|
|
||||||
# 保证 duration 不为负
|
|
||||||
if dur < 0:
|
|
||||||
dur = 0.0
|
|
||||||
|
|
||||||
return TrimConfig(start_time=start, end_time=end, duration=dur)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_valid(self) -> bool:
|
|
||||||
"""裁剪是否有效(时长大于最小阈值)."""
|
|
||||||
return self.duration >= MIN_TRIM_DURATION
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_noop(self) -> bool:
|
|
||||||
"""是否等价于不裁剪(从0开始取全部)."""
|
|
||||||
return self.start_time <= 0 and self.duration <= 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def trim_from_start(self) -> bool:
|
|
||||||
"""是否从开头裁剪(start_time == 0)."""
|
|
||||||
return self.start_time <= 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TrimSegment:
|
|
||||||
"""多段裁剪中的一段."""
|
|
||||||
|
|
||||||
segment_id: str # 段 ID(用于生成唯一标签)
|
|
||||||
trim: TrimConfig # 裁剪配置
|
|
||||||
order: int = 0 # 排序
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
|
|
||||||
"""从字典构造."""
|
|
||||||
return cls(
|
|
||||||
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
|
|
||||||
trim=TrimConfig(
|
|
||||||
start_time=float(data.get("start_time", 0) or 0),
|
|
||||||
end_time=float(data.get("end_time", 0) or 0),
|
|
||||||
duration=float(data.get("duration", 0) or 0),
|
|
||||||
),
|
|
||||||
order=int(data.get("order", default_order)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrimEngine:
|
class TrimEngine:
|
||||||
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜."""
|
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜.
|
||||||
|
|
||||||
|
薄包装层,实际逻辑委托给 packages.domain.trim_config。
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_video_trim_filter(
|
def build_video_trim_filter(
|
||||||
@@ -175,38 +43,8 @@ class TrimEngine:
|
|||||||
trim: TrimConfig,
|
trim: TrimConfig,
|
||||||
output_label: str,
|
output_label: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""构建视频裁剪滤镜链.
|
"""构建视频裁剪滤镜链."""
|
||||||
|
return _build_video_trim_filter(input_label, trim, output_label)
|
||||||
Args:
|
|
||||||
input_label: 输入视频标签,如 "[0:v]"
|
|
||||||
trim: 裁剪配置(已解析钳制)
|
|
||||||
output_label: 输出视频标签,如 "[v0_trimmed]"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
|
|
||||||
"""
|
|
||||||
if trim.is_noop:
|
|
||||||
# 不裁剪,直接直通(仅重置时间戳)
|
|
||||||
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
|
|
||||||
# trim 滤镜参数
|
|
||||||
trim_args: list[str] = []
|
|
||||||
if trim.start_time > 0:
|
|
||||||
trim_args.append(f"start={trim.start_time:.3f}")
|
|
||||||
if trim.duration > 0:
|
|
||||||
trim_args.append(f"duration={trim.duration:.3f}")
|
|
||||||
elif trim.end_time > 0:
|
|
||||||
# end 用 duration 表示(start 到 end 的时长)
|
|
||||||
# 但 validate_and_resolve 后应该已经有 duration 了
|
|
||||||
pass
|
|
||||||
|
|
||||||
parts.append(f"trim={':'.join(trim_args)}")
|
|
||||||
parts.append("setpts=PTS-STARTPTS")
|
|
||||||
|
|
||||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
|
||||||
return filter_str
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_audio_trim_filter(
|
def build_audio_trim_filter(
|
||||||
@@ -214,126 +52,18 @@ class TrimEngine:
|
|||||||
trim: TrimConfig,
|
trim: TrimConfig,
|
||||||
output_label: str,
|
output_label: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""构建音频裁剪滤镜链.
|
"""构建音频裁剪滤镜链."""
|
||||||
|
return _build_audio_trim_filter(input_label, trim, output_label)
|
||||||
Args:
|
|
||||||
input_label: 输入音频标签,如 "[0:a]"
|
|
||||||
trim: 裁剪配置(已解析钳制)
|
|
||||||
output_label: 输出音频标签,如 "[a0_trimmed]"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FFmpeg filter 字符串,如 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]"
|
|
||||||
"""
|
|
||||||
if trim.is_noop:
|
|
||||||
return f"{input_label}asetpts=PTS-STARTPTS{output_label}"
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
|
|
||||||
trim_args: list[str] = []
|
|
||||||
if trim.start_time > 0:
|
|
||||||
trim_args.append(f"start={trim.start_time:.3f}")
|
|
||||||
if trim.duration > 0:
|
|
||||||
trim_args.append(f"duration={trim.duration:.3f}")
|
|
||||||
|
|
||||||
parts.append(f"atrim={':'.join(trim_args)}")
|
|
||||||
parts.append("asetpts=PTS-STARTPTS")
|
|
||||||
|
|
||||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
|
||||||
return filter_str
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_segments(
|
def resolve_segments(
|
||||||
segments: list[TrimSegment],
|
segments: list[TrimSegment],
|
||||||
asset_duration: float,
|
asset_duration: float,
|
||||||
) -> list[TrimSegment]:
|
) -> list[TrimSegment]:
|
||||||
"""解析并钳制多段裁剪配置,过滤无效段.
|
"""解析并钳制多段裁剪配置,过滤无效段."""
|
||||||
|
return _resolve_segments(segments, asset_duration)
|
||||||
Args:
|
|
||||||
segments: 原始段列表
|
|
||||||
asset_duration: 素材实际时长
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
解析后的有效段列表,按 order 排序
|
|
||||||
"""
|
|
||||||
resolved: list[TrimSegment] = []
|
|
||||||
for i, seg in enumerate(segments):
|
|
||||||
resolved_trim = seg.trim.validate_and_resolve(asset_duration)
|
|
||||||
if not resolved_trim.is_valid:
|
|
||||||
logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration)
|
|
||||||
continue
|
|
||||||
resolved.append(
|
|
||||||
TrimSegment(
|
|
||||||
segment_id=seg.segment_id,
|
|
||||||
trim=resolved_trim,
|
|
||||||
order=seg.order if seg.order >= 0 else i,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved.sort(key=lambda s: s.order)
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
|
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
|
||||||
"""从 clip config 中解析多段裁剪配置.
|
"""从 clip config 中解析多段裁剪配置."""
|
||||||
|
return _parse_segments_from_config(config)
|
||||||
config 中支持:
|
|
||||||
- trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ]
|
|
||||||
- trim_start / trim_end / trim_duration: 单段裁剪(兼容旧格式)
|
|
||||||
"""
|
|
||||||
if not config:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 优先解析多段
|
|
||||||
raw_segments = config.get("trim_segments", [])
|
|
||||||
if raw_segments and isinstance(raw_segments, list):
|
|
||||||
segments = []
|
|
||||||
for i, raw in enumerate(raw_segments):
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
segments.append(TrimSegment.from_dict(raw, default_order=i))
|
|
||||||
return segments
|
|
||||||
|
|
||||||
# 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造
|
|
||||||
has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
|
||||||
if has_single:
|
|
||||||
seg = TrimSegment(
|
|
||||||
segment_id="main",
|
|
||||||
trim=TrimConfig(
|
|
||||||
start_time=float(config.get("trim_start", 0) or 0),
|
|
||||||
end_time=float(config.get("trim_end", 0) or 0),
|
|
||||||
duration=float(config.get("trim_duration", 0) or 0),
|
|
||||||
),
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
return [seg]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
|
|
||||||
"""从 clip config 中提取单段裁剪配置.
|
|
||||||
|
|
||||||
兼容以下字段名:
|
|
||||||
- trim_start / trim_end / trim_duration
|
|
||||||
- start_time / end_time / duration(在 trim 子字典里)
|
|
||||||
"""
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# trim 子字典
|
|
||||||
if "trim" in config and isinstance(config["trim"], dict):
|
|
||||||
return TrimConfig.from_dict(config["trim"])
|
|
||||||
|
|
||||||
# 扁平字段
|
|
||||||
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
|
||||||
if not has_any:
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"start_time": config.get("trim_start", 0),
|
|
||||||
"end_time": config.get("trim_end", 0),
|
|
||||||
"duration": config.get("trim_duration", 0),
|
|
||||||
}
|
|
||||||
return TrimConfig.from_dict(data)
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
|
|||||||
from video_processing.tts_engine import TtsEngine
|
from video_processing.tts_engine import TtsEngine
|
||||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||||
|
|
||||||
|
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
|
||||||
|
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
|
||||||
|
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
|
||||||
|
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
|
||||||
|
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
|
||||||
|
from packages.domain.render_layer_utils import estimate_total_duration as _estimate_total_duration_pure
|
||||||
|
from packages.domain.render_layer_utils import resolve_layer_role as _resolve_layer_role_pure
|
||||||
from packages.domain.tts_config import TtsConfig
|
from packages.domain.tts_config import TtsConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -107,47 +114,16 @@ class RenderResult:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
|
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
|
||||||
"""根据 clip_type 和 config.role 确定图层角色。
|
"""根据 clip_type 和 config.role 确定图层角色(向后兼容别名)。
|
||||||
|
|
||||||
映射规则:
|
实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。
|
||||||
intro / outro → "main"(按 order 排在首/尾)
|
|
||||||
overlay → "overlay"(画中画叠加,z=1)
|
|
||||||
corner_voice → "corner_voice"(右上角小窗,z=1)
|
|
||||||
background → "background"(全屏底图,z=0)
|
|
||||||
b_roll → "broll"(z=0)
|
|
||||||
main + config.role=b_roll → "broll"
|
|
||||||
main (default) → "main"
|
|
||||||
"""
|
"""
|
||||||
role = config.get("role", "")
|
return _resolve_layer_role_pure(clip_type, config)
|
||||||
|
|
||||||
if clip_type in ("intro", "outro"):
|
|
||||||
return "main"
|
|
||||||
if clip_type == "overlay":
|
|
||||||
return "overlay"
|
|
||||||
if clip_type == "corner_voice":
|
|
||||||
return "corner_voice"
|
|
||||||
if clip_type == "background":
|
|
||||||
return "background"
|
|
||||||
if clip_type == "b_roll":
|
|
||||||
return "broll"
|
|
||||||
# main type
|
|
||||||
if role == "b_roll":
|
|
||||||
return "broll"
|
|
||||||
if role == "audio":
|
|
||||||
return "audio"
|
|
||||||
return "main"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
|
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_LAYER_Z_INDEX: dict[str, int] = {
|
_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
|
||||||
"background": -1,
|
|
||||||
"broll": 0,
|
|
||||||
"main": 0,
|
|
||||||
"overlay": 1,
|
|
||||||
"corner_voice": 1,
|
|
||||||
"audio": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 图层默认 PiP 位置(相对输出画布的偏移)
|
# 图层默认 PiP 位置(相对输出画布的偏移)
|
||||||
_PIP_SCALE = 0.25 # PiP 占主画面的比例
|
_PIP_SCALE = 0.25 # PiP 占主画面的比例
|
||||||
@@ -489,29 +465,9 @@ class UnifiedRenderService:
|
|||||||
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
|
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
|
||||||
"""估算视频总时长(用于字幕等需要)。
|
"""估算视频总时长(用于字幕等需要)。
|
||||||
|
|
||||||
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。
|
实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
|
||||||
"""
|
"""
|
||||||
# 找主图层(第一个有视频内容的图层)
|
return _estimate_total_duration_pure(layers, self.transition_duration)
|
||||||
main_layer = None
|
|
||||||
for role in ("main", "broll", "background"):
|
|
||||||
for layer in layers:
|
|
||||||
if layer.role == role:
|
|
||||||
main_layer = layer
|
|
||||||
break
|
|
||||||
if main_layer:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not main_layer or not main_layer.clips:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
|
|
||||||
|
|
||||||
# 减去转场重叠时间(粗略估算)
|
|
||||||
n_clips = len(main_layer.clips)
|
|
||||||
if n_clips > 1:
|
|
||||||
total -= (n_clips - 1) * self.transition_duration
|
|
||||||
|
|
||||||
return max(0.1, total)
|
|
||||||
|
|
||||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||||
"""根据 plan.config 生成 ASS 字幕文件。
|
"""根据 plan.config 生成 ASS 字幕文件。
|
||||||
@@ -1869,10 +1825,11 @@ class UnifiedRenderService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||||
"""计算 clip 的有效时长(原速 trim 后时长)."""
|
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||||
if clip.duration > 0:
|
|
||||||
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
|
||||||
return clip.actual_duration if clip.actual_duration > 0 else 0.0
|
"""
|
||||||
|
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
|
||||||
|
|
||||||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1969,17 +1926,20 @@ class UnifiedRenderService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clip_speed(clip: ResolvedClip) -> float:
|
def _clip_speed(clip: ResolvedClip) -> float:
|
||||||
"""获取 clip 的播放速度,无效值回退到 1.0."""
|
"""获取 clip 的播放速度,无效值回退到 1.0。
|
||||||
speed = getattr(clip, "playback_speed", 1.0)
|
|
||||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
|
||||||
return 1.0
|
"""
|
||||||
return float(speed)
|
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
||||||
"""计算调速后的 clip 实际时长(用于拼接计算)."""
|
"""计算调速后的 clip 实际时长(用于拼接计算)。
|
||||||
base = UnifiedRenderService._clip_effective_duration(clip)
|
|
||||||
speed = UnifiedRenderService._clip_speed(clip)
|
实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。
|
||||||
if abs(speed - 1.0) < 1e-6:
|
"""
|
||||||
return base
|
return _clip_adjusted_duration_pure(
|
||||||
return base / speed
|
clip.duration,
|
||||||
|
clip.actual_duration,
|
||||||
|
getattr(clip, "playback_speed", 1.0),
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,135 +6,35 @@
|
|||||||
- 9宫格位置 + 边距配置
|
- 9宫格位置 + 边距配置
|
||||||
- 透明度/大小缩放
|
- 透明度/大小缩放
|
||||||
- 滚动水印(跑马灯)
|
- 滚动水印(跑马灯)
|
||||||
|
|
||||||
|
注:核心领域模型已抽离到 packages/domain/watermark_config.py,
|
||||||
|
本模块保留薄包装层,确保向后兼容。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from packages.domain.watermark_config import (
|
||||||
|
WATERMARK_POSITIONS,
|
||||||
|
WatermarkConfig,
|
||||||
|
)
|
||||||
|
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
|
||||||
|
build_image_watermark_filter as _build_image_watermark_filter,
|
||||||
|
)
|
||||||
|
from packages.domain.watermark_config import build_text_watermark_filter as _build_text_watermark_filter
|
||||||
|
from packages.domain.watermark_config import calc_position as _calc_position_base
|
||||||
|
from packages.domain.watermark_config import calc_scroll_x as _calc_scroll_x_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 9宫格位置枚举
|
|
||||||
WATERMARK_POSITIONS = {
|
|
||||||
"top_left": "左上",
|
|
||||||
"top_center": "中上",
|
|
||||||
"top_right": "右上",
|
|
||||||
"center_left": "左中",
|
|
||||||
"center": "中心",
|
|
||||||
"center_right": "右中",
|
|
||||||
"bottom_left": "左下",
|
|
||||||
"bottom_center": "中下",
|
|
||||||
"bottom_right": "右下",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WatermarkConfig:
|
|
||||||
"""水印配置.
|
|
||||||
|
|
||||||
mode: "image" 图片水印 | "text" 文字水印
|
|
||||||
position: 9宫格位置
|
|
||||||
opacity: 透明度 0.0-1.0
|
|
||||||
scale: 缩放比例(图片水印),0.1-1.0
|
|
||||||
margin: 边距(像素)
|
|
||||||
scroll: 是否滚动(跑马灯)
|
|
||||||
scroll_speed: 滚动速度(像素/秒)
|
|
||||||
"""
|
|
||||||
|
|
||||||
mode: str = "text" # image | text
|
|
||||||
position: str = "bottom_right"
|
|
||||||
|
|
||||||
# 图片水印
|
|
||||||
image_path: str = "" # 本地图片路径
|
|
||||||
scale: float = 0.2 # 相对输出宽度的比例
|
|
||||||
opacity: float = 0.8 # 0.0-1.0
|
|
||||||
|
|
||||||
# 文字水印
|
|
||||||
text: str = ""
|
|
||||||
font_size: int = 24
|
|
||||||
font_color: str = "white"
|
|
||||||
font_path: str = "" # 字体文件路径
|
|
||||||
|
|
||||||
# 边距
|
|
||||||
margin_x: int = 20
|
|
||||||
margin_y: int = 20
|
|
||||||
|
|
||||||
# 滚动水印
|
|
||||||
scroll: bool = False
|
|
||||||
scroll_speed: int = 50 # 像素/秒
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
|
|
||||||
"""从字典构造,空配置返回 None(不加水印)."""
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
enabled = data.get("enabled", False)
|
|
||||||
if not enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mode = data.get("mode", "text")
|
|
||||||
|
|
||||||
# 图片模式需要 image_path;文字模式需要 text
|
|
||||||
if mode == "image":
|
|
||||||
image_path = data.get("image_path", "") or data.get("image", "") or ""
|
|
||||||
if not image_path:
|
|
||||||
logger.warning("图片水印缺少 image_path,跳过水印")
|
|
||||||
return None
|
|
||||||
elif mode == "text":
|
|
||||||
text = data.get("text", "") or ""
|
|
||||||
if not text:
|
|
||||||
logger.warning("文字水印缺少 text,跳过水印")
|
|
||||||
return None
|
|
||||||
|
|
||||||
position = data.get("position", "bottom_right")
|
|
||||||
if position not in WATERMARK_POSITIONS:
|
|
||||||
position = "bottom_right"
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
mode=mode,
|
|
||||||
position=position,
|
|
||||||
image_path=str(data.get("image_path", data.get("image", "")) or ""),
|
|
||||||
scale=float(data.get("scale", 0.2)),
|
|
||||||
opacity=float(data.get("opacity", 0.8)),
|
|
||||||
text=str(data.get("text", "") or ""),
|
|
||||||
font_size=int(data.get("font_size", 24)),
|
|
||||||
font_color=str(data.get("font_color", "white")),
|
|
||||||
font_path=str(data.get("font_path", "") or ""),
|
|
||||||
margin_x=int(data.get("margin_x", 20)),
|
|
||||||
margin_y=int(data.get("margin_y", 20)),
|
|
||||||
scroll=bool(data.get("scroll", False)),
|
|
||||||
scroll_speed=int(data.get("scroll_speed", 50)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self) -> tuple[bool, str]:
|
|
||||||
"""校验配置是否有效."""
|
|
||||||
if self.position not in WATERMARK_POSITIONS:
|
|
||||||
return False, f"不支持的位置: {self.position}"
|
|
||||||
|
|
||||||
if not (0.0 <= self.opacity <= 1.0):
|
|
||||||
return False, "透明度必须在 0-1 之间"
|
|
||||||
|
|
||||||
if self.mode == "image":
|
|
||||||
if not self.image_path:
|
|
||||||
return False, "图片水印缺少图片路径"
|
|
||||||
if not (0.01 <= self.scale <= 1.0):
|
|
||||||
return False, "缩放比例必须在 0.01-1.0 之间"
|
|
||||||
elif self.mode == "text":
|
|
||||||
if not self.text:
|
|
||||||
return False, "文字水印缺少文字内容"
|
|
||||||
if self.font_size <= 0:
|
|
||||||
return False, "字体大小必须大于 0"
|
|
||||||
else:
|
|
||||||
return False, f"不支持的水印模式: {self.mode}"
|
|
||||||
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
class WatermarkEngine:
|
class WatermarkEngine:
|
||||||
"""水印引擎 — 生成 FFmpeg 水印滤镜."""
|
"""水印引擎 — 生成 FFmpeg 水印滤镜.
|
||||||
|
|
||||||
|
薄包装层,实际逻辑委托给 packages.domain.watermark_config。
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calc_position(
|
def calc_position(
|
||||||
@@ -150,27 +50,7 @@ class WatermarkEngine:
|
|||||||
|
|
||||||
坐标系:左上角为 (0, 0)
|
坐标系:左上角为 (0, 0)
|
||||||
"""
|
"""
|
||||||
if position == "top_left":
|
return _calc_position_base(position, output_width, output_height, wm_width, wm_height, margin_x, margin_y)
|
||||||
return margin_x, margin_y
|
|
||||||
elif position == "top_center":
|
|
||||||
return (output_width - wm_width) // 2, margin_y
|
|
||||||
elif position == "top_right":
|
|
||||||
return output_width - wm_width - margin_x, margin_y
|
|
||||||
elif position == "center_left":
|
|
||||||
return margin_x, (output_height - wm_height) // 2
|
|
||||||
elif position == "center":
|
|
||||||
return (output_width - wm_width) // 2, (output_height - wm_height) // 2
|
|
||||||
elif position == "center_right":
|
|
||||||
return output_width - wm_width - margin_x, (output_height - wm_height) // 2
|
|
||||||
elif position == "bottom_left":
|
|
||||||
return margin_x, output_height - wm_height - margin_y
|
|
||||||
elif position == "bottom_center":
|
|
||||||
return (output_width - wm_width) // 2, output_height - wm_height - margin_y
|
|
||||||
elif position == "bottom_right":
|
|
||||||
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
|
|
||||||
else:
|
|
||||||
# 默认右下角
|
|
||||||
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
|
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
|
||||||
@@ -178,12 +58,7 @@ class WatermarkEngine:
|
|||||||
|
|
||||||
从右向左滚动(跑马灯效果)
|
从右向左滚动(跑马灯效果)
|
||||||
"""
|
"""
|
||||||
# x 从 W 到 -wm_width,整个宽度 + wm_width 的距离
|
return _calc_scroll_x_base(position, output_width, wm_width, speed)
|
||||||
# 使用 overlay 的 enable 表达式
|
|
||||||
# x = 'W - (t * speed)' → 不对,应该是持续滚动
|
|
||||||
# 标准跑马灯:x = -w + (t * speed) % (W + w)
|
|
||||||
# 但 FFmpeg overlay 支持表达式
|
|
||||||
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_image_watermark_filter(
|
def build_image_watermark_filter(
|
||||||
@@ -208,51 +83,15 @@ class WatermarkEngine:
|
|||||||
(filter_complex_str, input_args_list)
|
(filter_complex_str, input_args_list)
|
||||||
input_args 是 ["-i", wm_image_path] 格式
|
input_args 是 ["-i", wm_image_path] 格式
|
||||||
"""
|
"""
|
||||||
# 计算水印尺寸(按输出宽度比例缩放)
|
return _build_image_watermark_filter(
|
||||||
wm_width = int(output_width * config.scale)
|
input_video_label,
|
||||||
wm_height = -1 # 保持比例
|
wm_image_path,
|
||||||
wm_filter = f"scale={wm_width}:{wm_height}"
|
|
||||||
|
|
||||||
# 透明度处理
|
|
||||||
if config.opacity < 1.0:
|
|
||||||
wm_filter += f",format=rgba,colorchannelmixer=aa={config.opacity}"
|
|
||||||
|
|
||||||
# 水印预处理标签
|
|
||||||
wm_pre_label = "[wm_scaled]"
|
|
||||||
|
|
||||||
# 计算位置
|
|
||||||
x, y = WatermarkEngine.calc_position(
|
|
||||||
config.position,
|
|
||||||
output_width,
|
output_width,
|
||||||
output_height,
|
output_height,
|
||||||
wm_width,
|
output_label,
|
||||||
wm_width, # 高度未知,先用宽度估算
|
config,
|
||||||
config.margin_x,
|
|
||||||
config.margin_y,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 滚动水印
|
|
||||||
if config.scroll:
|
|
||||||
# 从右向左滚动:x = W - (t * speed) mod (W + wm_w)
|
|
||||||
# 使用 overlay 表达式
|
|
||||||
x_expr = f"{output_width}-mod({config.scroll_speed}*t\\,{output_width}+{wm_width}"
|
|
||||||
y_expr = str(y)
|
|
||||||
overlay_expr = f"x={x_expr}:y={y_expr}"
|
|
||||||
else:
|
|
||||||
overlay_expr = f"x={x}:y={y}"
|
|
||||||
|
|
||||||
# 构建滤镜
|
|
||||||
# 先缩放水印图
|
|
||||||
filter_parts = [
|
|
||||||
f"[1:v]{wm_filter}{wm_pre_label}",
|
|
||||||
f"{input_video_label}{wm_pre_label}overlay={overlay_expr}{output_label}",
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_complex = ";".join(filter_parts)
|
|
||||||
input_args = ["-i", wm_image_path]
|
|
||||||
|
|
||||||
return filter_complex, input_args
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_text_watermark_filter(
|
def build_text_watermark_filter(
|
||||||
input_video_label: str,
|
input_video_label: str,
|
||||||
@@ -273,42 +112,4 @@ class WatermarkEngine:
|
|||||||
Returns:
|
Returns:
|
||||||
FFmpeg filter 字符串
|
FFmpeg filter 字符串
|
||||||
"""
|
"""
|
||||||
# 转义文字中的特殊字符
|
return _build_text_watermark_filter(input_video_label, output_label, config, output_width, output_height)
|
||||||
text = config.text.replace(":", "\\:").replace("'", "\\'")
|
|
||||||
|
|
||||||
# 字体配置
|
|
||||||
font_config = []
|
|
||||||
if config.font_path:
|
|
||||||
font_path_escaped = config.font_path.replace(":", "\\:").replace("'", "\\'")
|
|
||||||
font_config.append(f"fontfile='{font_path_escaped}'")
|
|
||||||
font_config.append(f"fontsize={config.font_size}")
|
|
||||||
font_config.append(f"fontcolor={config.font_color}@{config.opacity}")
|
|
||||||
|
|
||||||
# 估算文字宽高(粗略估算,用于位置计算)
|
|
||||||
# 每个汉字约等于 font_size 宽高
|
|
||||||
approx_w = len(config.text) * config.font_size
|
|
||||||
approx_h = config.font_size
|
|
||||||
|
|
||||||
# 位置计算
|
|
||||||
x, y = WatermarkEngine.calc_position(
|
|
||||||
config.position,
|
|
||||||
output_width,
|
|
||||||
output_height,
|
|
||||||
approx_w,
|
|
||||||
approx_h,
|
|
||||||
config.margin_x,
|
|
||||||
config.margin_y,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 滚动水印
|
|
||||||
if config.scroll:
|
|
||||||
x_expr = f"w-mod({config.scroll_speed}*t\\,W+w)"
|
|
||||||
pos_config = [f"x={x_expr}", f"y={y}"]
|
|
||||||
else:
|
|
||||||
pos_config = [f"x={x}", f"y={y}"]
|
|
||||||
|
|
||||||
# 组装 drawtext
|
|
||||||
drawtext_parts = [f"text='{text}'"] + font_config + pos_config
|
|
||||||
drawtext = "drawtext=" + ":".join(drawtext_parts)
|
|
||||||
|
|
||||||
return f"{input_video_label}{drawtext}{output_label}"
|
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from packages.domain.classification import AssetClassification
|
from packages.domain.classification import AssetClassification
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ from .asset_quality_scoring import (
|
|||||||
MotionAnalysis,
|
MotionAnalysis,
|
||||||
QualityScore,
|
QualityScore,
|
||||||
VideoInfo,
|
VideoInfo,
|
||||||
calculate_category_scores,
|
|
||||||
calculate_quality_score,
|
calculate_quality_score,
|
||||||
classify_from_analysis,
|
classify_from_analysis,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,19 +20,18 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from video_processing.ffmpeg_utils import probe_duration
|
||||||
from worker_app.celery_app import celery_app
|
from worker_app.celery_app import celery_app
|
||||||
from worker_app.db import SessionLocal
|
from worker_app.db import SessionLocal
|
||||||
|
from worker_app.tasks.generation_plan_builder import VirtualClip as _VirtualClip
|
||||||
|
from worker_app.tasks.generation_plan_builder import VirtualPlan as _VirtualPlan
|
||||||
|
from worker_app.tasks.generation_plan_builder import apply_template_clip_effects as _apply_template_clip_effects
|
||||||
|
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
|
||||||
|
from worker_app.tasks.generation_plan_builder import (
|
||||||
|
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
|
||||||
|
)
|
||||||
|
|
||||||
from packages.domain.bgm_utils import merge_bgm_config
|
from packages.domain.bgm_utils import merge_bgm_config
|
||||||
from video_processing.ffmpeg_utils import probe_duration
|
|
||||||
from worker_app.tasks.generation_plan_builder import (
|
|
||||||
VirtualPlan as _VirtualPlan,
|
|
||||||
VirtualClip as _VirtualClip,
|
|
||||||
build_error_info as _build_error_info,
|
|
||||||
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
|
|
||||||
apply_template_clip_effects as _apply_template_clip_effects,
|
|
||||||
build_clips_by_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
OUTPUT_WIDTH = 1280
|
OUTPUT_WIDTH = 1280
|
||||||
OUTPUT_HEIGHT = 720
|
OUTPUT_HEIGHT = 720
|
||||||
@@ -119,7 +118,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
|||||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||||
from video_processing.oss_helpers import (
|
from video_processing.oss_helpers import (
|
||||||
download_asset,
|
download_asset,
|
||||||
get_signed_download_url,
|
get_signed_download_url,
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from __future__ import annotations
|
|||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from typing import Any
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
# ── 数据类 ───────────────────────────────────────────────────────────────────
|
# ── 数据类 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -14,70 +14,12 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
SQLAlchemyIngestJobRepository,
|
SQLAlchemyIngestJobRepository,
|
||||||
)
|
)
|
||||||
from packages.domain import Asset, AssetStatus, IngestJobStatus
|
from packages.domain import Asset, AssetStatus, IngestJobStatus
|
||||||
|
from packages.domain.media_validation import is_valid_media as _is_valid_media
|
||||||
|
from packages.domain.media_validation import safe_parse_fps as _safe_parse_fps
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
|
|
||||||
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
|
|
||||||
MIN_AUDIO_FILE_SIZE = 100 # 100B
|
|
||||||
MIN_IMAGE_FILE_SIZE = 100 # 100B
|
|
||||||
|
|
||||||
# 支持的视频编码格式(白名单,尽可能放宽)
|
|
||||||
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
|
|
||||||
SUPPORTED_VIDEO_CODECS = {
|
|
||||||
"h264",
|
|
||||||
"avc1",
|
|
||||||
"avc", # H.264 / AVC
|
|
||||||
"hevc",
|
|
||||||
"h265",
|
|
||||||
"hev1",
|
|
||||||
"hvc1", # H.265 / HEVC
|
|
||||||
"vp9",
|
|
||||||
"vp09", # VP9
|
|
||||||
"av1",
|
|
||||||
"av01", # AV1
|
|
||||||
"vp8",
|
|
||||||
"vp08", # VP8
|
|
||||||
"mpeg4",
|
|
||||||
"mp4v", # MPEG-4
|
|
||||||
"mpeg2video",
|
|
||||||
"mpg2", # MPEG-2
|
|
||||||
"wmv2",
|
|
||||||
"wmv1",
|
|
||||||
"vc1", # WMV / VC-1
|
|
||||||
"flv1",
|
|
||||||
"flv",
|
|
||||||
"vp6f", # Flash / FLV
|
|
||||||
"theora",
|
|
||||||
"ogg", # Theora
|
|
||||||
"prores",
|
|
||||||
"prores_ks",
|
|
||||||
"apcn",
|
|
||||||
"apch",
|
|
||||||
"apco",
|
|
||||||
"apcs",
|
|
||||||
"ap4h",
|
|
||||||
"ap4x", # Apple ProRes
|
|
||||||
"dnxhd",
|
|
||||||
"dnxhr", # DNxHD / DNxHR
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_parse_fps(fps_str: str) -> float:
|
|
||||||
"""Safely parse fps from a fraction string like \"30/1\" or \"30000/1001\"."""
|
|
||||||
try:
|
|
||||||
if "/" in fps_str:
|
|
||||||
num, den = fps_str.split("/", 1)
|
|
||||||
den_val = float(den)
|
|
||||||
if den_val == 0:
|
|
||||||
return 0.0
|
|
||||||
return float(num) / den_val
|
|
||||||
return float(fps_str)
|
|
||||||
except (ValueError, ZeroDivisionError):
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
|
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
|
||||||
"""
|
"""
|
||||||
提取媒体文件的元数据。
|
提取媒体文件的元数据。
|
||||||
@@ -207,38 +149,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
|
|||||||
return metadata, success
|
return metadata, success
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_media(metadata: dict, media_type: str) -> bool:
|
|
||||||
"""根据元数据判断文件是否为有效媒体文件。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: extract_media_metadata 返回的元数据
|
|
||||||
media_type: 媒体类型
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True 表示文件有效
|
|
||||||
"""
|
|
||||||
size = int(metadata.get("size_bytes", 0))
|
|
||||||
|
|
||||||
if media_type == "video":
|
|
||||||
duration = float(metadata.get("duration", 0))
|
|
||||||
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
|
|
||||||
return False
|
|
||||||
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
|
|
||||||
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
|
|
||||||
codec = str(metadata.get("codec", "")).lower()
|
|
||||||
if codec and codec not in SUPPORTED_VIDEO_CODECS:
|
|
||||||
logger.info("检测到非白名单视频编码 %s,仍允许 ingested,渲染层会统一转码", codec)
|
|
||||||
return True
|
|
||||||
if media_type == "audio":
|
|
||||||
duration = float(metadata.get("duration", 0))
|
|
||||||
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
|
|
||||||
if media_type == "image":
|
|
||||||
width = int(metadata.get("width", 0))
|
|
||||||
height = int(metadata.get("height", 0))
|
|
||||||
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="worker.ingest_asset")
|
@celery_app.task(name="worker.ingest_asset")
|
||||||
def ingest_asset(job_id: str) -> dict:
|
def ingest_asset(job_id: str) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Executable
+249
@@ -0,0 +1,249 @@
|
|||||||
|
"""AI 响应解析纯逻辑模块.
|
||||||
|
|
||||||
|
抽离自 ai_service.py 的解析函数,方便单测覆盖,同时保持向后兼容。
|
||||||
|
包括:
|
||||||
|
- 标题列表解析(JSON/编号/换行/破折号格式)
|
||||||
|
- 语义匹配结果解析(多种JSON格式)
|
||||||
|
- 标题降级生成
|
||||||
|
- 关键词匹配降级
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ── 标题解析 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_titles_from_response(content: str) -> list[str]:
|
||||||
|
"""从模型返回中解析标题列表.
|
||||||
|
|
||||||
|
支持多种返回格式:
|
||||||
|
- JSON 数组: ["标题1", "标题2"]
|
||||||
|
- 编号列表: 1. 标题1 / 2. 标题2
|
||||||
|
- 换行分隔: 标题1\n标题2
|
||||||
|
- 带破折号: - 标题1
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
try:
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [str(item).strip() for item in data if str(item).strip()]
|
||||||
|
if isinstance(data, dict) and "titles" in data:
|
||||||
|
titles = data["titles"]
|
||||||
|
if isinstance(titles, list):
|
||||||
|
return [str(t).strip() for t in titles if str(t).strip()]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 尝试按行解析
|
||||||
|
titles: list[str] = []
|
||||||
|
for line in content.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 去掉编号前缀 "1. " "1、" "(1)"
|
||||||
|
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||||||
|
# 去掉破折号前缀 "- " "• "
|
||||||
|
line = re.sub(r"^[-•·]\s*", "", line)
|
||||||
|
# 去掉引号
|
||||||
|
line = line.strip('"').strip("'").strip("「」")
|
||||||
|
if line and len(line) < 100: # 过滤过长的行
|
||||||
|
titles.append(line)
|
||||||
|
|
||||||
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
# ── 语义匹配解析 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_semantic_match_response(
|
||||||
|
content: str,
|
||||||
|
asset_ids: list[str],
|
||||||
|
) -> dict[str, float] | None:
|
||||||
|
"""从模型返回中解析素材匹配度.
|
||||||
|
|
||||||
|
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||||
|
score 范围 0-1,自动截断到 [0, 1]。
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
|
||||||
|
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "matches" in data and isinstance(data["matches"], list):
|
||||||
|
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||||
|
for item in data["matches"]:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
aid = item.get("asset_id") or item.get("id")
|
||||||
|
score = item.get("score", 0)
|
||||||
|
if aid and isinstance(score, (int, float)):
|
||||||
|
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||||
|
else:
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||||
|
|
||||||
|
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
aid = item.get("asset_id") or item.get("id")
|
||||||
|
score = item.get("score", 0)
|
||||||
|
if aid and isinstance(score, (int, float)):
|
||||||
|
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||||
|
|
||||||
|
# 至少一半素材有评分才算成功
|
||||||
|
if asset_ids and len(result) >= max(1, len(asset_ids) // 2):
|
||||||
|
return result
|
||||||
|
# 没有 asset_ids 时,只要有结果就返回
|
||||||
|
if not asset_ids and result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 标题降级生成 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def generate_titles_fallback(
|
||||||
|
description: str,
|
||||||
|
style_info: dict[str, Any],
|
||||||
|
count: int = 5,
|
||||||
|
) -> list[str]:
|
||||||
|
"""本地降级:基于模板规则生成标题.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: 视频内容描述
|
||||||
|
style_info: 标题风格配置 {"name": ..., "examples": [...]}
|
||||||
|
count: 生成数量
|
||||||
|
"""
|
||||||
|
examples = style_info.get("examples", [])
|
||||||
|
|
||||||
|
# 从描述中提取关键词(取前几个词)
|
||||||
|
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||||||
|
keyword = keywords[0] if keywords else "精彩内容"
|
||||||
|
|
||||||
|
# 基于模板生成
|
||||||
|
example_0 = examples[0][:10] + "..." if examples else "必看"
|
||||||
|
example_1 = examples[1] if len(examples) > 1 else "你不知道的事"
|
||||||
|
|
||||||
|
templates = [
|
||||||
|
f"「{keyword}」{example_0}",
|
||||||
|
f"{keyword}:{example_1}",
|
||||||
|
f"关于{keyword},你不知道的3件事",
|
||||||
|
f"{keyword}入门指南,新手必看",
|
||||||
|
f"深度解析:{keyword}背后的秘密",
|
||||||
|
f"{keyword}怎么做?手把手教你",
|
||||||
|
f"干货分享 | {keyword}全攻略",
|
||||||
|
f"建议收藏:{keyword}实用技巧",
|
||||||
|
f"{keyword}避坑指南,别再踩雷了",
|
||||||
|
f"一分钟搞懂{keyword}",
|
||||||
|
]
|
||||||
|
|
||||||
|
random.shuffle(templates)
|
||||||
|
return templates[: min(count, len(templates))]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 关键词匹配降级 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_match_fallback(
|
||||||
|
description: str,
|
||||||
|
assets: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""本地降级:基于关键词的简单匹配.
|
||||||
|
|
||||||
|
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||||
|
作为匹配度评分。0-1分。
|
||||||
|
"""
|
||||||
|
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||||
|
desc = description.lower()
|
||||||
|
keywords: set[str] = set()
|
||||||
|
|
||||||
|
# 英文单词
|
||||||
|
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||||
|
keywords.add(word)
|
||||||
|
# 中文2-4字片段
|
||||||
|
for i in range(len(desc)):
|
||||||
|
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||||
|
fragment = desc[i:j]
|
||||||
|
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||||
|
keywords.add(fragment)
|
||||||
|
|
||||||
|
if not keywords:
|
||||||
|
# 没有关键词时给所有素材中等分数
|
||||||
|
results = []
|
||||||
|
for asset in assets:
|
||||||
|
new_asset = dict(asset)
|
||||||
|
new_asset["match_score"] = 0.5
|
||||||
|
new_asset["match_reason"] = "fallback_default"
|
||||||
|
results.append(new_asset)
|
||||||
|
return results
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for asset in assets:
|
||||||
|
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||||
|
asset_text_parts = [
|
||||||
|
str(asset.get("name", "")).lower(),
|
||||||
|
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||||
|
str(asset.get("description", "")).lower(),
|
||||||
|
]
|
||||||
|
asset_text = " | ".join(asset_text_parts)
|
||||||
|
|
||||||
|
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||||
|
hit_count = 0
|
||||||
|
hit_keywords: list[str] = []
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in asset_text:
|
||||||
|
hit_count += 1
|
||||||
|
hit_keywords.append(kw)
|
||||||
|
|
||||||
|
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||||
|
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||||
|
|
||||||
|
# 名称命中加分(名称匹配更重要)
|
||||||
|
name = str(asset.get("name", "")).lower()
|
||||||
|
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||||
|
name_bonus = min(0.2, name_hits * 0.05)
|
||||||
|
|
||||||
|
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||||
|
score = round(score, 3)
|
||||||
|
|
||||||
|
new_asset = dict(asset)
|
||||||
|
new_asset["match_score"] = score
|
||||||
|
new_asset["match_reason"] = "fallback_keyword"
|
||||||
|
results.append(new_asset)
|
||||||
|
|
||||||
|
# 按匹配度降序
|
||||||
|
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||||
|
return results
|
||||||
Executable
+306
@@ -0,0 +1,306 @@
|
|||||||
|
"""ASS 字幕构建领域模型 — 纯逻辑,无文件IO依赖.
|
||||||
|
|
||||||
|
抽离自 render_subtitles.py,包含:
|
||||||
|
- 颜色转换(hex → ASS &HBBGGRR)
|
||||||
|
- 位置对齐映射
|
||||||
|
- ASS Style 行构建
|
||||||
|
- 文本转义
|
||||||
|
- 时间格式化
|
||||||
|
- 完整 ASS 内容生成(返回字符串,不写文件)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Title/Subtitle 默认边距(像素)
|
||||||
|
TITLE_MARGIN_TOP = 60
|
||||||
|
TITLE_MARGIN_BOTTOM = 60
|
||||||
|
TITLE_MARGIN_SIDE = 40
|
||||||
|
|
||||||
|
|
||||||
|
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_ass_color(hex_color: str) -> str:
|
||||||
|
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hex_color: HEX 颜色字符串,支持 #RRGGBB 或 RRGGBB 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ASS 格式颜色,如 &H0000FF(红色)
|
||||||
|
"""
|
||||||
|
hex_color = hex_color.lstrip("#")
|
||||||
|
if len(hex_color) != 6:
|
||||||
|
return "&H000000"
|
||||||
|
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||||
|
return f"&H{b.upper()}{g.upper()}{r.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 位置对齐 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def position_to_ass_alignment(position: str) -> int:
|
||||||
|
"""将文字位置映射为 ASS \\an 对齐编号.
|
||||||
|
|
||||||
|
ASS 对齐编号(数字小键盘布局):
|
||||||
|
7 8 9
|
||||||
|
4 5 6
|
||||||
|
1 2 3
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: 位置字符串 top/center/bottom
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ASS 对齐编号,默认 8(顶部居中)
|
||||||
|
"""
|
||||||
|
mapping = {
|
||||||
|
"top": 8,
|
||||||
|
"center": 5,
|
||||||
|
"bottom": 2,
|
||||||
|
}
|
||||||
|
return mapping.get(position, 8)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Style 行构建 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_ass_style(
|
||||||
|
style_name: str,
|
||||||
|
*,
|
||||||
|
font_name: str = "思源黑体",
|
||||||
|
font_size: int = 48,
|
||||||
|
primary_color: str = "&H00FFFFFF",
|
||||||
|
outline_color: str = "&H00000000",
|
||||||
|
outline_width: float = 1.0,
|
||||||
|
shadow_blur: float = 0.0,
|
||||||
|
shadow_offset: tuple[int, int] = (0, 0),
|
||||||
|
bold: bool = False,
|
||||||
|
italic: bool = False,
|
||||||
|
alignment: int = 8,
|
||||||
|
margin_v: int = 60,
|
||||||
|
margin_l: int = 40,
|
||||||
|
margin_r: int = 40,
|
||||||
|
) -> str:
|
||||||
|
"""构建 ASS Style 行.
|
||||||
|
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
|
||||||
|
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
|
||||||
|
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style_name: 样式名称
|
||||||
|
font_name: 字体名称
|
||||||
|
font_size: 字体大小
|
||||||
|
primary_color: 主色(文字颜色)
|
||||||
|
outline_color: 描边颜色
|
||||||
|
outline_width: 描边宽度
|
||||||
|
shadow_blur: 阴影模糊度(>0 时启用阴影)
|
||||||
|
shadow_offset: 阴影偏移 (x, y)
|
||||||
|
bold: 是否粗体
|
||||||
|
italic: 是否斜体
|
||||||
|
alignment: 对齐方式(ASS \an 编号)
|
||||||
|
margin_v: 垂直边距
|
||||||
|
margin_l: 左边距
|
||||||
|
margin_r: 右边距
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的 Style: 行字符串
|
||||||
|
"""
|
||||||
|
bold_val = -1 if bold else 0
|
||||||
|
italic_val = -1 if italic else 0
|
||||||
|
|
||||||
|
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow)
|
||||||
|
back_color = primary_color
|
||||||
|
|
||||||
|
# Shadow 深度:shadow_offset[1] 作为纵向偏移
|
||||||
|
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Style: {style_name},{font_name},{font_size},{primary_color},"
|
||||||
|
f"&H000000FF,{outline_color},{back_color},"
|
||||||
|
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||||
|
f"1,{outline_width},{shadow_depth},{alignment},"
|
||||||
|
f"{margin_l},{margin_r},{margin_v},1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 文本转义 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def escape_ass_text(text: str) -> str:
|
||||||
|
r"""转义 ASS 文本中的特殊字符.
|
||||||
|
|
||||||
|
ASS 中换行用 \N(硬换行)或 \n(软换行),
|
||||||
|
大括号 {} 用于覆盖样式,需要转义.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 原始文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转义后的 ASS 文本
|
||||||
|
"""
|
||||||
|
# 将实际换行转为 ASS 硬换行
|
||||||
|
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||||
|
# 转义大括号(ASS 用它做样式覆盖标签)
|
||||||
|
text = text.replace("{", "(").replace("}", ")")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ── 时间格式化 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def format_ass_time(seconds: float) -> str:
|
||||||
|
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: 秒数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ASS 格式时间,如 "1:23:45.67"
|
||||||
|
"""
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = seconds % 60
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_ass_content(
|
||||||
|
*,
|
||||||
|
video_width: int,
|
||||||
|
video_height: int,
|
||||||
|
video_duration: float,
|
||||||
|
title_text: str = "",
|
||||||
|
title_config: dict[str, Any] | None = None,
|
||||||
|
subtitle_text: str = "",
|
||||||
|
subtitle_config: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""生成 ASS 字幕文件内容(纯字符串,不写文件).
|
||||||
|
|
||||||
|
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
|
||||||
|
各自可独立配置样式、位置和内容.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_width: 视频宽度(用于 ASS PlayResX)
|
||||||
|
video_height: 视频高度(用于 ASS PlayResY)
|
||||||
|
video_duration: 视频总时长(秒),字幕显示整个时长
|
||||||
|
title_text: 标题文本
|
||||||
|
title_config: 标题样式配置
|
||||||
|
subtitle_text: 字幕文本
|
||||||
|
subtitle_config: 字幕样式配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的 ASS 文件内容字符串;无字幕时返回空字符串
|
||||||
|
"""
|
||||||
|
title_config = title_config or {}
|
||||||
|
subtitle_config = subtitle_config or {}
|
||||||
|
|
||||||
|
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
||||||
|
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
||||||
|
|
||||||
|
if not title_enabled and not subtitle_enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
styles: list[str] = []
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
# ── Title 样式与事件 ──────────────────────────────────────────────────
|
||||||
|
if title_enabled:
|
||||||
|
title_color = hex_to_ass_color(title_config.get("color", "#ffffff"))
|
||||||
|
title_stroke = title_config.get("stroke", {}) or {}
|
||||||
|
title_shadow = title_config.get("shadow", {}) or {}
|
||||||
|
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
|
||||||
|
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
|
||||||
|
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
|
||||||
|
shadow_offset = (
|
||||||
|
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
|
||||||
|
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
|
||||||
|
|
||||||
|
styles.append(
|
||||||
|
build_ass_style(
|
||||||
|
"TitleStyle",
|
||||||
|
font_name=title_config.get("font", "思源黑体"),
|
||||||
|
font_size=int(title_config.get("size", 48)),
|
||||||
|
primary_color=title_color,
|
||||||
|
outline_color=stroke_color,
|
||||||
|
outline_width=stroke_width,
|
||||||
|
shadow_blur=shadow_blur,
|
||||||
|
shadow_offset=shadow_offset,
|
||||||
|
bold=bool(title_config.get("bold", True)),
|
||||||
|
italic=bool(title_config.get("italic", False)),
|
||||||
|
alignment=title_alignment,
|
||||||
|
margin_v=TITLE_MARGIN_TOP,
|
||||||
|
margin_l=TITLE_MARGIN_SIDE,
|
||||||
|
margin_r=TITLE_MARGIN_SIDE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_title_text = escape_ass_text(title_text)
|
||||||
|
|
||||||
|
events.append(
|
||||||
|
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
|
||||||
|
if subtitle_enabled:
|
||||||
|
sub_color = hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
|
||||||
|
sub_alignment = position_to_ass_alignment(subtitle_config.get("position", "bottom"))
|
||||||
|
|
||||||
|
styles.append(
|
||||||
|
build_ass_style(
|
||||||
|
"SubtitleStyle",
|
||||||
|
font_name=subtitle_config.get("font", "思源黑体"),
|
||||||
|
font_size=int(subtitle_config.get("size", 24)),
|
||||||
|
primary_color=sub_color,
|
||||||
|
outline_color="&H00000000",
|
||||||
|
outline_width=1.0,
|
||||||
|
shadow_blur=0.0,
|
||||||
|
shadow_offset=(0, 0),
|
||||||
|
bold=False,
|
||||||
|
italic=False,
|
||||||
|
alignment=sub_alignment,
|
||||||
|
margin_v=TITLE_MARGIN_BOTTOM,
|
||||||
|
margin_l=TITLE_MARGIN_SIDE,
|
||||||
|
margin_r=TITLE_MARGIN_SIDE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_subtitle_text = escape_ass_text(subtitle_text)
|
||||||
|
|
||||||
|
events.append(
|
||||||
|
"Dialogue: 0,0:00:00.00,"
|
||||||
|
f"{format_ass_time(video_duration)},"
|
||||||
|
"SubtitleStyle,,0,0,0,,"
|
||||||
|
f"{safe_subtitle_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
|
||||||
|
return f"""[Script Info]
|
||||||
|
ScriptType: v4.00+
|
||||||
|
PlayResX: {video_width}
|
||||||
|
PlayResY: {video_height}
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
WrapStyle: 2
|
||||||
|
Encoding: UTF-8
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
|
||||||
|
{chr(10).join(styles)}
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
{chr(10).join(events)}
|
||||||
|
"""
|
||||||
Executable
+214
@@ -0,0 +1,214 @@
|
|||||||
|
"""多轨道音频配置领域模型 — 纯逻辑,无外部依赖.
|
||||||
|
|
||||||
|
抽离自 multi_track_mixer.py 的数据类、常量和纯逻辑函数,
|
||||||
|
方便单测覆盖,同时保持向后兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
|
||||||
|
TRACK_TYPE_BGM = "bgm" # 背景音乐
|
||||||
|
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
|
||||||
|
TRACK_TYPE_SFX = "sfx" # 音效
|
||||||
|
TRACK_TYPE_AMBIENT = "ambient" # 环境音
|
||||||
|
|
||||||
|
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
|
||||||
|
|
||||||
|
# 各轨道默认音量(相对主音频)
|
||||||
|
DEFAULT_VOLUMES = {
|
||||||
|
TRACK_TYPE_MAIN: 1.0,
|
||||||
|
TRACK_TYPE_BGM: 0.3,
|
||||||
|
TRACK_TYPE_VOICEOVER: 1.0,
|
||||||
|
TRACK_TYPE_SFX: 0.7,
|
||||||
|
TRACK_TYPE_AMBIENT: 0.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
|
||||||
|
|
||||||
|
_VALID_TRACK_TYPES = {
|
||||||
|
TRACK_TYPE_MAIN,
|
||||||
|
TRACK_TYPE_BGM,
|
||||||
|
TRACK_TYPE_VOICEOVER,
|
||||||
|
TRACK_TYPE_SFX,
|
||||||
|
TRACK_TYPE_AMBIENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioTrack:
|
||||||
|
"""单条音频轨道配置."""
|
||||||
|
|
||||||
|
track_id: str = "" # 轨道唯一标识
|
||||||
|
track_type: str = TRACK_TYPE_SFX # 轨道类型
|
||||||
|
audio_path: str = "" # 音频文件路径
|
||||||
|
volume: float = 1.0 # 音量 0.0 ~ 2.0
|
||||||
|
fade_in: float = 0.0 # 淡入时长(秒)
|
||||||
|
fade_out: float = 0.0 # 淡出时长(秒)
|
||||||
|
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
|
||||||
|
duration: float = 0.0 # 持续时长(0表示到文件末尾)
|
||||||
|
enabled: bool = True # 是否启用
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, track: dict) -> "AudioTrack":
|
||||||
|
"""从字典创建 AudioTrack,带安全类型转换."""
|
||||||
|
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
|
||||||
|
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
volume = float(track.get("volume", default_vol))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
volume = default_vol
|
||||||
|
volume = max(0.0, min(2.0, volume))
|
||||||
|
|
||||||
|
try:
|
||||||
|
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
fade_in = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
fade_out = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = max(0.0, float(track.get("start_time", 0.0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
start_time = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
duration = max(0.0, float(track.get("duration", 0.0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = 0.0
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
track_id=str(track.get("track_id", "")),
|
||||||
|
track_type=track_type,
|
||||||
|
audio_path=str(track.get("audio_path", "")),
|
||||||
|
volume=volume,
|
||||||
|
fade_in=fade_in,
|
||||||
|
fade_out=fade_out,
|
||||||
|
start_time=start_time,
|
||||||
|
duration=duration,
|
||||||
|
enabled=bool(track.get("enabled", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||||
|
if not self.audio_path:
|
||||||
|
return False, "audio_path不能为空"
|
||||||
|
|
||||||
|
if self.volume < 0.0 or self.volume > 2.0:
|
||||||
|
return False, f"volume必须在0-2之间: {self.volume}"
|
||||||
|
|
||||||
|
if self.fade_in < 0:
|
||||||
|
return False, f"fade_in不能为负数: {self.fade_in}"
|
||||||
|
|
||||||
|
if self.fade_out < 0:
|
||||||
|
return False, f"fade_out不能为负数: {self.fade_out}"
|
||||||
|
|
||||||
|
if self.start_time < 0:
|
||||||
|
return False, f"start_time不能为负数: {self.start_time}"
|
||||||
|
|
||||||
|
if self.duration < 0:
|
||||||
|
return False, f"duration不能为负数: {self.duration}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_effective(self) -> bool:
|
||||||
|
"""是否为有效轨道(启用+有路径)."""
|
||||||
|
return self.enabled and bool(self.audio_path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MultiTrackMixConfig:
|
||||||
|
"""多轨道混音配置."""
|
||||||
|
|
||||||
|
tracks: list[AudioTrack] = field(default_factory=list)
|
||||||
|
master_volume: float = 1.0 # 主输出音量
|
||||||
|
normalize: bool = True # 是否自动归一化补偿
|
||||||
|
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
|
||||||
|
"""从 plan.config.audio_tracks 字典创建配置."""
|
||||||
|
if not config or not isinstance(config, dict):
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
tracks_raw = config.get("tracks", [])
|
||||||
|
tracks: list[AudioTrack] = []
|
||||||
|
|
||||||
|
if isinstance(tracks_raw, list):
|
||||||
|
for t in tracks_raw:
|
||||||
|
if isinstance(t, dict) and t.get("audio_path"):
|
||||||
|
try:
|
||||||
|
track = AudioTrack.from_dict(t)
|
||||||
|
if track.enabled and track.audio_path:
|
||||||
|
tracks.append(track)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[multi-track] skip invalid track config: %s", t)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
master_volume = float(config.get("master_volume", 1.0))
|
||||||
|
master_volume = max(0.0, min(2.0, master_volume))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
master_volume = 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_output_volume = float(config.get("max_output_volume", 1.5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_output_volume = 1.5
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
tracks=tracks,
|
||||||
|
master_volume=master_volume,
|
||||||
|
normalize=bool(config.get("normalize", True)),
|
||||||
|
max_output_volume=max_output_volume,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_effect(self) -> bool:
|
||||||
|
"""是否有有效轨道需要混音."""
|
||||||
|
return len([t for t in self.tracks if t.is_effective]) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_track_count(self) -> int:
|
||||||
|
"""有效轨道数量."""
|
||||||
|
return len([t for t in self.tracks if t.is_effective])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def main_tracks(self) -> list[AudioTrack]:
|
||||||
|
"""主音轨列表."""
|
||||||
|
return [t for t in self.tracks if t.track_type == TRACK_TYPE_MAIN and t.is_effective]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bgm_tracks(self) -> list[AudioTrack]:
|
||||||
|
"""BGM轨道列表."""
|
||||||
|
return [t for t in self.tracks if t.track_type == TRACK_TYPE_BGM and t.is_effective]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_audio_extension(filename: str) -> bool:
|
||||||
|
"""检查文件扩展名是否为支持的音频格式."""
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
return ext in ALLOWED_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_volume(volume: float, min_vol: float = 0.0, max_vol: float = 2.0) -> float:
|
||||||
|
"""限制音量在合法范围内."""
|
||||||
|
return max(min_vol, min(max_vol, volume))
|
||||||
Executable
+287
@@ -0,0 +1,287 @@
|
|||||||
|
"""绿幕抠像配置领域模型 — 纯逻辑,无FFmpeg依赖.
|
||||||
|
|
||||||
|
抽离自 chroma_key_engine.py,包含:
|
||||||
|
- ChromaKeyConfig 数据类(解析/钳制/效果判断)
|
||||||
|
- 预设配置(绿幕/蓝幕/红幕等)
|
||||||
|
- 颜色归一化
|
||||||
|
- colorkey / chromakey 滤镜构建
|
||||||
|
- 便捷函数(apply_chroma_key_if_needed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 预设配置 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 常见绿幕/蓝幕预设
|
||||||
|
CHROMA_KEY_PRESETS: dict[str, dict[str, Any]] = {
|
||||||
|
"green_screen": {
|
||||||
|
"key_color": "#00FF00",
|
||||||
|
"similarity": 0.3,
|
||||||
|
"blend": 0.1,
|
||||||
|
"spill_suppress": 0.5,
|
||||||
|
},
|
||||||
|
"blue_screen": {
|
||||||
|
"key_color": "#0000FF",
|
||||||
|
"similarity": 0.3,
|
||||||
|
"blend": 0.1,
|
||||||
|
"spill_suppress": 0.5,
|
||||||
|
},
|
||||||
|
"red_screen": {
|
||||||
|
"key_color": "#FF0000",
|
||||||
|
"similarity": 0.3,
|
||||||
|
"blend": 0.1,
|
||||||
|
"spill_suppress": 0.0,
|
||||||
|
},
|
||||||
|
"precise_green": {
|
||||||
|
"key_color": "#00FF00",
|
||||||
|
"similarity": 0.2,
|
||||||
|
"blend": 0.05,
|
||||||
|
"spill_suppress": 0.3,
|
||||||
|
},
|
||||||
|
"soft_green": {
|
||||||
|
"key_color": "#00FF00",
|
||||||
|
"similarity": 0.45,
|
||||||
|
"blend": 0.2,
|
||||||
|
"spill_suppress": 0.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_PRESETS = set(CHROMA_KEY_PRESETS.keys())
|
||||||
|
|
||||||
|
# 参数范围
|
||||||
|
MIN_SIMILARITY = 0.01
|
||||||
|
MAX_SIMILARITY = 1.0
|
||||||
|
MIN_BLEND = 0.0
|
||||||
|
MAX_BLEND = 1.0
|
||||||
|
MIN_SPILL_SUPPRESS = 0.0
|
||||||
|
MAX_SPILL_SUPPRESS = 1.0
|
||||||
|
|
||||||
|
# 默认值
|
||||||
|
DEFAULT_KEY_COLOR = "#00FF00"
|
||||||
|
DEFAULT_SIMILARITY = 0.3
|
||||||
|
DEFAULT_BLEND = 0.1
|
||||||
|
DEFAULT_SPILL_SUPPRESS = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChromaKeyConfig:
|
||||||
|
"""绿幕抠像配置.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enabled: 是否启用抠像
|
||||||
|
key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名
|
||||||
|
similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大
|
||||||
|
blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和
|
||||||
|
spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
key_color: str = DEFAULT_KEY_COLOR
|
||||||
|
similarity: float = DEFAULT_SIMILARITY
|
||||||
|
blend: float = DEFAULT_BLEND
|
||||||
|
spill_suppress: float = DEFAULT_SPILL_SUPPRESS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> ChromaKeyConfig:
|
||||||
|
"""从字典解析配置,参数越界自动钳制."""
|
||||||
|
if not data or not data.get("enabled", False):
|
||||||
|
return cls(enabled=False)
|
||||||
|
|
||||||
|
key_color = str(data.get("key_color", DEFAULT_KEY_COLOR)).strip()
|
||||||
|
|
||||||
|
def _safe_float(val: Any, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
similarity = _safe_float(data.get("similarity", DEFAULT_SIMILARITY), DEFAULT_SIMILARITY)
|
||||||
|
blend = _safe_float(data.get("blend", DEFAULT_BLEND), DEFAULT_BLEND)
|
||||||
|
spill_suppress = _safe_float(data.get("spill_suppress", DEFAULT_SPILL_SUPPRESS), DEFAULT_SPILL_SUPPRESS)
|
||||||
|
|
||||||
|
# 钳制到合法范围
|
||||||
|
similarity = max(MIN_SIMILARITY, min(MAX_SIMILARITY, similarity))
|
||||||
|
blend = max(MIN_BLEND, min(MAX_BLEND, blend))
|
||||||
|
spill_suppress = max(MIN_SPILL_SUPPRESS, min(MAX_SPILL_SUPPRESS, spill_suppress))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
enabled=True,
|
||||||
|
key_color=key_color,
|
||||||
|
similarity=similarity,
|
||||||
|
blend=blend,
|
||||||
|
spill_suppress=spill_suppress,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_preset(cls, preset_name: str) -> ChromaKeyConfig | None:
|
||||||
|
"""从预设名称创建配置."""
|
||||||
|
preset = CHROMA_KEY_PRESETS.get(preset_name)
|
||||||
|
if not preset:
|
||||||
|
return None
|
||||||
|
return cls(
|
||||||
|
enabled=True,
|
||||||
|
key_color=preset["key_color"],
|
||||||
|
similarity=preset["similarity"],
|
||||||
|
blend=preset["blend"],
|
||||||
|
spill_suppress=preset["spill_suppress"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_effect(self) -> bool:
|
||||||
|
"""判断是否有实际抠像效果."""
|
||||||
|
return self.enabled and self.similarity > 0
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置是否有效."""
|
||||||
|
if not self.enabled:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if not self.key_color:
|
||||||
|
return False, "key_color 不能为空"
|
||||||
|
|
||||||
|
if not (MIN_SIMILARITY <= self.similarity <= MAX_SIMILARITY):
|
||||||
|
return False, f"similarity 必须在 {MIN_SIMILARITY}~{MAX_SIMILARITY} 之间"
|
||||||
|
|
||||||
|
if not (MIN_BLEND <= self.blend <= MAX_BLEND):
|
||||||
|
return False, f"blend 必须在 {MIN_BLEND}~{MAX_BLEND} 之间"
|
||||||
|
|
||||||
|
if not (MIN_SPILL_SUPPRESS <= self.spill_suppress <= MAX_SPILL_SUPPRESS):
|
||||||
|
return False, f"spill_suppress 必须在 {MIN_SPILL_SUPPRESS}~{MAX_SPILL_SUPPRESS} 之间"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 颜色归一化 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_color(color_str: str) -> str:
|
||||||
|
"""将颜色字符串转为 FFmpeg colorkey 接受的格式.
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB
|
||||||
|
- "0xRRGGBB" → 直接使用
|
||||||
|
- 颜色名(green/blue/red/black/white 等)→ 直接透传
|
||||||
|
"""
|
||||||
|
color = color_str.strip()
|
||||||
|
|
||||||
|
# hex 格式
|
||||||
|
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
|
||||||
|
if hex_match:
|
||||||
|
return f"0x{hex_match.group(1).upper()}"
|
||||||
|
|
||||||
|
# 已经是 0x 格式
|
||||||
|
if color.lower().startswith("0x"):
|
||||||
|
return color.upper()
|
||||||
|
|
||||||
|
# 颜色名直接透传(FFmpeg 支持常见颜色名)
|
||||||
|
return color
|
||||||
|
|
||||||
|
|
||||||
|
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_colorkey_filter(
|
||||||
|
config: ChromaKeyConfig,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
) -> str:
|
||||||
|
"""构建 colorkey 滤镜字符串.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 抠像配置
|
||||||
|
input_label: 输入标签,如 "[0:v]" 或 "[v0]"
|
||||||
|
output_label: 输出标签,如 "[ck0]"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FFmpeg 滤镜字符串
|
||||||
|
"""
|
||||||
|
if not config.has_effect():
|
||||||
|
return f"{input_label}copy{output_label}"
|
||||||
|
|
||||||
|
color = normalize_color(config.key_color)
|
||||||
|
similarity = config.similarity
|
||||||
|
blend = config.blend
|
||||||
|
|
||||||
|
# 基础 colorkey 滤镜
|
||||||
|
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
|
||||||
|
|
||||||
|
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
|
||||||
|
if config.spill_suppress > 0:
|
||||||
|
spill = config.spill_suppress
|
||||||
|
g_gain = max(0.3, 1.0 - spill * 0.7)
|
||||||
|
r_gain = 1.0 + spill * 0.15
|
||||||
|
b_gain = 1.0 + spill * 0.15
|
||||||
|
parts.append(f"colorchannelmixer=rr={r_gain}:gg={g_gain}:bb={b_gain}:aa=1")
|
||||||
|
|
||||||
|
return f"{input_label}{','.join(parts)}{output_label}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_chromakey_filter(
|
||||||
|
config: ChromaKeyConfig,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
) -> str:
|
||||||
|
"""使用 chromakey 滤镜(更高级的版本,支持更多参数).
|
||||||
|
|
||||||
|
注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜,
|
||||||
|
优先使用 colorkey(兼容性更好)。
|
||||||
|
"""
|
||||||
|
if not config.has_effect():
|
||||||
|
return f"{input_label}copy{output_label}"
|
||||||
|
|
||||||
|
color = normalize_color(config.key_color)
|
||||||
|
similarity = config.similarity
|
||||||
|
blend = config.blend
|
||||||
|
|
||||||
|
return f"{input_label}chromakey=color={color}:similarity={similarity}:blend={blend}{output_label}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具函数 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def apply_chroma_key_if_needed(
|
||||||
|
clip_config: dict[str, Any] | None,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clip_config: clip 的 config 字典
|
||||||
|
input_label: 输入标签
|
||||||
|
output_label: 输出标签
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
滤镜字符串,不需要抠像时返回 None
|
||||||
|
"""
|
||||||
|
if not clip_config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chroma_key_data = clip_config.get("chroma_key")
|
||||||
|
if not chroma_key_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = ChromaKeyConfig.from_dict(chroma_key_data)
|
||||||
|
if not config.has_effect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return build_colorkey_filter(config, input_label, output_label)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_names() -> list[str]:
|
||||||
|
"""获取所有预设名称列表."""
|
||||||
|
return sorted(list(CHROMA_KEY_PRESETS.keys()))
|
||||||
Executable
+267
@@ -0,0 +1,267 @@
|
|||||||
|
"""片段操作工具 — EditPlanClip 分割/合并等纯逻辑操作。
|
||||||
|
|
||||||
|
从 edit_plan_service.py 抽离的纯函数集合,专门负责:
|
||||||
|
- 片段分割:将一个片段从指定位置拆分为两个
|
||||||
|
- 片段合并:将多个连续片段合并为一个
|
||||||
|
- Order 重排计算
|
||||||
|
|
||||||
|
所有函数均为纯函数,不依赖数据库或外部 IO。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_SPLIT_DURATION = 5.0
|
||||||
|
ROUND_PRECISION = 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SplitResult:
|
||||||
|
"""片段分割结果。"""
|
||||||
|
|
||||||
|
left_duration: float
|
||||||
|
right_duration: float
|
||||||
|
right_start_time: float
|
||||||
|
left_trim_end: float
|
||||||
|
right_trim_start: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MergeResult:
|
||||||
|
"""片段合并结果。"""
|
||||||
|
|
||||||
|
total_duration: float
|
||||||
|
merged_text: str
|
||||||
|
merged_config: dict[str, Any]
|
||||||
|
first_order: int
|
||||||
|
shift_amount: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── 分割 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def validate_split_time(split_time: float, duration: float) -> None:
|
||||||
|
"""校验分割时间是否合法。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
split_time: 分割点(秒)
|
||||||
|
duration: 原片段时长(秒)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 分割时间不在 (0, duration) 范围内
|
||||||
|
"""
|
||||||
|
if split_time <= 0 or split_time >= duration:
|
||||||
|
raise ValueError(f"分割时间必须在 (0, {duration:.3f}) 范围内,当前: {split_time}")
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_split(
|
||||||
|
duration: float,
|
||||||
|
split_time: float,
|
||||||
|
start_time: float = 0.0,
|
||||||
|
*,
|
||||||
|
precision: int = ROUND_PRECISION,
|
||||||
|
) -> SplitResult:
|
||||||
|
"""计算片段分割后的各项参数。
|
||||||
|
|
||||||
|
左半部分:从 0 到 split_time
|
||||||
|
右半部分:从 split_time 到 duration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: 原片段时长(秒)
|
||||||
|
split_time: 分割点(秒)
|
||||||
|
start_time: 原片段起始时间(秒),右半部分 start_time 需要加上 left_duration
|
||||||
|
precision: 小数精度(默认 3 位,即毫秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SplitResult 包含左右部分的时长、右半部分 start_time、trim 信息
|
||||||
|
"""
|
||||||
|
validate_split_time(split_time, duration)
|
||||||
|
|
||||||
|
left_duration = round(split_time, precision)
|
||||||
|
right_duration = round(duration - split_time, precision)
|
||||||
|
right_start_time = round(start_time + left_duration, precision)
|
||||||
|
|
||||||
|
return SplitResult(
|
||||||
|
left_duration=left_duration,
|
||||||
|
right_duration=right_duration,
|
||||||
|
right_start_time=right_start_time,
|
||||||
|
left_trim_end=right_duration,
|
||||||
|
right_trim_start=left_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 合并 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def validate_merge_clips(clips: list[Any]) -> tuple[str, int]:
|
||||||
|
"""校验待合并的片段列表。
|
||||||
|
|
||||||
|
校验项:
|
||||||
|
1. 至少 2 个片段
|
||||||
|
2. 属于同一计划
|
||||||
|
3. order 连续
|
||||||
|
4. 类型一致
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clips: 按任意顺序排列的片段列表(会自动按 order 排序)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(plan_id, first_order) 元组
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 校验失败
|
||||||
|
"""
|
||||||
|
if len(clips) < 2:
|
||||||
|
raise ValueError("至少需要 2 个片段才能合并")
|
||||||
|
|
||||||
|
# 校验:同一计划
|
||||||
|
plan_id = clips[0].plan_id
|
||||||
|
for c in clips[1:]:
|
||||||
|
if c.plan_id != plan_id:
|
||||||
|
raise ValueError("只能合并同一计划下的片段")
|
||||||
|
|
||||||
|
# 按 order 排序
|
||||||
|
sorted_clips = sorted(clips, key=lambda c: c.order)
|
||||||
|
|
||||||
|
# 校验:order 连续
|
||||||
|
for i in range(1, len(sorted_clips)):
|
||||||
|
if sorted_clips[i].order != sorted_clips[i - 1].order + 1:
|
||||||
|
raise ValueError(f"片段不连续:order {sorted_clips[i-1].order} → {sorted_clips[i].order}")
|
||||||
|
|
||||||
|
# 校验:类型一致
|
||||||
|
clip_type = sorted_clips[0].clip_type
|
||||||
|
for c in sorted_clips[1:]:
|
||||||
|
if c.clip_type != clip_type:
|
||||||
|
raise ValueError("只能合并相同类型的片段")
|
||||||
|
|
||||||
|
return plan_id, sorted_clips[0].order
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_merge(
|
||||||
|
clips: list[Any],
|
||||||
|
*,
|
||||||
|
precision: int = ROUND_PRECISION,
|
||||||
|
) -> MergeResult:
|
||||||
|
"""计算多个片段合并后的参数。
|
||||||
|
|
||||||
|
合并规则:
|
||||||
|
- 时长:所有片段时长之和
|
||||||
|
- 文案:用换行连接非空文案
|
||||||
|
- config:后面的覆盖前面的,移除 trim_start/trim_end
|
||||||
|
- first_order:第一个片段的 order
|
||||||
|
- shift_amount:合并后 order 前移位数(n-1)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clips: 待合并片段列表(会自动按 order 排序)
|
||||||
|
precision: 时长精度(默认 3 位)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MergeResult 合并结果
|
||||||
|
"""
|
||||||
|
if not clips:
|
||||||
|
raise ValueError("合并的片段列表不能为空")
|
||||||
|
|
||||||
|
# 按 order 排序
|
||||||
|
sorted_clips = sorted(clips, key=lambda c: c.order)
|
||||||
|
|
||||||
|
# 总时长
|
||||||
|
total_duration = round(sum(c.duration for c in sorted_clips), precision)
|
||||||
|
|
||||||
|
# 合并文案
|
||||||
|
merged_text = "\n".join(c.text_content for c in sorted_clips if c.text_content and c.text_content.strip())
|
||||||
|
|
||||||
|
# 合并 config(后面的覆盖前面的)
|
||||||
|
merged_config: dict[str, Any] = {}
|
||||||
|
for c in sorted_clips:
|
||||||
|
if c.config:
|
||||||
|
merged_config.update(c.config)
|
||||||
|
# 清理 trim 相关字段(合并后就是完整片段了)
|
||||||
|
merged_config.pop("trim_start", None)
|
||||||
|
merged_config.pop("trim_end", None)
|
||||||
|
|
||||||
|
first_order = sorted_clips[0].order
|
||||||
|
shift_amount = len(sorted_clips) - 1
|
||||||
|
|
||||||
|
return MergeResult(
|
||||||
|
total_duration=total_duration,
|
||||||
|
merged_text=merged_text,
|
||||||
|
merged_config=merged_config,
|
||||||
|
first_order=first_order,
|
||||||
|
shift_amount=shift_amount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Order 重排 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_reorder_new_orders(
|
||||||
|
ordered_ids: list[str],
|
||||||
|
current_items: list[Any],
|
||||||
|
*,
|
||||||
|
id_attr: str = "id",
|
||||||
|
order_attr: str = "order",
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""根据新顺序计算每个 item 的新 order 值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ordered_ids: 按新顺序排列的 ID 列表
|
||||||
|
current_items: 当前所有 item 列表
|
||||||
|
id_attr: ID 属性名
|
||||||
|
order_attr: order 属性名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{item_id: new_order} 映射
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ID 列表与当前 items 不匹配
|
||||||
|
"""
|
||||||
|
current_ids = {getattr(c, id_attr) for c in current_items}
|
||||||
|
ordered_id_set = set(ordered_ids)
|
||||||
|
|
||||||
|
if ordered_id_set != current_ids:
|
||||||
|
raise ValueError("ID 列表与当前 items 不匹配")
|
||||||
|
|
||||||
|
return {item_id: idx for idx, item_id in enumerate(ordered_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_shift_orders(
|
||||||
|
items: list[Any],
|
||||||
|
threshold_order: int,
|
||||||
|
shift: int,
|
||||||
|
*,
|
||||||
|
excluded_ids: set[str] | None = None,
|
||||||
|
order_attr: str = "order",
|
||||||
|
id_attr: str = "id",
|
||||||
|
) -> list[tuple[Any, int]]:
|
||||||
|
"""计算 order 需要偏移的 items 及新 order 值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: 所有 item 列表
|
||||||
|
threshold_order: 只处理 order > threshold_order 的 item
|
||||||
|
shift: 偏移量(正数加,负数减)
|
||||||
|
excluded_ids: 排除的 ID 集合
|
||||||
|
order_attr: order 属性名
|
||||||
|
id_attr: ID 属性名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(item, new_order), ...] 列表
|
||||||
|
"""
|
||||||
|
excluded = excluded_ids or set()
|
||||||
|
result: list[tuple[Any, int]] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_id = getattr(item, id_attr)
|
||||||
|
if item_id in excluded:
|
||||||
|
continue
|
||||||
|
current_order = getattr(item, order_attr)
|
||||||
|
if current_order > threshold_order:
|
||||||
|
result.append((item, current_order + shift))
|
||||||
|
|
||||||
|
return result
|
||||||
Executable
+268
@@ -0,0 +1,268 @@
|
|||||||
|
"""色彩调色配置领域模型 — 纯逻辑,无 FFmpeg 依赖.
|
||||||
|
|
||||||
|
抽离自 color_grade_engine.py 的数据类、预设常量和纯逻辑函数,
|
||||||
|
方便单测覆盖,同时保持向后兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 预设常量 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PRESET_FRESH = "fresh" # 清新
|
||||||
|
PRESET_JAPANESE = "japanese" # 日系
|
||||||
|
PRESET_VINTAGE = "vintage" # 复古
|
||||||
|
PRESET_CINEMA = "cinema" # 电影
|
||||||
|
PRESET_FILM = "film" # 胶片
|
||||||
|
PRESET_BW = "black_white" # 黑白
|
||||||
|
PRESET_WARM = "warm" # 暖色
|
||||||
|
PRESET_COOL = "cool" # 冷色
|
||||||
|
|
||||||
|
VALID_PRESETS = {
|
||||||
|
PRESET_FRESH,
|
||||||
|
PRESET_JAPANESE,
|
||||||
|
PRESET_VINTAGE,
|
||||||
|
PRESET_CINEMA,
|
||||||
|
PRESET_FILM,
|
||||||
|
PRESET_BW,
|
||||||
|
PRESET_WARM,
|
||||||
|
PRESET_COOL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 预设名称 → 中文显示名
|
||||||
|
PRESET_DISPLAY_NAMES = {
|
||||||
|
PRESET_FRESH: "清新",
|
||||||
|
PRESET_JAPANESE: "日系",
|
||||||
|
PRESET_VINTAGE: "复古",
|
||||||
|
PRESET_CINEMA: "电影",
|
||||||
|
PRESET_FILM: "胶片",
|
||||||
|
PRESET_BW: "黑白",
|
||||||
|
PRESET_WARM: "暖色",
|
||||||
|
PRESET_COOL: "冷色",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 预设参数配置
|
||||||
|
# 每个预设包含:brightness, contrast, saturation, temperature, hue
|
||||||
|
PRESET_PARAMS: dict[str, dict[str, float]] = {
|
||||||
|
PRESET_FRESH: {
|
||||||
|
"brightness": 8,
|
||||||
|
"contrast": 10,
|
||||||
|
"saturation": 120,
|
||||||
|
"temperature": -8,
|
||||||
|
"hue": 5,
|
||||||
|
},
|
||||||
|
PRESET_JAPANESE: {
|
||||||
|
"brightness": 12,
|
||||||
|
"contrast": -15,
|
||||||
|
"saturation": 70,
|
||||||
|
"temperature": 10,
|
||||||
|
"hue": -5,
|
||||||
|
},
|
||||||
|
PRESET_VINTAGE: {
|
||||||
|
"brightness": -5,
|
||||||
|
"contrast": 5,
|
||||||
|
"saturation": 60,
|
||||||
|
"temperature": 25,
|
||||||
|
"hue": -8,
|
||||||
|
},
|
||||||
|
PRESET_CINEMA: {
|
||||||
|
"brightness": -8,
|
||||||
|
"contrast": 20,
|
||||||
|
"saturation": 75,
|
||||||
|
"temperature": -15,
|
||||||
|
"hue": -3,
|
||||||
|
},
|
||||||
|
PRESET_FILM: {
|
||||||
|
"brightness": -3,
|
||||||
|
"contrast": 12,
|
||||||
|
"saturation": 95,
|
||||||
|
"temperature": 15,
|
||||||
|
"hue": -2,
|
||||||
|
},
|
||||||
|
PRESET_BW: {
|
||||||
|
"brightness": 0,
|
||||||
|
"contrast": 15,
|
||||||
|
"saturation": 0,
|
||||||
|
"temperature": 0,
|
||||||
|
"hue": 0,
|
||||||
|
},
|
||||||
|
PRESET_WARM: {
|
||||||
|
"brightness": 5,
|
||||||
|
"contrast": 8,
|
||||||
|
"saturation": 110,
|
||||||
|
"temperature": 30,
|
||||||
|
"hue": -5,
|
||||||
|
},
|
||||||
|
PRESET_COOL: {
|
||||||
|
"brightness": 3,
|
||||||
|
"contrast": 8,
|
||||||
|
"saturation": 105,
|
||||||
|
"temperature": -25,
|
||||||
|
"hue": 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 参数范围 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PARAM_RANGES: dict[str, tuple[float, float]] = {
|
||||||
|
"brightness": (-100.0, 100.0),
|
||||||
|
"contrast": (-100.0, 100.0),
|
||||||
|
"saturation": (0.0, 200.0),
|
||||||
|
"temperature": (-100.0, 100.0),
|
||||||
|
"hue": (-180.0, 180.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 默认值(零调整)
|
||||||
|
DEFAULT_PARAMS: dict[str, float] = {
|
||||||
|
"brightness": 0.0,
|
||||||
|
"contrast": 0.0,
|
||||||
|
"saturation": 100.0,
|
||||||
|
"temperature": 0.0,
|
||||||
|
"hue": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_PARAM_KEYS = ("brightness", "contrast", "saturation", "temperature", "hue")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ColorGradeConfig:
|
||||||
|
"""色彩调色配置.
|
||||||
|
|
||||||
|
优先级:自定义参数 > 预设参数
|
||||||
|
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
preset: str = "" # 预设名称,空表示不使用预设
|
||||||
|
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
|
||||||
|
brightness: float | None = None
|
||||||
|
contrast: float | None = None
|
||||||
|
saturation: float | None = None
|
||||||
|
temperature: float | None = None
|
||||||
|
hue: float | None = None
|
||||||
|
|
||||||
|
def resolve_params(self) -> dict[str, float]:
|
||||||
|
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 brightness, contrast, saturation, temperature, hue 的参数字典
|
||||||
|
"""
|
||||||
|
# 1. 从默认值开始
|
||||||
|
params = dict(DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
# 2. 应用预设
|
||||||
|
if self.preset and self.preset in PRESET_PARAMS:
|
||||||
|
params.update(PRESET_PARAMS[self.preset])
|
||||||
|
|
||||||
|
# 3. 应用自定义覆盖
|
||||||
|
if self.brightness is not None:
|
||||||
|
params["brightness"] = self.brightness
|
||||||
|
if self.contrast is not None:
|
||||||
|
params["contrast"] = self.contrast
|
||||||
|
if self.saturation is not None:
|
||||||
|
params["saturation"] = self.saturation
|
||||||
|
if self.temperature is not None:
|
||||||
|
params["temperature"] = self.temperature
|
||||||
|
if self.hue is not None:
|
||||||
|
params["hue"] = self.hue
|
||||||
|
|
||||||
|
# 4. 边界钳制
|
||||||
|
for key, (min_val, max_val) in PARAM_RANGES.items():
|
||||||
|
params[key] = max(min_val, min(max_val, params[key]))
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def has_effect(self) -> bool:
|
||||||
|
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
|
||||||
|
|
||||||
|
用于优化:无效果时跳过滤镜,不浪费性能。
|
||||||
|
"""
|
||||||
|
params = self.resolve_params()
|
||||||
|
for key, default in DEFAULT_PARAMS.items():
|
||||||
|
if abs(params[key] - default) > 0.001:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
|
||||||
|
"""从字典解析配置."""
|
||||||
|
if not data or not data.get("enabled", False):
|
||||||
|
return cls(enabled=False)
|
||||||
|
|
||||||
|
preset = data.get("preset", "")
|
||||||
|
if preset and preset not in VALID_PRESETS:
|
||||||
|
logger.warning("未知的调色预设: %s,忽略预设", preset)
|
||||||
|
preset = ""
|
||||||
|
|
||||||
|
def _get_float(key: str) -> float | None:
|
||||||
|
val = data.get(key)
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
enabled=True,
|
||||||
|
preset=preset,
|
||||||
|
brightness=_get_float("brightness"),
|
||||||
|
contrast=_get_float("contrast"),
|
||||||
|
saturation=_get_float("saturation"),
|
||||||
|
temperature=_get_float("temperature"),
|
||||||
|
hue=_get_float("hue"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("调色配置解析失败: %s,使用默认配置", e)
|
||||||
|
return cls(enabled=False)
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||||
|
if not self.enabled:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if self.preset and self.preset not in VALID_PRESETS:
|
||||||
|
return False, f"未知的预设: {self.preset}"
|
||||||
|
|
||||||
|
# 解析后的参数自然在合法范围内(resolve_params 会钳制)
|
||||||
|
# 这里检查是否有明显无效的自定义值
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
val = getattr(self, key)
|
||||||
|
if val is not None:
|
||||||
|
min_val, max_val = PARAM_RANGES[key]
|
||||||
|
if val < min_val or val > max_val:
|
||||||
|
return False, f"{key}超出范围[{min_val}, {max_val}]: {val}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_names() -> list[tuple[str, str]]:
|
||||||
|
"""获取所有预设的 (name, display_name) 列表."""
|
||||||
|
return [(p, PRESET_DISPLAY_NAMES[p]) for p in sorted(VALID_PRESETS)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_params(preset: str) -> dict[str, float] | None:
|
||||||
|
"""获取指定预设的参数,不存在返回 None."""
|
||||||
|
return PRESET_PARAMS.get(preset)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_param(param_name: str, value: float) -> float:
|
||||||
|
"""将参数钳制到合法范围内."""
|
||||||
|
if param_name not in PARAM_RANGES:
|
||||||
|
return value
|
||||||
|
min_val, max_val = PARAM_RANGES[param_name]
|
||||||
|
return max(min_val, min(max_val, value))
|
||||||
Executable
+219
@@ -0,0 +1,219 @@
|
|||||||
|
"""片头片尾配置领域模型 — 纯逻辑,无外部依赖.
|
||||||
|
|
||||||
|
抽离自 intro_outro_engine.py 的数据类和纯逻辑函数,
|
||||||
|
方便单测覆盖,同时保持向后兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INTRO_OUTRO_TYPE_NONE = "none"
|
||||||
|
INTRO_OUTRO_TYPE_VIDEO = "video"
|
||||||
|
INTRO_OUTRO_TYPE_TEXT = "text"
|
||||||
|
INTRO_OUTRO_TYPE_FOLLOW = "follow"
|
||||||
|
|
||||||
|
TRANSITION_FADE = "fade"
|
||||||
|
TRANSITION_SLIDE = "slide"
|
||||||
|
TRANSITION_WIPE = "wipe"
|
||||||
|
|
||||||
|
_VALID_INTRO_TYPES = {INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_VIDEO, INTRO_OUTRO_TYPE_TEXT}
|
||||||
|
_VALID_OUTRO_TYPES = {
|
||||||
|
INTRO_OUTRO_TYPE_NONE,
|
||||||
|
INTRO_OUTRO_TYPE_VIDEO,
|
||||||
|
INTRO_OUTRO_TYPE_TEXT,
|
||||||
|
INTRO_OUTRO_TYPE_FOLLOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntroOutroConfig:
|
||||||
|
"""片头片尾配置.
|
||||||
|
|
||||||
|
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
# 片头
|
||||||
|
intro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text
|
||||||
|
intro_video_path: str = "" # 视频片段路径
|
||||||
|
intro_duration: float = 3.0 # 片头时长(秒)
|
||||||
|
|
||||||
|
# 文字片头配置
|
||||||
|
intro_background: str = "#000000" # 背景色
|
||||||
|
intro_title: str = ""
|
||||||
|
intro_subtitle: str = ""
|
||||||
|
intro_title_color: str = "white"
|
||||||
|
intro_title_size: int = 48
|
||||||
|
intro_subtitle_color: str = "gray"
|
||||||
|
intro_subtitle_size: int = 24
|
||||||
|
|
||||||
|
# 片尾
|
||||||
|
outro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text | follow
|
||||||
|
outro_video_path: str = "" # 视频片段路径
|
||||||
|
outro_duration: float = 3.0 # 片尾时长(秒)
|
||||||
|
|
||||||
|
# 文字片尾配置
|
||||||
|
outro_background: str = "#000000"
|
||||||
|
outro_title: str = "感谢观看"
|
||||||
|
outro_subtitle: str = "点赞关注不迷路"
|
||||||
|
outro_title_color: str = "white"
|
||||||
|
outro_title_size: int = 48
|
||||||
|
outro_subtitle_color: str = "gray"
|
||||||
|
outro_subtitle_size: int = 24
|
||||||
|
|
||||||
|
# 转场
|
||||||
|
transition_effect: str = TRANSITION_FADE
|
||||||
|
transition_duration: float = 0.5
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig":
|
||||||
|
"""从字典构造."""
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
enabled = data.get("enabled", False)
|
||||||
|
if not enabled:
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
intro = data.get("intro", {}) or {}
|
||||||
|
outro = data.get("outro", {}) or {}
|
||||||
|
|
||||||
|
# 安全解析数值,失败时回退到默认值
|
||||||
|
try:
|
||||||
|
intro_duration = float(intro.get("duration", 3.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
intro_duration = 3.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
intro_title_size = int(intro.get("title_size", 48))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
intro_title_size = 48
|
||||||
|
|
||||||
|
try:
|
||||||
|
intro_subtitle_size = int(intro.get("subtitle_size", 24))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
intro_subtitle_size = 24
|
||||||
|
|
||||||
|
try:
|
||||||
|
outro_duration = float(outro.get("duration", 3.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
outro_duration = 3.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
outro_title_size = int(outro.get("title_size", 48))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
outro_title_size = 48
|
||||||
|
|
||||||
|
try:
|
||||||
|
outro_subtitle_size = int(outro.get("subtitle_size", 24))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
outro_subtitle_size = 24
|
||||||
|
|
||||||
|
try:
|
||||||
|
transition_duration = float(data.get("transition_duration", 0.5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
transition_duration = 0.5
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
enabled=True,
|
||||||
|
# 片头
|
||||||
|
intro_type=str(intro.get("type", INTRO_OUTRO_TYPE_NONE)),
|
||||||
|
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
|
||||||
|
intro_duration=intro_duration,
|
||||||
|
intro_background=str(intro.get("background", "#000000")),
|
||||||
|
intro_title=str(intro.get("title", "") or ""),
|
||||||
|
intro_subtitle=str(intro.get("subtitle", "") or ""),
|
||||||
|
intro_title_color=str(intro.get("title_color", "white")),
|
||||||
|
intro_title_size=intro_title_size,
|
||||||
|
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
|
||||||
|
intro_subtitle_size=intro_subtitle_size,
|
||||||
|
# 片尾
|
||||||
|
outro_type=str(outro.get("type", INTRO_OUTRO_TYPE_NONE)),
|
||||||
|
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
|
||||||
|
outro_duration=outro_duration,
|
||||||
|
outro_background=str(outro.get("background", "#000000")),
|
||||||
|
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
|
||||||
|
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
|
||||||
|
outro_title_color=str(outro.get("title_color", "white")),
|
||||||
|
outro_title_size=outro_title_size,
|
||||||
|
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
|
||||||
|
outro_subtitle_size=outro_subtitle_size,
|
||||||
|
# 转场
|
||||||
|
transition_effect=str(data.get("transition", TRANSITION_FADE)),
|
||||||
|
transition_duration=transition_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_intro(self) -> bool:
|
||||||
|
"""是否有片头(视频或文字类型)."""
|
||||||
|
return self.enabled and self.intro_type in (
|
||||||
|
INTRO_OUTRO_TYPE_VIDEO,
|
||||||
|
INTRO_OUTRO_TYPE_TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_outro(self) -> bool:
|
||||||
|
"""是否有片尾(视频/文字/follow类型)."""
|
||||||
|
return self.enabled and self.outro_type in (
|
||||||
|
INTRO_OUTRO_TYPE_VIDEO,
|
||||||
|
INTRO_OUTRO_TYPE_TEXT,
|
||||||
|
INTRO_OUTRO_TYPE_FOLLOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_extra_duration(self) -> float:
|
||||||
|
"""片头片尾总共增加的时长(秒)."""
|
||||||
|
total = 0.0
|
||||||
|
if self.has_intro and self.intro_duration > 0:
|
||||||
|
total += self.intro_duration
|
||||||
|
if self.has_outro and self.outro_duration > 0:
|
||||||
|
total += self.outro_duration
|
||||||
|
return total
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||||
|
if not self.enabled:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if self.intro_type not in _VALID_INTRO_TYPES:
|
||||||
|
return False, f"无效的片头类型: {self.intro_type}"
|
||||||
|
|
||||||
|
if self.outro_type not in _VALID_OUTRO_TYPES:
|
||||||
|
return False, f"无效的片尾类型: {self.outro_type}"
|
||||||
|
|
||||||
|
if self.intro_type == INTRO_OUTRO_TYPE_VIDEO and not self.intro_video_path:
|
||||||
|
return False, "视频片头缺少 video_path"
|
||||||
|
if self.intro_type == INTRO_OUTRO_TYPE_TEXT and not self.intro_title:
|
||||||
|
return False, "文字片头缺少 title"
|
||||||
|
|
||||||
|
if self.outro_type == INTRO_OUTRO_TYPE_VIDEO and not self.outro_video_path:
|
||||||
|
return False, "视频片尾缺少 video_path"
|
||||||
|
if self.outro_type in (INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_FOLLOW) and not self.outro_title:
|
||||||
|
return False, "文字片尾缺少 title"
|
||||||
|
|
||||||
|
if self.intro_duration <= 0:
|
||||||
|
return False, "片头时长必须大于 0"
|
||||||
|
if self.outro_duration <= 0:
|
||||||
|
return False, "片尾时长必须大于 0"
|
||||||
|
|
||||||
|
if self.transition_duration < 0:
|
||||||
|
return False, "转场时长不能为负数"
|
||||||
|
|
||||||
|
if self.intro_title_size <= 0:
|
||||||
|
return False, "片头标题字号必须大于 0"
|
||||||
|
if self.intro_subtitle_size <= 0:
|
||||||
|
return False, "片头副标题字号必须大于 0"
|
||||||
|
if self.outro_title_size <= 0:
|
||||||
|
return False, "片尾标题字号必须大于 0"
|
||||||
|
if self.outro_subtitle_size <= 0:
|
||||||
|
return False, "片尾副标题字号必须大于 0"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""媒体文件有效性校验与元数据解析工具。
|
||||||
|
|
||||||
|
从 worker ingest 任务中抽取的纯逻辑模块,包含:
|
||||||
|
- FPS 解析:从分数格式字符串(如 30000/1001)安全解析帧率
|
||||||
|
- 媒体有效性校验:根据元数据判断视频/音频/图片文件是否有效
|
||||||
|
- 常量定义:最小文件大小、支持的视频编码白名单
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
|
||||||
|
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
|
||||||
|
MIN_AUDIO_FILE_SIZE = 100 # 100B
|
||||||
|
MIN_IMAGE_FILE_SIZE = 100 # 100B
|
||||||
|
|
||||||
|
# 支持的视频编码格式(白名单,尽可能放宽)
|
||||||
|
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
|
||||||
|
SUPPORTED_VIDEO_CODECS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"h264",
|
||||||
|
"avc1",
|
||||||
|
"avc", # H.264 / AVC
|
||||||
|
"hevc",
|
||||||
|
"h265",
|
||||||
|
"hev1",
|
||||||
|
"hvc1", # H.265 / HEVC
|
||||||
|
"vp9",
|
||||||
|
"vp09", # VP9
|
||||||
|
"av1",
|
||||||
|
"av01", # AV1
|
||||||
|
"vp8",
|
||||||
|
"vp08", # VP8
|
||||||
|
"mpeg4",
|
||||||
|
"mp4v", # MPEG-4
|
||||||
|
"mpeg2video",
|
||||||
|
"mpg2", # MPEG-2
|
||||||
|
"wmv2",
|
||||||
|
"wmv1",
|
||||||
|
"vc1", # WMV / VC-1
|
||||||
|
"flv1",
|
||||||
|
"flv",
|
||||||
|
"vp6f", # Flash / FLV
|
||||||
|
"theora",
|
||||||
|
"ogg", # Theora
|
||||||
|
"prores",
|
||||||
|
"prores_ks",
|
||||||
|
"apcn",
|
||||||
|
"apch",
|
||||||
|
"apco",
|
||||||
|
"apcs",
|
||||||
|
"ap4h",
|
||||||
|
"ap4x", # Apple ProRes
|
||||||
|
"dnxhd",
|
||||||
|
"dnxhr", # DNxHD / DNxHR
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_parse_fps(fps_str: str) -> float:
|
||||||
|
"""Safely parse fps from a fraction string like "30/1" or "30000/1001".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fps_str: FPS 字符串,支持小数格式("30.0")或分数格式("30000/1001")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
解析得到的帧率浮点数;解析失败或分母为0时返回 0.0
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if "/" in fps_str:
|
||||||
|
num, den = fps_str.split("/", 1)
|
||||||
|
den_val = float(den)
|
||||||
|
if den_val == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(num) / den_val
|
||||||
|
return float(fps_str)
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_media(metadata: dict, media_type: str) -> bool:
|
||||||
|
"""根据元数据判断文件是否为有效媒体文件。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: 媒体元数据字典,可能包含 size_bytes / duration / codec / width / height 等
|
||||||
|
media_type: 媒体类型(video / audio / image)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 表示文件有效
|
||||||
|
"""
|
||||||
|
size = int(metadata.get("size_bytes", 0))
|
||||||
|
|
||||||
|
if media_type == "video":
|
||||||
|
duration = float(metadata.get("duration", 0))
|
||||||
|
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
|
||||||
|
return False
|
||||||
|
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
|
||||||
|
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
|
||||||
|
codec = str(metadata.get("codec", "")).lower()
|
||||||
|
if codec and codec not in SUPPORTED_VIDEO_CODECS:
|
||||||
|
# 非白名单编码仍允许通过,仅记录日志(调用方负责日志)
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
if media_type == "audio":
|
||||||
|
duration = float(metadata.get("duration", 0))
|
||||||
|
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
|
||||||
|
if media_type == "image":
|
||||||
|
width = int(metadata.get("width", 0))
|
||||||
|
height = int(metadata.get("height", 0))
|
||||||
|
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
|
||||||
|
return False
|
||||||
Executable
+231
@@ -0,0 +1,231 @@
|
|||||||
|
"""音频降噪配置领域模型 — 纯逻辑,无FFmpeg依赖.
|
||||||
|
|
||||||
|
抽离自 noise_reduction_engine.py,包含:
|
||||||
|
- NoiseReductionLevel 枚举(low/medium/high/custom)
|
||||||
|
- NoiseReductionConfig 数据类(解析/钳制/效果判断)
|
||||||
|
- 等级预设参数
|
||||||
|
- afftdn / arnndn 滤镜构建
|
||||||
|
- 便捷函数(apply_noise_reduction_if_needed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseReductionLevel(str, Enum):
|
||||||
|
"""降噪等级预设."""
|
||||||
|
|
||||||
|
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
|
||||||
|
MEDIUM = "medium" # 中度降噪,平衡效果和音质
|
||||||
|
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
|
||||||
|
CUSTOM = "custom" # 自定义参数
|
||||||
|
|
||||||
|
|
||||||
|
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB)
|
||||||
|
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
|
||||||
|
_LEVEL_PARAMS: dict[NoiseReductionLevel, dict[str, float]] = {
|
||||||
|
NoiseReductionLevel.LOW: {
|
||||||
|
"nf": -35, # 噪音阈值(dB),越负越保守
|
||||||
|
"tn": -10, # 噪音频谱平滑度
|
||||||
|
"tr": 50, # 时间分辨率(ms)
|
||||||
|
},
|
||||||
|
NoiseReductionLevel.MEDIUM: {
|
||||||
|
"nf": -25,
|
||||||
|
"tn": -10,
|
||||||
|
"tr": 50,
|
||||||
|
},
|
||||||
|
NoiseReductionLevel.HIGH: {
|
||||||
|
"nf": -15,
|
||||||
|
"tn": -5,
|
||||||
|
"tr": 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 参数范围
|
||||||
|
MIN_NOISE_FLOOR = -60.0
|
||||||
|
MAX_NOISE_FLOOR = -5.0
|
||||||
|
|
||||||
|
# 默认值
|
||||||
|
DEFAULT_LEVEL = NoiseReductionLevel.MEDIUM
|
||||||
|
DEFAULT_NOISE_FLOOR = -25.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NoiseReductionConfig:
|
||||||
|
"""音频降噪配置.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enabled: 是否启用降噪
|
||||||
|
level: 降噪等级 low/medium/high/custom
|
||||||
|
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
|
||||||
|
voice_enhance: 是否启用人声增强
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
level: NoiseReductionLevel = DEFAULT_LEVEL
|
||||||
|
noise_floor: float = DEFAULT_NOISE_FLOOR # dB
|
||||||
|
voice_enhance: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> NoiseReductionConfig:
|
||||||
|
"""从字典解析配置,参数越界自动钳制."""
|
||||||
|
if not data or not data.get("enabled", False):
|
||||||
|
return cls(enabled=False)
|
||||||
|
|
||||||
|
level_str = str(data.get("level", "medium")).lower()
|
||||||
|
try:
|
||||||
|
level = NoiseReductionLevel(level_str)
|
||||||
|
except ValueError:
|
||||||
|
level = DEFAULT_LEVEL
|
||||||
|
|
||||||
|
try:
|
||||||
|
noise_floor = float(data.get("noise_floor", DEFAULT_NOISE_FLOOR))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
noise_floor = DEFAULT_NOISE_FLOOR
|
||||||
|
|
||||||
|
voice_enhance = bool(data.get("voice_enhance", False))
|
||||||
|
|
||||||
|
# 钳制到合法范围
|
||||||
|
noise_floor = max(MIN_NOISE_FLOOR, min(MAX_NOISE_FLOOR, noise_floor))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
enabled=True,
|
||||||
|
level=level,
|
||||||
|
noise_floor=noise_floor,
|
||||||
|
voice_enhance=voice_enhance,
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_effect(self) -> bool:
|
||||||
|
"""判断是否有实际降噪效果."""
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
def get_effective_noise_floor(self) -> float:
|
||||||
|
"""获取实际生效的噪音阈值(dB)."""
|
||||||
|
if self.level == NoiseReductionLevel.CUSTOM:
|
||||||
|
return self.noise_floor
|
||||||
|
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[DEFAULT_LEVEL])
|
||||||
|
return float(params["nf"])
|
||||||
|
|
||||||
|
def get_level_params(self) -> dict[str, float]:
|
||||||
|
"""获取当前等级的完整参数字典."""
|
||||||
|
if self.level == NoiseReductionLevel.CUSTOM:
|
||||||
|
return {
|
||||||
|
"nf": self.noise_floor,
|
||||||
|
"tn": -10.0,
|
||||||
|
"tr": 50.0,
|
||||||
|
}
|
||||||
|
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[DEFAULT_LEVEL])
|
||||||
|
return {k: float(v) for k, v in params.items()}
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置是否有效."""
|
||||||
|
if not self.enabled:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
if not (MIN_NOISE_FLOOR <= self.noise_floor <= MAX_NOISE_FLOOR):
|
||||||
|
return False, f"noise_floor 必须在 {MIN_NOISE_FLOOR}~{MAX_NOISE_FLOOR} dB 之间"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_afftdn_filter(
|
||||||
|
config: NoiseReductionConfig,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
) -> str:
|
||||||
|
"""构建 afftdn 音频降噪滤镜字符串.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 降噪配置
|
||||||
|
input_label: 输入标签,如 "[0:a]" 或 "[a0]"
|
||||||
|
output_label: 输出标签,如 "[nr0]"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FFmpeg 滤镜字符串
|
||||||
|
"""
|
||||||
|
if not config.has_effect():
|
||||||
|
return f"{input_label}anull{output_label}"
|
||||||
|
|
||||||
|
params = config.get_level_params()
|
||||||
|
nf = params["nf"]
|
||||||
|
tn = params["tn"]
|
||||||
|
tr = params["tr"]
|
||||||
|
|
||||||
|
# 构建 afftdn 滤镜
|
||||||
|
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
|
||||||
|
|
||||||
|
# 人声增强:通过 highpass + 压缩 + 响度归一化实现
|
||||||
|
if config.voice_enhance:
|
||||||
|
filter_parts.append("highpass=f=80")
|
||||||
|
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
|
||||||
|
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
|
||||||
|
|
||||||
|
return f"{input_label}{','.join(filter_parts)}{output_label}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_arnndn_filter(
|
||||||
|
config: NoiseReductionConfig,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
model_file: str,
|
||||||
|
) -> str:
|
||||||
|
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件).
|
||||||
|
|
||||||
|
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
|
||||||
|
"""
|
||||||
|
if not config.has_effect():
|
||||||
|
return f"{input_label}anull{output_label}"
|
||||||
|
|
||||||
|
return f"{input_label}arnndn=m={model_file}{output_label}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 便捷函数 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def apply_noise_reduction_if_needed(
|
||||||
|
config_data: dict[str, Any] | None,
|
||||||
|
input_label: str,
|
||||||
|
output_label: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""便捷函数:根据配置判断是否需要应用音频降噪.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_data: 降噪配置字典
|
||||||
|
input_label: 输入标签
|
||||||
|
output_label: 输出标签
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
滤镜字符串,不需要降噪时返回 None
|
||||||
|
"""
|
||||||
|
if not config_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = NoiseReductionConfig.from_dict(config_data)
|
||||||
|
if not config.has_effect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return build_afftdn_filter(config, input_label, output_label)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_level_names() -> list[str]:
|
||||||
|
"""获取所有降噪等级名称列表."""
|
||||||
|
return [level.value for level in NoiseReductionLevel]
|
||||||
Executable
+265
@@ -0,0 +1,265 @@
|
|||||||
|
"""画中画(PiP)配置领域模型 — 纯逻辑,无外部依赖.
|
||||||
|
|
||||||
|
抽离自 pip_engine.py 的数据类和纯逻辑函数,
|
||||||
|
方便单测覆盖,同时保持向后兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 位置常量 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
POSITION_TOP_LEFT = "top_left"
|
||||||
|
POSITION_TOP_CENTER = "top_center"
|
||||||
|
POSITION_TOP_RIGHT = "top_right"
|
||||||
|
POSITION_CENTER_LEFT = "center_left"
|
||||||
|
POSITION_CENTER = "center"
|
||||||
|
POSITION_CENTER_RIGHT = "center_right"
|
||||||
|
POSITION_BOTTOM_LEFT = "bottom_left"
|
||||||
|
POSITION_BOTTOM_CENTER = "bottom_center"
|
||||||
|
POSITION_BOTTOM_RIGHT = "bottom_right"
|
||||||
|
|
||||||
|
_VALID_POSITIONS = {
|
||||||
|
POSITION_TOP_LEFT,
|
||||||
|
POSITION_TOP_CENTER,
|
||||||
|
POSITION_TOP_RIGHT,
|
||||||
|
POSITION_CENTER_LEFT,
|
||||||
|
POSITION_CENTER,
|
||||||
|
POSITION_CENTER_RIGHT,
|
||||||
|
POSITION_BOTTOM_LEFT,
|
||||||
|
POSITION_BOTTOM_CENTER,
|
||||||
|
POSITION_BOTTOM_RIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动画类型
|
||||||
|
ANIMATION_FADE = "fade"
|
||||||
|
ANIMATION_SLIDE_LEFT = "slide_left"
|
||||||
|
ANIMATION_SLIDE_RIGHT = "slide_right"
|
||||||
|
ANIMATION_SLIDE_TOP = "slide_top"
|
||||||
|
ANIMATION_SLIDE_BOTTOM = "slide_bottom"
|
||||||
|
ANIMATION_SCALE = "scale"
|
||||||
|
|
||||||
|
_VALID_ANIMATIONS = {
|
||||||
|
ANIMATION_FADE,
|
||||||
|
ANIMATION_SLIDE_LEFT,
|
||||||
|
ANIMATION_SLIDE_RIGHT,
|
||||||
|
ANIMATION_SLIDE_TOP,
|
||||||
|
ANIMATION_SLIDE_BOTTOM,
|
||||||
|
ANIMATION_SCALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PiPLayerConfig:
|
||||||
|
"""单个画中画图层配置."""
|
||||||
|
|
||||||
|
# 素材来源
|
||||||
|
source: str = ""
|
||||||
|
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
|
||||||
|
|
||||||
|
# 位置配置
|
||||||
|
position: str = POSITION_BOTTOM_RIGHT
|
||||||
|
x: int | str = 0
|
||||||
|
y: int | str = 0
|
||||||
|
margin: int = 20
|
||||||
|
|
||||||
|
# 大小配置
|
||||||
|
width: int | str = "25%"
|
||||||
|
height: int | str = "" # 空则按比例自适应
|
||||||
|
|
||||||
|
# 样式
|
||||||
|
opacity: float = 1.0
|
||||||
|
corner_radius: int = 0
|
||||||
|
border_width: int = 0
|
||||||
|
border_color: str = "white"
|
||||||
|
|
||||||
|
# 时间控制
|
||||||
|
start_time: float = 0.0
|
||||||
|
duration: float = 0.0 # 0表示全程显示
|
||||||
|
|
||||||
|
# 动画
|
||||||
|
animation_in: str = ""
|
||||||
|
animation_out: str = ""
|
||||||
|
animation_duration: float = 0.5
|
||||||
|
|
||||||
|
# 层级
|
||||||
|
z_index: int = 1
|
||||||
|
|
||||||
|
def validate(self) -> tuple[bool, str]:
|
||||||
|
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||||
|
if not self.source:
|
||||||
|
return False, "source不能为空"
|
||||||
|
|
||||||
|
if self.position != "custom" and self.position not in _VALID_POSITIONS:
|
||||||
|
return False, f"无效的position: {self.position}"
|
||||||
|
|
||||||
|
if self.opacity < 0 or self.opacity > 1:
|
||||||
|
return False, "opacity必须在0-1之间"
|
||||||
|
|
||||||
|
if self.corner_radius < 0:
|
||||||
|
return False, "corner_radius不能为负数"
|
||||||
|
|
||||||
|
if self.start_time < 0:
|
||||||
|
return False, "start_time不能为负数"
|
||||||
|
|
||||||
|
if self.duration < 0:
|
||||||
|
return False, "duration不能为负数"
|
||||||
|
|
||||||
|
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
|
||||||
|
return False, f"无效的入场动画: {self.animation_in}"
|
||||||
|
|
||||||
|
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
|
||||||
|
return False, f"无效的出场动画: {self.animation_out}"
|
||||||
|
|
||||||
|
if self.animation_duration < 0:
|
||||||
|
return False, "animation_duration不能为负数"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PiPConfig:
|
||||||
|
"""画中画整体配置."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
layers: list[PiPLayerConfig] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
|
||||||
|
"""从字典解析配置."""
|
||||||
|
if not data or not data.get("enabled", False):
|
||||||
|
return cls(enabled=False)
|
||||||
|
|
||||||
|
layers_data = data.get("layers", [])
|
||||||
|
layers: list[PiPLayerConfig] = []
|
||||||
|
for layer_data in layers_data:
|
||||||
|
try:
|
||||||
|
layer = PiPLayerConfig(
|
||||||
|
source=layer_data.get("source", ""),
|
||||||
|
source_type=layer_data.get("source_type", "asset_id"),
|
||||||
|
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
|
||||||
|
x=layer_data.get("x", 0),
|
||||||
|
y=layer_data.get("y", 0),
|
||||||
|
margin=int(layer_data.get("margin", 20)),
|
||||||
|
width=layer_data.get("width", "25%"),
|
||||||
|
height=layer_data.get("height", ""),
|
||||||
|
opacity=float(layer_data.get("opacity", 1.0)),
|
||||||
|
corner_radius=int(layer_data.get("corner_radius", 0)),
|
||||||
|
border_width=int(layer_data.get("border_width", 0)),
|
||||||
|
border_color=layer_data.get("border_color", "white"),
|
||||||
|
start_time=float(layer_data.get("start_time", 0.0)),
|
||||||
|
duration=float(layer_data.get("duration", 0.0)),
|
||||||
|
animation_in=layer_data.get("animation_in", ""),
|
||||||
|
animation_out=layer_data.get("animation_out", ""),
|
||||||
|
animation_duration=float(layer_data.get("animation_duration", 0.5)),
|
||||||
|
z_index=int(layer_data.get("z_index", 1)),
|
||||||
|
)
|
||||||
|
valid, err = layer.validate()
|
||||||
|
if valid:
|
||||||
|
layers.append(layer)
|
||||||
|
else:
|
||||||
|
logger.warning("PiP图层配置无效,跳过: %s", err)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning("PiP图层解析失败,跳过: %s", e)
|
||||||
|
|
||||||
|
# 按 z_index 排序
|
||||||
|
layers.sort(key=lambda layer: layer.z_index)
|
||||||
|
|
||||||
|
return cls(enabled=bool(layers), layers=layers)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def layer_count(self) -> int:
|
||||||
|
"""有效图层数量."""
|
||||||
|
return len(self.layers)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_z_index(self) -> int:
|
||||||
|
"""最大 z_index."""
|
||||||
|
if not self.layers:
|
||||||
|
return 0
|
||||||
|
return max(l.z_index for l in self.layers)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_value(value: int | str, base: int, default_pct: float = 0.25) -> int:
|
||||||
|
"""解析尺寸值(像素或百分比).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: 尺寸值,int(像素)或 str(如 "30%")
|
||||||
|
base: 基准尺寸(用于百分比计算)
|
||||||
|
default_pct: 解析失败时的默认百分比
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
像素尺寸,>= 1
|
||||||
|
"""
|
||||||
|
if isinstance(value, int):
|
||||||
|
return max(1, value)
|
||||||
|
if isinstance(value, str) and value.endswith("%"):
|
||||||
|
try:
|
||||||
|
pct = float(value.rstrip("%")) / 100.0
|
||||||
|
return max(1, int(base * pct))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return max(1, int(base * default_pct))
|
||||||
|
try:
|
||||||
|
return max(1, int(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return max(1, int(base * default_pct))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pip_position(
|
||||||
|
position: str,
|
||||||
|
output_width: int,
|
||||||
|
output_height: int,
|
||||||
|
pip_width: int,
|
||||||
|
pip_height: int,
|
||||||
|
margin: int = 20,
|
||||||
|
custom_x: int | str = 0,
|
||||||
|
custom_y: int | str = 0,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""计算画中画的实际像素位置 (x, y).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: 9宫格位置或 "custom"
|
||||||
|
output_width: 画布宽度
|
||||||
|
output_height: 画布高度
|
||||||
|
pip_width: 画中画宽度
|
||||||
|
pip_height: 画中画高度
|
||||||
|
margin: 9宫格边距
|
||||||
|
custom_x: 自定义x(position=custom时有效)
|
||||||
|
custom_y: 自定义y(position=custom时有效)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(x, y) 像素坐标
|
||||||
|
"""
|
||||||
|
W = output_width
|
||||||
|
H = output_height
|
||||||
|
m = margin
|
||||||
|
|
||||||
|
if position == "custom":
|
||||||
|
x = parse_size_value(custom_x, W)
|
||||||
|
y = parse_size_value(custom_y, H)
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
pos_map = {
|
||||||
|
POSITION_TOP_LEFT: (m, m),
|
||||||
|
POSITION_TOP_CENTER: ((W - pip_width) // 2, m),
|
||||||
|
POSITION_TOP_RIGHT: (W - pip_width - m, m),
|
||||||
|
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2),
|
||||||
|
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2),
|
||||||
|
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2),
|
||||||
|
POSITION_BOTTOM_LEFT: (m, H - pip_height - m),
|
||||||
|
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
|
||||||
|
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
|
||||||
|
}
|
||||||
|
return pos_map.get(position, pos_map[POSITION_BOTTOM_RIGHT])
|
||||||
@@ -321,11 +321,7 @@ def create_clips_from_configs(
|
|||||||
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||||
|
|
||||||
# transition_effect 可能是枚举或字符串
|
# transition_effect 可能是枚举或字符串
|
||||||
transition = (
|
transition = cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||||
cfg.transition_effect.value
|
|
||||||
if hasattr(cfg.transition_effect, "value")
|
|
||||||
else cfg.transition_effect
|
|
||||||
)
|
|
||||||
|
|
||||||
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
|
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
|
||||||
clip_cfg = cfg.config or {}
|
clip_cfg = cfg.config or {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user