Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0623d700b7 | |||
| c70403f4c3 | |||
| 0e7498d8aa | |||
| a5430a4738 | |||
| ac1d6f2e4b |
@@ -1,336 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Trace 上报脚本 - 用于 Gitea Actions workflow 中上报 Trace 数据到 AgentLoop
|
||||
|
||||
在 CI workflow 的每个 job 中调用:
|
||||
- 开始时:python3 .gitea/scripts/ci_trace_report.py --status running
|
||||
- 结束时:python3 .gitea/scripts/ci_trace_report.py --status ok --start-time $CI_TRACE_START_TIME
|
||||
|
||||
环境变量(Gitea Actions 内置):
|
||||
GITEA_REPOSITORY / GITHUB_REPOSITORY - 仓库名 (owner/repo)
|
||||
GITEA_WORKFLOW / GITHUB_WORKFLOW - workflow 名称
|
||||
GITEA_JOB / GITHUB_JOB - job ID
|
||||
GITEA_SHA / GITHUB_SHA - commit SHA
|
||||
GITEA_REF_NAME / GITHUB_REF_NAME - 分支名
|
||||
GITEA_RUN_ID / GITHUB_RUN_ID - run ID
|
||||
GITEA_ACTOR / GITHUB_ACTOR - 触发者
|
||||
GITEA_EVENT_NAME / GITHUB_EVENT_NAME - 事件类型
|
||||
PR_NUMBER / GITEA_PR_NUMBER - PR 号(如果是 PR 触发)
|
||||
|
||||
AgentLoop 配置(通过 Secrets 注入):
|
||||
AGENTLOOP_LICENSE_KEY - LicenseKey(必填)
|
||||
AGENTLOOP_ENDPOINT - Trace 上报地址(可选,有默认值)
|
||||
AGENTLOOP_PROJECT - SLS Project 名(可选)
|
||||
AGENTLOOP_WORKSPACE - CMS Workspace 名(可选)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
# ========== 默认配置 ==========
|
||||
DEFAULT_ENDPOINT = "https://proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou.cn-hangzhou.log.aliyuncs.com/apm/trace/opentelemetry/v1/traces"
|
||||
DEFAULT_PROJECT = "proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou"
|
||||
DEFAULT_WORKSPACE = "agentloop-13b8d6efb7fde6e9b193eb982ade68e2"
|
||||
|
||||
|
||||
# ========== OTLP Protobuf 手动编码 ==========
|
||||
|
||||
def _encode_varint(value):
|
||||
result = bytearray()
|
||||
while value > 0x7F:
|
||||
result.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
result.append(value & 0x7F)
|
||||
return bytes(result)
|
||||
|
||||
def _encode_tag(field_number, wire_type):
|
||||
return _encode_varint((field_number << 3) | wire_type)
|
||||
|
||||
def _encode_string_field(field_number, value):
|
||||
value_bytes = value.encode('utf-8')
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
def _encode_bytes_field(field_number, value_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
|
||||
|
||||
def _encode_int_field(field_number, value):
|
||||
return _encode_tag(field_number, 0) + _encode_varint(value & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def _encode_message_field(field_number, message_bytes):
|
||||
return _encode_tag(field_number, 2) + _encode_varint(len(message_bytes)) + message_bytes
|
||||
|
||||
def _encode_key_value(key, value_str):
|
||||
any_value = _encode_string_field(1, value_str)
|
||||
return _encode_string_field(1, key) + _encode_message_field(2, any_value)
|
||||
|
||||
def _encode_status(status_code, status_msg=""):
|
||||
data = _encode_int_field(1, status_code)
|
||||
if status_msg:
|
||||
data += _encode_string_field(2, status_msg)
|
||||
return data
|
||||
|
||||
def _encode_span(trace_id_bytes, span_id_bytes, parent_span_id_bytes,
|
||||
name, start_time_unix_nano, end_time_unix_nano,
|
||||
span_kind, attributes, status_code, status_msg=""):
|
||||
data = b""
|
||||
data += _encode_bytes_field(1, trace_id_bytes)
|
||||
data += _encode_bytes_field(2, span_id_bytes)
|
||||
if parent_span_id_bytes:
|
||||
data += _encode_bytes_field(3, parent_span_id_bytes)
|
||||
data += _encode_string_field(4, name)
|
||||
data += _encode_int_field(5, span_kind)
|
||||
data += _encode_int_field(6, start_time_unix_nano)
|
||||
data += _encode_int_field(7, end_time_unix_nano)
|
||||
for key, value in attributes.items():
|
||||
kv = _encode_key_value(key, str(value))
|
||||
data += _encode_message_field(9, kv)
|
||||
status = _encode_status(status_code, status_msg)
|
||||
data += _encode_message_field(12, status)
|
||||
return data
|
||||
|
||||
def _encode_resource_spans(service_name, scope_spans_bytes):
|
||||
svc_kv = _encode_key_value("service.name", service_name)
|
||||
resource = _encode_message_field(1, svc_kv)
|
||||
data = _encode_message_field(1, resource)
|
||||
data += _encode_message_field(2, scope_spans_bytes)
|
||||
return data
|
||||
|
||||
def _encode_scope_spans(scope_name, spans_bytes_list):
|
||||
scope = _encode_string_field(1, scope_name)
|
||||
data = _encode_message_field(1, scope)
|
||||
for span_bytes in spans_bytes_list:
|
||||
data += _encode_message_field(2, span_bytes)
|
||||
return data
|
||||
|
||||
def _encode_traces_data(resource_spans_bytes_list):
|
||||
data = b""
|
||||
for rs_bytes in resource_spans_bytes_list:
|
||||
data += _encode_message_field(1, rs_bytes)
|
||||
return data
|
||||
|
||||
|
||||
# ========== 辅助函数 ==========
|
||||
|
||||
def _gen_trace_id():
|
||||
return uuid.uuid4().bytes
|
||||
|
||||
def _gen_span_id():
|
||||
return uuid.uuid4().bytes[:8]
|
||||
|
||||
def _env(name, default=""):
|
||||
"""获取环境变量,支持 GITEA_ 和 GITHUB_ 前缀"""
|
||||
val = os.getenv(name, "")
|
||||
if val:
|
||||
return val
|
||||
# 尝试另一种前缀
|
||||
if name.startswith("GITEA_"):
|
||||
alt = "GITHUB_" + name[6:]
|
||||
return os.getenv(alt, default)
|
||||
if name.startswith("GITHUB_"):
|
||||
alt = "GITEA_" + name[7:]
|
||||
return os.getenv(alt, default)
|
||||
return default
|
||||
|
||||
|
||||
def _get_pr_number():
|
||||
"""从环境变量或事件文件中获取 PR 号"""
|
||||
# 直接从环境变量
|
||||
pr = os.getenv("PR_NUMBER", "") or os.getenv("GITEA_PR_NUMBER", "")
|
||||
if pr:
|
||||
return pr
|
||||
|
||||
# 从 GITHUB_EVENT_PATH 读取
|
||||
event_path = os.getenv("GITHUB_EVENT_PATH", "") or os.getenv("GITEA_EVENT_PATH", "")
|
||||
if event_path and os.path.isfile(event_path):
|
||||
try:
|
||||
with open(event_path, "r") as f:
|
||||
event = json.load(f)
|
||||
if "pull_request" in event and "number" in event["pull_request"]:
|
||||
return str(event["pull_request"]["number"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _get_ci_attributes():
|
||||
"""从 CI 环境变量中收集属性"""
|
||||
attrs = {
|
||||
"ci.repo": _env("GITEA_REPOSITORY") or _env("GITHUB_REPOSITORY") or "unknown",
|
||||
"ci.workflow": _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "unknown",
|
||||
"ci.job": _env("GITEA_JOB") or _env("GITHUB_JOB") or "unknown",
|
||||
"ci.commit_sha": _env("GITEA_SHA") or _env("GITHUB_SHA") or "unknown",
|
||||
"ci.branch": _env("GITEA_REF_NAME") or _env("GITHUB_REF_NAME") or "unknown",
|
||||
"ci.run_id": _env("GITEA_RUN_ID") or _env("GITHUB_RUN_ID") or "unknown",
|
||||
"ci.actor": _env("GITEA_ACTOR") or _env("GITHUB_ACTOR") or "unknown",
|
||||
"ci.event": _env("GITEA_EVENT_NAME") or _env("GITHUB_EVENT_NAME") or "unknown",
|
||||
}
|
||||
pr = _get_pr_number()
|
||||
if pr:
|
||||
attrs["ci.pr_number"] = pr
|
||||
return attrs
|
||||
|
||||
|
||||
# ========== 核心上报逻辑 ==========
|
||||
|
||||
def build_trace(service_name, trace_name, status, duration_ms, attributes=None):
|
||||
"""构建一条完整的 Trace 数据,返回 protobuf bytes。"""
|
||||
trace_id = _gen_trace_id()
|
||||
end_time = int(time.time() * 1e9)
|
||||
start_time = end_time - int(duration_ms * 1e6)
|
||||
status_code = 1 if status in ("ok", "running") else 2
|
||||
status_msg = "" if status in ("ok", "running") else "Job failed"
|
||||
|
||||
main_attrs = {
|
||||
"agent.trace_name": trace_name,
|
||||
"agent.service": service_name,
|
||||
"ci.trace_status": status,
|
||||
}
|
||||
if attributes:
|
||||
main_attrs.update(attributes)
|
||||
|
||||
main_span = _encode_span(
|
||||
trace_id_bytes=trace_id,
|
||||
span_id_bytes=_gen_span_id(),
|
||||
parent_span_id_bytes=b"",
|
||||
name=trace_name,
|
||||
start_time_unix_nano=start_time,
|
||||
end_time_unix_nano=end_time,
|
||||
span_kind=1,
|
||||
attributes=main_attrs,
|
||||
status_code=status_code,
|
||||
status_msg=status_msg,
|
||||
)
|
||||
|
||||
scope_spans = _encode_scope_spans("ci-trace", [main_span])
|
||||
resource_spans = _encode_resource_spans(service_name, scope_spans)
|
||||
return _encode_traces_data([resource_spans])
|
||||
|
||||
|
||||
def report_ci_trace(service_name, trace_name, status="ok", duration_ms=1000,
|
||||
endpoint=None, license_key=None, project=None, workspace=None,
|
||||
extra_attributes=None):
|
||||
"""
|
||||
上报 CI Trace 数据。返回 (success: bool, message: str)
|
||||
永远不会抛出异常,失败也返回 False。
|
||||
"""
|
||||
try:
|
||||
endpoint = endpoint or os.getenv("AGENTLOOP_ENDPOINT", DEFAULT_ENDPOINT)
|
||||
license_key = license_key or os.getenv("AGENTLOOP_LICENSE_KEY", "")
|
||||
project = project or os.getenv("AGENTLOOP_PROJECT", DEFAULT_PROJECT)
|
||||
workspace = workspace or os.getenv("AGENTLOOP_WORKSPACE", DEFAULT_WORKSPACE)
|
||||
|
||||
if not license_key:
|
||||
return False, "[Trace] 跳过上报:未配置 AGENTLOOP_LICENSE_KEY"
|
||||
|
||||
# 收集 CI 属性
|
||||
attrs = _get_ci_attributes()
|
||||
if extra_attributes:
|
||||
attrs.update(extra_attributes)
|
||||
|
||||
payload = build_trace(
|
||||
service_name=service_name,
|
||||
trace_name=trace_name,
|
||||
status=status,
|
||||
duration_ms=duration_ms,
|
||||
attributes=attrs,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-protobuf",
|
||||
"x-arms-license-key": license_key,
|
||||
"x-arms-project": project,
|
||||
"x-cms-workspace": workspace,
|
||||
}
|
||||
|
||||
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
status_code = resp.status
|
||||
resp_body = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
status_code = e.code
|
||||
resp_body = e.read().decode("utf-8", errors="replace")
|
||||
|
||||
if status_code in (200, 202):
|
||||
return True, f"[Trace] ✓ 上报成功: {service_name} / {trace_name} ({status}, {duration_ms}ms)"
|
||||
else:
|
||||
return False, f"[Trace] ⚠ 上报失败: HTTP {status_code} - {resp_body[:200]}"
|
||||
except Exception as e:
|
||||
return False, f"[Trace] ⚠ 上报异常: {type(e).__name__}: {str(e)}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AgentLoop Trace 上报")
|
||||
parser.add_argument("--service", dest="service_name",
|
||||
default=os.getenv("TRACE_SERVICE", ""),
|
||||
help="服务名称(也可通过 TRACE_SERVICE 环境变量设置)")
|
||||
parser.add_argument("--name", dest="trace_name",
|
||||
default=os.getenv("TRACE_NAME", ""),
|
||||
help="Trace 名称(也可通过 TRACE_NAME 环境变量设置)")
|
||||
parser.add_argument("--status", default=os.getenv("TRACE_STATUS", "ok"),
|
||||
choices=["ok", "error", "running"],
|
||||
help="状态: ok / error / running(默认 ok)")
|
||||
parser.add_argument("--start-time", dest="start_time",
|
||||
default=os.getenv("TRACE_START_TIME", ""),
|
||||
help="开始时间戳(秒),用于计算 duration;不填则用默认 1s")
|
||||
parser.add_argument("--duration-ms", dest="duration_ms", type=int, default=0,
|
||||
help="直接指定耗时(毫秒),优先级高于 --start-time")
|
||||
parser.add_argument("--attrs", default="",
|
||||
help="附加属性(JSON 字符串)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须参数检查
|
||||
if not args.service_name:
|
||||
print("[Trace] ⚠ 跳过上报:未指定 service(--service 或 TRACE_SERVICE)")
|
||||
sys.exit(0)
|
||||
|
||||
# 计算耗时
|
||||
duration_ms = args.duration_ms
|
||||
if duration_ms <= 0 and args.start_time:
|
||||
try:
|
||||
start_ts = float(args.start_time)
|
||||
duration_ms = int((time.time() - start_ts) * 1000)
|
||||
except (ValueError, TypeError):
|
||||
duration_ms = 1000
|
||||
if duration_ms <= 0:
|
||||
duration_ms = 1000 # 默认 1 秒
|
||||
|
||||
# 解析附加属性
|
||||
extra_attrs = {}
|
||||
if args.attrs:
|
||||
try:
|
||||
extra_attrs = json.loads(args.attrs)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 生成 trace_name:如果没指定,用 workflow+job
|
||||
trace_name = args.trace_name
|
||||
if not trace_name:
|
||||
wf = _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "CI"
|
||||
job = _env("GITEA_JOB") or _env("GITHUB_JOB") or "job"
|
||||
trace_name = f"{wf} / {job}"
|
||||
|
||||
success, msg = report_ci_trace(
|
||||
service_name=args.service_name,
|
||||
trace_name=trace_name,
|
||||
status=args.status,
|
||||
duration_ms=duration_ms,
|
||||
extra_attributes=extra_attrs,
|
||||
)
|
||||
|
||||
print(msg)
|
||||
# 永远 exit 0,不影响 CI
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,14 +17,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
@@ -180,12 +172,3 @@ jobs:
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -17,14 +17,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
@@ -180,12 +172,3 @@ jobs:
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -95,14 +95,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -152,15 +144,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Build Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: runtime-builder
|
||||
@@ -217,14 +200,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -327,15 +302,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: runtime-builder
|
||||
@@ -389,14 +355,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -424,15 +382,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: runtime-builder
|
||||
@@ -486,14 +435,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -521,15 +462,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
build-production:
|
||||
name: Build Production ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
@@ -604,14 +536,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -662,15 +586,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Build Production ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder
|
||||
@@ -727,14 +642,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -825,15 +732,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: runtime-builder
|
||||
@@ -847,14 +745,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"'
|
||||
Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -882,102 +772,3 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
acr-cleanup:
|
||||
name: ACR Image Cleanup
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- build-staging
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'INNERPY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
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, '.')
|
||||
INNERPY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Clean up old ACR images
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "Running ACR cleanup (keep 20 commit tags, PR tags keep 7 days)..."
|
||||
python3 scripts/ci/acr_cleanup.py --execute --keep 20 --pr-days 7
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="ACR Image Cleanup" python3 scripts/ci_notify.py
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
+1
-102
@@ -35,14 +35,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -71,15 +63,6 @@ jobs:
|
||||
echo "🔧 包含全栈变更,运行完整CI"
|
||||
fi
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -98,14 +81,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -258,15 +233,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -286,14 +252,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -467,15 +425,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
@@ -498,14 +447,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -603,15 +544,6 @@ jobs:
|
||||
NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py
|
||||
|
||||
'
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-check
|
||||
@@ -623,14 +555,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -672,15 +596,6 @@ jobs:
|
||||
'
|
||||
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
frontend-unit-test:
|
||||
name: Frontend Unit Tests
|
||||
runs-on: ci-check
|
||||
@@ -693,14 +608,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -730,12 +637,4 @@ jobs:
|
||||
|
||||
NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py
|
||||
|
||||
' - name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
'
|
||||
@@ -22,14 +22,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
@@ -42,12 +34,3 @@ jobs:
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -25,14 +25,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
@@ -59,12 +51,3 @@ jobs:
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -64,14 +64,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
@@ -115,15 +107,6 @@ jobs:
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
@@ -178,14 +161,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
@@ -269,15 +244,6 @@ jobs:
|
||||
fi
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
@@ -332,14 +298,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
@@ -375,15 +333,6 @@ jobs:
|
||||
exit $EXIT_CODE
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
@@ -507,14 +456,6 @@ jobs:
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
@@ -640,15 +581,6 @@ jobs:
|
||||
fi
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
@@ -724,20 +656,3 @@ jobs:
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -61,14 +61,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Extract PR number
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -201,12 +193,3 @@ jobs:
|
||||
> /dev/null
|
||||
echo "Cleanup comment posted"
|
||||
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -72,14 +72,6 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record start time & report trace start
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -280,12 +272,3 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
||||
- name: Report trace end (always)
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
|
||||
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
|
||||
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
|
||||
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
|
||||
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add user_id to generated_videos
|
||||
|
||||
Revision ID: 044_user_id_generated_videos
|
||||
Revises: 043_updated_at_generation_tasks
|
||||
Create Date: 2026-07-19 08:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "044_user_id_generated_videos"
|
||||
down_revision = "043_updated_at_generation_tasks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "user_id")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""backfill user_id for generated_videos from generation_tasks
|
||||
|
||||
Revision ID: 045_backfill_user_id_generated_videos
|
||||
Revises: 044_user_id_generated_videos
|
||||
Create Date: 2026-07-19 10:50:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "045_backfill_user_id"
|
||||
down_revision = "044_user_id_generated_videos"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
|
||||
# 取 generation_tasks.created_by_user_id 作为 user_id
|
||||
# 回填不到的(无关联task的兜底记录)保持空字符串
|
||||
op.execute("""
|
||||
UPDATE generated_videos gv
|
||||
SET user_id = gt.created_by_user_id
|
||||
FROM generation_tasks gt
|
||||
WHERE gv.generation_task_id = gt.id
|
||||
AND gv.user_id = ''
|
||||
AND gt.created_by_user_id != ''
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 降级不做处理(无法精确区分哪些是回填的)
|
||||
pass
|
||||
@@ -1,33 +0,0 @@
|
||||
"""add video_title to generation_tasks
|
||||
|
||||
Revision ID: 046_add_video_title_to_generation_tasks
|
||||
Revises: 045_backfill_user_id_generated_videos
|
||||
Create Date: 2026-07-19 11:20:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "046_task_title"
|
||||
down_revision = "045_backfill_user_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"video_title",
|
||||
sa.String(255),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "video_title")
|
||||
@@ -201,7 +201,7 @@ def list_assets(
|
||||
items = asset_repository.find_by_library_and_file_type(
|
||||
library_id, ft, skip=skip, limit=limit, status=status_list
|
||||
)
|
||||
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
|
||||
total = len(items)
|
||||
else:
|
||||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
|
||||
total = asset_repository.count_by_project(library.project_id, status=status_list)
|
||||
@@ -216,11 +216,11 @@ def list_assets(
|
||||
if project_id:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
if ft:
|
||||
items = asset_repository.find_by_project_and_file_type(
|
||||
project_id, ft, skip=skip, limit=limit, status=status_list
|
||||
)
|
||||
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list)
|
||||
paged = items
|
||||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
||||
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
|
||||
total = len(items)
|
||||
paged = items[skip : skip + limit]
|
||||
else:
|
||||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
|
||||
total = asset_repository.count_by_project(project_id, status=status_list)
|
||||
@@ -243,43 +243,22 @@ def list_assets(
|
||||
if not project_ids:
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
if ft:
|
||||
# 有 kind 过滤:逐项目查 file_type,凑够一页
|
||||
total = 0
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list)
|
||||
total += proj_total
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project_and_file_type(
|
||||
pid, ft, skip=offset, limit=remaining, status=status_list
|
||||
)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
else:
|
||||
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
|
||||
# 跨项目分页:逐项目累积直到凑够一页
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project(pid, status=status_list)
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
|
||||
# 跨项目分页:逐项目累积直到凑够一页
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project(pid, status=status_list)
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged_items],
|
||||
@@ -302,14 +281,7 @@ def list_assets(
|
||||
all_items = asset_repository.find_by_library(library_id, status=status_list)
|
||||
elif project_id:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
if kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
if ft:
|
||||
all_items = asset_repository.find_by_project_and_file_type(project_id, ft, status=status_list)
|
||||
else:
|
||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
||||
else:
|
||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
||||
else:
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
@@ -318,18 +290,12 @@ def list_assets(
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
if kind and kind_to_file_type.get(kind):
|
||||
all_items.extend(
|
||||
asset_repository.find_by_project_and_file_type(proj.id, kind_to_file_type[kind], status=status_list)
|
||||
)
|
||||
else:
|
||||
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
|
||||
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
|
||||
|
||||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||||
if kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
if ft:
|
||||
all_items = [i for i in all_items if i.file_type == ft]
|
||||
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
|
||||
filtered = _apply_memory_filters(all_items)
|
||||
total = len(filtered)
|
||||
paged = filtered[skip : skip + limit]
|
||||
|
||||
@@ -58,7 +58,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -269,7 +268,6 @@ def create_generation_task(
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
)
|
||||
@@ -407,7 +405,6 @@ def retry_generation_task(
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
Executable → Regular
+2
-3
@@ -57,7 +57,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
|
||||
@router.get("/videos", response_model=ListVideosResponse)
|
||||
def list_videos(
|
||||
project_id: str | None = Query(None, description="项目ID,可选过滤"),
|
||||
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
|
||||
status: str | None = Query(None, description="按状态筛选"),
|
||||
review_status: str | None = Query(None, description="按复核状态筛选"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
@@ -66,10 +66,9 @@ def list_videos(
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。"""
|
||||
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
|
||||
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
||||
items, total = use_case.execute(
|
||||
user_id=current_user.user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
review_status=review_status,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Literal, Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
|
||||
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||
from app.schemas.voice import (
|
||||
PresetVoiceItemResponse,
|
||||
PresetVoiceListResponse,
|
||||
@@ -27,7 +27,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
|
||||
QuotaExceededError,
|
||||
UpdateVoiceLibraryUseCase,
|
||||
)
|
||||
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
||||
from packages.domain.preset_voices import PRESET_VOICES
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
||||
# key: voice_id, value: (audio_url, timestamp)
|
||||
_preset_preview_cache: dict[str, tuple[str, float]] = {}
|
||||
PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
# 每个预置音色的默认试听文本
|
||||
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
||||
|
||||
|
||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
|
||||
return PresetVoiceListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/presets/{voice_id}/preview")
|
||||
def get_preset_voice_preview(
|
||||
voice_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> dict:
|
||||
"""获取预置音色试听音频(实时 TTS 合成)。
|
||||
|
||||
- 首次调用会合成并缓存7天
|
||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
||||
|
||||
# 有自定义文本时不缓存
|
||||
use_cache = not text.strip()
|
||||
|
||||
if use_cache and voice_id in _preset_preview_cache:
|
||||
audio_url, cached_at = _preset_preview_cache[voice_id]
|
||||
if time.time() - cached_at < PREVIEW_CACHE_TTL:
|
||||
return {"voice_id": voice_id, "audio_url": audio_url, "cached": True}
|
||||
|
||||
# 合成试听音频
|
||||
preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name)
|
||||
try:
|
||||
result = cosyvoice.synthesize_speech(
|
||||
text=preview_text,
|
||||
voice_id=preset.voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
|
||||
audio_url = result.audio_url
|
||||
|
||||
# 缓存(仅默认试听文本)
|
||||
if use_cache:
|
||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
||||
|
||||
return {
|
||||
"voice_id": voice_id,
|
||||
"audio_url": audio_url,
|
||||
"text": preview_text,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 原有 CRUD 端点(保持向后兼容)====================
|
||||
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||
# ── 素材库自动匹配 ──
|
||||
@@ -73,7 +71,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -184,19 +184,13 @@ export const getAssetsByKind = async (
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string | number> = { kind }
|
||||
const params: Record<string, string> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -143,7 +143,7 @@ export interface CreateEditPlanRequest {
|
||||
name: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到智能剪辑时关联) */
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
|
||||
Executable → Regular
+3
-3
@@ -20,7 +20,7 @@ export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
pip: "画中画",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
@@ -85,7 +85,7 @@ export interface EditingTemplate {
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
@@ -122,7 +122,7 @@ export interface SaveTemplatePayload {
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface CreateGenerationTaskResponse {
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 创建生成任务(智能剪辑) */
|
||||
/** 创建生成任务(一键生成) */
|
||||
export const createGenerationTask = async (
|
||||
params: CreateGenerationTaskRequest,
|
||||
): Promise<CreateGenerationTaskResponse> => {
|
||||
|
||||
@@ -130,7 +130,7 @@ export interface SaveTtsToLibraryRequest {
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音库 */
|
||||
/** 将 TTS 合成结果保存到配音素材库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
|
||||
@@ -43,8 +43,8 @@ export interface PageHeadProps {
|
||||
|
||||
const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/dashboard": "首页",
|
||||
"/app/generate": "智能剪辑",
|
||||
"/app/assets": "视频库",
|
||||
"/app/generate": "一键生成",
|
||||
"/app/assets": "素材库",
|
||||
"/app/voices": "配音库",
|
||||
"/app/titles": "标题库",
|
||||
"/app/products": "成片库",
|
||||
@@ -62,7 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/editing-planner": "剪辑规划",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音库",
|
||||
"/app/voice-materials": "配音素材库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
|
||||
@@ -47,7 +47,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "视频库",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "智能剪辑",
|
||||
label: "一键生成",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
@@ -132,7 +132,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "智能剪辑",
|
||||
label: "一键生成",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
@@ -155,7 +155,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{
|
||||
key: "assets",
|
||||
label: "视频库",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 视频库页面 — V21 设计系统
|
||||
* 两栏布局:左侧视频库列表(260px)+ 右侧素材网格
|
||||
* 素材库页面 — V21 设计系统
|
||||
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
AudioOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
@@ -57,7 +56,7 @@ import "./assets.css"
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "image" | "voice"
|
||||
type AssetKind = "video" | "image"
|
||||
type StatusType = "ok" | "warn" | "bad" | "info"
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -89,7 +88,6 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("audio/")) return "voice"
|
||||
return "image"
|
||||
}
|
||||
|
||||
@@ -144,7 +142,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
})
|
||||
|
||||
@@ -193,8 +191,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
return <VideoCameraOutlined />
|
||||
case "image":
|
||||
return <PictureOutlined />
|
||||
case "voice":
|
||||
return <AudioOutlined />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +200,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
return "视频"
|
||||
case "image":
|
||||
return "图片"
|
||||
case "voice":
|
||||
return "配音"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,8 +210,6 @@ const thumbGradient = (kind: AssetKind): string => {
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +350,7 @@ const AssetCard: React.FC<{
|
||||
const AssetLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 获取视频库列表 ── */
|
||||
/* ── 获取素材库列表 ── */
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
@@ -366,14 +358,11 @@ const AssetLibrary: React.FC = () => {
|
||||
})
|
||||
|
||||
const libraries = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiLibraries) ? apiLibraries : [])
|
||||
.map(mapLibrary)
|
||||
.filter((lib) => lib.kind === "video"),
|
||||
() => (Array.isArray(apiLibraries) ? apiLibraries : []).map(mapLibrary),
|
||||
[apiLibraries],
|
||||
)
|
||||
|
||||
/* ── 当前选中的视频库 ── */
|
||||
/* ── 当前选中的素材库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("")
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
@@ -407,10 +396,10 @@ const AssetLibrary: React.FC = () => {
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库创建成功")
|
||||
message.success("素材库创建成功")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建视频库失败")
|
||||
message.error("创建素材库失败")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -418,10 +407,10 @@ const AssetLibrary: React.FC = () => {
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库已删除")
|
||||
message.success("素材库已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除视频库失败")
|
||||
message.error("删除素材库失败")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -439,7 +428,7 @@ const AssetLibrary: React.FC = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
/* 新建视频库 */
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [newLibName, setNewLibName] = useState("")
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
|
||||
@@ -480,7 +469,7 @@ const AssetLibrary: React.FC = () => {
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
|
||||
/* 按视频库类型过滤(如果筛选类型不是 all) */
|
||||
/* 按素材库类型过滤(如果筛选类型不是 all) */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType)
|
||||
}
|
||||
@@ -532,7 +521,7 @@ const AssetLibrary: React.FC = () => {
|
||||
return
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
message.warning("请先选择或创建一个素材库")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -562,10 +551,10 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 新建视频库 */
|
||||
/* 新建素材库 */
|
||||
const handleCreateLibrary = async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入视频库名称")
|
||||
message.warning("请输入素材库名称")
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -582,7 +571,7 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除视频库 */
|
||||
/* 删除素材库 */
|
||||
const handleDeleteLibrary = async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id)
|
||||
@@ -838,7 +827,7 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:视频库列表 ─── */}
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
<div className="xx-asset-library-list">
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
@@ -851,7 +840,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
title={`确定删除视频库 "${lib.name}"?`}
|
||||
title={`确定删除素材库 "${lib.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
handleDeleteLibrary(lib.id)
|
||||
@@ -863,7 +852,7 @@ const AssetLibrary: React.FC = () => {
|
||||
<button
|
||||
className="xx-asset-library-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除视频库"
|
||||
title="删除素材库"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
@@ -875,10 +864,10 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建视频库 */}
|
||||
{/* 新建素材库 */}
|
||||
<div className="xx-asset-library-add" onClick={() => setCreateModalOpen(true)}>
|
||||
<PlusOutlined />
|
||||
新建视频库
|
||||
新建素材库
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1029,15 +1018,15 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-empty-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换视频库</p>
|
||||
<p className="xx-assets-empty-title">暂无素材,请上传或切换素材库</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 新建视频库弹窗 ─── */}
|
||||
{/* ─── 新建素材库弹窗 ─── */}
|
||||
<AntModal
|
||||
title="新建视频库"
|
||||
title="新建素材库"
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreateLibrary}
|
||||
@@ -1050,7 +1039,7 @@ const AssetLibrary: React.FC = () => {
|
||||
<div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入视频库名称"
|
||||
placeholder="请输入素材库名称"
|
||||
value={newLibName}
|
||||
onChange={(e) => setNewLibName(e.target.value)}
|
||||
maxLength={50}
|
||||
|
||||
Regular → Executable
+3
-3
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 视频库页面 - V21 设计系统样式
|
||||
* 两栏布局:左侧视频库列表(260px)+ 右侧素材网格
|
||||
* 素材库页面 - V21 设计系统样式
|
||||
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
|
||||
* 统一使用 CSS 变量,支持深色/浅色主题
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左侧视频库列表
|
||||
左侧素材库列表
|
||||
============================================================ */
|
||||
.xx-asset-library-list {
|
||||
display: flex;
|
||||
|
||||
Regular → Executable
+1
-1
@@ -203,7 +203,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>系统会对比您上传的视频与素材库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
|
||||
Regular → Executable
+2
-2
@@ -560,7 +560,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
|
||||
{/* 素材导入抽屉 */}
|
||||
<Drawer
|
||||
title="从视频库导入"
|
||||
title="从素材库导入"
|
||||
open={importDrawerOpen}
|
||||
onClose={() => setImportDrawerOpen(false)}
|
||||
width={480}
|
||||
@@ -611,7 +611,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="视频库为空" />
|
||||
<Empty description="素材库为空" />
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -4257,7 +4257,7 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
混剪配置面板 (PiP Configuration Panel)
|
||||
画中画配置面板 (PiP Configuration Panel)
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
.pip-config-panel-drawer .ant-drawer-body {
|
||||
|
||||
@@ -26,11 +26,11 @@ import type {
|
||||
GeneratedVideo,
|
||||
MediaAsset,
|
||||
TransitionEffect,
|
||||
TitleConfig,
|
||||
} from "@/api/editPlans"
|
||||
import {
|
||||
getMediaAssets,
|
||||
getEditPlanGenerations,
|
||||
generateCover,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
TitleSettings,
|
||||
} from "./types"
|
||||
import {
|
||||
DEFAULT_TRANSITION,
|
||||
@@ -95,7 +96,7 @@ import PipConfigPanel from "./components/PipConfigPanel"
|
||||
import FilterPanel from "./components/FilterPanel"
|
||||
import GreenScreenPanel from "./components/GreenScreenPanel"
|
||||
import StickerPanel from "./components/StickerPanel"
|
||||
|
||||
import CoverSelector from "./components/CoverSelector"
|
||||
import SaveModal from "./components/SaveModal"
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal"
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||
@@ -104,10 +105,17 @@ import "./EditingPlanner.css"
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||||
{ key: "pip", label: "混剪", icon: "🖼️" },
|
||||
{ key: "pip", label: "画中画", icon: "🖼️" },
|
||||
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
|
||||
{ key: "one_take", label: "一镜到底", icon: "🎥" },
|
||||
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
|
||||
{ key: "voice_pip", label: "口播+画中画", icon: "🎭" },
|
||||
]
|
||||
|
||||
const COVER_SCHEMES = [
|
||||
{ key: "ai_frame", label: "AI选帧" },
|
||||
{ key: "manual", label: "手动选" },
|
||||
{ key: "upload", label: "上传" },
|
||||
{ key: "ai_reselect", label: "AI重选" },
|
||||
]
|
||||
|
||||
const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
|
||||
@@ -138,18 +146,29 @@ const EditingPlanner: React.FC = () => {
|
||||
} = useUndoRedo<ClipData[]>([])
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── AI 操作状态 ── */
|
||||
|
||||
const [aiCoverLoading, setAiCoverLoading] = useState(false)
|
||||
|
||||
/* ── 封面方案 ── */
|
||||
const [currentCoverScheme, setCurrentCoverScheme] = useState<string>("ai_frame")
|
||||
|
||||
/* ── 左栏筛选 ── */
|
||||
const [currentFilter, setCurrentFilter] = useState("全部")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
/* ── 标题配置(只读,从模板/计划继承) ── */
|
||||
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
position: "bottom",
|
||||
font_preset: "思源黑体",
|
||||
font_color: "#ffffff",
|
||||
font_size: 28,
|
||||
/* ── 标题/字幕/BGM 设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 24,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: true,
|
||||
color: "#ffffff",
|
||||
})
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
|
||||
@@ -184,7 +203,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 混剪 ── */
|
||||
/* ── 画中画 ── */
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
})
|
||||
@@ -208,10 +227,11 @@ const EditingPlanner: React.FC = () => {
|
||||
})
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 封面配置(只读,从模板/计划继承) ── */
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
/* ── 封面 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
@@ -348,14 +368,15 @@ const EditingPlanner: React.FC = () => {
|
||||
}))
|
||||
resetClips(mapped)
|
||||
|
||||
setTitleConfig({
|
||||
ai_auto_select: tpl.title_config.ai_auto_select,
|
||||
content: tpl.title_config.content,
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config.ai_auto_select,
|
||||
title: tpl.title_config.content,
|
||||
position: tpl.title_config.position,
|
||||
font_preset: tpl.title_config.font_preset,
|
||||
font_size: tpl.title_config.font_size,
|
||||
font_color: tpl.title_config.font_color || "#ffffff",
|
||||
})
|
||||
font: tpl.title_config.font_preset,
|
||||
size: tpl.title_config.font_size,
|
||||
color: tpl.title_config.font_color || "#ffffff",
|
||||
}))
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
@@ -402,14 +423,15 @@ const EditingPlanner: React.FC = () => {
|
||||
// 还原 config 中的编辑器状态
|
||||
const cfg = plan.config
|
||||
if (cfg.title_config) {
|
||||
setTitleConfig({
|
||||
ai_auto_select: cfg.title_config!.ai_auto_select,
|
||||
content: cfg.title_config!.content,
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content,
|
||||
position: cfg.title_config!.position,
|
||||
font_preset: cfg.title_config!.font_preset,
|
||||
font_size: cfg.title_config!.font_size,
|
||||
font_color: cfg.title_config!.font_color || "#ffffff",
|
||||
})
|
||||
font: cfg.title_config!.font_preset,
|
||||
size: cfg.title_config!.font_size,
|
||||
color: cfg.title_config!.font_color || "#ffffff",
|
||||
}))
|
||||
}
|
||||
if (cfg.subtitle_config) {
|
||||
setSubtitleSettings((prev) => ({
|
||||
@@ -432,7 +454,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev) => ({
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
@@ -752,7 +774,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setIntroOutroSettings(config)
|
||||
}, [])
|
||||
|
||||
/* ── 混剪配置变更 ── */
|
||||
/* ── 画中画配置变更 ── */
|
||||
const handlePipChange = useCallback((config: PipConfig) => {
|
||||
setPipSettings(config)
|
||||
}, [])
|
||||
@@ -772,9 +794,44 @@ const EditingPlanner: React.FC = () => {
|
||||
setStickerSettings(config)
|
||||
}, [])
|
||||
|
||||
/* ── 封面配置变更 ── */
|
||||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||||
setCoverSettings(config)
|
||||
}, [])
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (coverType: "ai_frame" | "ai_regenerate") => {
|
||||
if (!loadedTemplateId) return
|
||||
const assetIds = selectedAssetIds
|
||||
if (assetIds.length === 0) {
|
||||
message.warning("请先在素材库中选择素材")
|
||||
return
|
||||
}
|
||||
setAiCoverLoading(true)
|
||||
try {
|
||||
await generateCover(loadedTemplateId, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: coverType,
|
||||
})
|
||||
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect")
|
||||
message.success("AI 封面生成成功")
|
||||
} catch {
|
||||
message.error("AI 封面生成失败")
|
||||
} finally {
|
||||
setAiCoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建剪辑计划 config(编辑器状态 → API config) */
|
||||
const buildPlanConfig = (): EditPlanConfig => ({
|
||||
title_config: titleConfig,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
@@ -821,7 +878,7 @@ const EditingPlanner: React.FC = () => {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
cover_config: { ...coverSettings },
|
||||
})
|
||||
|
||||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||||
@@ -844,7 +901,14 @@ const EditingPlanner: React.FC = () => {
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
title_config: titleConfig,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
position: titleSettings.position,
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
@@ -891,7 +955,7 @@ const EditingPlanner: React.FC = () => {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
cover_config: { ...coverSettings },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
@@ -1231,8 +1295,10 @@ const EditingPlanner: React.FC = () => {
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
isPlaying={isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
coverConfig={coverConfig}
|
||||
currentCoverScheme={currentCoverScheme}
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
@@ -1241,7 +1307,9 @@ const EditingPlanner: React.FC = () => {
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
onAiGenerateCover={handleAiGenerateCover}
|
||||
/>
|
||||
|
||||
{/* 下半部:水平时间线 */}
|
||||
@@ -1287,11 +1355,15 @@ const EditingPlanner: React.FC = () => {
|
||||
<div className="ep-right-tab-content">
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
|
||||
}
|
||||
@@ -1314,6 +1386,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1650,7 +1723,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onChange={handleIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 混剪配置面板 ═══ */}
|
||||
{/* ═══ 画中画配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={() => setPipDrawerOpen(false)}
|
||||
@@ -1683,6 +1756,15 @@ const EditingPlanner: React.FC = () => {
|
||||
onChange={handleStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 封面选择器 ═══ */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={() => setCoverDrawerOpen(false)}
|
||||
config={coverSettings}
|
||||
onChange={handleCoverChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Executable → Regular
+335
-10
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 字幕设置 + BGM设置 + 片段详情
|
||||
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { TemplateMode } from "@/api/editingPlanner"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -33,11 +33,13 @@ interface BgmSettings {
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
selectedClip: ClipData | null
|
||||
titleSettings: TitleSettings
|
||||
subtitleSettings: SubtitleSettings
|
||||
bgmSettings: BgmSettings
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentMode: TemplateMode
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
@@ -45,7 +47,7 @@ interface ClipPropertiesPanelProps {
|
||||
onOpenBgmDrawer?: () => void
|
||||
/** 打开字幕样式配置 Drawer */
|
||||
onOpenSubtitleDrawer?: () => void
|
||||
/** 配音素材列表(从配音库 API 获取) */
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[]
|
||||
/** 配音素材加载中 */
|
||||
voiceMaterialsLoading?: boolean
|
||||
@@ -63,7 +65,7 @@ interface ClipPropertiesPanelProps {
|
||||
onOpenWatermarkDrawer?: () => void
|
||||
/** 打开片头片尾设置面板 Drawer */
|
||||
onOpenIntroOutroDrawer?: () => void
|
||||
/** 打开混剪设置面板 Drawer */
|
||||
/** 打开画中画设置面板 Drawer */
|
||||
onOpenPipDrawer?: () => void
|
||||
/** 打开滤镜调色面板 Drawer */
|
||||
onOpenFilterDrawer?: () => void
|
||||
@@ -72,6 +74,7 @@ interface ClipPropertiesPanelProps {
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -89,20 +92,190 @@ const ANIMATION_OPTIONS = [
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
]
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
* 不绑定字体,用户可自由搭配任意字体
|
||||
* 预览统一用系统字体展示效果
|
||||
*/
|
||||
const TITLE_PRESETS = [
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
style: {
|
||||
size: 32,
|
||||
color: "#d4a843",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: {
|
||||
size: 24,
|
||||
color: "#333333",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: {
|
||||
size: 36,
|
||||
color: "#ff4081",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: {
|
||||
size: 24,
|
||||
color: "#1a1a1a",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#1a1a1a",
|
||||
fontSize: "17px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#e8d5b7",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_glow",
|
||||
label: "霓虹发光",
|
||||
style: {
|
||||
size: 32,
|
||||
color: "#00e5ff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "handwriting",
|
||||
label: "手写字",
|
||||
style: {
|
||||
size: 28,
|
||||
color: "#333333",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */
|
||||
function 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 CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
@@ -121,6 +294,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -159,6 +333,142 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
}
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">📝</span>
|
||||
标题设置
|
||||
</div>
|
||||
|
||||
<div className="ep-toggle-row">
|
||||
<span className="ep-toggle-label">AI 自动选择</span>
|
||||
<div
|
||||
className={`ep-toggle ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({
|
||||
aiAutoSelect: !titleSettings.aiAutoSelect,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">位置</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) => onTitleSettingsChange({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">字体</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) => onTitleSettingsChange({ font: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">大小</label>
|
||||
<div className="ep-slider-row">
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) => onTitleSettingsChange({ size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">预设样式</label>
|
||||
<div className="ep-title-presets-grid">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = getActivePreset(titleSettings) === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`ep-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({
|
||||
size: p.style.size,
|
||||
color: p.style.color,
|
||||
bold: p.style.bold,
|
||||
italic: p.style.italic,
|
||||
stroke: p.style.stroke,
|
||||
shadow: p.style.shadow,
|
||||
})
|
||||
}
|
||||
title={p.label}
|
||||
>
|
||||
<span className="ep-title-preset-preview-text" style={p.previewStyle}>
|
||||
标题
|
||||
</span>
|
||||
<span className="ep-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">样式</label>
|
||||
<div className="ep-style-btns">
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={() => onTitleSettingsChange({ bold: !titleSettings.bold })}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={() => onTitleSettingsChange({ italic: !titleSettings.italic })}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={() => onTitleSettingsChange({ stroke: !titleSettings.stroke })}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={() => onTitleSettingsChange({ shadow: !titleSettings.shadow })}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
@@ -312,16 +622,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 混剪 ═══ */}
|
||||
{/* ═══ 画中画 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
混剪
|
||||
画中画
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置混剪图层</span>
|
||||
<span className="ep-advanced-btn-label">配置画中画图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -372,6 +682,21 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">选择视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
@@ -616,12 +941,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<div className="ep-clip-detail-label">当前模式</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{currentMode === "pip"
|
||||
? "混剪"
|
||||
? "画中画"
|
||||
: currentMode === "voice_over"
|
||||
? "人物口播"
|
||||
: currentMode === "one_take"
|
||||
? "一镜到底"
|
||||
: "口播+混剪"}
|
||||
: "口播+画中画"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "混剪",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
|
||||
Executable → Regular
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 混剪配置面板 — Drawer 形式
|
||||
* 画中画配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
@@ -187,7 +187,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🖼️ 混剪设置"
|
||||
title="🖼️ 画中画设置"
|
||||
placement="right"
|
||||
width={520}
|
||||
open={open}
|
||||
|
||||
Executable → Regular
+68
-38
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览 + 封面预览 并排
|
||||
* 封面为只读展示(从模板/计划继承)
|
||||
* 手机模型预览(150x267) + 封面预览(150x267) 并排
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/editPlans"
|
||||
import type { CoverConfig } from "../types"
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types"
|
||||
|
||||
interface CoverScheme {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -20,11 +23,15 @@ interface PreviewPlayerProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
titleConfig?: TitleConfig
|
||||
coverConfig?: CoverConfig
|
||||
currentCoverScheme: string
|
||||
coverSchemes: CoverScheme[]
|
||||
aiCoverLoading: boolean
|
||||
titleSettings?: TitleSettings
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onCoverSchemeChange: (scheme: string) => void
|
||||
onPlayPause: () => void
|
||||
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void
|
||||
}
|
||||
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
@@ -34,23 +41,21 @@ const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧封面",
|
||||
upload: "上传封面",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
titleConfig,
|
||||
coverConfig,
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
aiCoverLoading,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
}) => {
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId)
|
||||
const displayClip = selectedClip || clips[0]
|
||||
@@ -85,28 +90,27 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 */}
|
||||
{titleConfig && !titleConfig.ai_auto_select && titleConfig.content && (
|
||||
{titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && (
|
||||
<div
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
|
||||
fontFamily: titleConfig.font_preset,
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.6)",
|
||||
fontSize: `${Math.min(titleSettings.size, 20)}px`,
|
||||
fontFamily: titleSettings.font,
|
||||
fontWeight: titleSettings.bold ? "bold" : "normal",
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
textShadow: titleSettings.shadow ? "2px 2px 4px rgba(0,0,0,0.5)" : "none",
|
||||
WebkitTextStroke: titleSettings.stroke ? "1px rgba(0,0,0,0.6)" : "none",
|
||||
top:
|
||||
titleConfig.position === "top"
|
||||
titleSettings.position === "top"
|
||||
? "8px"
|
||||
: titleConfig.position === "center"
|
||||
: titleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: titleConfig.position === "bottom" ? "30px" : "auto",
|
||||
transform: titleConfig.position === "center" ? "translateY(-50%)" : "none",
|
||||
color: titleConfig.font_color,
|
||||
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
|
||||
transform: titleSettings.position === "center" ? "translateY(-50%)" : "none",
|
||||
}}
|
||||
>
|
||||
{titleConfig.content}
|
||||
{titleSettings.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -133,24 +137,50 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面预览(只读) */}
|
||||
{/* 封面预览 */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
|
||||
<img
|
||||
src={coverConfig.thumbnail_url || coverConfig.upload_url}
|
||||
alt="封面预览"
|
||||
className="ep-cover-img"
|
||||
/>
|
||||
) : displayClip ? (
|
||||
{displayClip ? (
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"}
|
||||
</div>
|
||||
{/* AI 封面操作按钮 */}
|
||||
<div className="ep-cover-ai-btns">
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_frame")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 智能选帧"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🤖"} AI 选帧
|
||||
</button>
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_regenerate")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 重新生成封面"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🔄"} AI 重选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面方案按钮(竖排4个) */}
|
||||
<div className="ep-cover-tags">
|
||||
{coverSchemes.map((scheme) => (
|
||||
<button
|
||||
key={scheme.key}
|
||||
className={`ep-cover-tag ${currentCoverScheme === scheme.key ? "active" : ""}`}
|
||||
onClick={() => onCoverSchemeChange(scheme.key)}
|
||||
>
|
||||
{scheme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
/** 片段类型标签 */
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
|
||||
@@ -313,7 +313,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
{config.mode === "upload" && (
|
||||
<div className="tts-upload-hint">
|
||||
<p>请在右侧面板的「配音素材」中选择已上传的配音文件。</p>
|
||||
<p>如需上传新配音,请前往配音库页面。</p>
|
||||
<p>如需上传新配音,请前往配音素材库页面。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
outro: { kind: "none", duration: 3 },
|
||||
}
|
||||
|
||||
/* ──────── 混剪配置 ──────── */
|
||||
/* ──────── 画中画配置 ──────── */
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
@@ -196,7 +196,7 @@ export type PipAnimType = "none" | "fade_in" | "slide_in"
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down"
|
||||
|
||||
/** 混剪图层 */
|
||||
/** 画中画图层 */
|
||||
export interface PipLayer {
|
||||
id: string
|
||||
/** 图层名称(用户可编辑) */
|
||||
@@ -235,9 +235,9 @@ export interface PipLayer {
|
||||
z_index: number
|
||||
}
|
||||
|
||||
/** 混剪配置 */
|
||||
/** 画中画配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用混剪 */
|
||||
/** 是否启用画中画 */
|
||||
enabled: boolean
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[]
|
||||
@@ -507,7 +507,7 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
|
||||
export interface ClipData {
|
||||
id: string
|
||||
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
|
||||
type: ClipType // 片段类型:voice(口播)或 pip(画中画)
|
||||
duration: number // 时长(秒)
|
||||
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
|
||||
Executable → Regular
+78
-682
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||
* 一键生成页面 — V21 原型 1:1 还原
|
||||
* 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
|
||||
@@ -31,8 +31,7 @@ import {
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans"
|
||||
import { getEditingTemplates } from "@/api/editingPlanner"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import apiClient from "@/api/client"
|
||||
@@ -76,167 +75,12 @@ const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
{ key: 3, label: "选择标题" },
|
||||
{ key: 4, label: "选择配音" },
|
||||
{ key: 5, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题设置常量 ── */
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
const TITLE_PRESETS = [
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_glow",
|
||||
label: "霓虹发光",
|
||||
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "handwriting",
|
||||
label: "手写字",
|
||||
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/* ── 封面设置常量 ── */
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const COVER_MODE_ICONS: Record<string, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
function 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
|
||||
}
|
||||
/* ── 标题选项从 API 加载,不再硬编码 ── */
|
||||
|
||||
/* ================================================================
|
||||
组件
|
||||
@@ -248,7 +92,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板(从 API 加载) ── */
|
||||
/* ── 模板(从 API 加载用户自制模板) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
@@ -267,11 +111,8 @@ const GeneratePage: React.FC = () => {
|
||||
/* 素材选择模式:手动选择 / 自动匹配 */
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
/* ── 标题 ── */
|
||||
const [title, setTitle] = useState("")
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
@@ -280,27 +121,8 @@ const GeneratePage: React.FC = () => {
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
if (tpl?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) {
|
||||
setTitle(tpl.title_config.content)
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
@@ -355,17 +177,8 @@ const GeneratePage: React.FC = () => {
|
||||
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
if (config.title_config?.content) {
|
||||
setTitle(config.title_config.content)
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
@@ -386,31 +199,8 @@ const GeneratePage: React.FC = () => {
|
||||
const loadPlanConfig = async () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId)
|
||||
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
||||
if (plan.name) setTitle(plan.name)
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
@@ -455,7 +245,7 @@ const GeneratePage: React.FC = () => {
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
// 自动选中第一个素材库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
@@ -583,7 +373,7 @@ const GeneratePage: React.FC = () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
已保存到配音素材库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
@@ -591,7 +381,7 @@ const GeneratePage: React.FC = () => {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
去素材库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
@@ -650,19 +440,19 @@ const GeneratePage: React.FC = () => {
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
/** 保存成功后跳转到视频库 */
|
||||
/** 保存成功后跳转到素材库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
titleSettings,
|
||||
title,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
})
|
||||
if (!titleSettings.title.trim()) {
|
||||
if (!title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
}
|
||||
@@ -698,18 +488,9 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
const plan = await createEditPlan({
|
||||
template_id: selectedTemplate,
|
||||
name: titleSettings.title.trim(),
|
||||
name: title.trim(),
|
||||
config: {
|
||||
asset_ids: selectedMaterials,
|
||||
title_config: {
|
||||
ai_auto_select: titleSettings.aiAutoSelect,
|
||||
content: titleSettings.title,
|
||||
position: titleSettings.position,
|
||||
font_preset: titleSettings.font,
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
@@ -873,7 +654,7 @@ const GeneratePage: React.FC = () => {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
return "素材数据异常,请返回素材库重新检查"
|
||||
}
|
||||
// 网络/超时
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
@@ -892,7 +673,7 @@ const GeneratePage: React.FC = () => {
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [
|
||||
titleSettings,
|
||||
title,
|
||||
selectedMaterials,
|
||||
selectedVoice,
|
||||
voiceMode,
|
||||
@@ -908,7 +689,6 @@ const GeneratePage: React.FC = () => {
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
@@ -957,14 +737,14 @@ const GeneratePage: React.FC = () => {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
if (currentStep === 3 && !title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode])
|
||||
}, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode])
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
@@ -1081,13 +861,13 @@ const GeneratePage: React.FC = () => {
|
||||
onClick={() => setMaterialMode("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
选择素材库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 视频库选择(两种模式共用) ── */}
|
||||
{/* ── 素材库选择(两种模式共用) ── */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<label>选择素材库</label>
|
||||
<select value={selectedLibraryId} onChange={(e) => setSelectedLibraryId(e.target.value)}>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
@@ -1117,7 +897,7 @@ const GeneratePage: React.FC = () => {
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
@@ -1185,7 +965,7 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-auto-match-body">
|
||||
<h4 className="xx-auto-match-title">智能素材匹配</h4>
|
||||
<p className="xx-auto-match-desc">
|
||||
系统将根据所选模板和标题,从视频库中自动分析并匹配最合适的素材进行视频生成。
|
||||
系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。
|
||||
无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。
|
||||
</p>
|
||||
<div className="xx-auto-match-features">
|
||||
@@ -1202,7 +982,7 @@ const GeneratePage: React.FC = () => {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
扫描视频库中…
|
||||
扫描素材库中…
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
@@ -1212,7 +992,7 @@ const GeneratePage: React.FC = () => {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
当前视频库共 {materials.items.length} 个素材可供匹配
|
||||
当前素材库共 {materials.items.length} 个素材可供匹配
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
@@ -1220,236 +1000,54 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 3:生成预览 */
|
||||
/** 步骤 3:选择标题 */
|
||||
const renderStep3 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">剪辑计划预览</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{getTemplateName()}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 4:选择标题 + 标题样式设置 */
|
||||
const renderStep4 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{/* AI 自动选择开关 */}
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
setTitleSettings((prev) => ({ ...prev, aiAutoSelect: !prev.aiAutoSelect }))
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={title || undefined}
|
||||
onChange={(val) => setTitle(val || "")}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
{/* 标题内容选择 */}
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={titleSettings.title || undefined}
|
||||
onChange={(val) => setTitleSettings((prev) => ({ ...prev, title: val || "" }))}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={titleSettings.title}
|
||||
onChange={(e) => setTitleSettings((prev) => ({ ...prev, title: e.target.value }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题样式设置区 */}
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) =>
|
||||
setTitleSettings((prev) => ({ ...prev, position: e.target.value }))
|
||||
}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) => setTitleSettings((prev) => ({ ...prev, font: e.target.value }))}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) =>
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
size: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<div className="xx-title-presets-grid">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = getActivePreset(titleSettings) === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
setTitleSettings((prev) => ({
|
||||
...prev,
|
||||
size: p.style.size,
|
||||
color: p.style.color,
|
||||
bold: p.style.bold,
|
||||
italic: p.style.italic,
|
||||
stroke: p.style.stroke,
|
||||
shadow: p.style.shadow,
|
||||
}))
|
||||
}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={p.previewStyle as React.CSSProperties}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={() => setTitleSettings((prev) => ({ ...prev, bold: !prev.bold }))}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={() => setTitleSettings((prev) => ({ ...prev, italic: !prev.italic }))}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={() => setTitleSettings((prev) => ({ ...prev, stroke: !prev.stroke }))}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={() => setTitleSettings((prev) => ({ ...prev, shadow: !prev.shadow }))}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
{userTitles.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 8 }}>
|
||||
标题库为空,请前往「标题管理」添加标题,或手动输入
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 4:选择配音 */
|
||||
const renderStep5 = () => (
|
||||
const renderStep4 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
@@ -1633,7 +1231,7 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
|
||||
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音库</span>
|
||||
<span>保存到配音素材库</span>
|
||||
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
@@ -1824,198 +1422,8 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 5:选择封面 */
|
||||
const renderStep6 = () => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => setCoverSettings((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setCoverSettings((prev) => ({ ...prev, mode: m }))}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) =>
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
frame_time: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setCoverSettings((prev) => ({ ...prev, frame_time: t }))}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
}))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img
|
||||
src={coverSettings.upload_url}
|
||||
alt="封面预览"
|
||||
className="xx-cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 步骤 6:确认生成 */
|
||||
const renderStep7 = () => (
|
||||
/** 步骤 5:确认生成 */
|
||||
const renderStep5 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
@@ -2031,18 +1439,12 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{titleSettings.title || "未选择"}</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{getVoiceName()}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">
|
||||
{coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
@@ -2158,10 +1560,6 @@ const GeneratePage: React.FC = () => {
|
||||
return renderStep4()
|
||||
case 5:
|
||||
return renderStep5()
|
||||
case 6:
|
||||
return renderStep6()
|
||||
case 7:
|
||||
return renderStep7()
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -2178,7 +1576,7 @@ const GeneratePage: React.FC = () => {
|
||||
<div>
|
||||
<h2>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
智能剪辑
|
||||
一键生成
|
||||
</h2>
|
||||
<p>快速生成短视频,支持多种风格和素材组合</p>
|
||||
</div>
|
||||
@@ -2238,7 +1636,7 @@ const GeneratePage: React.FC = () => {
|
||||
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
{currentStep < 5 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
@@ -2292,9 +1690,7 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 字幕预览 */}
|
||||
<div className="xx-preview-caption">
|
||||
{titleSettings.title || "3秒抓住注意力,30秒讲清卖点"}
|
||||
</div>
|
||||
<div className="xx-preview-caption">{title || "3秒抓住注意力,30秒讲清卖点"}</div>
|
||||
|
||||
{/* 时间线标题 */}
|
||||
<div className="xx-preview-title">剪辑计划预览</div>
|
||||
|
||||
Executable → Regular
+1
-614
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原样式
|
||||
* 一键生成页面 — V21 原型 1:1 还原样式
|
||||
* 对照 frontend-v21-ui-prototype-final.html #view-generate
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
@@ -1136,616 +1136,3 @@
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
标题设置(选择标题步骤)
|
||||
============================================================ */
|
||||
.xx-title-ai-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.xx-toggle-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.xx-switch.active {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.xx-switch.active .xx-switch-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.xx-title-style-section {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.xx-section-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.xx-title-style-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-half-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-field-label-row label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.xx-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: var(--primary-200);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-title-preset-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active .xx-title-preset-card-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 样式按钮组 */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-style-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-style-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-style-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
/* ================================================================
|
||||
封面设置
|
||||
================================================================ */
|
||||
|
||||
.xx-cover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-header-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.xx-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.xx-switch-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: 0.2s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.xx-switch-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.xx-switch input:checked + .xx-switch-slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-switch input:checked + .xx-switch-slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.xx-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab:hover {
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-cover-mode-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-cover-auto {
|
||||
padding: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-cover-auto-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.xx-cover-auto-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-50);
|
||||
border: 1px solid var(--primary-200);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-frame {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-preview {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-placeholder {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xx-cover-frame-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-time {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-frame-slider {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-slider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-frame-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-cover-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.xx-cover-frame-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-cover-quick-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-quick-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-quick-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-upload {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-upload-area {
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-cover-upload-area:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-cover-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-cover-upload-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.xx-cover-upload-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.xx-cover-upload-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-cover-upload-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-cover-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-cover-preview-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-cover-preview-ratio {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
生成预览步骤
|
||||
================================================================ */
|
||||
|
||||
.xx-preview-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
border: 1px solid rgba(82, 196, 26, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-plan-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-plan-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-plan-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-preview-plan-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const HeroSection: React.FC = () => {
|
||||
<h1 className="hp-hero-title">
|
||||
上传素材,AI自动剪辑
|
||||
<br />
|
||||
智能剪辑短视频
|
||||
一键生成短视频
|
||||
</h1>
|
||||
<p className="hp-hero-desc">
|
||||
基于先进的 AI 技术,自动识别视频亮点,智能剪辑、配音、加字幕。 30
|
||||
@@ -71,7 +71,7 @@ const FEATURES = [
|
||||
{
|
||||
icon: "🤖",
|
||||
title: "AI 智能剪辑",
|
||||
desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。",
|
||||
desc: "自动识别视频高光片段,智能去除冗余内容,一键生成精彩短视频。",
|
||||
},
|
||||
{
|
||||
icon: "🎙️",
|
||||
|
||||
@@ -1058,7 +1058,7 @@ const ProductLibrary: React.FC = () => {
|
||||
<div className="xx-products-empty-icon">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<p>暂无成片,去智能剪辑吧</p>
|
||||
<p>暂无成片,去一键生成吧</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -927,7 +927,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
|
||||
// 自动创建 voice 素材库(如果不存在)
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
|
||||
mutationFn: () => createAssetLibrary({ name: "配音素材库", kind: "voice" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
},
|
||||
@@ -1032,7 +1032,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
if (!lib) throw new Error("无法创建配音素材库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
@@ -1474,7 +1474,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
await saveTtsToLibrary(ttsJobId, {
|
||||
name: ttsText.slice(0, 20) || "AI配音",
|
||||
})
|
||||
message.success("已保存到配音库")
|
||||
message.success("已保存到配音素材库")
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setTtsOpen(false)
|
||||
} catch {
|
||||
@@ -1513,7 +1513,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
return (
|
||||
<div className="vmat-page">
|
||||
<PageHead
|
||||
title="配音库"
|
||||
title="配音素材库"
|
||||
description="管理配音音频素材,支持上传、试听、编辑元信息"
|
||||
actions={pageActions}
|
||||
/>
|
||||
|
||||
Executable → Regular
+6
-96
@@ -36,13 +36,7 @@ import {
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import "./voices.css"
|
||||
|
||||
@@ -51,7 +45,7 @@ import "./voices.css"
|
||||
* ============================================================ */
|
||||
type VoiceGender = "male" | "female" | "child" | "elderly"
|
||||
type VoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
type TabKey = "preset" | "cloned" | "material"
|
||||
type TabKey = "preset" | "cloned"
|
||||
|
||||
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
|
||||
interface PresetVoiceDisplay {
|
||||
@@ -676,13 +670,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
/* 获取或创建默认配音库 */
|
||||
/* 获取或创建默认配音素材库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建")
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
@@ -775,7 +769,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) })
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
showToast("已保存到配音库", "success")
|
||||
showToast("已保存到配音素材库", "success")
|
||||
setTtsOpen(false)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "保存失败"
|
||||
@@ -804,12 +798,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
@@ -826,7 +814,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
)
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
@@ -997,17 +984,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
我的克隆
|
||||
<span className="xx-voices-tab-count">{cloneCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("material")
|
||||
handlePause()
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
配音素材
|
||||
<span className="xx-voices-tab-count">{materialCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "preset" && (
|
||||
@@ -1157,72 +1133,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "material" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
{materialLoading && (
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||
<div className="vmat-thumb" />
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||
<div className="vmat-skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!materialLoading && (materialData?.length || 0) > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{(materialData || []).map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>
|
||||
{asset.file_size
|
||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!materialLoading && (materialData?.length || 0) === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>上传您的音频素材,用于视频配音</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
|
||||
<UploadOutlined /> 上传音频
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
@@ -1759,7 +1669,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
保存到配音素材库
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Executable → Regular
-102
@@ -970,105 +970,3 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
配音素材卡片(与配音库Tab集成)
|
||||
================================================================ */
|
||||
|
||||
.vmat-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.vmat-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vmat-thumb-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.vmat-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vmat-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vmat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vmat-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vmat-card--skeleton .vmat-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.vmat-skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
animation: vmat-shimmer 1.5s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.vmat-skeleton-title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes vmat-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@ module.exports = {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
refreshAccessToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { baseURL: "/api/v1" },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
},
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "@/api/auth"
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -75,192 +44,4 @@ describe("normalizeUser", () => {
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers id over user_id when both present", () => {
|
||||
const result = normalizeUser({
|
||||
id: "id-first",
|
||||
user_id: "userid-second",
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.id).toBe("id-first")
|
||||
expect(result.user_id).toBe("id-first")
|
||||
})
|
||||
|
||||
it("prefers is_email_verified over email_verified", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
is_email_verified: true,
|
||||
email_verified: false,
|
||||
})
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
expect(result.email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults email verified to false when both missing", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.is_email_verified).toBe(false)
|
||||
expect(result.email_verified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth API functions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } })
|
||||
})
|
||||
|
||||
describe("login", () => {
|
||||
it("calls login API with correct params", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { access_token: "acc", refresh_token: "ref", user_id: "1" },
|
||||
})
|
||||
const result = await login({ email: "test@test.com", password: "pass" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/login", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
})
|
||||
expect(result.access_token).toBe("acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("login failed"))
|
||||
await expect(login({ email: "t", password: "p" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("calls register API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "ok" } })
|
||||
const result = await register({
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/register", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(result.message).toBe("ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("register failed"))
|
||||
await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout", () => {
|
||||
it("calls logout API", async () => {
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
await logout()
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/logout")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("logout failed"))
|
||||
await expect(logout()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCurrentUser", () => {
|
||||
it("fetches and normalizes user", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "u1",
|
||||
email: "user@test.com",
|
||||
username: "user1",
|
||||
display_name: "User One",
|
||||
email_verified: true,
|
||||
},
|
||||
})
|
||||
const result = await getCurrentUser()
|
||||
expect(mockGet).toHaveBeenCalledWith("/auth/me")
|
||||
expect(result.id).toBe("u1")
|
||||
expect(result.email).toBe("user@test.com")
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("fetch failed"))
|
||||
await expect(getCurrentUser()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("refreshAccessToken", () => {
|
||||
it("calls refresh endpoint with raw axios", async () => {
|
||||
mockAxiosPost.mockResolvedValue({
|
||||
data: { access_token: "new-acc", refresh_token: "new-ref" },
|
||||
})
|
||||
const result = await refreshAccessToken("old-refresh")
|
||||
expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", {
|
||||
refresh_token: "old-refresh",
|
||||
})
|
||||
expect(result.access_token).toBe("new-acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockAxiosPost.mockRejectedValue(new Error("refresh failed"))
|
||||
await expect(refreshAccessToken("tok")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestPasswordReset", () => {
|
||||
it("calls forgot-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "sent" } })
|
||||
const result = await requestPasswordReset("test@test.com")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", {
|
||||
email: "test@test.com",
|
||||
})
|
||||
expect(result.message).toBe("sent")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(requestPasswordReset("e")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("calls reset-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "reset ok" } })
|
||||
const result = await resetPassword("token123", "newpass")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", {
|
||||
token: "token123",
|
||||
new_password: "newpass",
|
||||
})
|
||||
expect(result.message).toBe("reset ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(resetPassword("t", "p")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("verifyEmail", () => {
|
||||
it("calls verify-email API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "verified" } })
|
||||
const result = await verifyEmail("verify-token")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", {
|
||||
token: "verify-token",
|
||||
})
|
||||
expect(result.message).toBe("verified")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("verify failed"))
|
||||
await expect(verifyEmail("t")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: {
|
||||
getState: vi.fn(() => ({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
|
||||
fulfilled: (config: unknown) => unknown
|
||||
rejected: (error: unknown) => unknown
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
|
||||
fulfilled: (response: unknown) => unknown
|
||||
rejected: (error: unknown) => Promise<unknown>
|
||||
}>
|
||||
|
||||
const requestInterceptor = requestHandlers[0]?.fulfilled!
|
||||
const requestErrorInterceptor = requestHandlers[0]?.rejected!
|
||||
const responseInterceptor = responseHandlers[0]?.fulfilled!
|
||||
const responseErrorInterceptor = responseHandlers[0]?.rejected!
|
||||
|
||||
function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) {
|
||||
const err = {
|
||||
config: { headers: {} },
|
||||
message: "error",
|
||||
} as {
|
||||
config: { headers: Record<string, string>; _retry?: boolean; url?: string }
|
||||
response?: { status: number; data: unknown }
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
if (hasResponse && status !== undefined) {
|
||||
err.response = { status, data }
|
||||
}
|
||||
if (code) err.code = code
|
||||
return err
|
||||
}
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("request interceptor", () => {
|
||||
it("adds Authorization header when token exists", () => {
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBe("Bearer test-token")
|
||||
})
|
||||
|
||||
it("skips Authorization header when no token", () => {
|
||||
const config = { headers: {} }
|
||||
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
|
||||
expect(result.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects on request error", async () => {
|
||||
const error = new Error("request error")
|
||||
await expect(requestErrorInterceptor(error) as Promise<never>).rejects.toThrow(
|
||||
"request error",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - success", () => {
|
||||
it("passes through successful response", () => {
|
||||
const response = { data: { success: true }, status: 200 }
|
||||
expect(responseInterceptor(response)).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - timeout & network", () => {
|
||||
it("shows timeout message for ECONNABORTED", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, "ECONNABORTED")
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows timeout message for timeout string", async () => {
|
||||
const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" }
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
|
||||
})
|
||||
|
||||
it("shows network error when no response", async () => {
|
||||
const err = makeAxiosError(undefined, undefined, undefined, false)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置")
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - server error messages", () => {
|
||||
it("shows detail field", async () => {
|
||||
const err = makeAxiosError(400, { detail: "参数错误" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("参数错误")
|
||||
})
|
||||
|
||||
it("shows message field", async () => {
|
||||
const err = makeAxiosError(400, { message: "操作失败" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("操作失败")
|
||||
})
|
||||
|
||||
it("shows msg field", async () => {
|
||||
const err = makeAxiosError(400, { msg: "出错了" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("出错了")
|
||||
})
|
||||
|
||||
it("handles nested message object", async () => {
|
||||
const err = makeAxiosError(400, { message: { message: "深层错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("深层错误")
|
||||
})
|
||||
|
||||
it("handles nested msg object", async () => {
|
||||
const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("嵌套错误")
|
||||
})
|
||||
|
||||
it("stringifies object with no string fields", async () => {
|
||||
const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}')
|
||||
})
|
||||
|
||||
it("marks __msgShown when message displayed", async () => {
|
||||
const err = makeAxiosError(400, { detail: "test" }) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: { detail: string } }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response interceptor - HTTP status codes", () => {
|
||||
it("shows file too large for 413", async () => {
|
||||
const err = makeAxiosError(413, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试")
|
||||
})
|
||||
|
||||
it("shows unsupported format for 415", async () => {
|
||||
const err = makeAxiosError(415, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("不支持的文件格式")
|
||||
})
|
||||
|
||||
it("shows service unavailable for 503", async () => {
|
||||
const err = makeAxiosError(503, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 500", async () => {
|
||||
const err = makeAxiosError(500, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("shows server busy for 502", async () => {
|
||||
const err = makeAxiosError(502, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
||||
})
|
||||
|
||||
it("no message for 4xx without server msg", async () => {
|
||||
const err = makeAxiosError(403, {})
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("no __msgShown for unhandled 4xx", async () => {
|
||||
const err = makeAxiosError(403, {}) as {
|
||||
config: { headers: Record<string, string> }
|
||||
response: { status: number; data: Record<string, never> }
|
||||
message: string
|
||||
__msgShown?: boolean
|
||||
}
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(err.__msgShown).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("safeExtractString edge cases", () => {
|
||||
it("returns empty string for numeric message", async () => {
|
||||
const err = makeAxiosError(400, { message: 123 })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns empty string for null data", async () => {
|
||||
const err = makeAxiosError(400, null)
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles detail with nested detail object", async () => {
|
||||
const err = makeAxiosError(400, { detail: { detail: "nested detail" } })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(message.error).toHaveBeenCalledWith("nested detail")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiClient - 401 token refresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem("access_token", "old-access")
|
||||
localStorage.setItem("refresh_token", "old-refresh")
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("logs out when no refresh token on 401", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
|
||||
it("refreshes token on 401 and calls setAuth", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
createVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAsset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Modal: ({ open, children, onCancel, onOk, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Button: ({ children, onClick, disabled, buttonType }: any) =>
|
||||
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
|
||||
}))
|
||||
|
||||
describe("CloneModal", () => {
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render input phase when open", () => {
|
||||
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should call onClose when cancel", () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CloneModal open={true} onClose={onClose} />)
|
||||
// just verify render doesn't crash
|
||||
expect(onClose).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="page-head">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
Select: () => <select />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Empty: () => <div>Empty</div>,
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: ({ children }: any) => <div>{children}</div>,
|
||||
Progress: () => <div />,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Table: () => <div />,
|
||||
Pagination: () => <div />,
|
||||
Tabs: () => <div />,
|
||||
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Popconfirm: ({ children }: any) => <span>{children}</span>,
|
||||
Form: ({ children }: any) => <form>{children}</form>,
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
InputNumber: () => <input type="number" />,
|
||||
Select: () => <select />,
|
||||
Empty: () => <div>Empty</div>,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Badge: ({ children }: any) => <span>{children}</span>,
|
||||
Tooltip: ({ children }: any) => <span>{children}</span>,
|
||||
Upload: { Dragger: ({ children }: any) => <div>{children}</div> },
|
||||
Progress: () => <div />,
|
||||
Switch: () => <input type="checkbox" />,
|
||||
Radio: ({ children }: any) => <span>{children}</span>,
|
||||
RadioGroup: ({ children }: any) => <div>{children}</div>,
|
||||
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
|
||||
Popover: ({ children }: any) => <span>{children}</span>,
|
||||
Divider: () => <hr />,
|
||||
Dropdown: ({ children }: any) => <span>{children}</span>,
|
||||
Menu: () => <div />,
|
||||
Checkbox: ({ children }: any) => <span>{children}</span>,
|
||||
List: () => <div />,
|
||||
Avatar: ({ children }: any) => <span>{children}</span>,
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
Result: ({ status, title }: any) => <div data-status={status}>{title}</div>,
|
||||
Spin: () => <div>Loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
PlusOutlined: () => <span>+</span>,
|
||||
SearchOutlined: () => <span>S</span>,
|
||||
InboxOutlined: () => <span>I</span>,
|
||||
VideoCameraOutlined: () => <span>V</span>,
|
||||
PictureOutlined: () => <span>P</span>,
|
||||
PlayCircleOutlined: () => <span>▶</span>,
|
||||
CheckOutlined: () => <span>✓</span>,
|
||||
DeleteOutlined: () => <span>×</span>,
|
||||
ExperimentOutlined: () => <span>E</span>,
|
||||
LoadingOutlined: () => <span>L</span>,
|
||||
ExclamationCircleOutlined: () => <span>!</span>,
|
||||
TagsOutlined: () => <span>T</span>,
|
||||
EditOutlined: () => <span>E</span>,
|
||||
DownloadOutlined: () => <span>D</span>,
|
||||
MoreOutlined: () => <span>M</span>,
|
||||
FolderOutlined: () => <span>F</span>,
|
||||
FolderAddOutlined: () => <span>FA</span>,
|
||||
UploadOutlined: () => <span>U</span>,
|
||||
AudioOutlined: () => <span>A</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
getAssetDiagnosis: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
batchClassifyAssets: vi.fn().mockResolvedValue({}),
|
||||
batchMarkAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
import AssetLibrary from "@/pages/assets/AssetLibrary"
|
||||
|
||||
describe("AssetLibrary", () => {
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows empty state when no assets", () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter>
|
||||
<AssetLibrary />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 空状态文案应该出现
|
||||
expect(getByText(/暂无素材/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Executable → Regular
+3
-16
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act, cleanup } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
@@ -13,25 +13,12 @@ vi.mock("@/components/layout/PageHead", () => ({
|
||||
import Billing from "@/pages/subscription/Billing"
|
||||
|
||||
describe("Billing Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should render without crashing", async () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<Billing />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// 跑完所有pending的timers和microtasks,确保异步状态更新都执行完
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import EditPlans from "@/pages/edit-plans/EditPlans"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn().mockImplementation((opts: any) => {
|
||||
const key = opts?.queryKey?.[0] || ""
|
||||
if (key === "templates-list-simple") {
|
||||
return { data: [], isLoading: false, isError: false, refetch: vi.fn() }
|
||||
}
|
||||
return {
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
CheckCircleOutlined: () => <span>CheckCircleOutlined</span>,
|
||||
ClockCircleOutlined: () => <span>ClockCircleOutlined</span>,
|
||||
SyncOutlined: () => <span>SyncOutlined</span>,
|
||||
CloseCircleOutlined: () => <span>CloseCircleOutlined</span>,
|
||||
EditOutlined: () => <span>EditOutlined</span>,
|
||||
DeleteOutlined: () => <span>DeleteOutlined</span>,
|
||||
FileTextOutlined: () => <span>FileTextOutlined</span>,
|
||||
ThunderboltOutlined: () => <span>ThunderboltOutlined</span>,
|
||||
CopyOutlined: () => <span>CopyOutlined</span>,
|
||||
UnorderedListOutlined: () => <span>UnorderedListOutlined</span>,
|
||||
StopOutlined: () => <span>StopOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/templates", () => ({
|
||||
getTemplatesList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getEditPlans: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteEditPlan: vi.fn(),
|
||||
generateEditPlan: vi.fn(),
|
||||
cancelGeneration: vi.fn(),
|
||||
copyEditPlan: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("EditPlans", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<EditPlans />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,327 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
VideoCameraOutlined: () => React.createElement("span", null, "V"),
|
||||
PictureOutlined: () => React.createElement("span", null, "P"),
|
||||
SoundOutlined: () => React.createElement("span", null, "S"),
|
||||
PlusOutlined: () => React.createElement("span", null, "+"),
|
||||
DeleteOutlined: () => React.createElement("span", null, "D"),
|
||||
EditOutlined: () => React.createElement("span", null, "E"),
|
||||
CopyOutlined: () => React.createElement("span", null, "C"),
|
||||
DownloadOutlined: () => React.createElement("span", null, "D"),
|
||||
PlayCircleOutlined: () => React.createElement("span", null, ">"),
|
||||
PauseCircleOutlined: () => React.createElement("span", null, "||"),
|
||||
LeftOutlined: () => React.createElement("span", null, "<"),
|
||||
RightOutlined: () => React.createElement("span", null, ">"),
|
||||
UpOutlined: () => React.createElement("span", null, "^"),
|
||||
DownOutlined: () => React.createElement("span", null, "v"),
|
||||
SaveOutlined: () => React.createElement("span", null, "S"),
|
||||
UndoOutlined: () => React.createElement("span", null, "U"),
|
||||
RedoOutlined: () => React.createElement("span", null, "R"),
|
||||
CloseOutlined: () => React.createElement("span", null, "X"),
|
||||
CheckOutlined: () => React.createElement("span", null, "v"),
|
||||
SettingOutlined: () => React.createElement("span", null, "S"),
|
||||
AppstoreOutlined: () => React.createElement("span", null, "#"),
|
||||
UnorderedListOutlined: () => React.createElement("span", null, "="),
|
||||
HistoryOutlined: () => React.createElement("span", null, "H"),
|
||||
UploadOutlined: () => React.createElement("span", null, "U"),
|
||||
SearchOutlined: () => React.createElement("span", null, "S"),
|
||||
FilterOutlined: () => React.createElement("span", null, "F"),
|
||||
FontColorsOutlined: () => React.createElement("span", null, "A"),
|
||||
BgColorsOutlined: () => React.createElement("span", null, "B"),
|
||||
AudioOutlined: () => React.createElement("span", null, "A"),
|
||||
MusicOutlined: () => React.createElement("span", null, "M"),
|
||||
ScissorOutlined: () => React.createElement("span", null, "X"),
|
||||
ThunderboltOutlined: () => React.createElement("span", null, "T"),
|
||||
ExperimentOutlined: () => React.createElement("span", null, "E"),
|
||||
BulbOutlined: () => React.createElement("span", null, "B"),
|
||||
FundOutlined: () => React.createElement("span", null, "F"),
|
||||
LayoutOutlined: () => React.createElement("span", null, "L"),
|
||||
ColumnHeightOutlined: () => React.createElement("span", null, "C"),
|
||||
SwapOutlined: () => React.createElement("span", null, "S"),
|
||||
}))
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Tabs: ({ items }: any) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
items?.map?.(() => React.createElement("div")),
|
||||
),
|
||||
TabPane: () => React.createElement("div"),
|
||||
Drawer: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Slider: () => React.createElement("div"),
|
||||
ColorPicker: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Row: ({ children }: any) => React.createElement("div", null, children),
|
||||
Col: ({ children }: any) => React.createElement("div", null, children),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
createEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
updateEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
getTemplateCategories: vi.fn().mockResolvedValue({ items: [] }),
|
||||
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
cancelGeneration: vi.fn().mockResolvedValue({}),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "default-lib" }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "default-project" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/bgm", () => ({
|
||||
DEFAULT_BGM_MIX_CONFIG: { volume: 1, fade_in: 0, fade_out: 0 },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/components/MediaPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "MediaPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PreviewPlayer", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PreviewPlayer" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TimelinePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TimelinePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/ClipPropertiesPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "ClipPropertiesPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/EditorClipList", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "EditorClipList" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/BgmSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "BgmSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SubtitleStylePanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SubtitleStylePanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TransitionSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TransitionSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SpeedPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SpeedPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/TtsPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "TtsPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/WatermarkPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "WatermarkPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/IntroOutroPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "IntroOutroPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/PipConfigPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "PipConfigPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/FilterPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "FilterPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GreenScreenPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
|
||||
}))
|
||||
|
||||
// === useUndoRedo hook mock ===
|
||||
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn((initial: any) => ({
|
||||
state: initial,
|
||||
setState: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Types mock ===
|
||||
vi.mock("@/pages/editing-planner/types", () => ({
|
||||
DEFAULT_TRANSITION: { type: "fade", duration: 0.5 },
|
||||
DEFAULT_SPEED: { rate: 1 },
|
||||
DEFAULT_TTS_CONFIG: { enabled: false },
|
||||
DEFAULT_WATERMARK: { enabled: false },
|
||||
DEFAULT_INTRO_OUTRO: { enabled: false },
|
||||
DEFAULT_PIP_CONFIG: { enabled: false },
|
||||
DEFAULT_FILTER_CONFIG: { enabled: false },
|
||||
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
|
||||
DEFAULT_STICKER_CONFIG: { enabled: false },
|
||||
DEFAULT_COVER_CONFIG: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
|
||||
DEFAULT_SUBTITLE_STYLE: {
|
||||
font_size: 24,
|
||||
font_color: "#ffffff",
|
||||
background_color: "#000000",
|
||||
},
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import EditingPlanner from "@/pages/editing-planner/EditingPlanner"
|
||||
|
||||
describe("EditingPlanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with templateId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?templateId=tpl-123"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders with planId query param", () => {
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ["?planId=plan-456"] },
|
||||
React.createElement(EditingPlanner),
|
||||
),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,287 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"CheckCircleFilled",
|
||||
"CheckCircleOutlined",
|
||||
"CloseCircleOutlined",
|
||||
"LoadingOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"ShareAltOutlined",
|
||||
"SaveOutlined",
|
||||
"PlusOutlined",
|
||||
"MinusOutlined",
|
||||
"CloseOutlined",
|
||||
"SearchOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => {
|
||||
const Typography = {
|
||||
Text: ({ children }: any) => React.createElement("span", null, children),
|
||||
Title: ({ children }: any) => React.createElement("h1", null, children),
|
||||
Paragraph: ({ children }: any) => React.createElement("p", null, children),
|
||||
}
|
||||
return {
|
||||
Typography,
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) =>
|
||||
React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
Steps: () => React.createElement("div"),
|
||||
Step: () => React.createElement("div"),
|
||||
}
|
||||
})
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
MODE_LABELS: { pip: "画中画" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/titles", () => ({
|
||||
getTitles: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
formatDuration: vi.fn((s: number) => `${s}s`),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
interceptors: { request: { handlers: [] }, response: { handlers: [] } },
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
put: vi.fn().mockResolvedValue({ data: {} }),
|
||||
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}))
|
||||
|
||||
// === Hooks mock ===
|
||||
vi.mock("@/hooks/useCloneProgress", () => ({
|
||||
useCloneProgress: vi.fn(() => ({
|
||||
progress: 0,
|
||||
status: "idle",
|
||||
start: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Components mock ===
|
||||
vi.mock("@/components/voice/CloneModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CloneModal" }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
// CSS mock
|
||||
vi.mock("@/pages/generate/generate.css", () => ({}))
|
||||
|
||||
import GeneratePage from "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("GeneratePage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(GeneratePage)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(GeneratePage)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import MyTemplates from "@/pages/my-templates/MyTemplates"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
SearchOutlined: () => <span>SearchOutlined</span>,
|
||||
EditOutlined: () => <span>EditOutlined</span>,
|
||||
CopyOutlined: () => <span>CopyOutlined</span>,
|
||||
DeleteOutlined: () => <span>DeleteOutlined</span>,
|
||||
VideoCameraOutlined: () => <span>VideoCameraOutlined</span>,
|
||||
AppstoreOutlined: () => <span>AppstoreOutlined</span>,
|
||||
PlusOutlined: () => <span>PlusOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
getEditingTemplates: vi.fn(),
|
||||
getTemplateCategories: vi.fn().mockResolvedValue([]),
|
||||
deleteEditingTemplate: vi.fn(),
|
||||
createEditingTemplate: vi.fn(),
|
||||
MODE_LABELS: { template: "模板", clip: "剪辑" } as Record<string, string>,
|
||||
MODE_COLORS: { template: "blue", clip: "green" } as Record<string, string>,
|
||||
}))
|
||||
|
||||
describe("MyTemplates", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<MyTemplates />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import PlanClipsManager from "@/pages/edit-plans/PlanClipsManager"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ templateId: "test-123" }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { clips: [], name: "Test Plan" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
ArrowLeftOutlined: () => <span>ArrowLeftOutlined</span>,
|
||||
PlusOutlined: () => <span>PlusOutlined</span>,
|
||||
DeleteOutlined: () => <span>DeleteOutlined</span>,
|
||||
EditOutlined: () => <span>EditOutlined</span>,
|
||||
UploadOutlined: () => <span>UploadOutlined</span>,
|
||||
OrderedListOutlined: () => <span>OrderedListOutlined</span>,
|
||||
SaveOutlined: () => <span>SaveOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getPlanClips: vi.fn().mockResolvedValue({ clips: [], name: "" }),
|
||||
updatePlanClipsOrder: vi.fn(),
|
||||
deletePlanClip: vi.fn(),
|
||||
createPlanClip: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("PlanClipsManager", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<PlanClipsManager />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
// Hoisted mock icons factory - must be before all vi.mock calls
|
||||
const hoistedIcons = vi.hoisted(() => {
|
||||
const iconNames = [
|
||||
"AudioOutlined",
|
||||
"PlayCircleOutlined",
|
||||
"PauseCircleOutlined",
|
||||
"SearchOutlined",
|
||||
"PlusOutlined",
|
||||
"EditOutlined",
|
||||
"DeleteOutlined",
|
||||
"UploadOutlined",
|
||||
"UnorderedListOutlined",
|
||||
"AppstoreOutlined",
|
||||
"CloseOutlined",
|
||||
"SoundOutlined",
|
||||
"UserOutlined",
|
||||
"ManOutlined",
|
||||
"WomanOutlined",
|
||||
"CheckOutlined",
|
||||
"TagsOutlined",
|
||||
"MutedOutlined",
|
||||
"RobotOutlined",
|
||||
"LoadingOutlined",
|
||||
"FolderOutlined",
|
||||
"FolderAddOutlined",
|
||||
"MoreOutlined",
|
||||
"ExperimentOutlined",
|
||||
"ExclamationCircleOutlined",
|
||||
"DownloadOutlined",
|
||||
"InboxOutlined",
|
||||
"VideoCameraOutlined",
|
||||
"PictureOutlined",
|
||||
"SaveOutlined",
|
||||
"UndoOutlined",
|
||||
"RedoOutlined",
|
||||
"SettingOutlined",
|
||||
"HistoryOutlined",
|
||||
"FilterOutlined",
|
||||
"FontColorsOutlined",
|
||||
"BgColorsOutlined",
|
||||
"MusicOutlined",
|
||||
"ScissorOutlined",
|
||||
"ThunderboltOutlined",
|
||||
"BulbOutlined",
|
||||
"FundOutlined",
|
||||
"LayoutOutlined",
|
||||
"ColumnHeightOutlined",
|
||||
"SwapOutlined",
|
||||
"LeftOutlined",
|
||||
"RightOutlined",
|
||||
"UpOutlined",
|
||||
"DownOutlined",
|
||||
"CopyOutlined",
|
||||
]
|
||||
const icons: Record<string, React.FC> = {}
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = () => React.createElement("span", null, name.charAt(0))
|
||||
})
|
||||
return icons
|
||||
})
|
||||
|
||||
// === React Query mock ===
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
useInfiniteQuery: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// === Ant Design Icons mock ===
|
||||
vi.mock("@ant-design/icons", () => hoistedIcons)
|
||||
|
||||
// === Ant Design mock ===
|
||||
vi.mock("antd", () => ({
|
||||
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
Modal: ({ open, children, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Progress: () => React.createElement("div"),
|
||||
Popover: ({ children }: any) => React.createElement("span", null, children),
|
||||
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tabs: () => React.createElement("div"),
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
Segmented: () => React.createElement("div"),
|
||||
Spin: () => React.createElement("div", null, "Loading"),
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Divider: () => React.createElement("hr"),
|
||||
Space: ({ children }: any) => React.createElement("div", null, children),
|
||||
Dropdown: ({ children }: any) => React.createElement("span", null, children),
|
||||
Menu: () => React.createElement("div"),
|
||||
Badge: ({ children }: any) => React.createElement("span", null, children),
|
||||
Radio: ({ children }: any) => React.createElement("span", null, children),
|
||||
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
|
||||
Checkbox: ({ children }: any) => React.createElement("span", null, children),
|
||||
InputNumber: () => React.createElement("input", { type: "number" }),
|
||||
Form: ({ children }: any) => React.createElement("form", null, children),
|
||||
FormItem: ({ children }: any) => React.createElement("div", null, children),
|
||||
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
|
||||
List: () => React.createElement("div"),
|
||||
Table: () => React.createElement("div"),
|
||||
Pagination: () => React.createElement("div"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Avatar: ({ children }: any) => React.createElement("span", null, children),
|
||||
Collapse: ({ children }: any) => React.createElement("div", null, children),
|
||||
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Option: ({ children }: any) => React.createElement("option", null, children),
|
||||
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
|
||||
}))
|
||||
|
||||
// === UI Components mock ===
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
|
||||
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
|
||||
Modal: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Drawer: ({ open, children }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog" }, children) : null,
|
||||
Empty: () => React.createElement("div", null, "Empty"),
|
||||
Card: ({ children }: any) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: any) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children }: any) => React.createElement("span", null, children),
|
||||
Select: ({ children }: any) => React.createElement("select", null, children),
|
||||
Progress: () => React.createElement("div"),
|
||||
Upload: ({ children }: any) => React.createElement("div", null, children),
|
||||
Slider: () => React.createElement("div"),
|
||||
Switch: () => React.createElement("input", { type: "checkbox" }),
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn().mockResolvedValue([]),
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteAssets: vi.fn().mockResolvedValue({}),
|
||||
batchTagAssets: vi.fn().mockResolvedValue({}),
|
||||
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tags", () => ({
|
||||
getTags: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
tagAsset: vi.fn().mockResolvedValue({}),
|
||||
untagAsset: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
|
||||
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
|
||||
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
// === PageHead mock ===
|
||||
vi.mock("@/components/layout/PageHead", () => ({
|
||||
default: ({ title }: { title: string }) =>
|
||||
React.createElement("div", { "data-testid": "page-head" }, title),
|
||||
}))
|
||||
|
||||
import VoiceMaterialLibrary from "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
|
||||
describe("VoiceMaterialLibrary", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)),
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advances timers without errors", () => {
|
||||
render(React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000)
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import BgmSelector from "@/pages/editing-planner/components/BgmSelector"
|
||||
|
||||
vi.mock("@/api/bgm", () => ({
|
||||
getBgmPresets: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: {
|
||||
enabled: true,
|
||||
music_id: "",
|
||||
volume: 50,
|
||||
} as any,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe("BgmSelector", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<BgmSelector {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<BgmSelector {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import ClipPropertiesPanel from "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockClip = {
|
||||
id: "clip-1",
|
||||
type: "voice",
|
||||
duration: 10,
|
||||
startOffset: 0,
|
||||
} as any
|
||||
|
||||
const defaultProps = {
|
||||
selectedClip: mockClip,
|
||||
titleSettings: { enabled: true, text: "Test Title" } as any,
|
||||
subtitleSettings: { enabled: true } as any,
|
||||
bgmSettings: { enabled: false } as any,
|
||||
clipsCount: 3,
|
||||
totalDuration: 60,
|
||||
currentMode: "template" as const,
|
||||
onTitleSettingsChange: vi.fn(),
|
||||
onSubtitleSettingsChange: vi.fn(),
|
||||
onBgmSettingsChange: vi.fn(),
|
||||
onClipUpdate: vi.fn(),
|
||||
onOpenBgmDrawer: vi.fn(),
|
||||
onOpenSubtitleDrawer: vi.fn(),
|
||||
voiceMaterials: [],
|
||||
voiceMaterialsLoading: false,
|
||||
onRefreshVoiceMaterials: vi.fn(),
|
||||
onClipVoiceSelect: vi.fn(),
|
||||
onOpenTransitionDrawer: vi.fn(),
|
||||
onOpenSpeedDrawer: vi.fn(),
|
||||
onOpenTtsDrawer: vi.fn(),
|
||||
onOpenWatermarkDrawer: vi.fn(),
|
||||
}
|
||||
|
||||
describe("ClipPropertiesPanel", () => {
|
||||
it("should render with selected clip", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<ClipPropertiesPanel {...defaultProps} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render without selected clip", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<ClipPropertiesPanel {...defaultProps} selectedClip={null} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
|
||||
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_COVER_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
describe("CoverSelector", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
voiceoverDuration: null,
|
||||
estimatedDuration: 60,
|
||||
onDurationChange: vi.fn(),
|
||||
onGenerate: vi.fn(),
|
||||
task: null,
|
||||
submitting: false,
|
||||
onCancel: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe("GenerationProgressModal", () => {
|
||||
it("should render setup phase", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="setup" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase without task", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="progress" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase with task data", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="progress"
|
||||
task={
|
||||
{
|
||||
id: "task-123",
|
||||
status: "generating_video",
|
||||
progress: 50,
|
||||
current_step: "generating_video",
|
||||
user_message: "正在生成视频",
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render completed phase", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="completed"
|
||||
task={{ id: "task-1", status: "completed", progress: 100 } as any}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase with retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
error_message: "生成失败",
|
||||
retryable: true,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase without retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
retryable: false,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should not render when closed", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal {...baseProps} phase="setup" open={false} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import IntroOutroPanel from "@/pages/editing-planner/components/IntroOutroPanel"
|
||||
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_INTRO_OUTRO,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe("IntroOutroPanel", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<IntroOutroPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<IntroOutroPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import PipConfigPanel from "@/pages/editing-planner/components/PipConfigPanel"
|
||||
import { DEFAULT_PIP_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_PIP_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
describe("PipConfigPanel", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<PipConfigPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<PipConfigPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import StickerPanel from "@/pages/editing-planner/components/StickerPanel"
|
||||
import { DEFAULT_STICKER_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_STICKER_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
describe("StickerPanel", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<StickerPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<StickerPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import TimelinePanel from "@/pages/editing-planner/components/TimelinePanel"
|
||||
|
||||
const mockClips = [
|
||||
{ id: "clip-1", type: "voice", duration: 10, startOffset: 0 } as any,
|
||||
{ id: "clip-2", type: "pip", duration: 5, startOffset: 0 } as any,
|
||||
{ id: "clip-3", type: "voice", duration: 15, startOffset: 0 } as any,
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
clips: mockClips,
|
||||
selectedClipId: "clip-1",
|
||||
currentMode: "template",
|
||||
onClipSelect: vi.fn(),
|
||||
onClipReorder: vi.fn(),
|
||||
onClipRemove: vi.fn(),
|
||||
onAddClip: vi.fn(),
|
||||
onClipTrim: vi.fn(),
|
||||
onClipSplit: vi.fn(),
|
||||
onClipResetTrim: vi.fn(),
|
||||
currentTime: 0,
|
||||
pixelsPerSecond: 30,
|
||||
onZoomChange: vi.fn(),
|
||||
onSeek: vi.fn(),
|
||||
}
|
||||
|
||||
describe("TimelinePanel", () => {
|
||||
it("should render with clips", () => {
|
||||
const { container } = render(<TimelinePanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render with empty clips", () => {
|
||||
const { container } = render(
|
||||
<TimelinePanel {...defaultProps} clips={[]} selectedClipId={null} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render without selected clip", () => {
|
||||
const { container } = render(<TimelinePanel {...defaultProps} selectedClipId={null} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import TtsPanel from "@/pages/editing-planner/components/TtsPanel"
|
||||
import { DEFAULT_TTS_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
getTtsVoices: vi.fn().mockResolvedValue([]),
|
||||
previewTts: vi.fn().mockResolvedValue({ url: "" }),
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_TTS_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe("TtsPanel", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import WatermarkPanel from "@/pages/editing-planner/components/WatermarkPanel"
|
||||
import { DEFAULT_WATERMARK } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_WATERMARK,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe("WatermarkPanel", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<WatermarkPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<WatermarkPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,493 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
onSuccess: undefined,
|
||||
onError: undefined,
|
||||
})),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editPlans", () => ({
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
deleteEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
reorderEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({ deleted_count: 0 }),
|
||||
createClipsFromAssets: vi.fn().mockResolvedValue({ created_count: 0 }),
|
||||
}))
|
||||
|
||||
vi.mock("./useUndoRedo", () => ({
|
||||
useUndoRedo: vi.fn(() => ({
|
||||
state: [],
|
||||
set: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
import { useEditPlanClips } from "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
|
||||
describe("useEditPlanClips", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns default state with planId", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toEqual([])
|
||||
expect(result.current.clipsTotal).toBe(0)
|
||||
expect(result.current.clipsLoading).toBe(false)
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("returns clips from query data", () => {
|
||||
const mockClips = [
|
||||
{ id: "clip-1", type: "video", asset_id: "a1", order: 0 },
|
||||
{ id: "clip-2", type: "video", asset_id: "a2", order: 1 },
|
||||
]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 2 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clips).toHaveLength(2)
|
||||
expect(result.current.clipsTotal).toBe(2)
|
||||
expect(result.current.clips[0].id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("handles loading state", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-123"))
|
||||
|
||||
expect(result.current.clipsLoading).toBe(true)
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("disables query when no planId", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
expect(result.current.clips).toEqual([])
|
||||
})
|
||||
|
||||
it("setSelectedClipId updates selection", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip finds matching clip", () => {
|
||||
const mockClips = [{ id: "clip-1", type: "video", order: 0 }]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: mockClips, total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip?.id).toBe("clip-1")
|
||||
})
|
||||
|
||||
it("selectedClip returns null when no match", () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { items: [{ id: "c1", order: 0 }], total: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("nonexistent")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClip).toBeNull()
|
||||
})
|
||||
|
||||
it("addClip calls createMutation with order", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => {
|
||||
// 捕获 onSuccess/onError 回调
|
||||
return {
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.addClip({ type: "video", asset_id: "a1" })
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("removeClip calls deleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith("clip-1")
|
||||
})
|
||||
|
||||
it("removeClip clears selection if selected clip is deleted", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
expect(result.current.selectedClipId).toBe("clip-1")
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("removeClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.removeClip("clip-1")
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("updateClip calls updateMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("clip-1", { duration: 10 })
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
clipId: "clip-1",
|
||||
data: { duration: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it("updateClip does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.updateClip("c1", {})
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips calls batchDeleteMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
it("batchRemoveClips does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("batchRemoveClips clears selection if selected is in batch", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedClipId("clip-1")
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.batchRemoveClips(["clip-1", "clip-2"])
|
||||
})
|
||||
|
||||
expect(result.current.selectedClipId).toBeNull()
|
||||
})
|
||||
|
||||
it("reorderClips calls reorderMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith([{ id: "c1", order: 0 }])
|
||||
})
|
||||
|
||||
it("reorderClips does nothing with empty items", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.reorderClips([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets calls importMutation", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(["asset-1", "asset-2"])
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing with empty array", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets([])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("importFromAssets does nothing when no planId", () => {
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useMutation).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips(undefined))
|
||||
|
||||
act(() => {
|
||||
result.current.importFromAssets(["a1"])
|
||||
})
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns local undo redo state", () => {
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.localClips).toEqual([])
|
||||
expect(typeof result.current.setLocalClips).toBe("function")
|
||||
expect(typeof result.current.undo).toBe("function")
|
||||
expect(typeof result.current.redo).toBe("function")
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
expect(typeof result.current.resetLocalClips).toBe("function")
|
||||
})
|
||||
|
||||
it("exposes mutation status flags", () => {
|
||||
vi.mocked(useMutation).mockImplementation((options: any) => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useEditPlanClips("plan-1"))
|
||||
|
||||
expect(result.current.isCreating).toBe(false)
|
||||
expect(result.current.isUpdating).toBe(false)
|
||||
expect(result.current.isDeleting).toBe(false)
|
||||
expect(result.current.isReordering).toBe(false)
|
||||
expect(result.current.isImporting).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
DEFAULT_SUBTITLE_STYLE,
|
||||
type SubtitleStyleConfig,
|
||||
type SubtitleMode,
|
||||
} from "@/pages/editing-planner/types/subtitle"
|
||||
|
||||
describe("subtitle types & defaults", () => {
|
||||
it("DEFAULT_SUBTITLE_STYLE has correct shape", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE).toMatchObject<SubtitleStyleConfig>({
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
})
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE enabled is boolean", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.enabled).toBe("boolean")
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE fontSize is number", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.fontSize).toBe("number")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontSize).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE position is valid", () => {
|
||||
expect(["top", "center", "bottom"]).toContain(DEFAULT_SUBTITLE_STYLE.position)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE mode is valid SubtitleMode", () => {
|
||||
const mode: SubtitleMode = DEFAULT_SUBTITLE_STYLE.mode
|
||||
expect(["manual", "asr"]).toContain(mode)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE asrLanguage is valid", () => {
|
||||
expect(["zh", "en"]).toContain(DEFAULT_SUBTITLE_STYLE.asrLanguage)
|
||||
})
|
||||
|
||||
it("DEFAULT_SUBTITLE_STYLE has all required fields", () => {
|
||||
const keys = Object.keys(DEFAULT_SUBTITLE_STYLE)
|
||||
expect(keys.length).toBeGreaterThanOrEqual(10)
|
||||
expect(keys).toContain("enabled")
|
||||
expect(keys).toContain("mode")
|
||||
expect(keys).toContain("fontSize")
|
||||
expect(keys).toContain("fontColor")
|
||||
expect(keys).toContain("stroke")
|
||||
expect(keys).toContain("shadow")
|
||||
expect(keys).toContain("position")
|
||||
expect(keys).toContain("font")
|
||||
expect(keys).toContain("animation")
|
||||
expect(keys).toContain("asrLanguage")
|
||||
})
|
||||
|
||||
it("fontColor is valid hex color", () => {
|
||||
expect(DEFAULT_SUBTITLE_STYLE.fontColor).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
})
|
||||
|
||||
it("animation is string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.animation).toBe("string")
|
||||
})
|
||||
|
||||
it("font is non-empty string", () => {
|
||||
expect(typeof DEFAULT_SUBTITLE_STYLE.font).toBe("string")
|
||||
expect(DEFAULT_SUBTITLE_STYLE.font.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: vi.fn((selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/home/HomePage", () => ({
|
||||
default: () => <div data-testid="home-page">Home</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/layout/MainLayout", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="main-layout">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { router } from "@/router"
|
||||
|
||||
// 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致)
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const HomeRoute = () => {
|
||||
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
if (isAuthenticated && hasAccessToken) {
|
||||
return <Navigate to="/app/dashboard" replace />
|
||||
}
|
||||
|
||||
return <div data-testid="home-page">Home</div>
|
||||
}
|
||||
|
||||
describe("router - ProtectedRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("redirects to login when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to login when authenticated but no token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.removeItem("access_token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("login")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders children when authenticated and has token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/app"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected">Protected</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login">Login</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("protected")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router - HomeRoute", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("shows home page when not authenticated", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: false }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("redirects to dashboard when authenticated with token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
localStorage.setItem("access_token", "test-token")
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("dashboard")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("shows home when authenticated but no localStorage token", () => {
|
||||
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
|
||||
selector({ isAuthenticated: true }),
|
||||
)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("home-page")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("router config", () => {
|
||||
it("exports router", () => {
|
||||
expect(router).toBeDefined()
|
||||
})
|
||||
|
||||
it("router has correct number of top-level routes", () => {
|
||||
const routes = router.routes
|
||||
expect(Array.isArray(routes)).toBe(true)
|
||||
expect(routes.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it("includes login route", () => {
|
||||
const loginRoute = router.routes.find((r: any) => r.path === "/login")
|
||||
expect(loginRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes register route", () => {
|
||||
const registerRoute = router.routes.find((r: any) => r.path === "/register")
|
||||
expect(registerRoute).toBeDefined()
|
||||
})
|
||||
|
||||
it("includes root route", () => {
|
||||
const rootRoute = router.routes.find((r: any) => r.path === "/")
|
||||
expect(rootRoute).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -17,12 +17,12 @@ export default defineConfig({
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["node_modules/", "src/test/", "e2e/", "**/*.d.ts", "**/*.config.*", "**/mockData"],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
// CI 覆盖率门禁(极低门槛起步,逐步提升)
|
||||
// 当前实际覆盖率约 0.8%/0.9%/1.1%,先设极低门槛确保CI跑通
|
||||
thresholds: {
|
||||
lines: 50,
|
||||
branches: 50,
|
||||
functions: 20,
|
||||
lines: 0.5,
|
||||
functions: 0.5,
|
||||
branches: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,20 +35,7 @@ def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
|
||||
ValueError: 不支持的供应商
|
||||
"""
|
||||
if provider is None:
|
||||
provider = os.environ.get("TTS_PROVIDER", "")
|
||||
|
||||
if not provider:
|
||||
# 自动检测:配置了 CosyVoice API Key 则默认用 cosyvoice,否则用 mock
|
||||
try:
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
settings = get_shared_settings()
|
||||
if getattr(settings, "cosyvoice_api_key", ""):
|
||||
provider = "cosyvoice"
|
||||
else:
|
||||
provider = "mock"
|
||||
except Exception:
|
||||
provider = "mock"
|
||||
provider = os.environ.get("TTS_PROVIDER", "mock")
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
@@ -58,13 +45,6 @@ def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
|
||||
from packages.adapters.tts.mock_tts_service import MockTtsService
|
||||
|
||||
_PROVIDERS["mock"] = MockTtsService
|
||||
elif provider in ("cosyvoice", "aliyun", "dashscope"):
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
_PROVIDERS["cosyvoice"] = CosyVoiceTtsService
|
||||
_PROVIDERS["aliyun"] = CosyVoiceTtsService
|
||||
_PROVIDERS["dashscope"] = CosyVoiceTtsService
|
||||
provider = "cosyvoice"
|
||||
else:
|
||||
logger.warning("未知 TTS provider: %s,回退到 mock", provider)
|
||||
from packages.adapters.tts.mock_tts_service import MockTtsService
|
||||
|
||||
@@ -18,7 +18,6 @@ def create_video_record_and_dedup(
|
||||
*,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
user_id: str = "",
|
||||
batch_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
@@ -29,8 +28,6 @@ def create_video_record_and_dedup(
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
fps: float = 25.0,
|
||||
name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> int:
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
|
||||
@@ -60,14 +57,11 @@ def create_video_record_and_dedup(
|
||||
|
||||
try:
|
||||
video_id = uuid4().hex
|
||||
# 使用传入的名称,没有则 fallback 到默认命名
|
||||
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
generation_task_id=generation_task_id,
|
||||
name=video_name,
|
||||
name=f"generated-{generation_task_id[:8]}.mp4",
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
@@ -82,22 +76,17 @@ def create_video_record_and_dedup(
|
||||
video_repo.create(generated_video)
|
||||
|
||||
# 生成封面缩略图
|
||||
if thumbnail_url:
|
||||
generated_video.thumbnail_url = thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, thumbnail_url)
|
||||
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
|
||||
else:
|
||||
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
|
||||
if _thumbnail_url:
|
||||
generated_video.thumbnail_url = _thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, _thumbnail_url)
|
||||
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
|
||||
except Exception as thumb_err:
|
||||
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
|
||||
thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
|
||||
if thumbnail_url:
|
||||
generated_video.thumbnail_url = thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, thumbnail_url)
|
||||
logger.info("Thumbnail generated for video %s: %s", video_id, thumbnail_url)
|
||||
except Exception as thumb_err:
|
||||
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
|
||||
@@ -142,20 +142,19 @@ def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
local_path: 本地文件路径
|
||||
storage_key: 目标存储键
|
||||
|
||||
Returns:
|
||||
公开访问 URL,上传失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
|
||||
@@ -63,7 +63,6 @@ class RenderAdapterResult:
|
||||
success: bool
|
||||
output_url: str = ""
|
||||
output_path: Path | None = None
|
||||
thumbnail_url: str = ""
|
||||
duration: float = 0.0
|
||||
file_size: int = 0
|
||||
width: int = 0
|
||||
@@ -225,22 +224,6 @@ class RenderAdapter:
|
||||
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
|
||||
output_url = upload_to_oss(result.output_path, storage_key)
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
|
||||
# 5. 生成缩略图(在清理临时目录前)
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
@@ -259,7 +242,6 @@ class RenderAdapter:
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
output_path=result.output_path,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
|
||||
@@ -16,10 +16,8 @@ def extract_first_frame(
|
||||
width: int = 640,
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
seek_ratio: float = 0.15,
|
||||
min_seek_seconds: float = 1.0,
|
||||
) -> str:
|
||||
"""抽取视频封面图(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
"""抽取视频第一帧作为封面图。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
@@ -27,8 +25,6 @@ def extract_first_frame(
|
||||
width: 输出宽度(默认 640,-1 表示按比例缩放)
|
||||
height: 输出高度(默认 -1,按比例缩放)
|
||||
timeout: 超时时间(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||||
|
||||
Returns:
|
||||
生成的缩略图文件路径
|
||||
@@ -36,38 +32,44 @@ def extract_first_frame(
|
||||
Raises:
|
||||
subprocess.CalledProcessError: ffmpeg 执行失败
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
_is_temp_output = False
|
||||
if output_path is None:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
output_path = tmp.name
|
||||
_is_temp_output = True
|
||||
|
||||
# -ss 00:00:01 取第1秒帧(避免首帧黑屏)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 短视频可能没有第1秒,退回到第0帧
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
seek_str,
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
@@ -76,48 +78,12 @@ def extract_first_frame(
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 失败时退回到第0帧兜底
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
# 失败时清理自己创建的临时文件
|
||||
if _is_temp_output and output_path:
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _format_seek_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
|
||||
@@ -801,16 +801,6 @@ class UnifiedRenderService:
|
||||
if ass_path is not None:
|
||||
return False, "有字幕叠加"
|
||||
|
||||
# 有调速 → 需要重编码 → 不能 copy
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
return False, f"有调速: speed={speed:.2f}x"
|
||||
|
||||
# 有倒放 → 需要重编码 → 不能 copy
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio):
|
||||
return False, "有倒放效果"
|
||||
|
||||
# 探测输入视频参数
|
||||
info = probe_video_info(str(clip.local_path))
|
||||
|
||||
@@ -1078,9 +1068,8 @@ class UnifiedRenderService:
|
||||
# background 以外的视频素材,默认带音频
|
||||
has_audio = role != "background"
|
||||
if has_audio:
|
||||
# 检查是否需要音频降噪
|
||||
af_parts: list[str] = []
|
||||
|
||||
# 音频降噪
|
||||
try:
|
||||
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
|
||||
|
||||
@@ -1094,29 +1083,17 @@ class UnifiedRenderService:
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
|
||||
|
||||
# 音频调速(与视频setpts对应,保持音画同步)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
try:
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
if af_parts:
|
||||
command.extend(["-af", ",".join(af_parts)])
|
||||
|
||||
speed_cfg = SpeedConfig(speed=speed)
|
||||
speed_engine = SpeedEngine()
|
||||
af_parts.append(speed_engine.build_audio_filter(speed_cfg))
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] 直通模式音频调速应用失败,跳过: %s", e)
|
||||
command.extend(["-c:a", "aac", "-b:a", "128k"])
|
||||
|
||||
# 音频倒放
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and reverse_config.reverse_audio:
|
||||
af_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
|
||||
if af_filter:
|
||||
af_parts.append(af_filter)
|
||||
|
||||
if af_parts:
|
||||
command.extend(["-af", ",".join(af_parts)])
|
||||
|
||||
command.extend(["-c:a", "aac", "-b:a", "128k"])
|
||||
command.extend(["-af", af_filter])
|
||||
|
||||
# 统一截断时长(同时作用于视频和音频)
|
||||
if final_duration > 0:
|
||||
|
||||
@@ -135,23 +135,17 @@ def _finalize_render_success(
|
||||
generation_task_id: str,
|
||||
output_path: Path,
|
||||
engine: str,
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
project_id = plan.project_id or ""
|
||||
batch_id = plan.config.get("batch_id", "")
|
||||
mode = plan.config.get("mode", "edit_plan")
|
||||
# 从 plan.config.title.text 读取视频名称
|
||||
plan_config = plan.config or {}
|
||||
title_cfg = plan_config.get("title", {}) or {}
|
||||
video_name = (title_cfg.get("text") or "").strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
if generation_task_id:
|
||||
try:
|
||||
create_video_record_and_dedup(
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=plan.created_by_user_id or "",
|
||||
batch_id=batch_id,
|
||||
file_url=output_url or "",
|
||||
file_size=file_size,
|
||||
@@ -162,8 +156,6 @@ def _finalize_render_success(
|
||||
width=width,
|
||||
height=height,
|
||||
fps=OUTPUT_FPS,
|
||||
name=video_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
except Exception as dedup_err:
|
||||
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
|
||||
@@ -282,7 +274,6 @@ def _render_with_unified(
|
||||
|
||||
output_path = result.output_path or Path("")
|
||||
output_url = result.output_url
|
||||
thumbnail_url = result.thumbnail_url or ""
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
|
||||
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
|
||||
@@ -307,7 +298,6 @@ def _render_with_unified(
|
||||
generation_task_id=generation_task_id,
|
||||
output_path=output_path,
|
||||
engine="unified",
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -372,23 +362,6 @@ def _render_with_legacy(
|
||||
)
|
||||
|
||||
logger.info("执行 FFmpeg (legacy): plan_id=%s cmd=%s", plan_id, " ".join(compose_cmd.command)[:500])
|
||||
|
||||
# 开始渲染,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 40.0:
|
||||
gen_task.progress = 40.0
|
||||
gen_task.append_log(
|
||||
stage="render_start",
|
||||
message="开始FFmpeg渲染(legacy)",
|
||||
level="INFO",
|
||||
progress=40.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
@@ -422,192 +395,14 @@ def _render_with_legacy(
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 获取文件大小 + 实际时长
|
||||
# 获取文件大小
|
||||
file_size = output_path.stat().st_size if output_path.exists() else 0
|
||||
duration = compose_cmd.estimated_duration or 0.0
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
actual_duration = probe_duration(str(output_path))
|
||||
if actual_duration > 0:
|
||||
duration = actual_duration
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 标题/字幕叠加(legacy 引擎补齐) ────────────────────────────────
|
||||
plan_config = plan.config or {}
|
||||
title_cfg = plan_config.get("title", {}) or {}
|
||||
subtitle_cfg = plan_config.get("subtitle", {}) or {}
|
||||
title_text = title_cfg.get("text", "") or ""
|
||||
subtitle_text = subtitle_cfg.get("text", "") or ""
|
||||
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True) and bool(subtitle_text.strip())
|
||||
# ASR 自动字幕 legacy 暂不支持(需要额外 ASR 服务,统一用 unified 引擎)
|
||||
has_subtitle_overlay = title_enabled or subtitle_enabled
|
||||
|
||||
if has_subtitle_overlay and output_path.exists() and duration > 0:
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
|
||||
ass_path = tmpdir_path / f"subtitles_{plan_id}.ass"
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=output_width,
|
||||
video_height=output_height,
|
||||
video_duration=duration,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
subtitle_text=subtitle_text,
|
||||
subtitle_config=subtitle_cfg,
|
||||
)
|
||||
# 用 subtitles 滤镜叠加 ASS 字幕,音频直接 copy
|
||||
subtitled_path = tmpdir_path / f"{plan_id}_subtitled.mp4"
|
||||
# 处理 Windows 路径下的 ass 滤镜转义问题
|
||||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", r"\:")
|
||||
run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(output_path),
|
||||
"-vf",
|
||||
f"subtitles='{ass_filter_path}'",
|
||||
"-c:a",
|
||||
"copy",
|
||||
str(subtitled_path),
|
||||
],
|
||||
timeout=1800,
|
||||
)
|
||||
if subtitled_path.exists() and subtitled_path.stat().st_size > 0:
|
||||
output_path = subtitled_path
|
||||
file_size = subtitled_path.stat().st_size
|
||||
logger.info(
|
||||
"legacy 标题/字幕叠加完成: plan_id=%s title=%s subtitle=%s",
|
||||
plan_id,
|
||||
title_enabled,
|
||||
subtitle_enabled,
|
||||
)
|
||||
except Exception as sub_err:
|
||||
logger.warning("legacy 标题/字幕叠加失败(不影响主流程): plan_id=%s err=%s", plan_id, sub_err)
|
||||
|
||||
# ── TTS 配音混音(legacy 引擎补齐) ────────────────────────────────
|
||||
tts_cfg = plan_config.get("tts", {}) or {}
|
||||
tts_enabled = tts_cfg.get("enabled", False) and bool(tts_cfg.get("text", "").strip())
|
||||
|
||||
if tts_enabled and output_path.exists() and duration > 0:
|
||||
try:
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
tts_config = TtsConfig.parse(tts_cfg)
|
||||
if tts_config.enabled and tts_config.text.strip():
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
tts_service = get_tts_service()
|
||||
voiceover_path = tmpdir_path / f"voiceover_{plan_id}.wav"
|
||||
|
||||
# 生成配音音频
|
||||
audio_path = tts_service.synthesize(
|
||||
text=tts_config.text,
|
||||
voice_id=tts_config.voice_id,
|
||||
speed=tts_config.speed,
|
||||
pitch=tts_config.pitch,
|
||||
output_path=voiceover_path,
|
||||
)
|
||||
|
||||
if audio_path and audio_path.exists() and audio_path.stat().st_size > 0:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
mixed_path = tmpdir_path / f"{plan_id}_with_voiceover.mp4"
|
||||
|
||||
# 混音:配音音量按配置调整
|
||||
voice_volume = max(0.0, min(1.0, tts_config.volume))
|
||||
|
||||
if tts_config.overlap_mode == "mix":
|
||||
# 混音模式:原音 + 配音混合
|
||||
filter_complex = (
|
||||
f"[0:a]volume=1.0[a0];"
|
||||
f"[1:a]volume={voice_volume:.2f}[a1];"
|
||||
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=0[aout]"
|
||||
)
|
||||
else:
|
||||
# replace 模式:配音替换原音
|
||||
filter_complex = f"[1:a]volume={voice_volume:.2f}[aout]"
|
||||
|
||||
run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(output_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"0:v",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
str(mixed_path),
|
||||
],
|
||||
timeout=1800,
|
||||
)
|
||||
|
||||
if mixed_path.exists() and mixed_path.stat().st_size > 0:
|
||||
output_path = mixed_path
|
||||
file_size = mixed_path.stat().st_size
|
||||
logger.info(
|
||||
"legacy TTS 配音混音完成: plan_id=%s voice_id=%s mode=%s",
|
||||
plan_id,
|
||||
tts_config.voice_id,
|
||||
tts_config.overlap_mode,
|
||||
)
|
||||
except Exception as tts_err:
|
||||
logger.warning("legacy TTS 配音混音失败(不影响主流程): plan_id=%s err=%s", plan_id, tts_err)
|
||||
|
||||
# 渲染完成,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 80.0:
|
||||
gen_task.progress = 80.0
|
||||
gen_task.append_log(
|
||||
stage="render_done",
|
||||
message="FFmpeg渲染完成(legacy)",
|
||||
level="INFO",
|
||||
progress=80.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 上传到 OSS
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
output_url = upload_to_oss(output_path, storage_key)
|
||||
|
||||
# 上传完成,更新进度
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.progress < 95.0:
|
||||
gen_task.progress = 95.0
|
||||
gen_task.append_log(
|
||||
stage="upload_done",
|
||||
message="OSS上传完成(legacy)",
|
||||
level="INFO",
|
||||
progress=95.0,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
|
||||
@@ -174,7 +174,6 @@ class _VirtualClip:
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
playback_speed: float = 1.0
|
||||
status: str = "ready"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -303,16 +302,6 @@ def _apply_template_clip_effects(
|
||||
existing_config[key] = template_clip_config[key]
|
||||
clip.config = existing_config
|
||||
|
||||
# 3. 调速:同步到 clip.playback_speed 顶级字段(渲染引擎读此字段)
|
||||
template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed")
|
||||
if template_speed:
|
||||
try:
|
||||
speed_val = float(template_speed)
|
||||
if speed_val > 0:
|
||||
clip.playback_speed = speed_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def _build_plan_and_clips_from_task(
|
||||
task_id: str,
|
||||
@@ -484,44 +473,9 @@ def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> No
|
||||
|
||||
|
||||
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
"""下载配音文件。
|
||||
|
||||
支持两种来源(按优先级):
|
||||
1. 配音素材库 asset — 将 voice_library_id 当 asset_id 查 asset 表,
|
||||
找到则用 asset.storage_key 下载(用户上传到配音库的音频)
|
||||
2. 旧版 voice/{id}.mp3 路径 — 向后兼容
|
||||
"""
|
||||
"""下载配音文件"""
|
||||
if not voice_library_id:
|
||||
return False
|
||||
|
||||
# 方式1:先尝试当 asset_id 查素材库(用户上传到配音库的音频)
|
||||
try:
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyAssetRepository(session)
|
||||
asset = repo.find_by_id(voice_library_id)
|
||||
if asset and asset.storage_key:
|
||||
# 是素材库的配音 asset,用 storage_key 下载
|
||||
logger.info(
|
||||
"配音素材来自素材库: asset_id=%s storage_key=%s",
|
||||
voice_library_id,
|
||||
asset.storage_key,
|
||||
)
|
||||
ok = download_asset(asset.storage_key, local_path)
|
||||
if ok and local_path.exists() and local_path.stat().st_size > 0:
|
||||
return True
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning("查询配音asset失败,fallback旧路径: %s", e)
|
||||
|
||||
# 方式2:旧版路径(向后兼容)
|
||||
storage_key = f"voice/{voice_library_id}.mp3"
|
||||
return download_asset(storage_key, local_path)
|
||||
|
||||
@@ -967,10 +921,8 @@ def _validate_template_exists(template_id: str) -> None:
|
||||
|
||||
|
||||
def _load_template_plan_config(template_id: str) -> dict:
|
||||
"""从模板加载 plan 级配置(BGM、字幕、标题等效果层)。
|
||||
"""从模板加载 plan 级配置(BGM、字幕、滤镜等效果层)。
|
||||
|
||||
TemplateModel 里 bgm_config / subtitle_config / title_config 是独立字段,
|
||||
需要组装成 plan.config 的格式({bgm, subtitle, title})后再注入。
|
||||
模板不存在时返回空 dict,不阻塞主流程。
|
||||
"""
|
||||
if not template_id:
|
||||
@@ -991,26 +943,17 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
if template is None:
|
||||
logger.warning("模板不存在,跳过配置加载: template_id=%s", template_id)
|
||||
return {}
|
||||
config = template.config or {}
|
||||
if isinstance(config, str):
|
||||
import json
|
||||
|
||||
# 从独立字段组装成 plan.config 格式
|
||||
plan_config: dict[str, Any] = {}
|
||||
title_cfg = template.title_config or {}
|
||||
subtitle_cfg = template.subtitle_config or {}
|
||||
bgm_cfg = template.bgm_config or {}
|
||||
|
||||
if title_cfg:
|
||||
plan_config["title"] = title_cfg
|
||||
if subtitle_cfg:
|
||||
plan_config["subtitle"] = subtitle_cfg
|
||||
if bgm_cfg:
|
||||
plan_config["bgm"] = bgm_cfg
|
||||
|
||||
config = json.loads(config)
|
||||
logger.info(
|
||||
"模板配置加载成功: template_id=%s keys=%s",
|
||||
template_id,
|
||||
list(plan_config.keys()),
|
||||
list(config.keys()),
|
||||
)
|
||||
return plan_config
|
||||
return config
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
@@ -1202,7 +1145,6 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"task_asset_ids": list(gen_task.asset_ids or []),
|
||||
"batch_id": getattr(gen_task, "batch_id", "") or "",
|
||||
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
|
||||
"video_title": getattr(gen_task, "video_title", "") or "",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1377,8 +1319,6 @@ def _upload_and_record(
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
editing_mode,
|
||||
user_id: str = "",
|
||||
video_name: str = "",
|
||||
) -> tuple[str, float, int, int]:
|
||||
"""上传 OSS、创建视频记录并查重。
|
||||
|
||||
@@ -1426,7 +1366,6 @@ def _upload_and_record(
|
||||
video_count = create_video_record_and_dedup(
|
||||
generation_task_id=task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
@@ -1434,7 +1373,6 @@ def _upload_and_record(
|
||||
video_path=str(output_path),
|
||||
mode=editing_mode.value,
|
||||
session=dedup_session,
|
||||
name=video_name,
|
||||
)
|
||||
finally:
|
||||
dedup_session.close()
|
||||
@@ -1573,8 +1511,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
editing_mode=editing_mode,
|
||||
user_id=user_id,
|
||||
video_name=task_info.get("video_title", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -280,7 +280,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 先从 OSS 下载文件到本地临时目录,再提取元数据
|
||||
# (storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow)
|
||||
local_file = None
|
||||
thumbnail_url = None
|
||||
try:
|
||||
suffix = Path(job.storage_key).suffix or ".bin"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
@@ -292,27 +291,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
metadata, extract_success = {}, False
|
||||
else:
|
||||
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
|
||||
|
||||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||||
thumbnail_url = None
|
||||
if media_type == "video" and extract_success:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
|
||||
if thumbnail_url:
|
||||
logger.info(
|
||||
"素材缩略图生成成功: job_id=%s url=%s",
|
||||
job_id,
|
||||
thumbnail_url[:80],
|
||||
)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
|
||||
job_id,
|
||||
thumb_err,
|
||||
)
|
||||
finally:
|
||||
if local_file and local_file.exists():
|
||||
try:
|
||||
@@ -381,7 +359,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
codec=metadata.get("codec") or None,
|
||||
status=AssetStatus.READY,
|
||||
file_hash=job.file_hash,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
|
||||
@@ -1232,14 +1232,6 @@
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "generation_task_id",
|
||||
@@ -1413,13 +1405,6 @@
|
||||
],
|
||||
"name": "ix_generated_videos_status",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"name": "ix_generated_videos_user_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
@@ -1620,14 +1605,6 @@
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "video_title",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(255)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "metadata",
|
||||
|
||||
@@ -53,48 +53,6 @@ class SQLAlchemyAssetRepository:
|
||||
models = query.order_by(AssetModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def count_by_library_and_file_type(
|
||||
self,
|
||||
library_id: str,
|
||||
file_type: str,
|
||||
status: list[str] | None = None,
|
||||
) -> int:
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id, AssetModel.file_type == file_type
|
||||
)
|
||||
if status:
|
||||
query = query.filter(AssetModel.status.in_(status))
|
||||
return query.count()
|
||||
|
||||
def find_by_project_and_file_type(
|
||||
self,
|
||||
project_id: str,
|
||||
file_type: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: list[str] | None = None,
|
||||
) -> list[Asset]:
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.project_id == project_id, AssetModel.file_type == file_type
|
||||
)
|
||||
if status:
|
||||
query = query.filter(AssetModel.status.in_(status))
|
||||
models = query.order_by(AssetModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def count_by_project_and_file_type(
|
||||
self,
|
||||
project_id: str,
|
||||
file_type: str,
|
||||
status: list[str] | None = None,
|
||||
) -> int:
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.project_id == project_id, AssetModel.file_type == file_type
|
||||
)
|
||||
if status:
|
||||
query = query.filter(AssetModel.status.in_(status))
|
||||
return query.count()
|
||||
|
||||
def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model is None:
|
||||
|
||||
@@ -14,7 +14,6 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
model = GeneratedVideoModel(
|
||||
id=video.id,
|
||||
project_id=video.project_id,
|
||||
user_id=video.user_id,
|
||||
generation_task_id=video.generation_task_id,
|
||||
name=video.name,
|
||||
file_url=video.file_url,
|
||||
@@ -44,7 +43,6 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
return GeneratedVideo(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
user_id=getattr(model, "user_id", ""),
|
||||
generation_task_id=model.generation_task_id,
|
||||
name=model.name,
|
||||
file_url=model.file_url,
|
||||
@@ -105,18 +103,15 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
def list_paginated(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
review_status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[GeneratedVideo], int]:
|
||||
"""分页查询成片列表,支持按用户、项目、状态、复核状态筛选。"""
|
||||
"""分页查询成片列表,支持按项目、状态、复核状态筛选。"""
|
||||
query = self.session.query(GeneratedVideoModel)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(GeneratedVideoModel.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(GeneratedVideoModel.project_id == project_id)
|
||||
if status:
|
||||
@@ -188,7 +183,6 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
return GeneratedVideo(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
user_id=getattr(model, "user_id", ""),
|
||||
generation_task_id=model.generation_task_id,
|
||||
name=model.name,
|
||||
file_url=model.file_url,
|
||||
|
||||
@@ -33,7 +33,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||
asset_select_mode=model.asset_select_mode or "",
|
||||
batch_id=model.batch_id or "",
|
||||
video_title=getattr(model, "video_title", "") or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -69,7 +68,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
source_edit_plan_id=task.source_edit_plan_id or None,
|
||||
asset_select_mode=task.asset_select_mode or "",
|
||||
batch_id=task.batch_id or "",
|
||||
video_title=task.video_title or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -228,8 +226,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.source_edit_plan_id = task.source_edit_plan_id or None
|
||||
model.asset_select_mode = task.asset_select_mode or ""
|
||||
model.batch_id = task.batch_id or ""
|
||||
if hasattr(model, "video_title"):
|
||||
model.video_title = task.video_title or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -266,7 +266,6 @@ class GenerationTaskModel(Base):
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
asset_select_mode = Column(String(20), nullable=False, default="")
|
||||
batch_id = Column(String(36), nullable=False, default="", index=True)
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -283,7 +282,6 @@ class GeneratedVideoModel(Base):
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
generation_task_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
# file_url: 完整可访问的 URL,用于客户端直接访问视频
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
"""CosyVoice TTS 服务适配器.
|
||||
|
||||
将 CosyVoiceService 包装为 TtsService 接口,供统一渲染管道的 TtsEngine 使用。
|
||||
支持阿里云百炼 CosyVoice 真实音色合成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from packages.ports.tts_service import TtsError, TtsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CosyVoiceTtsService(TtsService):
|
||||
"""CosyVoice TTS 服务适配器.
|
||||
|
||||
包装 CosyVoiceService,实现 TtsService 接口。
|
||||
合成流程:调用 CosyVoice API → 获取音频 URL → 下载到本地 → (可选)转码为目标格式
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str = "",
|
||||
base_url: str = "",
|
||||
model: str = "",
|
||||
sample_rate: int = 24000,
|
||||
format: str = "mp3",
|
||||
ffmpeg_bin: str = "ffmpeg",
|
||||
) -> None:
|
||||
"""初始化 CosyVoice TTS 服务.
|
||||
|
||||
Args:
|
||||
api_key: DashScope API Key,为空时从配置读取
|
||||
base_url: DashScope API Base URL
|
||||
model: 语音合成模型
|
||||
sample_rate: 默认采样率
|
||||
format: 默认输出格式
|
||||
ffmpeg_bin: ffmpeg 可执行文件路径(用于转码)
|
||||
"""
|
||||
# 延迟导入避免循环依赖
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
|
||||
self._service = CosyVoiceService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
)
|
||||
self._default_sample_rate = sample_rate
|
||||
self._default_format = format
|
||||
self._ffmpeg_bin = ffmpeg_bin
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "cosyvoice"
|
||||
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice_id: str = "",
|
||||
speed: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
output_path: Path | None = None,
|
||||
sample_rate: int = 22050,
|
||||
format: str = "wav",
|
||||
) -> Path:
|
||||
"""调用 CosyVoice 合成语音并下载到本地.
|
||||
|
||||
Args:
|
||||
text: 输入文本
|
||||
voice_id: 音色 ID(CosyVoice 音色名,如 longxiaochun_v3)
|
||||
speed: 语速 (0.5 ~ 2.0)
|
||||
pitch: 语调(半音,-12 ~ 12)— CosyVoice 原生不支持,用 ffmpeg 后处理实现
|
||||
output_path: 输出文件路径(None 则自动生成)
|
||||
sample_rate: 采样率
|
||||
format: 输出格式 (wav/mp3)
|
||||
|
||||
Returns:
|
||||
输出音频文件路径
|
||||
"""
|
||||
if not text.strip():
|
||||
raise TtsError("文本不能为空")
|
||||
|
||||
if not voice_id:
|
||||
voice_id = "longxiaochun_v3" # 默认音色
|
||||
|
||||
# 语速边界
|
||||
speed = max(0.5, min(2.0, speed))
|
||||
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
suffix = f".{format}"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.close()
|
||||
output_path = Path(tmp.name)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 1. 调用 CosyVoice 合成(默认 mp3 格式,兼容性最好)
|
||||
result = self._service.synthesize_speech(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
sample_rate=sample_rate or self._default_sample_rate,
|
||||
format="mp3", # 先下mp3,后面按需转码
|
||||
speed=speed,
|
||||
)
|
||||
|
||||
if not result.audio_url:
|
||||
raise TtsError("CosyVoice 未返回音频 URL")
|
||||
|
||||
# 2. 下载音频文件
|
||||
downloaded = self._download_audio(result.audio_url, output_path.parent / "_cosyvoice_tmp.mp3")
|
||||
|
||||
if not downloaded.exists() or downloaded.stat().st_size == 0:
|
||||
raise TtsError("音频下载失败或文件为空")
|
||||
|
||||
# 3. 如需转码(wav)或 pitch 调整,用 ffmpeg 处理
|
||||
need_transcode = (format != "mp3") or abs(pitch) > 0.01
|
||||
|
||||
if need_transcode:
|
||||
self._post_process(downloaded, output_path, format=format, pitch=pitch, sample_rate=sample_rate)
|
||||
else:
|
||||
# 直接移动文件
|
||||
downloaded.rename(output_path)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise TtsError("输出文件为空或不存在")
|
||||
|
||||
return output_path
|
||||
|
||||
except TtsError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("CosyVoice TTS 合成失败: %s", e)
|
||||
raise TtsError(f"CosyVoice TTS 合成失败: {e}") from e
|
||||
|
||||
def estimate_duration(self, text: str, *, speed: float = 1.0) -> float:
|
||||
"""估算音频时长(秒).
|
||||
|
||||
CosyVoice 不返回预估时长,按中文语速经验值估算:
|
||||
- 正常语速约 4 字/秒
|
||||
"""
|
||||
if not text:
|
||||
return 0.0
|
||||
char_count = len([c for c in text if not c.isspace()])
|
||||
if char_count == 0:
|
||||
return 0.0
|
||||
base_duration = char_count / 4.0 # 4 字/秒
|
||||
return base_duration / max(0.1, speed)
|
||||
|
||||
def available_voices(self) -> list[str]:
|
||||
"""支持的音色列表."""
|
||||
from packages.domain.preset_voices import get_preset_voices
|
||||
|
||||
return [v.voice_id for v in get_preset_voices()]
|
||||
|
||||
def _download_audio(self, url: str, save_path: Path) -> Path:
|
||||
"""下载音频文件.
|
||||
|
||||
Args:
|
||||
url: 音频 URL
|
||||
save_path: 保存路径
|
||||
|
||||
Returns:
|
||||
保存路径
|
||||
"""
|
||||
import httpx
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise TtsError(f"不支持的音频 URL scheme: {parsed.scheme}")
|
||||
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
with open(save_path, "wb") as f:
|
||||
for chunk in response.iter_bytes():
|
||||
f.write(chunk)
|
||||
|
||||
return save_path
|
||||
|
||||
def _post_process(
|
||||
self,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
format: str = "wav",
|
||||
pitch: float = 0.0,
|
||||
sample_rate: int = 22050,
|
||||
) -> None:
|
||||
"""后处理:转码 + pitch 调整.
|
||||
|
||||
Args:
|
||||
input_path: 输入文件路径
|
||||
output_path: 输出文件路径
|
||||
format: 输出格式
|
||||
pitch: 语调偏移(半音)
|
||||
sample_rate: 输出采样率
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# 构建滤镜
|
||||
filter_parts = []
|
||||
|
||||
# pitch 调整:通过 asetrate 实现
|
||||
if abs(pitch) > 0.01:
|
||||
pitch_factor = 2 ** (pitch / 12)
|
||||
new_rate = int(sample_rate * pitch_factor)
|
||||
filter_parts.append(f"asetrate={new_rate}")
|
||||
filter_parts.append(f"aresample={sample_rate}")
|
||||
|
||||
filter_str = ",".join(filter_parts) if filter_parts else None
|
||||
|
||||
# 编码参数
|
||||
if format == "mp3":
|
||||
codec_args = ["-acodec", "libmp3lame", "-b:a", "128k"]
|
||||
else: # wav
|
||||
codec_args = ["-acodec", "pcm_s16le"]
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
str(input_path),
|
||||
]
|
||||
|
||||
if filter_str:
|
||||
command.extend(["-af", filter_str])
|
||||
|
||||
command.extend(codec_args)
|
||||
command.extend(["-ar", str(sample_rate), "-ac", "1", str(output_path)])
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise TtsError(f"音频后处理失败: {result.stderr[-500:]}")
|
||||
@@ -21,7 +21,6 @@ class ListGeneratedVideosPaginatedUseCase:
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
review_status: str | None = None,
|
||||
@@ -33,7 +32,6 @@ class ListGeneratedVideosPaginatedUseCase:
|
||||
if page_size < 1 or page_size > 100:
|
||||
page_size = 20
|
||||
return self.generated_video_repository.list_paginated(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
review_status=review_status,
|
||||
|
||||
@@ -21,7 +21,6 @@ class CreateGenerationTaskCommand:
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
|
||||
@@ -49,7 +48,6 @@ class CreateGenerationTaskUseCase:
|
||||
source_edit_plan_id=command.source_edit_plan_id,
|
||||
asset_select_mode=command.asset_select_mode,
|
||||
batch_id=command.batch_id,
|
||||
video_title=command.video_title,
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
)
|
||||
|
||||
@@ -204,13 +204,6 @@ class Asset:
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@property
|
||||
def file_type(self) -> str:
|
||||
"""文件类型(从 mime_type 推导,如 video/audio/image)."""
|
||||
if "/" in self.mime_type:
|
||||
return self.mime_type.split("/")[0]
|
||||
return self.mime_type
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
@@ -249,7 +242,7 @@ class Asset:
|
||||
storage_key=storage_key.strip(),
|
||||
mime_type=mime_type.strip(),
|
||||
file_size=file_size,
|
||||
thumbnail_url=str(thumbnail_url) if thumbnail_url else None,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
|
||||
Executable → Regular
-3
@@ -18,7 +18,6 @@ class GeneratedVideo:
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
user_id: str = ""
|
||||
thumbnail_url: str | None = None
|
||||
status: str = "completed"
|
||||
review_status: str = "pending_review"
|
||||
@@ -37,7 +36,6 @@ class GeneratedVideo:
|
||||
name: str,
|
||||
file_url: str,
|
||||
*,
|
||||
user_id: str = "",
|
||||
file_size: int = 0,
|
||||
duration: float = 0.0,
|
||||
width: int = 0,
|
||||
@@ -57,7 +55,6 @@ class GeneratedVideo:
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
project_id=project_id.strip(),
|
||||
user_id=user_id.strip(),
|
||||
generation_task_id=generation_task_id.strip(),
|
||||
name=name.strip(),
|
||||
file_url=file_url.strip(),
|
||||
|
||||
@@ -90,7 +90,6 @@ class GenerationTask:
|
||||
created_by_user_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -111,7 +110,6 @@ class GenerationTask:
|
||||
source_edit_plan_id: str = "",
|
||||
asset_select_mode: str = "",
|
||||
batch_id: str = "",
|
||||
video_title: str = "",
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
) -> "GenerationTask":
|
||||
@@ -133,7 +131,6 @@ class GenerationTask:
|
||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||
asset_select_mode=asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=video_title.strip(),
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ class GeneratedVideoRepository(Protocol):
|
||||
def list_paginated(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
review_status: str | None = None,
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACR 镜像清理脚本
|
||||
策略:
|
||||
- 版本tag (v*): 永久保留
|
||||
- 固定tag (latest, main, develop, master): 永久保留
|
||||
- 缓存镜像 (*-cache): 永久保留
|
||||
- PR预览tag (pr-*): 保留 N 天(默认7天)
|
||||
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
|
||||
|
||||
使用方式:
|
||||
python3 acr_cleanup.py --dry-run # 预览,不实际删除
|
||||
python3 acr_cleanup.py --execute # 实际执行删除
|
||||
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 配置
|
||||
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
|
||||
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
|
||||
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
|
||||
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
|
||||
USERNAME = os.environ.get("ACR_USERNAME", "")
|
||||
PASSWORD = os.environ.get("ACR_PASSWORD", "")
|
||||
|
||||
REPOS = [
|
||||
"xiaoxia-saas-api",
|
||||
"xiaoxia-saas-worker",
|
||||
"xiaoxia-saas-web",
|
||||
"api-cache",
|
||||
"worker-cache",
|
||||
"web-cache",
|
||||
]
|
||||
|
||||
# 缓存镜像仓库(所有tag永久保留)
|
||||
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
|
||||
|
||||
# OCI / Docker manifest types
|
||||
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
|
||||
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
|
||||
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
|
||||
def get_token(repo, action="pull"):
|
||||
"""获取仓库访问token"""
|
||||
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
|
||||
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
|
||||
req = urllib.request.Request(token_url)
|
||||
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("token", "")
|
||||
|
||||
|
||||
def get_tags(repo, token):
|
||||
"""获取仓库所有tag"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return data.get("tags", []) or []
|
||||
|
||||
|
||||
def http_get_json(url, token, accept_header):
|
||||
"""带Authorization的GET请求,返回(json_data, headers)"""
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", accept_header)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read()), resp.headers
|
||||
|
||||
|
||||
def get_manifest_info(repo, tag, token):
|
||||
"""
|
||||
获取tag的manifest信息,支持OCI index和普通manifest两种格式。
|
||||
返回: {digest, created, media_type}
|
||||
- digest: 顶层manifest的digest(用于删除)
|
||||
- created: 镜像创建时间
|
||||
"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
|
||||
result = {"digest": "", "created": "", "media_type": "", "error": ""}
|
||||
|
||||
# 先尝试 OCI index 格式(ACR多用这种)
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_INDEX)
|
||||
top_digest = headers.get("Docker-Content-Digest", "")
|
||||
result["digest"] = top_digest
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
|
||||
|
||||
# OCI index:找amd64的manifest,再取config blob
|
||||
manifests = data.get("manifests", [])
|
||||
amd64_manifest = None
|
||||
for m in manifests:
|
||||
arch = m.get("platform", {}).get("architecture", "")
|
||||
if arch == "amd64":
|
||||
amd64_manifest = m
|
||||
break
|
||||
# 没有amd64就用第一个
|
||||
if not amd64_manifest and manifests:
|
||||
amd64_manifest = manifests[0]
|
||||
|
||||
if amd64_manifest:
|
||||
inner_digest = amd64_manifest["digest"]
|
||||
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
|
||||
try:
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
|
||||
except Exception:
|
||||
# 退而求其次用v2格式
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
|
||||
|
||||
config_digest = inner_data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 再尝试普通 OCI manifest 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
# 最后试 Docker v2 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
|
||||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
|
||||
config_digest = data.get("config", {}).get("digest", "")
|
||||
if config_digest:
|
||||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||||
try:
|
||||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||||
result["created"] = blob_data.get("created", "")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def delete_manifest(repo, digest, token):
|
||||
"""按digest删除manifest(会级联删除所有指向它的tag)"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
|
||||
req = urllib.request.Request(url, method="DELETE")
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
req.add_header("Accept", ACCEPT_INDEX)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
|
||||
req.add_header("Accept", ACCEPT_MANIFEST_V2)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return True, resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
|
||||
def parse_time(created_str):
|
||||
"""解析ISO时间字符串"""
|
||||
if not created_str:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
if created_str.endswith("Z"):
|
||||
created_str = created_str[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(created_str)
|
||||
except Exception:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def is_version_tag(tag):
|
||||
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
|
||||
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
|
||||
|
||||
|
||||
def is_fixed_tag(tag):
|
||||
"""判断是否是固定tag"""
|
||||
return tag in ("latest", "main", "develop", "master", "dev", "stable")
|
||||
|
||||
|
||||
def is_pr_tag(tag):
|
||||
"""判断是否是PR预览tag"""
|
||||
return tag.startswith("pr-")
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
"""清理单个仓库"""
|
||||
print("=" * 60)
|
||||
print("仓库:", repo)
|
||||
print("=" * 60)
|
||||
|
||||
# 缓存仓库不清理
|
||||
if repo in CACHE_REPOS:
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
|
||||
return len(tags), 0
|
||||
|
||||
token_pull = get_token(repo, "pull")
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 总tag数:", len(tags))
|
||||
|
||||
# 分类
|
||||
version_tags = []
|
||||
fixed_tags = []
|
||||
pr_tags_list = []
|
||||
commit_tags = []
|
||||
|
||||
for tag in tags:
|
||||
if is_version_tag(tag):
|
||||
version_tags.append(tag)
|
||||
elif is_fixed_tag(tag):
|
||||
fixed_tags.append(tag)
|
||||
elif is_pr_tag(tag):
|
||||
pr_tags_list.append(tag)
|
||||
else:
|
||||
commit_tags.append(tag)
|
||||
|
||||
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
|
||||
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
|
||||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天")
|
||||
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个")
|
||||
|
||||
# 获取所有commit tag的创建时间
|
||||
print()
|
||||
print(" 获取commit tag创建时间...")
|
||||
tag_info_list = []
|
||||
errors = 0
|
||||
for i, tag in enumerate(commit_tags):
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["error"] or not info["digest"]:
|
||||
errors += 1
|
||||
# 取不到信息的tag,放到最后(最旧处理),但标记一下
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
|
||||
else:
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
|
||||
if (i + 1) % 20 == 0:
|
||||
print(" 已获取", i + 1, "/", len(commit_tags), "...")
|
||||
|
||||
if errors:
|
||||
print(" 注意:", errors, "个tag获取manifest失败")
|
||||
|
||||
# 按时间倒序排序(空时间放最后)
|
||||
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||||
|
||||
# 确定要删除的commit tag
|
||||
to_delete = []
|
||||
if len(tag_info_list) > keep_count:
|
||||
to_delete = tag_info_list[keep_count:]
|
||||
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "个")
|
||||
# 打印保留范围
|
||||
kept = tag_info_list[:keep_count]
|
||||
valid_kept = [t for t in kept if t["created"]]
|
||||
if valid_kept:
|
||||
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
|
||||
to_del_valid = [t for t in to_delete if t["digest"]]
|
||||
print(" 可删除(有digest):", len(to_del_valid), "个")
|
||||
else:
|
||||
print(" commit tag数量不足", keep_count, ",无需清理")
|
||||
|
||||
# PR tag按时间清理
|
||||
pr_to_delete = []
|
||||
if pr_tags_list:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||||
print()
|
||||
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
|
||||
for tag in pr_tags_list:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
print(" PR tag将删除:", len(pr_to_delete), "个")
|
||||
|
||||
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
|
||||
|
||||
if not all_to_delete:
|
||||
print()
|
||||
print(" 无需删除任何tag")
|
||||
return len(tags), 0
|
||||
|
||||
# 执行删除
|
||||
print()
|
||||
if dry_run:
|
||||
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
|
||||
# 去重digest
|
||||
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
|
||||
print(" 去重后唯一digest数:", len(unique_digests))
|
||||
for item in all_to_delete[:5]:
|
||||
created_str = item.get("created", "")[:10] or "未知"
|
||||
print(" -", item["tag"][:20], "(" + created_str + ")")
|
||||
if len(all_to_delete) > 5:
|
||||
print(" ... 还有", len(all_to_delete) - 5, "个")
|
||||
return len(tags), len(unique_digests)
|
||||
|
||||
token_delete = get_token(repo, "delete")
|
||||
deleted = 0
|
||||
failed = 0
|
||||
# 按digest去重,避免重复删除同一镜像
|
||||
seen_digests = set()
|
||||
unique_delete = []
|
||||
for item in all_to_delete:
|
||||
if item["digest"] and item["digest"] not in seen_digests:
|
||||
seen_digests.add(item["digest"])
|
||||
unique_delete.append(item)
|
||||
|
||||
print(" 开始删除", len(unique_delete), "个唯一manifest...")
|
||||
for item in unique_delete:
|
||||
success, result = delete_manifest(repo, item["digest"], token_delete)
|
||||
if success:
|
||||
deleted += 1
|
||||
print(" 已删除:", item["tag"][:20])
|
||||
else:
|
||||
failed += 1
|
||||
print(" 删除失败:", item["tag"][:20], "-", result)
|
||||
|
||||
print()
|
||||
print(" 删除完成: 成功", deleted, "个,失败", failed, "个")
|
||||
return len(tags), deleted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
|
||||
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20)")
|
||||
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
|
||||
parser.add_argument("--execute", action="store_true", help="实际执行删除")
|
||||
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须指定 --dry-run 或 --execute
|
||||
if not args.dry_run and not args.execute:
|
||||
print("请指定 --dry-run(预览)或 --execute(执行)")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
|
||||
print(" python3 acr_cleanup.py --execute # 实际执行清理")
|
||||
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
|
||||
sys.exit(1)
|
||||
|
||||
# 凭证检查
|
||||
global USERNAME, PASSWORD
|
||||
if not USERNAME or not PASSWORD:
|
||||
# 尝试从docker config读取
|
||||
try:
|
||||
docker_config_path = os.path.expanduser("~/.docker/config.json")
|
||||
with open(docker_config_path) as f:
|
||||
config = json.load(f)
|
||||
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
|
||||
if auth:
|
||||
creds = base64.b64decode(auth).decode().strip()
|
||||
USERNAME, PASSWORD = creds.split(":", 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not USERNAME or not PASSWORD:
|
||||
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
|
||||
print("或确保已执行 docker login", REGISTRY)
|
||||
sys.exit(1)
|
||||
|
||||
dry_run = args.dry_run or not args.execute
|
||||
mode = "预览模式" if dry_run else "执行模式"
|
||||
print("ACR 镜像清理工具 -", mode)
|
||||
print("Registry:", REGISTRY)
|
||||
print("Namespace:", NAMESPACE)
|
||||
print("保留commit tag数:", args.keep)
|
||||
print("PR预览保留天数:", args.pr_days)
|
||||
print()
|
||||
|
||||
repos_to_clean = REPOS
|
||||
if args.repo:
|
||||
repos_to_clean = [args.repo]
|
||||
|
||||
total_deleted = 0
|
||||
total_tags = 0
|
||||
for repo in repos_to_clean:
|
||||
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
|
||||
total_tags += count
|
||||
total_deleted += deleted
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("清理完成")
|
||||
print(" 总tag数:", total_tags)
|
||||
if dry_run:
|
||||
print(" 预览将删除(去重后):", total_deleted, "个manifest")
|
||||
else:
|
||||
print(" 已删除:", total_deleted, "个manifest")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,174 +0,0 @@
|
||||
"""CosyVoice TTS 适配器单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfig:
|
||||
"""TTS 配置解析测试."""
|
||||
|
||||
def test_parse_full_config(self):
|
||||
"""完整配置解析."""
|
||||
cfg = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "longxiaochun_v3",
|
||||
"text": "你好世界",
|
||||
"speed": 1.2,
|
||||
"pitch": 2.0,
|
||||
"volume": 0.7,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.voice_id == "longxiaochun_v3"
|
||||
assert cfg.text == "你好世界"
|
||||
assert cfg.speed == 1.2
|
||||
assert cfg.pitch == 2.0
|
||||
assert cfg.volume == 0.7
|
||||
assert cfg.align_mode == "full"
|
||||
assert cfg.overlap_mode == "mix"
|
||||
|
||||
def test_parse_disabled(self):
|
||||
"""禁用状态."""
|
||||
cfg = TtsConfig.parse({"enabled": False})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_parse_none(self):
|
||||
"""空配置."""
|
||||
cfg = TtsConfig.parse(None)
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_parse_empty_text_still_enabled(self):
|
||||
"""有 enabled 但无 text,配置仍然有效(调用方判断是否有文本)."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "voice_id": "test"})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.text == ""
|
||||
|
||||
def test_speed_clamp(self):
|
||||
"""语速边界钳制."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
cfg2 = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert cfg2.speed == 0.5
|
||||
|
||||
def test_volume_clamp(self):
|
||||
"""音量边界钳制."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
||||
assert cfg.volume == 1.0
|
||||
|
||||
def test_invalid_align_mode(self):
|
||||
"""无效对齐模式回退到默认."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert cfg.align_mode == "full"
|
||||
|
||||
|
||||
class TestCosyVoiceTtsAdapter:
|
||||
"""CosyVoice TTS 适配器测试."""
|
||||
|
||||
def test_import_ok(self):
|
||||
"""适配器能正常导入."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
assert CosyVoiceTtsService is not None
|
||||
|
||||
def test_provider_name(self):
|
||||
"""provider_name 属性."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
# 用 mock 替换底层 CosyVoiceService
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = CosyVoiceTtsService()
|
||||
assert svc.provider_name == "cosyvoice"
|
||||
|
||||
def test_available_voices(self):
|
||||
"""可用音色列表来自预设音色."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = CosyVoiceTtsService()
|
||||
voices = svc.available_voices()
|
||||
assert len(voices) > 0
|
||||
assert "longxiaochun_v3" in voices
|
||||
assert "longxiaoxia_v3" in voices
|
||||
|
||||
def test_estimate_duration(self):
|
||||
"""时长估算."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = CosyVoiceTtsService()
|
||||
d = svc.estimate_duration("你好世界", speed=1.0)
|
||||
assert d > 0
|
||||
# 4 个字,4 字/秒 = 1 秒
|
||||
assert abs(d - 1.0) < 0.1
|
||||
|
||||
def test_estimate_duration_speed(self):
|
||||
"""语速影响时长估算."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = CosyVoiceTtsService()
|
||||
d_normal = svc.estimate_duration("你好世界", speed=1.0)
|
||||
d_fast = svc.estimate_duration("你好世界", speed=2.0)
|
||||
assert d_fast < d_normal
|
||||
assert abs(d_fast - d_normal / 2.0) < 0.01
|
||||
|
||||
def test_synthesize_empty_text_raises(self):
|
||||
"""空文本抛出异常."""
|
||||
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
|
||||
from packages.ports.tts_service import TtsError
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = CosyVoiceTtsService()
|
||||
with pytest.raises(TtsError, match="文本不能为空"):
|
||||
svc.synthesize(" ")
|
||||
|
||||
|
||||
class TestTtsServiceFactory:
|
||||
"""TTS 服务工厂测试."""
|
||||
|
||||
def test_mock_provider(self):
|
||||
"""mock provider 正常."""
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
svc = get_tts_service("mock")
|
||||
assert svc.provider_name == "mock"
|
||||
|
||||
def test_cosyvoice_provider(self):
|
||||
"""cosyvoice provider 注册正常."""
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = get_tts_service("cosyvoice")
|
||||
assert svc.provider_name == "cosyvoice"
|
||||
|
||||
def test_aliyun_alias(self):
|
||||
"""aliyun 别名映射到 cosyvoice."""
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = get_tts_service("aliyun")
|
||||
assert svc.provider_name == "cosyvoice"
|
||||
|
||||
def test_dashscope_alias(self):
|
||||
"""dashscope 别名映射到 cosyvoice."""
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
|
||||
svc = get_tts_service("dashscope")
|
||||
assert svc.provider_name == "cosyvoice"
|
||||
|
||||
def test_unknown_fallback_to_mock(self):
|
||||
"""未知 provider 回退到 mock."""
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
svc = get_tts_service("unknown_provider")
|
||||
assert svc.provider_name == "mock"
|
||||
@@ -111,358 +111,3 @@ class TestVideoCreationLogic:
|
||||
generation_task_id = ""
|
||||
project_id = "proj-456"
|
||||
assert bool(generation_task_id) is False
|
||||
|
||||
|
||||
class TestVideoNameParameter:
|
||||
"""验证成片库视频名称逻辑:用户设置标题时用标题,没设置时用默认命名。"""
|
||||
|
||||
def test_name_from_user_title(self):
|
||||
"""用户设置了标题 → 用标题作为视频名称。"""
|
||||
# 验证函数签名包含 name 参数
|
||||
import inspect
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
sig = inspect.signature(create_video_record_and_dedup)
|
||||
assert "name" in sig.parameters, "create_video_record_and_dedup 应支持 name 参数"
|
||||
|
||||
def test_generated_video_requires_name(self):
|
||||
"""GeneratedVideo.create 要求 name 非空。"""
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
with pytest.raises(ValueError, match="name cannot be empty"):
|
||||
GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
user_id="user-1",
|
||||
generation_task_id="task-1",
|
||||
name="",
|
||||
file_url="https://example.com/test.mp4",
|
||||
)
|
||||
|
||||
def test_name_fallback_when_empty(self):
|
||||
"""name 为空或纯空格时,调用方应 fallback 到默认命名。"""
|
||||
# 模拟 _finalize_render_success 中的逻辑
|
||||
title_text_empty = ""
|
||||
title_text_spaces = " "
|
||||
generation_task_id = "task-abcdef123456"
|
||||
|
||||
# 空标题 → fallback
|
||||
video_name_1 = title_text_empty.strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
assert video_name_1 == f"generated-{generation_task_id[:8]}.mp4"
|
||||
|
||||
# 纯空格 → fallback
|
||||
video_name_2 = title_text_spaces.strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
assert video_name_2 == f"generated-{generation_task_id[:8]}.mp4"
|
||||
|
||||
# 有标题 → 用标题
|
||||
title_text = "我的旅行vlog"
|
||||
video_name_3 = title_text.strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
assert video_name_3 == "我的旅行vlog"
|
||||
|
||||
|
||||
class TestUserIdFilter:
|
||||
"""验证成片库按 user_id 过滤的核心逻辑。"""
|
||||
|
||||
def _setup_repo(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyGeneratedVideoRepository(session), session
|
||||
|
||||
def test_list_paginated_filters_by_user_id(self):
|
||||
"""list_paginated 传入 user_id 时只返回该用户的视频。"""
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
repo, session = self._setup_repo()
|
||||
try:
|
||||
# 用户A的2个视频
|
||||
for i in range(2):
|
||||
v = GeneratedVideo.create(
|
||||
project_id=f"proj-a-{i}",
|
||||
user_id="user-a",
|
||||
generation_task_id=f"task-a-{i}",
|
||||
name=f"video-a-{i}.mp4",
|
||||
file_url=f"https://oss.example.com/a-{i}.mp4",
|
||||
)
|
||||
repo.create(v)
|
||||
|
||||
# 用户B的3个视频
|
||||
for i in range(3):
|
||||
v = GeneratedVideo.create(
|
||||
project_id=f"proj-b-{i}",
|
||||
user_id="user-b",
|
||||
generation_task_id=f"task-b-{i}",
|
||||
name=f"video-b-{i}.mp4",
|
||||
file_url=f"https://oss.example.com/b-{i}.mp4",
|
||||
)
|
||||
repo.create(v)
|
||||
|
||||
# 查用户A → 2条
|
||||
items, total = repo.list_paginated(user_id="user-a", page=1, page_size=10)
|
||||
assert total == 2
|
||||
assert len(items) == 2
|
||||
assert all(it.user_id == "user-a" for it in items)
|
||||
|
||||
# 查用户B → 3条
|
||||
items, total = repo.list_paginated(user_id="user-b", page=1, page_size=10)
|
||||
assert total == 3
|
||||
assert len(items) == 3
|
||||
assert all(it.user_id == "user-b" for it in items)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_list_paginated_user_id_plus_project_id(self):
|
||||
"""同时传 user_id 和 project_id 时两个条件同时过滤。"""
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
repo, session = self._setup_repo()
|
||||
try:
|
||||
# 用户A的proj-1视频
|
||||
v1 = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
user_id="user-a",
|
||||
generation_task_id="task-1",
|
||||
name="v1.mp4",
|
||||
file_url="https://oss.example.com/v1.mp4",
|
||||
)
|
||||
repo.create(v1)
|
||||
|
||||
# 用户B的proj-1视频(不同用户同项目)
|
||||
v2 = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
user_id="user-b",
|
||||
generation_task_id="task-2",
|
||||
name="v2.mp4",
|
||||
file_url="https://oss.example.com/v2.mp4",
|
||||
)
|
||||
repo.create(v2)
|
||||
|
||||
# 用户A + proj-1 → 只有1条
|
||||
items, total = repo.list_paginated(user_id="user-a", project_id="proj-1", page=1, page_size=10)
|
||||
assert total == 1
|
||||
assert items[0].user_id == "user-a"
|
||||
assert items[0].generation_task_id == "task-1"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_generated_video_has_user_id_field(self):
|
||||
"""GeneratedVideo domain 对象有 user_id 字段。"""
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
v = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
user_id="user-123",
|
||||
generation_task_id="task-1",
|
||||
name="test.mp4",
|
||||
file_url="https://example.com/test.mp4",
|
||||
)
|
||||
assert v.user_id == "user-123"
|
||||
|
||||
def test_created_video_persists_user_id(self):
|
||||
"""创建视频后 user_id 能正确持久化和读取。"""
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
repo, session = self._setup_repo()
|
||||
try:
|
||||
v = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
user_id="user-persist-test",
|
||||
generation_task_id="task-persist",
|
||||
name="persist.mp4",
|
||||
file_url="https://example.com/persist.mp4",
|
||||
)
|
||||
repo.create(v)
|
||||
|
||||
fetched = repo.get(v.id)
|
||||
assert fetched is not None
|
||||
assert fetched.user_id == "user-persist-test"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class TestThumbnailInDedupHelpers:
|
||||
"""验证 dedup_helpers 中缩略图相关逻辑(全 mock,不依赖 cv2)。"""
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
"""用 mock 模块替代需要 cv2 的 dedup 模块,避免导入失败。
|
||||
|
||||
注意:光往 sys.modules 塞不够,patch() 走属性访问链,
|
||||
必须给 video_processing 包设置对应子模块属性。
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# 先 mock 掉 cv2
|
||||
if "cv2" not in sys.modules:
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
|
||||
# mock video_processing.dedup
|
||||
mock_dedup = MagicMock()
|
||||
mock_dedup.VideoDeduplicator = MagicMock()
|
||||
sys.modules["video_processing.dedup"] = mock_dedup
|
||||
|
||||
# mock video_processing.thumbnail_generator
|
||||
mock_thumb = MagicMock()
|
||||
mock_thumb.generate_and_upload_thumbnail = MagicMock()
|
||||
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
|
||||
|
||||
# 关键:给 video_processing 包设置子模块属性,让 patch() 能通过属性访问找到
|
||||
import video_processing
|
||||
|
||||
video_processing.dedup = mock_dedup
|
||||
video_processing.thumbnail_generator = mock_thumb
|
||||
|
||||
def test_pre_generated_thumbnail_url_is_reused(self):
|
||||
"""传入 thumbnail_url 时直接复用,不调用 generate_and_upload_thumbnail。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
pre_thumb_url = "https://oss.example.com/pre-thumb.jpg"
|
||||
|
||||
try:
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
|
||||
mock_dedup = mock_dedup_cls.return_value
|
||||
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail") as mock_gen:
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-reuse",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
thumbnail_url=pre_thumb_url,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# 预生成缩略图时不应调用 generate_and_upload_thumbnail
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-reuse").first()
|
||||
assert video is not None
|
||||
assert video.thumbnail_url == pre_thumb_url
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_thumbnail_generated_when_not_provided(self):
|
||||
"""未传 thumbnail_url 时调用 generate_and_upload_thumbnail 生成。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
generated_thumb_url = "https://oss.example.com/generated-thumb.jpg"
|
||||
|
||||
try:
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
|
||||
mock_dedup = mock_dedup_cls.return_value
|
||||
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
return_value=generated_thumb_url,
|
||||
) as mock_gen:
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-gen",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# 应调用一次缩略图生成
|
||||
mock_gen.assert_called_once()
|
||||
# 验证参数:video_path 和 storage_key
|
||||
call_args = mock_gen.call_args
|
||||
assert call_args[0][0] == "/tmp/fake.mp4"
|
||||
assert "thumbnails" in call_args[0][1]
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-gen").first()
|
||||
assert video is not None
|
||||
assert video.thumbnail_url == generated_thumb_url
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_thumbnail_generation_failure_does_not_block(self):
|
||||
"""缩略图生成失败不影响主流程。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
|
||||
mock_dedup = mock_dedup_cls.return_value
|
||||
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
side_effect=RuntimeError("cv2 not found"),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-fail",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1 # 不阻断
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-fail").first()
|
||||
assert video is not None
|
||||
# 缩略图生成失败时 thumbnail_url 为 None 或空串
|
||||
assert not video.thumbnail_url
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -339,10 +339,9 @@ class TestIngestAssetValidation:
|
||||
True,
|
||||
)
|
||||
with patch("worker_app.tasks.ingest.SessionLocal", return_value=db):
|
||||
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail", return_value=None):
|
||||
from worker_app.tasks.ingest import ingest_asset
|
||||
from worker_app.tasks.ingest import ingest_asset
|
||||
|
||||
result = ingest_asset("job-valid-001")
|
||||
result = ingest_asset("job-valid-001")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert "asset_id" in result
|
||||
|
||||
@@ -408,108 +408,3 @@ class TestTemplateClipEffectMapping:
|
||||
|
||||
result = _extract_intro_outro_from_clip_configs(clip_configs)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestTemplatePlanConfigLoading:
|
||||
"""验证从模板加载 plan 级配置(BGM、字幕、标题)的逻辑。"""
|
||||
|
||||
def _mock_template(
|
||||
self,
|
||||
title_config=None,
|
||||
subtitle_config=None,
|
||||
bgm_config=None,
|
||||
is_active=True,
|
||||
):
|
||||
template = MagicMock()
|
||||
template.id = "tmpl_001"
|
||||
template.name = "Test Template"
|
||||
template.is_active = is_active
|
||||
template.title_config = title_config or {}
|
||||
template.subtitle_config = subtitle_config or {}
|
||||
template.bgm_config = bgm_config or {}
|
||||
return template
|
||||
|
||||
def _mock_session(self, template):
|
||||
session = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
session.query.return_value = mock_query
|
||||
filter_result = MagicMock()
|
||||
mock_query.filter.return_value = filter_result
|
||||
filter_result.first.return_value = template
|
||||
return session
|
||||
|
||||
def test_load_template_config_assembles_three_fields(self):
|
||||
"""模板的三个独立字段正确组装成 plan.config 格式。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
title_cfg = {"enabled": True, "text": "我的标题", "font_size": 36}
|
||||
subtitle_cfg = {"enabled": True, "auto_generated": True, "language": "zh"}
|
||||
bgm_cfg = {"enabled": True, "preset_id": "bgm-001", "volume": 0.5}
|
||||
|
||||
template = self._mock_template(
|
||||
title_config=title_cfg,
|
||||
subtitle_config=subtitle_cfg,
|
||||
bgm_config=bgm_cfg,
|
||||
)
|
||||
session = self._mock_session(template)
|
||||
|
||||
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
|
||||
result = _load_template_plan_config("tmpl_001")
|
||||
|
||||
assert result["title"] == title_cfg
|
||||
assert result["subtitle"] == subtitle_cfg
|
||||
assert result["bgm"] == bgm_cfg
|
||||
|
||||
def test_load_template_config_empty_template_returns_empty(self):
|
||||
"""模板三个字段都为空时返回空 dict。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
template = self._mock_template()
|
||||
session = self._mock_session(template)
|
||||
|
||||
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
|
||||
result = _load_template_plan_config("tmpl_001")
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_load_template_config_only_bgm(self):
|
||||
"""只有 BGM 配置时只返回 bgm 字段。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
bgm_cfg = {"enabled": True, "audio_url": "https://example.com/bgm.mp3"}
|
||||
template = self._mock_template(bgm_config=bgm_cfg)
|
||||
session = self._mock_session(template)
|
||||
|
||||
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
|
||||
result = _load_template_plan_config("tmpl_001")
|
||||
|
||||
assert "bgm" in result
|
||||
assert result["bgm"] == bgm_cfg
|
||||
assert "title" not in result
|
||||
assert "subtitle" not in result
|
||||
|
||||
def test_load_template_config_empty_template_id(self):
|
||||
"""空 template_id 直接返回空 dict。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
result = _load_template_plan_config("")
|
||||
assert result == {}
|
||||
|
||||
result = _load_template_plan_config(None)
|
||||
assert result == {}
|
||||
|
||||
def test_load_template_config_not_found_returns_empty(self):
|
||||
"""模板不存在时返回空 dict,不抛异常。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
session = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
session.query.return_value = mock_query
|
||||
filter_result = MagicMock()
|
||||
mock_query.filter.return_value = filter_result
|
||||
filter_result.first.return_value = None
|
||||
|
||||
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
|
||||
result = _load_template_plan_config("tmpl_nonexist")
|
||||
|
||||
assert result == {}
|
||||
|
||||
Regular → Executable
+2
-88
@@ -308,99 +308,13 @@ class TestRenderPlan:
|
||||
assert result.width == 1280
|
||||
assert result.height == 720
|
||||
assert result.clip_count == 2
|
||||
# 缩略图URL(即使生成失败也应该是空串,不为None)
|
||||
assert hasattr(result, "thumbnail_url")
|
||||
|
||||
@patch("video_processing.render_adapter.upload_to_oss")
|
||||
@patch("video_processing.render_adapter.UnifiedRenderService")
|
||||
@patch("video_processing.render_adapter.download_asset")
|
||||
def test_thumbnail_generated_on_success(self, mock_download, mock_render_cls, mock_upload, tmp_path):
|
||||
"""渲染成功后生成缩略图,thumbnail_url 正确返回。"""
|
||||
|
||||
def _fake_download(storage_key, local_path):
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(b"fake video")
|
||||
return True
|
||||
|
||||
mock_download.side_effect = _fake_download
|
||||
|
||||
mock_render = MagicMock()
|
||||
mock_render.render.return_value = MagicMock(
|
||||
output_path=tmp_path / "out.mp4",
|
||||
duration=5.0,
|
||||
file_size=1024,
|
||||
width=1280,
|
||||
height=720,
|
||||
)
|
||||
mock_render_cls.return_value = mock_render
|
||||
mock_upload.return_value = "https://oss.example.com/out.mp4"
|
||||
|
||||
fake_thumb = "https://oss.example.com/rendered/plan_thumb/thumbnail.jpg"
|
||||
|
||||
plan = FakePlan(id="plan_thumb")
|
||||
clips = [_make_clip("c1", order=0, duration=5.0)]
|
||||
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
|
||||
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
return_value=fake_thumb,
|
||||
):
|
||||
result = adapter.render_plan(
|
||||
"plan_thumb",
|
||||
work_dir=tmp_path / "work",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.thumbnail_url == fake_thumb
|
||||
|
||||
@patch("video_processing.render_adapter.upload_to_oss")
|
||||
@patch("video_processing.render_adapter.UnifiedRenderService")
|
||||
@patch("video_processing.render_adapter.download_asset")
|
||||
def test_thumbnail_failure_does_not_block(self, mock_download, mock_render_cls, mock_upload, tmp_path):
|
||||
"""缩略图生成失败不影响主流程,thumbnail_url 为空串。"""
|
||||
|
||||
def _fake_download(storage_key, local_path):
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(b"fake video")
|
||||
return True
|
||||
|
||||
mock_download.side_effect = _fake_download
|
||||
|
||||
mock_render = MagicMock()
|
||||
mock_render.render.return_value = MagicMock(
|
||||
output_path=tmp_path / "out.mp4",
|
||||
duration=5.0,
|
||||
file_size=1024,
|
||||
width=1280,
|
||||
height=720,
|
||||
)
|
||||
mock_render_cls.return_value = mock_render
|
||||
mock_upload.return_value = "https://oss.example.com/out.mp4"
|
||||
|
||||
plan = FakePlan(id="plan_thumb_fail")
|
||||
clips = [_make_clip("c1", order=0, duration=5.0)]
|
||||
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
|
||||
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
side_effect=RuntimeError("cv2 not available"),
|
||||
):
|
||||
result = adapter.render_plan(
|
||||
"plan_thumb_fail",
|
||||
work_dir=tmp_path / "work",
|
||||
)
|
||||
|
||||
assert result.success # 不阻断
|
||||
assert result.thumbnail_url == ""
|
||||
|
||||
# 验证 UnifiedRenderService 被正确调用
|
||||
mock_render_cls.assert_called_once()
|
||||
call_kwargs = mock_render_cls.call_args
|
||||
assert call_kwargs.kwargs["plan"] is plan
|
||||
assert len(call_kwargs.kwargs["clips"]) == 1
|
||||
assert len(call_kwargs.kwargs["asset_path_map"]) == 1
|
||||
assert len(call_kwargs.kwargs["clips"]) == 2
|
||||
assert len(call_kwargs.kwargs["asset_path_map"]) == 2
|
||||
|
||||
@patch("video_processing.render_adapter.upload_to_oss")
|
||||
@patch("video_processing.render_adapter.UnifiedRenderService")
|
||||
|
||||
Reference in New Issue
Block a user