mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
fix: 修复 Studio 处理、监控与 Windows 导入预览 (#221)
* fix: accept shared YouTube links and recover valid timeline segments * docs: record project review and reproducible recovery gaps * fix: isolate timeline retries and recover rejected Studio imports * docs: audit Studio analytics and Sentry coverage for 1.4 * fix: cover Studio telemetry and caught workflow failures * fix: finish Windows Proactor cleanup after peer resets Fixes PYTHON-FASTAPI-3 * docs: record Windows reset fix validation * fix: clean Windows backend descendants and repair import thumbnails * fix: offer explicit compatible source previews in Studio * fix: keep compatible preview recovery available and document validation
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
name: Windows asyncio regression
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/core/windows_asyncio.py'
|
||||
- 'backend/tests/test_windows_asyncio.py'
|
||||
- 'backend/app_factory.py'
|
||||
- 'scripts/lib/desktop_build_common.sh'
|
||||
- '.github/workflows/windows-asyncio.yml'
|
||||
- 'src-tauri/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/core/windows_asyncio.py'
|
||||
- 'backend/tests/test_windows_asyncio.py'
|
||||
- 'backend/app_factory.py'
|
||||
- 'scripts/lib/desktop_build_common.sh'
|
||||
- '.github/workflows/windows-asyncio.yml'
|
||||
- 'src-tauri/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-asyncio:
|
||||
name: Windows Proactor cleanup
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
# Match the desktop bundle; review the shim when upgrading Python.
|
||||
python-version: "3.13.13"
|
||||
- run: python -m pip install pytest
|
||||
- run: python -m pytest backend/tests/test_windows_asyncio.py -q
|
||||
|
||||
- name: Backend process tree lifecycle
|
||||
run: cargo test --manifest-path src-tauri/tests/windows-job/Cargo.toml
|
||||
- name: Prepare compile-only resource placeholders
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force src-tauri/resources/python, src-tauri/resources/backend, src-tauri/resources/ffmpeg, frontend/dist | Out-Null
|
||||
Set-Content frontend/dist/index.html '<html></html>'
|
||||
- name: Compile desktop integration (not an installer build)
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml
|
||||
+5
-1
@@ -7,7 +7,11 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
_(本周尚无改动)_
|
||||
### 修复
|
||||
- 时间线重试只使用本次有效结果,避免失败后误用上次候选;原始响应缓存现在会正常解析,无效缓存不会自动触发模型请求。
|
||||
- 智能导入后台任务提交失败后可明确重试,已有方案、草稿和成片保留;修改方案失败时同步恢复偏好。
|
||||
- 时间线兼容常见时间戳格式;相邻短片段在丢弃前尝试合并,保留既有时长限制。
|
||||
- 保留的旧导入组件支持更多 YouTube 分享链接格式;当前 Studio 入口增加独立链接回归覆盖。
|
||||
|
||||
## [1.4.0] - 2026-09-27
|
||||
|
||||
|
||||
+50
-5
@@ -1,11 +1,56 @@
|
||||
# AutoClip — 项目状态 / 进度 / 计划
|
||||
|
||||
> 更新:2026-09-23 · **v1.3.3** 发版(项目列表旧枚举、进度轮询、导入崩溃、Whisper 安装/转写/下载)。
|
||||
> v1.3.0 及更早需要先手动安装 v1.3.1,之后才能在设置里检查更新。
|
||||
> 2026-09-20 的下载快照是 v1.2.1(Windows 1136 / DMG 334)。不要用刚挂上的下载数做平台对比。
|
||||
> 更新:2026-09-28。当前主线代码 **v1.4.0**(`0241198c`);本轮修复位于 `codex/recent-feedback-fixes`,尚未合入主线或发布。
|
||||
> 当前状态以本节和链接的验收记录为准;下方保留的 v1.3.3 及更早内容是历史快照,不代表当前入口或待办状态。
|
||||
|
||||
AutoClip 是一款 AI 视频切片工具:输入 B站/YouTube 链接或本地视频,自动识别精彩片段、
|
||||
生成切片与合集。本文是项目当前状态与近期计划的单一事实来源;长期规划见 `ROADMAP.md`。
|
||||
## 当前交付与验证范围
|
||||
|
||||
- 1.4 已包含统一 Studio 导入→推荐/确认→制作→编辑→导出;默认字幕分析,视觉调用须遵守用户选择与确认边界。未确认不开始正式制作。
|
||||
- macOS 原生导入至保存、受控旧版本升级/回退已有证据,见 [RC 验收](docs/RC_ACCEPTANCE_1_4.md) 与 [发布记录](docs/RELEASE_1_4.md)。这些记录不等于第二台全新 Mac 或真实用户数据库迁移验收。
|
||||
- Windows 构建已有记录,真机安装/导入/保存仍待验收。多游戏完整观感、事件边界和推广差异化也仍待验收;不承诺广告投放效果。
|
||||
- 开发入口包括桌面、Docker、脚本和 CLI/MCP。模型配置/隐私开关、旧项目兼容和正式发布能力继续保留。
|
||||
|
||||
## 本轮反馈与审查修复(未发布)
|
||||
|
||||
分支:`codex/recent-feedback-fixes`;前一轮反馈修复提交 `485ad01a`,审查记录提交 `511d7b68`。
|
||||
|
||||
- 时间线支持常见时间戳格式并拒绝倒序/越界区间;相邻短片段先尝试合并,再按原有时长限制过滤。
|
||||
- **审查 P1 已修复**:只汇总本次验证成功的时间线块,旧结果文件不再冒充本次成功;字幕校正只使用本次大纲涉及的字幕块。
|
||||
- **审查 P2 已修复**:原始缓存与新模型响应共用解析路径。合法缓存恢复为候选;无效缓存明确跳过,不自动触发付费请求。
|
||||
- **审查 P2 已修复**:智能导入的状态预留与任务提交共用锁;提交失败恢复已有方案/草稿/成片,首次失败写可重试状态。修改方案提交失败同时恢复数据库偏好,避免 JSON 与数据库不一致。
|
||||
- 新增回归覆盖失败后重试、旧块残留、缓存有效/无效、当前 Studio YouTube 分享链接;新增错误提示提供八语翻译。最终验证数量见 [项目审查记录](docs/PROJECT_REVIEW_2026-09-27.md) 的修复验收补充。
|
||||
|
||||
## YouTube 入口说明与调整方向
|
||||
|
||||
当前首页使用 `CreativeImport` → `/studio/import` → `jobs.inspect_project`。旧 `BilibiliDownload` 已没有其他组件引用,上轮 URL 正则修复只影响这份保留代码,对 1.4 当前页面没有直接效果。
|
||||
|
||||
Studio 入口已能接受 HTTPS 的 watch、youtu.be、Shorts、live、移动端及不同查询参数顺序,并显示接口返回的错误。本轮补充的是当前入口的 API 回归,不重复增加旧正则;测试使用本地素材替代下载,不能据此宣称 YouTube 网络下载全部成功。
|
||||
|
||||
后续应围绕 Studio 补“网站字幕→字幕分析→出片”的真实链路验收,并评估复用旧下载器的字幕处理。登录/Cookies、地区、网络及 yt-dlp 上游失败单独诊断。不可达旧 UI 可在核对依赖后集中清理,不因它无引用就删除仍可能被 API/外部调用的后端入口。
|
||||
|
||||
## 1.4 埋点与异常监控
|
||||
|
||||
本轮已实施 Studio 导入/推荐/重试/多目标制作/编辑改写/视觉设置/导出/原生保存与社交发布事件,新增本次部分成功与执行耗时;后台已捕获的异常补 Sentry 安全上报,固定阶段/错误分类标签能通过脱敏白名单。旧用户数据和隐私开关保留。
|
||||
|
||||
新版 [PostHog Studio 看板](https://us.posthog.com/project/450605/dashboard/2140564) 已建立,生产与 validation 分开;真实隔离应用已验证两平台收到新事件。Sentry 验收错误为 PYTHON-FASTAPI-17,缺模型配置经修复后以 PYTHON-FASTAPI-19 / warning 上报;工程异常与配置警告两个线上视图已保存。代码仍在修复分支,尚未发布;不等于所有正式安装包已验收。完整事件契约、查询、测试和剩余验收见 [Studio 监控实施记录](docs/analytics/STUDIO_MONITORING.md),原始缺口见 [审查记录](docs/TELEMETRY_AUDIT_1_4.md)。
|
||||
|
||||
## Windows 连接重置修复(待发布)
|
||||
|
||||
针对 Sentry `PYTHON-FASTAPI-3`,已复现 CPython Proactor 的 socket.shutdown 遇到 reset 后跳过关闭与服务器解绑。新增仅用于 Windows 桌面 CPython 3.13 的清理兼容补丁;保留协议/其他异常上报以及异步子进程支持。本地后端 631 项通过,Windows CI 13 项通过(含真实 TCP RST/子进程);修复提交 `c6979acf`。原生安装包仍待冒烟,详细验收与发布前检查见 [修复记录](docs/WINDOWS_CONNECTION_RESET_FIX.md)。九千多次是跨版本累计,不能当成 1.4.0 的故障或用户数。
|
||||
|
||||
## 9 月 28 日新增反馈(待发布)
|
||||
|
||||
针对 #224,已修复无效 JPEG 封面、本地 Studio 导入漏缩略图、ffprobe 无超时,以及 Windows 后端子进程残留;编辑器新增用户主动生成的兼容预览,保留原片。#225 为 1.3.3 未安装 Whisper 的配置前置条件,现有诊断回归通过。本地后端 639 项、前端 136 项通过;Windows 13 项 IOCP + 2 项进程树测试及桌面编译通过,原生安装包仍待冒烟。修复追加到 PR #221;验证证据、旧版本升级注意事项及尚未复现的用户环境见 [最新反馈排查](docs/FEEDBACK_TRIAGE_2026-09-28.md)。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 审阅并合入本轮修复,按正常发布流程验证安装包,不把分支测试通过当成已发布。
|
||||
2. 完成 Studio 在线字幕下载验收、Windows 真机回归与更多真实素材质量评估。
|
||||
3. 逐步收敛 Ruff 关键运行错误规则;CI 当前仍允许后端 lint 失败。
|
||||
|
||||
## 历史快照(截至 2026-09-23 / v1.3.3)
|
||||
|
||||
以下发布版本、入口与待办仅记录当时状态;发生冲突时以上方当前状态为准。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, Q
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.core.database import get_db
|
||||
from backend.core.sentry_setup import capture_studio_exception
|
||||
from backend.models.project import Project
|
||||
from backend.models.clip import Clip
|
||||
from backend.schemas.project import ProjectCreate, ProjectType
|
||||
@@ -70,7 +71,8 @@ def save_vision_settings(body: vision_settings.VisionSettingsInput):
|
||||
def test_vision_settings(body: vision_settings.VisionSettingsInput):
|
||||
try:
|
||||
return vision_settings.test(body)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
capture_studio_exception(error, 'vision_test', analysis_mode='visual')
|
||||
raise HTTPException(502, '视觉连接测试失败,请检查接口地址、密钥和模型是否支持图片输入') from None
|
||||
|
||||
@router.post('/import')
|
||||
@@ -143,6 +145,24 @@ def workspace(project_id: str, db: Session = Depends(get_db)):
|
||||
data = call(store.read, project_id)
|
||||
return {**data, 'jobs': [{k: v for k, v in j.items() if k not in ('instance', 'snapshot')} for j in data['jobs']]}
|
||||
|
||||
@router.get('/{project_id}/source-preview')
|
||||
def source_preview_status(project_id: str, db: Session = Depends(get_db)):
|
||||
project_or_404(project_id, db)
|
||||
from backend.services.studio import preview
|
||||
return call(preview.status, project_id)
|
||||
|
||||
@router.post('/{project_id}/source-preview')
|
||||
def prepare_source_preview(project_id: str, db: Session = Depends(get_db)):
|
||||
project_or_404(project_id, db)
|
||||
from backend.services.studio import preview
|
||||
return call(preview.start, project_id)
|
||||
|
||||
@router.get('/{project_id}/source-preview/video')
|
||||
def source_preview_video(project_id: str, db: Session = Depends(get_db)):
|
||||
project_or_404(project_id, db)
|
||||
from backend.services.studio import preview
|
||||
return FileResponse(call(preview.ready_file, project_id), media_type='video/mp4')
|
||||
|
||||
@router.get('/{project_id}/source')
|
||||
def source_video(project_id: str, db: Session = Depends(get_db)):
|
||||
project_or_404(project_id, db)
|
||||
@@ -230,9 +250,17 @@ def correct_plan(project_id: str, body: ImportOptions, db: Session = Depends(get
|
||||
url = (project.project_metadata or {}).get('source_url')
|
||||
if not url:
|
||||
raise HTTPException(404, '原素材不存在,请重新导入')
|
||||
project.processing_config = {**(project.processing_config or {}), 'smart_import': body.model_dump()}
|
||||
previous_config = dict(project.processing_config or {})
|
||||
project.processing_config = {**previous_config, 'smart_import': body.model_dump()}
|
||||
db.commit()
|
||||
call(jobs.inspect_project, project_id, body, url, (project.processing_config or {}).get('creative_browser'))
|
||||
try:
|
||||
call(jobs.inspect_project, project_id, body, url, previous_config.get('creative_browser'))
|
||||
except Exception:
|
||||
# A rejected submission must not persist preferences for a plan
|
||||
# that was never produced. inspect_project restores the JSON state.
|
||||
project.processing_config = previous_config
|
||||
db.commit()
|
||||
raise
|
||||
return {'ok': True}
|
||||
|
||||
@router.post('/{project_id}/start')
|
||||
@@ -297,7 +325,8 @@ def rewrite(project_id: str, body: RewriteRequest, db: Session = Depends(get_db)
|
||||
try:
|
||||
result = intelligence.text_json('你是剪辑文案编辑。按用户要求优化 title 和 hook,保持事实;依据仅限原文与镜头证据。不能声称已修改镜头、声音或视频,也不能承诺投放效果。返回 {"title":"...","hook":"..."}。', {'instruction': body.instruction, 'language': body.draft.language, 'title': body.draft.title, 'hook': body.draft.hook, 'scenes': [s.model_dump() for s in body.draft.scenes]})
|
||||
candidate = Draft.model_validate({**body.draft.model_dump(), 'title': result['title'], 'hook': result['hook']})
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
capture_studio_exception(error, 'rewrite')
|
||||
raise HTTPException(502, '生成文案失败,请检查模型设置后重试;原稿未改动') from None
|
||||
return candidate
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ def create_app(mode: str = "web") -> FastAPI:
|
||||
# 设置模式环境变量
|
||||
os.environ["AUTOCLIP_MODE"] = mode
|
||||
|
||||
if mode == "desktop":
|
||||
from backend.core.windows_asyncio import install_windows_proactor_cleanup
|
||||
install_windows_proactor_cleanup()
|
||||
|
||||
try:
|
||||
from backend.core.sentry_setup import init_sentry
|
||||
init_sentry(mode)
|
||||
|
||||
@@ -14,6 +14,21 @@ from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STUDIO_ERROR_CODES = {"validation", "missing_resource", "unexpected", "timeout", "connection", "authentication", "rate_limited", "provider_error", "invalid_response", "output_truncated", "refused", "llm_not_configured", "whisper_not_installed", "whisper_install_failed", "transcription_empty", "subtitle_setup", "timeline_empty"}
|
||||
|
||||
|
||||
def studio_error_code(error: Exception) -> str:
|
||||
from backend.services.studio.intelligence import VisionRequestError
|
||||
from backend.pipeline.failures import PipelineFailure
|
||||
if isinstance(error, (VisionRequestError, PipelineFailure)) and error.code in STUDIO_ERROR_CODES:
|
||||
return error.code
|
||||
if isinstance(error, FileNotFoundError):
|
||||
return "missing_resource"
|
||||
if isinstance(error, ValueError):
|
||||
return "validation"
|
||||
return "unexpected"
|
||||
|
||||
|
||||
PRIVACY_FILE = "privacy.json"
|
||||
_initialized = False
|
||||
|
||||
@@ -112,8 +127,25 @@ def before_send(event: dict, hint: Optional[dict] = None) -> Optional[dict]:
|
||||
return None # Logging-only payloads can contain video text; do not send them.
|
||||
clean["exception"] = {"values": values}
|
||||
kind, fingerprint = _import_monitoring_fields(event, hint)
|
||||
tags = event.get("tags") or {}
|
||||
allowed = {
|
||||
"area": {"studio"}, "error_code": STUDIO_ERROR_CODES,
|
||||
"phase": {"screening", "production", "render", "analysis", "dispatch", "vision_test", "rewrite"},
|
||||
"analysis_mode": {"subtitle", "visual"},
|
||||
"goal": {"content", "highlight", "promo"},
|
||||
"runtime": {"python"}, "app_mode": {"web", "desktop"},
|
||||
"build_environment": {"production", "development", "validation", "unknown"},
|
||||
"telemetry_test": {"true"},
|
||||
}
|
||||
clean_tags = {key: value for key, value in tags.items()
|
||||
if key in allowed and isinstance(value, str) and value in allowed[key]}
|
||||
if kind:
|
||||
clean["tags"] = {"import_failure": kind}
|
||||
clean_tags["import_failure"] = kind
|
||||
if clean_tags:
|
||||
clean["tags"] = clean_tags
|
||||
if clean_tags.get("area") == "studio" and clean_tags.get("error_code") in {"validation", "missing_resource", "authentication", "rate_limited", "refused", "llm_not_configured", "whisper_not_installed", "whisper_install_failed", "transcription_empty", "subtitle_setup", "timeline_empty"}:
|
||||
clean["level"] = "warning"
|
||||
clean["fingerprint"] = ["studio", clean_tags.get("phase", "unknown"), clean_tags["error_code"]]
|
||||
if fingerprint:
|
||||
clean["fingerprint"] = fingerprint
|
||||
return clean
|
||||
@@ -159,6 +191,30 @@ def init_sentry(mode: str = "web") -> bool:
|
||||
)
|
||||
sentry_sdk.set_tag("runtime", "python")
|
||||
sentry_sdk.set_tag("app_mode", mode)
|
||||
sentry_sdk.set_tag("build_environment", os.getenv("AUTOCLIP_BUILD_ENVIRONMENT", "unknown"))
|
||||
_initialized = True
|
||||
logger.info("Sentry 已启用(backend)")
|
||||
return True
|
||||
|
||||
|
||||
def capture_studio_exception(error: Exception, phase: str, *, analysis_mode=None, goal=None):
|
||||
"""Report caught worker errors without allowing monitoring to fail the worker.
|
||||
|
||||
Consent is checked both here and in before_send. Scope is isolated so worker
|
||||
threads cannot leak their phase into unrelated requests. All tags are filtered.
|
||||
"""
|
||||
if not _initialized or not crash_reports_enabled():
|
||||
return None
|
||||
try:
|
||||
import sentry_sdk
|
||||
with sentry_sdk.new_scope() as scope:
|
||||
scope.set_tag("area", "studio")
|
||||
scope.set_tag("phase", phase)
|
||||
scope.set_tag("error_code", studio_error_code(error))
|
||||
if analysis_mode:
|
||||
scope.set_tag("analysis_mode", analysis_mode)
|
||||
if goal:
|
||||
scope.set_tag("goal", goal)
|
||||
return sentry_sdk.capture_exception(error)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""CPython 3.13 Proactor shutdown workaround for PYTHON-FASTAPI-3.
|
||||
|
||||
Keep the bundled Windows runtime's cleanup sequence, but tolerate a peer reset
|
||||
from socket.shutdown(). Do not filter ConnectionResetError in Sentry or change
|
||||
loop policy: Proactor is required for asyncio subprocess support on Windows.
|
||||
|
||||
Based on CPython's Lib/asyncio/proactor_events.py (PSF license).
|
||||
Revisit this private-API compatibility shim when upgrading bundled Python.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def _call_connection_lost(self, exc):
|
||||
if self._called_connection_lost:
|
||||
return
|
||||
try:
|
||||
self._protocol.connection_lost(exc)
|
||||
finally:
|
||||
if hasattr(self._sock, "shutdown") and self._sock.fileno() != -1:
|
||||
try:
|
||||
self._sock.shutdown(socket.SHUT_RDWR)
|
||||
except ConnectionResetError:
|
||||
# The peer is already gone. Still close the local socket and
|
||||
# detach from the server so wait_closed() can finish.
|
||||
pass
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
server = self._server
|
||||
if server is not None:
|
||||
server._detach(self)
|
||||
self._server = None
|
||||
self._called_connection_lost = True
|
||||
|
||||
|
||||
def install_windows_proactor_cleanup() -> bool:
|
||||
"""Install once, only on the desktop's supported CPython 3.13 runtime."""
|
||||
if sys.platform != "win32" or sys.version_info[:2] != (3, 13):
|
||||
return False
|
||||
from asyncio.proactor_events import _ProactorBasePipeTransport
|
||||
|
||||
if _ProactorBasePipeTransport._call_connection_lost is _call_connection_lost:
|
||||
return True
|
||||
_ProactorBasePipeTransport._call_connection_lost = _call_connection_lost
|
||||
return True
|
||||
@@ -348,7 +348,19 @@ def refine_timeline(items: Sequence[Dict[str, Any]], srt_entries: Sequence[Dict[
|
||||
|
||||
# 4) 仍然太短:与相邻段合并(间隔小)或丢弃
|
||||
result: List[Dict[str, Any]] = []
|
||||
for it in merged:
|
||||
pending = list(merged)
|
||||
for idx, it in enumerate(pending):
|
||||
# Keep a short prefix until its adjacent successors have had a chance
|
||||
# to form a usable segment. Previously every short topic was dropped
|
||||
# before there was a preceding result to merge it into.
|
||||
while it["_e"] - it["_s"] < profile.min_clip_sec and idx + 1 < len(pending):
|
||||
nxt = pending[idx + 1]
|
||||
if nxt["_s"] - it["_e"] > profile.merge_gap_sec or \
|
||||
nxt["_e"] - it["_s"] > profile.max_clip_sec:
|
||||
break
|
||||
_merge_into(it, nxt)
|
||||
report["merged"].append({"kept": _title(it), "absorbed": _title(nxt), "reason": "过短,与后一段合并"})
|
||||
pending.pop(idx + 1)
|
||||
if it["_e"] - it["_s"] >= profile.min_clip_sec:
|
||||
result.append(it)
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,7 @@ from collections import defaultdict
|
||||
# 导入依赖
|
||||
from ..utils.llm_client import LLMClient
|
||||
from ..utils.text_processor import TextProcessor
|
||||
from .quality import to_seconds, to_srt_time
|
||||
from ..core.shared_config import PROMPT_FILES, METADATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,11 +76,12 @@ class TimelineExtractor:
|
||||
logger.warning(f" > 话题 '{outline.get('title', '未知')}' 缺少 chunk_index,将被跳过。")
|
||||
|
||||
all_timeline_data = []
|
||||
current_srt_entries = []
|
||||
# 3. 遍历每个块,批量处理,并将结果存为独立的JSON文件
|
||||
for chunk_index, chunk_outlines in outlines_by_chunk.items():
|
||||
logger.info(f"处理块 {chunk_index},其中包含 {len(chunk_outlines)} 个话题...")
|
||||
|
||||
# 每次都重新处理,不使用缓存
|
||||
# 结果文件只用于诊断;本次返回值仅由本次验证成功的块组成
|
||||
chunk_output_path = self.timeline_chunks_dir / f"chunk_{chunk_index}.json"
|
||||
|
||||
try:
|
||||
@@ -100,100 +102,56 @@ class TimelineExtractor:
|
||||
chunk_start_time = srt_chunk_data[0]['start_time']
|
||||
chunk_end_time = srt_chunk_data[-1]['end_time']
|
||||
|
||||
raw_response = ""
|
||||
current_srt_entries.extend(srt_chunk_data)
|
||||
llm_cache_path = self.llm_raw_output_dir / f"chunk_{chunk_index}.txt"
|
||||
|
||||
if llm_cache_path.exists():
|
||||
logger.info(f" > 找到块 {chunk_index} 的LLM原始响应缓存,直接读取。")
|
||||
with open(llm_cache_path, 'r', encoding='utf-8') as f:
|
||||
raw_response = f.read()
|
||||
else:
|
||||
logger.info(f" > 未找到LLM缓存,开始调用API...")
|
||||
|
||||
# 构建用于LLM的SRT文本
|
||||
srt_text_for_prompt = ""
|
||||
for sub in srt_chunk_data:
|
||||
srt_text_for_prompt += f"{sub['index']}\\n{sub['start_time']} --> {sub['end_time']}\\n{sub['text']}\\n\\n"
|
||||
|
||||
# 为LLM准备一个"干净"的输入,只包含它需要的信息
|
||||
llm_input_outlines = [
|
||||
{"title": o.get("title"), "subtopics": o.get("subtopics")}
|
||||
for o in chunk_outlines
|
||||
]
|
||||
|
||||
input_data = {
|
||||
"outline": llm_input_outlines, # 使用干净的数据
|
||||
"srt_text": srt_text_for_prompt
|
||||
}
|
||||
|
||||
# 调用LLM获取原始响应,带重试机制
|
||||
parsed_items = None
|
||||
max_parse_retries = 2
|
||||
|
||||
for retry_count in range(max_parse_retries + 1):
|
||||
try:
|
||||
cached = llm_cache_path.exists()
|
||||
input_data = {
|
||||
"outline": [{"title": o.get("title"), "subtopics": o.get("subtopics")} for o in chunk_outlines],
|
||||
"srt_text": "\n\n".join(
|
||||
f"{sub['index']}\n{sub['start_time']} --> {sub['end_time']}\n{sub['text']}"
|
||||
for sub in srt_chunk_data
|
||||
),
|
||||
}
|
||||
# Cache replay follows the same parser. An invalid cache is a
|
||||
# failed chunk, never permission to silently make a paid call.
|
||||
attempts = 1 if cached else 3
|
||||
for retry_count in range(attempts):
|
||||
raw_response = ""
|
||||
try:
|
||||
if cached:
|
||||
raw_response = llm_cache_path.read_text(encoding='utf-8')
|
||||
else:
|
||||
raw_response = self.llm_client.call_with_retry(timeline_prompt, input_data)
|
||||
|
||||
if not raw_response:
|
||||
logger.warning(f" > 块 {chunk_index} LLM响应为空,跳过")
|
||||
break
|
||||
|
||||
# 保存原始响应到缓存
|
||||
cache_file = self.llm_raw_output_dir / f"chunk_{chunk_index}_attempt_{retry_count}.txt"
|
||||
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||
f.write(raw_response)
|
||||
|
||||
# 解析LLM的原始响应
|
||||
parsed_items = self._parse_and_validate_response(
|
||||
raw_response,
|
||||
chunk_start_time,
|
||||
chunk_end_time,
|
||||
chunk_index
|
||||
if raw_response:
|
||||
cache_file = self.llm_raw_output_dir / f"chunk_{chunk_index}_attempt_{retry_count}.txt"
|
||||
cache_file.write_text(raw_response, encoding='utf-8')
|
||||
if not raw_response:
|
||||
logger.warning("块 %s 响应为空,跳过", chunk_index)
|
||||
break
|
||||
parsed_items = self._parse_and_validate_response(
|
||||
raw_response, chunk_start_time, chunk_end_time, chunk_index
|
||||
)
|
||||
if parsed_items:
|
||||
chunk_output_path.write_text(
|
||||
json.dumps(parsed_items, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
if parsed_items:
|
||||
# 保存解析后的结果
|
||||
with open(chunk_output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(parsed_items, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f" > 块 {chunk_index} 成功解析 {len(parsed_items)} 个时间段")
|
||||
break # 成功解析,跳出重试循环
|
||||
else:
|
||||
if retry_count < max_parse_retries:
|
||||
logger.warning(f" > 块 {chunk_index} 解析失败,尝试重试 ({retry_count + 1}/{max_parse_retries + 1})")
|
||||
# 在重试时强化提示词,强调JSON格式
|
||||
input_data['additional_instruction'] = "\n\n【重要】输出要求:\n1. 必须以[开始,以]结束\n2. 使用英文双引号,不要使用中文引号\n3. 字符串中的引号必须转义为\\\"\n4. 不要添加任何解释文字或代码块标记\n5. 确保JSON格式完全正确"
|
||||
else:
|
||||
logger.error(f" > 块 {chunk_index} 经过 {max_parse_retries + 1} 次尝试仍然解析失败")
|
||||
# 保存最后一次的原始响应以便调试
|
||||
self._save_debug_response(raw_response, chunk_index, "final_parse_failure")
|
||||
|
||||
except Exception as parse_error:
|
||||
logger.error(f" > 块 {chunk_index} 第 {retry_count + 1} 次尝试解析过程中发生异常: {parse_error}")
|
||||
if retry_count == max_parse_retries:
|
||||
# 保存原始响应以便调试
|
||||
self._save_debug_response(raw_response if 'raw_response' in locals() else "No response", chunk_index, "parse_exception")
|
||||
continue
|
||||
|
||||
if not parsed_items:
|
||||
logger.warning(f" > 块 {chunk_index} 最终解析失败,跳过")
|
||||
continue
|
||||
all_timeline_data.extend(parsed_items)
|
||||
break
|
||||
input_data['additional_instruction'] = (
|
||||
"仅返回有效 JSON 数组,使用英文双引号;起止时间必须引用当前字幕块,结束晚于开始。"
|
||||
)
|
||||
except Exception as parse_error:
|
||||
logger.error("块 %s 第 %s 次解析失败: %s", chunk_index, retry_count + 1, parse_error)
|
||||
if retry_count == attempts - 1:
|
||||
self._save_debug_response(raw_response, chunk_index, "final_parse_failure")
|
||||
logger.warning("块 %s 无有效时间线%s", chunk_index, "(缓存无效)" if cached else "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" > 处理块 {chunk_index} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
# 4. 从所有中间文件中拼接最终结果
|
||||
logger.info("所有块处理完毕,开始从中间文件拼接最终结果...")
|
||||
all_timeline_data = []
|
||||
chunk_files = sorted(self.timeline_chunks_dir.glob("*.json"))
|
||||
for chunk_file in chunk_files:
|
||||
with open(chunk_file, 'r', encoding='utf-8') as f:
|
||||
chunk_data = json.load(f)
|
||||
all_timeline_data.extend(chunk_data)
|
||||
logger.info("本次成功提取 %s 个话题。", len(all_timeline_data))
|
||||
|
||||
logger.info(f"成功从 {len(chunk_files)} 个块文件中加载了 {len(all_timeline_data)} 个话题。")
|
||||
|
||||
# 最终排序:在返回所有结果前,按开始时间进行全局排序
|
||||
if all_timeline_data:
|
||||
logger.info("按开始时间对所有话题进行最终排序...")
|
||||
@@ -214,8 +172,8 @@ class TimelineExtractor:
|
||||
# 5. 程序化校正:对齐字幕边界 / 时长上下限 / 去重合并(docs/QUALITY_AND_PUBLISH_PLAN.md 线 1-B)
|
||||
if all_timeline_data:
|
||||
try:
|
||||
from .quality import load_srt_chunks, refine_timeline, save_report
|
||||
srt_entries = load_srt_chunks(self.metadata_dir)
|
||||
from .quality import refine_timeline, save_report
|
||||
srt_entries = sorted(current_srt_entries, key=lambda cue: to_seconds(cue["start_time"]))
|
||||
refined, report = refine_timeline(all_timeline_data, srt_entries, profile)
|
||||
save_report({"step2": report}, self.metadata_dir)
|
||||
logger.info(
|
||||
@@ -271,22 +229,20 @@ class TimelineExtractor:
|
||||
logger.warning(f" > 话题 '{timeline_item['outline']}' 结束时间格式不正确: {timeline_item['end_time']}")
|
||||
continue
|
||||
|
||||
start_time = self._convert_time_format(timeline_item['start_time'])
|
||||
end_time = self._convert_time_format(timeline_item['end_time'])
|
||||
|
||||
start_sec = self.text_processor.time_to_seconds(start_time)
|
||||
end_sec = self.text_processor.time_to_seconds(end_time)
|
||||
chunk_start_sec = self.text_processor.time_to_seconds(chunk_start)
|
||||
chunk_end_sec = self.text_processor.time_to_seconds(chunk_end)
|
||||
|
||||
if start_sec < chunk_start_sec:
|
||||
logger.warning(f" > 调整话题 '{timeline_item['outline']}' 的开始时间从 {start_time} 到 {chunk_start}")
|
||||
timeline_item['start_time'] = chunk_start
|
||||
|
||||
if end_sec > chunk_end_sec:
|
||||
logger.warning(f" > 调整话题 '{timeline_item['outline']}' 的结束时间从 {end_time} 到 {chunk_end}")
|
||||
timeline_item['end_time'] = chunk_end
|
||||
|
||||
start_sec = to_seconds(timeline_item['start_time'])
|
||||
end_sec = to_seconds(timeline_item['end_time'])
|
||||
chunk_start_sec = to_seconds(chunk_start)
|
||||
chunk_end_sec = to_seconds(chunk_end)
|
||||
start_sec = max(start_sec, chunk_start_sec)
|
||||
end_sec = min(end_sec, chunk_end_sec)
|
||||
if end_sec <= start_sec:
|
||||
logger.warning(" > 时间区间倒序或不在当前字幕块内,跳过: %s", timeline_item)
|
||||
continue
|
||||
# Normalize before downstream sorting: .5 means half a second,
|
||||
# not five milliseconds, and MM:SS must gain the hours field.
|
||||
timeline_item['start_time'] = to_srt_time(start_sec)
|
||||
timeline_item['end_time'] = to_srt_time(end_sec)
|
||||
|
||||
logger.info(f" > 定位成功: {timeline_item['outline']} ({timeline_item['start_time']} -> {timeline_item['end_time']})")
|
||||
validated_items.append(timeline_item)
|
||||
except Exception as e:
|
||||
@@ -315,9 +271,12 @@ class TimelineExtractor:
|
||||
"""
|
||||
验证时间格式是否正确 (HH:MM:SS,mmm)
|
||||
"""
|
||||
pattern = r'^\d{2}:\d{2}:\d{2},\d{3}$'
|
||||
return bool(re.match(pattern, time_str))
|
||||
|
||||
if not isinstance(time_str, str):
|
||||
return False
|
||||
# HH:MM:SS or MM:SS, optional comma/dot fraction; reject overflow.
|
||||
pattern = r'^(?:\d{2,}:)?[0-5]\d:[0-5]\d(?:[,.]\d{1,3})?$'
|
||||
return bool(re.fullmatch(pattern, time_str.strip()))
|
||||
|
||||
def _convert_time_format(self, time_str: str) -> str:
|
||||
"""
|
||||
转换时间格式:SRT格式 -> FFmpeg格式
|
||||
|
||||
@@ -285,7 +285,7 @@ def _probe(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
cmd = [get_ffprobe_path(), "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height:format=duration", "-of", "json", str(path)]
|
||||
raw = subprocess.check_output(cmd, text=True, encoding="utf-8", errors="ignore")
|
||||
raw = subprocess.check_output(cmd, text=True, encoding="utf-8", errors="ignore", timeout=20)
|
||||
data = json.loads(raw)
|
||||
stream = (data.get("streams") or [{}])[0]
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
from time import monotonic
|
||||
from backend.core.sentry_setup import capture_studio_exception, studio_error_code
|
||||
from copy import deepcopy
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -35,6 +37,7 @@ def export(project_id, draft):
|
||||
executor.submit(_render, project_id, draft, job['job_id'])
|
||||
except Exception as error:
|
||||
logger.warning('Studio export dispatch failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'dispatch')
|
||||
message = '导出任务未能启动,请重试;已有成片已保留'
|
||||
def failed(data):
|
||||
next(j for j in data['jobs'] if j['job_id'] == job['job_id']).update(status='failed', error=message)
|
||||
@@ -43,6 +46,7 @@ def export(project_id, draft):
|
||||
return {k: v for k, v in added.items() if k not in ('instance', 'snapshot')}
|
||||
|
||||
def _render(project_id, draft, job_id):
|
||||
started = monotonic()
|
||||
def update(**values):
|
||||
def mutate(data):
|
||||
next(j for j in data['jobs'] if j['job_id'] == job_id).update(values)
|
||||
@@ -50,11 +54,12 @@ def _render(project_id, draft, job_id):
|
||||
try:
|
||||
update(status='running', percent=5)
|
||||
result = render_draft(project_id, source(project_id), draft, job_id, lambda p: update(percent=p))
|
||||
update(status='completed', percent=100, result=result)
|
||||
update(status='completed', percent=100, result=result, duration_ms=round((monotonic() - started) * 1000))
|
||||
except Exception as error:
|
||||
logger.warning('Studio render failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'render')
|
||||
try:
|
||||
update(status='failed', error=str(error)[:700])
|
||||
update(status='failed', error=str(error)[:700], error_code=studio_error_code(error), duration_ms=round((monotonic() - started) * 1000))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -105,6 +110,7 @@ def _analyze(project_id, prefs, url, browser):
|
||||
mark_project(project_id, 'completed', studio_draft_count=len(store.read(project_id)['drafts']))
|
||||
except Exception as error:
|
||||
logger.warning('Studio analysis failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'analysis')
|
||||
def failed(data):
|
||||
data['analysis'] = {'status': 'failed', 'error': str(error)[:700]}
|
||||
if isinstance(error, VisionRequestError):
|
||||
@@ -138,6 +144,27 @@ def download(project_id, url, browser):
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_project_thumbnail(project_id):
|
||||
"""Local uploads need the same thumbnail initialization as URL imports."""
|
||||
from backend.core.database import SessionLocal
|
||||
from backend.models.project import Project
|
||||
from backend.utils.thumbnail_generator import generate_project_thumbnail
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
project = db.get(Project, project_id)
|
||||
if project is None or project.thumbnail:
|
||||
return
|
||||
thumbnail = generate_project_thumbnail(project_id, source(project_id))
|
||||
if thumbnail:
|
||||
with SessionLocal() as db:
|
||||
project = db.get(Project, project_id)
|
||||
if project is not None and not project.thumbnail:
|
||||
project.thumbnail = thumbnail
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning('Project thumbnail generation failed', exc_info=True)
|
||||
|
||||
|
||||
def run_content(project_id, video):
|
||||
"""Run the existing content pipeline in this worker, without a second broker queue."""
|
||||
from backend.tasks.processing import process_video_pipeline
|
||||
@@ -147,7 +174,12 @@ def run_content(project_id, video):
|
||||
'input_srt_path': str(srt) if srt.exists() else None,
|
||||
}, throw=True).get()
|
||||
if not result or not result.get('success'):
|
||||
raise RuntimeError((result or {}).get('error') or '内容切片未完成,请检查语音与文字模型设置后重试')
|
||||
message = (result or {}).get('error') or '内容切片未完成,请检查语音与文字模型设置后重试'
|
||||
failure = (result or {}).get('result') or {}
|
||||
if failure.get('error_code'):
|
||||
from backend.pipeline.failures import PipelineFailure
|
||||
raise PipelineFailure(failure.get('stage', ''), message, code=failure['error_code'])
|
||||
raise RuntimeError(message)
|
||||
|
||||
clips = result.get('result', {}).get('result', {}).get('titled_clips')
|
||||
if not clips:
|
||||
@@ -157,15 +189,29 @@ def run_content(project_id, video):
|
||||
|
||||
def inspect_project(project_id, options, url=None, browser=None):
|
||||
"""Only ingest and screen. Expensive production requires an explicit confirmation."""
|
||||
def begin(data):
|
||||
if (data.get('analysis') or {}).get('status') == 'running':
|
||||
# Reserve and dispatch together so another request cannot observe an
|
||||
# accepted task before submission succeeds. Preserve existing exports/plan.
|
||||
with store.lock:
|
||||
previous = store.read(project_id)
|
||||
if (previous.get('analysis') or {}).get('status') == 'running':
|
||||
raise ValueError('当前任务正在运行,请稍后再试')
|
||||
data['analysis'] = {'status':'running', 'phase':'screening', 'message':'准备素材' if url else '快速判断适合的制作类型', 'instance':store.INSTANCE, 'created_at':store.now()}
|
||||
store.change(project_id, begin)
|
||||
executor.submit(_inspect, project_id, options, url, browser)
|
||||
state = deepcopy(previous)
|
||||
state['analysis'] = {'status':'running', 'phase':'screening', 'message':'准备素材' if url else '快速判断适合的制作类型', 'instance':store.INSTANCE, 'created_at':store.now()}
|
||||
store.write(project_id, state)
|
||||
try:
|
||||
executor.submit(_inspect, project_id, options, url, browser)
|
||||
except Exception as error:
|
||||
logger.warning('Studio screening dispatch failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'dispatch')
|
||||
message = '导入任务未能启动,请重试;原素材与已有成片已保留'
|
||||
if not previous.get('analysis'):
|
||||
previous['analysis'] = {'status':'failed', 'phase':'screening', 'error':message}
|
||||
store.write(project_id, previous)
|
||||
raise ValueError(message) from None
|
||||
|
||||
|
||||
def _inspect(project_id, options, url, browser):
|
||||
started = monotonic()
|
||||
try:
|
||||
from backend.services.studio.planning import recommend
|
||||
mark_project(project_id, 'processing', awaiting_confirmation=False)
|
||||
@@ -173,13 +219,15 @@ def _inspect(project_id, options, url, browser):
|
||||
download(project_id, url, browser)
|
||||
store.change(project_id, lambda data:data['analysis'].update(message='快速判断适合的制作类型'))
|
||||
plan = recommend(source(project_id), options)
|
||||
ensure_project_thumbnail(project_id)
|
||||
plan['id'] = uuid.uuid4().hex
|
||||
store.change(project_id, lambda data:data.update(plan=plan, analysis={'status':'awaiting_confirmation', 'created_at':store.now()}))
|
||||
store.change(project_id, lambda data:data.update(plan=plan, analysis={'status':'awaiting_confirmation', 'created_at':store.now(), 'duration_ms':round((monotonic() - started) * 1000)}))
|
||||
mark_project(project_id, 'pending', creative=plan['preferences'], awaiting_confirmation=True)
|
||||
except Exception as error:
|
||||
logger.warning('Studio screening failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'screening')
|
||||
try:
|
||||
store.change(project_id, lambda data:data.update(analysis={'status':'failed','phase':'screening','error':str(error)[:700]}))
|
||||
store.change(project_id, lambda data:data.update(analysis={'status':'failed','phase':'screening','error':str(error)[:700], 'error_code':studio_error_code(error), 'duration_ms':round((monotonic() - started) * 1000)}))
|
||||
mark_project(project_id, 'failed')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -219,6 +267,7 @@ def confirm_project(project_id, body):
|
||||
executor.submit(_produce_selected, project_id, plan)
|
||||
except Exception as error:
|
||||
logger.warning('Studio production dispatch failed: %s', type(error).__name__)
|
||||
capture_studio_exception(error, 'dispatch')
|
||||
# No worker accepted this confirmation. Preserve the exact plan and
|
||||
# staging state so an explicit retry can use the same plan ID.
|
||||
store.write(project_id, previous)
|
||||
@@ -227,8 +276,12 @@ def confirm_project(project_id, body):
|
||||
|
||||
def _produce_selected(project_id, plan):
|
||||
from backend.services.studio import intelligence
|
||||
started = monotonic()
|
||||
succeeded_goals, failed_goals, reported = [], [], []
|
||||
result_count = 0
|
||||
labels = {'content':'内容切片','highlight':'精彩高光','promo':'推广成片'}
|
||||
errors = []
|
||||
error_codes = []
|
||||
diagnostics = []
|
||||
visual_error = None
|
||||
events = coverage = None
|
||||
@@ -262,6 +315,8 @@ def _produce_selected(project_id, plan):
|
||||
drafts = make_promos(project_id, subtitle_clips, prefs, intelligence._probe(video).get('duration'), instruction)
|
||||
store.change(project_id, lambda data:data['drafts'].extend({**d,'updated_at':store.now()} for d in drafts))
|
||||
mark_project(project_id, 'processing')
|
||||
result_count += len(subtitle_clips) if goal == 'content' else len(drafts)
|
||||
succeeded_goals.append(goal)
|
||||
continue
|
||||
if plan.get('confirmed_analysis', 'subtitle') != 'visual':
|
||||
raise ValueError('此确认未授权视觉分析;请重新选择处理方式')
|
||||
@@ -283,22 +338,38 @@ def _produce_selected(project_id, plan):
|
||||
for draft in drafts:
|
||||
draft['subtitles'] = True
|
||||
store.change(project_id, lambda data:data['drafts'].extend({**d,'updated_at':store.now()} for d in drafts))
|
||||
result_count += len(drafts)
|
||||
succeeded_goals.append(goal)
|
||||
except Exception as error:
|
||||
failed_goals.append(goal)
|
||||
error_codes.append(studio_error_code(error))
|
||||
if all(error is not previous_error for previous_error in reported):
|
||||
capture_studio_exception(error, 'production', analysis_mode=plan.get('confirmed_analysis', 'subtitle'), goal=goal)
|
||||
reported.append(error)
|
||||
errors.append(labels[goal] + ':' + str(error)[:500])
|
||||
if isinstance(error, VisionRequestError):
|
||||
diagnostics.append({'goal':goal, **error.diagnostics()})
|
||||
result = {'status':'failed' if errors else 'completed', 'created_at':store.now()}
|
||||
result = {'status':'failed' if errors else 'completed', 'created_at':store.now(),
|
||||
'outcome':'partial' if errors and succeeded_goals else 'failed' if errors else 'completed',
|
||||
'requested_goals':plan['selected_goals'], 'succeeded_goals':succeeded_goals,
|
||||
'failed_goals':failed_goals, 'result_count':result_count,
|
||||
'duration_ms':round((monotonic() - started) * 1000)}
|
||||
if coverage:
|
||||
result['coverage'] = coverage
|
||||
if diagnostics:
|
||||
result['diagnostics'] = diagnostics
|
||||
if errors:
|
||||
result['error'] = ';'.join(errors)
|
||||
result['error_code'] = error_codes[0] if len(set(error_codes)) == 1 else 'multiple'
|
||||
store.change(project_id, lambda data:data.update(analysis=result))
|
||||
mark_project(project_id, 'failed' if errors else 'completed', studio_draft_count=len(store.read(project_id)['drafts']))
|
||||
except Exception as error:
|
||||
capture_studio_exception(error, 'production', analysis_mode=plan.get('confirmed_analysis', 'subtitle'))
|
||||
try:
|
||||
store.change(project_id, lambda data:data.update(analysis={'status':'failed','error':str(error)[:700]}))
|
||||
store.change(project_id, lambda data:data.update(analysis={'status':'failed','error':str(error)[:700],
|
||||
'error_code':studio_error_code(error), 'outcome':'partial' if succeeded_goals else 'failed', 'requested_goals':plan['selected_goals'],
|
||||
'succeeded_goals':succeeded_goals, 'failed_goals':[g for g in plan['selected_goals'] if g not in succeeded_goals],
|
||||
'result_count':result_count, 'duration_ms':round((monotonic() - started) * 1000)}))
|
||||
mark_project(project_id,'failed')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -75,6 +75,8 @@ def recommend(video: Path, options: ImportOptions):
|
||||
response = intelligence.vision_call([{'type':'text', 'text':prompt}] + intelligence.sample(video, times, Path(tmp), width=384), config=config)
|
||||
result = Recommendation.model_validate(response)
|
||||
except (RuntimeError, ValueError, KeyError, TypeError) as error:
|
||||
from backend.core.sentry_setup import capture_studio_exception
|
||||
capture_studio_exception(error, 'screening', analysis_mode='visual')
|
||||
if isinstance(error, intelligence.VisionRequestError):
|
||||
diagnostics = {**error.diagnostics(), 'phase':'screening'}
|
||||
mode = 'fallback'
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Explicit, bounded local H.264 preview conversion; source media is immutable."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from backend.core.sentry_setup import capture_studio_exception
|
||||
from backend.services.studio import jobs, store
|
||||
from backend.utils.ffmpeg_utils import get_ffmpeg_path
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='source-preview')
|
||||
_lock = threading.RLock()
|
||||
_states = {}
|
||||
|
||||
|
||||
def _paths(project_id):
|
||||
source = jobs.source(project_id)
|
||||
stat = source.stat()
|
||||
identity = f'{source.resolve()}:{stat.st_size}:{stat.st_mtime_ns}'
|
||||
token = hashlib.sha256(identity.encode()).hexdigest()[:24]
|
||||
output = store.directory(project_id) / 'output' / 'preview' / f'{token}.mp4'
|
||||
return source, output, token
|
||||
|
||||
|
||||
def status(project_id):
|
||||
_, output, token = _paths(project_id)
|
||||
with _lock:
|
||||
if output.is_file() and output.stat().st_size:
|
||||
return {'status': 'completed', 'version': token}
|
||||
current = _states.get(token, {'status': 'idle'})
|
||||
# A deleted cache must be regeneratable in the same backend process.
|
||||
return {'status': 'idle'} if current['status'] == 'completed' else dict(current)
|
||||
|
||||
|
||||
def start(project_id):
|
||||
with _lock:
|
||||
source, output, token = _paths(project_id)
|
||||
current = status(project_id)
|
||||
if current['status'] in ('queued', 'running', 'completed'):
|
||||
return current
|
||||
if any(s['status'] in ('queued', 'running') for s in _states.values()):
|
||||
raise ValueError('已有兼容预览正在生成,请稍后重试')
|
||||
while len(_states) >= 64:
|
||||
del _states[next(iter(_states))]
|
||||
_states[token] = {'status': 'queued'}
|
||||
try:
|
||||
_executor.submit(_convert, source, output, token)
|
||||
except Exception:
|
||||
_states.pop(token, None)
|
||||
raise ValueError('兼容预览未能启动,请重试') from None
|
||||
return dict(_states[token])
|
||||
|
||||
|
||||
def ready_file(project_id):
|
||||
_, output, _ = _paths(project_id)
|
||||
if not output.is_file() or not output.stat().st_size:
|
||||
raise FileNotFoundError('兼容预览尚未生成')
|
||||
return output
|
||||
|
||||
|
||||
def _convert(source: Path, output: Path, token: str):
|
||||
temporary = output.with_name(f'{output.stem}.{uuid.uuid4().hex}.tmp.mp4')
|
||||
with _lock:
|
||||
_states[token] = {'status': 'running'}
|
||||
try:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run([
|
||||
get_ffmpeg_path(), '-nostdin', '-v', 'error', '-i', str(source),
|
||||
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn',
|
||||
'-vf', "scale=w='min(1280,iw)':h='min(720,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1",
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25', '-pix_fmt', 'yuv420p',
|
||||
'-threads', '2', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart',
|
||||
'-y', str(temporary),
|
||||
], capture_output=True, check=True, timeout=900)
|
||||
if not temporary.is_file() or not temporary.stat().st_size:
|
||||
raise ValueError('empty preview')
|
||||
if not source.exists() or not output.parent.exists():
|
||||
raise FileNotFoundError('source removed')
|
||||
temporary.replace(output)
|
||||
with _lock:
|
||||
_states[token] = {'status': 'completed', 'version': token}
|
||||
except Exception as error:
|
||||
capture_studio_exception(error, 'render')
|
||||
with _lock:
|
||||
_states[token] = {'status': 'failed', 'error': '兼容预览生成失败,请检查视频文件后重试'}
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Regression cases for #197 / #198 / #195 / #217; no network or model calls."""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from backend.pipeline.quality import profile_for, refine_timeline, to_srt_time
|
||||
from backend.pipeline.step2_timeline import TimelineExtractor
|
||||
from backend.utils.text_processor import TextProcessor
|
||||
|
||||
|
||||
def cue(s, e):
|
||||
return {"start_time": to_srt_time(s), "end_time": to_srt_time(e), "text": "speech"}
|
||||
|
||||
|
||||
def test_adjacent_short_topics_can_merge_before_being_dropped():
|
||||
cues = [cue(i, i + 5) for i in range(0, 30, 5)]
|
||||
items = [dict(outline=str(i), start_time=to_srt_time(i), end_time=to_srt_time(i + 10)) for i in (0, 10, 20)]
|
||||
out, report = refine_timeline(items, cues, profile_for(30))
|
||||
assert len(out) == 1
|
||||
assert out[0]["duration_sec"] == 30
|
||||
assert not report["dropped"]
|
||||
|
||||
|
||||
def test_isolated_short_topics_are_not_merged_across_long_silence():
|
||||
cues = [cue(0, 5), cue(30, 35)]
|
||||
items = [dict(outline=str(i), start_time=to_srt_time(i), end_time=to_srt_time(i + 5)) for i in (0, 30)]
|
||||
out, _ = refine_timeline(items, cues, profile_for(300))
|
||||
assert out == []
|
||||
|
||||
|
||||
def extractor(tmp_path):
|
||||
obj = TimelineExtractor.__new__(TimelineExtractor)
|
||||
obj.metadata_dir = tmp_path
|
||||
obj.text_processor = TextProcessor()
|
||||
obj.llm_client = SimpleNamespace(parse_json_response=json.loads, _validate_json_structure=lambda x: True)
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,end", [("00:00:01.500", "00:00:25.5"), ("00:01,5", "00:25,500"), ("00:00:01", "00:00:25")])
|
||||
def test_standard_timestamp_variants_are_normalized(tmp_path, start, end):
|
||||
out = extractor(tmp_path)._parse_and_validate_response(json.dumps([dict(outline="topic", start_time=start, end_time=end)]), "00:00:00,000", "00:00:30,000", 0)
|
||||
assert len(out) == 1
|
||||
assert out[0]["start_time"] == ("00:00:01,000" if start == "00:00:01" else "00:00:01,500")
|
||||
assert out[0]["end_time"] == ("00:00:25,000" if end == "00:00:25" else "00:00:25,500")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,end", [("00:00:40,000", "00:00:50,000"), ("00:00:20,000", "00:00:10,000"), ("00:60:01,000", "00:61:01,000"), ("NaN", "00:00:25,000")])
|
||||
def test_invalid_or_outside_chunk_ranges_are_rejected(tmp_path, start, end):
|
||||
assert extractor(tmp_path)._parse_and_validate_response(json.dumps([dict(outline="topic", start_time=start, end_time=end)]), "00:00:00,000", "00:00:30,000", 0) == []
|
||||
|
||||
|
||||
def test_full_extraction_normalizes_and_merges_model_topics(tmp_path):
|
||||
obj = extractor(tmp_path)
|
||||
obj.timeline_prompt = "Extract topics"
|
||||
obj.srt_chunks_dir = tmp_path / "step1_srt_chunks"
|
||||
obj.timeline_chunks_dir = tmp_path / "step2_timeline_chunks"
|
||||
obj.llm_raw_output_dir = tmp_path / "step2_llm_raw_output"
|
||||
obj.srt_chunks_dir.mkdir()
|
||||
cues = [dict(cue(i, i + 5), index=i // 5 + 1) for i in range(0, 30, 5)]
|
||||
(obj.srt_chunks_dir / "chunk_0.json").write_text(json.dumps(cues))
|
||||
response = json.dumps([dict(outline=str(i), start_time=to_srt_time(i).replace(",", "."), end_time=to_srt_time(i + 10).replace(",", ".")) for i in (0, 10, 20)])
|
||||
obj.llm_client.call_with_retry = lambda *a, **k: response
|
||||
out = obj.extract_timeline([dict(title="topic", subtopics=[], chunk_index=0)])
|
||||
assert len(out) == 1
|
||||
assert out[0]["start_time"] == "00:00:00,000"
|
||||
assert out[0]["end_time"] == "00:00:30,000"
|
||||
assert out[0]["duration_sec"] == 30
|
||||
assert json.loads((tmp_path / "quality_report.json").read_text())["step2"]["output"] == 1
|
||||
|
||||
|
||||
def test_short_source_remains_below_existing_minimum():
|
||||
# This fix does not silently lower the product's 20-second minimum.
|
||||
out, report = refine_timeline([dict(outline="short", **{k: v for k, v in cue(0, 10).items() if k != "text"})], [cue(0, 10)], profile_for(10))
|
||||
assert out == []
|
||||
assert len(report["dropped"]) == 1
|
||||
|
||||
|
||||
def test_merge_does_not_exceed_maximum():
|
||||
from dataclasses import replace
|
||||
profile = replace(profile_for(300), min_clip_sec=20, max_clip_sec=25)
|
||||
out, _ = refine_timeline([dict(outline=str(i), start_time=to_srt_time(i), end_time=to_srt_time(i + 15)) for i in (0, 15)], [cue(0, 15), cue(15, 30)], profile)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_partial_overlap_is_clamped_to_chunk(tmp_path):
|
||||
out = extractor(tmp_path)._parse_and_validate_response(json.dumps([dict(outline="topic", start_time="00:00:05.5", end_time="00:00:40")]), "00:00:10,000", "00:00:30,000", 1)
|
||||
assert out[0]["start_time"] == "00:00:10,000"
|
||||
assert out[0]["end_time"] == "00:00:30,000"
|
||||
|
||||
|
||||
def prepared_extractor(tmp_path):
|
||||
obj = extractor(tmp_path)
|
||||
obj.timeline_prompt = 'extract'
|
||||
obj.srt_chunks_dir = tmp_path / 'step1_srt_chunks'
|
||||
obj.timeline_chunks_dir = tmp_path / 'step2_timeline_chunks'
|
||||
obj.llm_raw_output_dir = tmp_path / 'step2_llm_raw_output'
|
||||
for directory in (obj.srt_chunks_dir, obj.timeline_chunks_dir, obj.llm_raw_output_dir):
|
||||
directory.mkdir()
|
||||
(obj.srt_chunks_dir / 'chunk_0.json').write_text(json.dumps([dict(cue(0, 30), index=1)]))
|
||||
obj.llm_client.call_with_retry = lambda *a, **k: ''
|
||||
return obj
|
||||
|
||||
|
||||
def topic(title='current', start=0, end=30):
|
||||
return dict(outline=title, start_time=to_srt_time(start), end_time=to_srt_time(end))
|
||||
|
||||
|
||||
def test_failed_run_never_returns_previous_timeline(tmp_path):
|
||||
obj = prepared_extractor(tmp_path)
|
||||
old = json.dumps([topic('stale')])
|
||||
(obj.timeline_chunks_dir / 'chunk_0.json').write_text(old)
|
||||
assert obj.extract_timeline([dict(title='new', chunk_index=0)]) == []
|
||||
assert (obj.timeline_chunks_dir / 'chunk_0.json').read_text() == old
|
||||
|
||||
|
||||
def test_current_run_ignores_obsolete_result_and_subtitle_chunks(tmp_path):
|
||||
obj = prepared_extractor(tmp_path)
|
||||
(obj.timeline_chunks_dir / 'chunk_9.json').write_text('malformed obsolete result')
|
||||
(obj.srt_chunks_dir / 'chunk_9.json').write_text(json.dumps([dict(cue(100, 5000), index=2)]))
|
||||
obj.llm_client.call_with_retry = lambda *a, **k: json.dumps([topic()])
|
||||
out = obj.extract_timeline([dict(title='current', chunk_index=0)])
|
||||
assert [item['outline'] for item in out] == ['current']
|
||||
report = json.loads((tmp_path / 'quality_report.json').read_text())['step2']
|
||||
assert report['profile']['total_sec'] == 30
|
||||
|
||||
|
||||
@pytest.mark.parametrize('response', [json.dumps([topic()]), 'not json', ''])
|
||||
def test_cached_response_uses_validation_without_network(tmp_path, response):
|
||||
obj = prepared_extractor(tmp_path)
|
||||
(obj.llm_raw_output_dir / 'chunk_0.txt').write_text(response)
|
||||
obj.llm_client.call_with_retry = lambda *a, **k: pytest.fail('cache must not trigger a paid call')
|
||||
out = obj.extract_timeline([dict(title='current', chunk_index=0)])
|
||||
assert len(out) == (1 if response.startswith('[') else 0)
|
||||
if out:
|
||||
assert json.loads((obj.timeline_chunks_dir / 'chunk_0.json').read_text())[0]['outline'] == 'current'
|
||||
|
||||
|
||||
def test_partial_failure_only_returns_successful_current_chunks(tmp_path):
|
||||
obj = prepared_extractor(tmp_path)
|
||||
(obj.srt_chunks_dir / 'chunk_1.json').write_text(json.dumps([dict(cue(40, 70), index=2)]))
|
||||
(obj.timeline_chunks_dir / 'chunk_1.json').write_text(json.dumps([topic('stale', 40, 70)]))
|
||||
responses = iter([json.dumps([topic()]), ''])
|
||||
obj.llm_client.call_with_retry = lambda *a, **k: next(responses)
|
||||
out = obj.extract_timeline([dict(title='current', chunk_index=0), dict(title='failed', chunk_index=1)])
|
||||
assert [item['outline'] for item in out] == ['current']
|
||||
@@ -112,3 +112,42 @@ def test_privacy_uses_web_data_directory(monkeypatch, tmp_path):
|
||||
sentry_setup.write_privacy(crash_reports=False)
|
||||
assert (tmp_path / "privacy.json").is_file()
|
||||
assert sentry_setup.crash_reports_enabled() is False
|
||||
|
||||
|
||||
def test_studio_tags_allowlisted_and_opt_out(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('AUTOCLIP_APP_DIR', str(tmp_path))
|
||||
event = {'exception': {'values': [{'type':'RuntimeError', 'value':'secret'}]},
|
||||
'tags': {'area':'studio', 'phase':'production', 'goal':'promo', 'analysis_mode':'visual',
|
||||
'build_environment':'validation', 'telemetry_test':'true', 'url':'secret', 'runtime':'secret'}}
|
||||
clean = sentry_setup.before_send(event)
|
||||
assert clean['tags'] == {k:v for k,v in event['tags'].items() if k not in ('url','runtime')}
|
||||
assert 'secret' not in json.dumps(clean)
|
||||
monkeypatch.setattr(sentry_setup, '_initialized', True)
|
||||
sentry_setup.write_privacy(crash_reports=False)
|
||||
assert sentry_setup.capture_studio_exception(RuntimeError('secret'), 'render') is None
|
||||
|
||||
|
||||
def test_studio_capture_uses_isolated_scope_and_never_breaks_worker(monkeypatch, tmp_path):
|
||||
import sentry_sdk
|
||||
monkeypatch.setenv('AUTOCLIP_APP_DIR', str(tmp_path))
|
||||
monkeypatch.setattr(sentry_setup, '_initialized', True)
|
||||
captured=[]
|
||||
def capture(error):
|
||||
captured.append(dict(sentry_sdk.get_current_scope()._tags))
|
||||
raise RuntimeError('transport unavailable')
|
||||
monkeypatch.setattr(sentry_sdk, 'capture_exception', capture)
|
||||
assert sentry_setup.capture_studio_exception(ValueError('secret'), 'production', goal='promo') is None
|
||||
assert captured[0]['phase'] == 'production'
|
||||
assert sentry_sdk.get_current_scope()._tags.get('area') != 'studio'
|
||||
|
||||
|
||||
def test_studio_expected_pipeline_failure_keeps_code_and_warning(monkeypatch, tmp_path):
|
||||
from backend.pipeline.failures import PipelineFailure
|
||||
monkeypatch.setenv('AUTOCLIP_APP_DIR', str(tmp_path))
|
||||
error = PipelineFailure('ANALYZE', 'private key text', code='llm_not_configured')
|
||||
assert sentry_setup.studio_error_code(error) == 'llm_not_configured'
|
||||
clean = sentry_setup.before_send({'tags': {'area':'studio', 'phase':'production', 'error_code':'llm_not_configured'},
|
||||
'exception': {'values': [{'type':'PipelineFailure', 'value':str(error)}]}})
|
||||
assert clean['level'] == 'warning'
|
||||
assert clean['fingerprint'] == ['studio', 'production', 'llm_not_configured']
|
||||
assert 'private' not in json.dumps(clean)
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_one_import_endpoint_routes_and_keeps_srt(client,source,monkeypatch,goal
|
||||
return {'events':[{'id':'e1','label':'Visible event','start':0,'end':1,'evidence':'Fixture'}]}
|
||||
monkeypatch.setattr(intelligence,'vision_call',provider)
|
||||
content_calls=[]
|
||||
monkeypatch.setattr(jobs,'run_content',lambda pid,video:content_calls.append((pid,video)))
|
||||
monkeypatch.setattr(jobs,'run_content',lambda pid,video:content_calls.append((pid,video)) or [{'id':'clip'}])
|
||||
response=client.post('/studio/import',data={'name':'Unified entry'},files={'video':('source.mp4',source.read_bytes(),'video/mp4'),'subtitle':('captions.srt',b'1\n00:00:00,000 --> 00:00:01,000\nHello\n','text/plain')})
|
||||
assert response.status_code==200,response.text
|
||||
pid=response.json()['project_id'];state=client.get('/studio/'+pid).json()
|
||||
@@ -284,6 +284,8 @@ def test_shared_visual_failure_is_not_retried_for_second_goal(client,source,monk
|
||||
monkeypatch.setattr(intelligence,'ready',lambda:True)
|
||||
monkeypatch.setattr(intelligence,'vision_call',lambda *a,**kw:recommendation())
|
||||
calls=[]
|
||||
reports=[]
|
||||
monkeypatch.setattr(jobs, 'capture_studio_exception', lambda error, phase, **kwargs: reports.append((error, phase)))
|
||||
failure=intelligence.VisionRequestError('rate_limited','请稍后重试',elapsed_seconds=2,http_status=429)
|
||||
failure.phase='scan'
|
||||
def analyze(*args):
|
||||
@@ -291,7 +293,7 @@ def test_shared_visual_failure_is_not_retried_for_second_goal(client,source,monk
|
||||
raise failure
|
||||
monkeypatch.setattr(jobs,'analyze',analyze)
|
||||
monkeypatch.setattr(jobs,'make_drafts',lambda *a,**kw:pytest.fail('no drafts without analysis'))
|
||||
monkeypatch.setattr(jobs,'run_content',lambda *a,**kw:calls.append('content'))
|
||||
monkeypatch.setattr(jobs,'run_content',lambda *a,**kw:calls.append('content') or [{'id':'clip'}])
|
||||
response=client.post('/studio/import',files={'video':('input.mp4',source.read_bytes(),'video/mp4')})
|
||||
pid=response.json()['project_id'];state=client.get('/studio/'+pid).json()
|
||||
body={'plan_id':state['plan']['id'],'goals':goals}
|
||||
@@ -305,6 +307,9 @@ def test_shared_visual_failure_is_not_retried_for_second_goal(client,source,monk
|
||||
assert store.read(pid)['analysis']['diagnostics']==details
|
||||
assert client.post('/studio/'+pid+'/start',json=body).status_code==409
|
||||
assert calls.count('scan')==1
|
||||
assert len(reports)==1
|
||||
assert result['analysis']['failed_goals']==[g for g in goals if g!='content']
|
||||
assert result['analysis']['outcome']==('partial' if 'content' in goals else 'failed')
|
||||
|
||||
|
||||
def test_hook_failure_preserves_other_goal_and_does_not_repeat_analysis(client,source,monkeypatch):
|
||||
@@ -327,6 +332,11 @@ def test_hook_failure_preserves_other_goal_and_does_not_repeat_analysis(client,s
|
||||
assert client.post('/studio/'+pid+'/start',json={'plan_id':plan['id'],'goals':['promo','highlight']}).status_code==200
|
||||
result=client.get('/studio/'+pid).json()
|
||||
assert calls==['scan','promo','highlight']
|
||||
assert result['analysis']['outcome']=='partial'
|
||||
assert result['analysis']['succeeded_goals']==['highlight']
|
||||
assert result['analysis']['failed_goals']==['promo']
|
||||
assert result['analysis']['result_count']==1
|
||||
assert result['analysis']['duration_ms']>=0
|
||||
assert [d['id'] for d in result['drafts']]==['highlight']
|
||||
assert result['analysis']['diagnostics']==[{'goal':'promo','code':'output_truncated','phase':'hooks','elapsed_seconds':8}]
|
||||
|
||||
@@ -420,3 +430,110 @@ def test_confirm_matches_each_output_aspect_without_extra_analysis(
|
||||
assert seen==list(zip(['highlight','promo'],expected))
|
||||
assert scans==[route]
|
||||
assert state['plan']['goal_preferences']['promo']['aspect']==expected[1]
|
||||
|
||||
|
||||
class RejectSubmission:
|
||||
def submit(self, *args):
|
||||
raise RuntimeError('private executor failure')
|
||||
|
||||
|
||||
def test_initial_import_dispatch_failure_is_retryable(client, source, monkeypatch):
|
||||
from backend.core.database import SessionLocal
|
||||
from backend.models import Project
|
||||
monkeypatch.setattr(jobs, 'executor', RejectSubmission())
|
||||
response = client.post('/studio/import', data={'name':'dispatch recovery'}, files={'video':('input.mp4', source.read_bytes(), 'video/mp4')})
|
||||
assert response.status_code == 422
|
||||
assert '导入任务未能启动' in response.json()['detail']
|
||||
assert 'private executor' not in response.text
|
||||
with SessionLocal() as db:
|
||||
project = db.query(Project).filter(Project.name == 'dispatch recovery').one()
|
||||
pid = str(project.id)
|
||||
assert project.status.value == 'failed'
|
||||
assert store.read(pid)['analysis']['status'] == 'failed'
|
||||
assert (store.directory(pid) / 'raw/input.mp4').read_bytes() == source.read_bytes()
|
||||
monkeypatch.setattr(jobs, 'executor', Immediate())
|
||||
monkeypatch.setattr(intelligence, 'ready', lambda: False)
|
||||
assert client.post('/studio/'+pid+'/analyze').status_code == 200
|
||||
assert store.read(pid)['analysis']['status'] == 'awaiting_confirmation'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["analyze", "plan"])
|
||||
def test_rescreen_dispatch_failure_preserves_plan_and_exports(client, source, monkeypatch, route):
|
||||
from backend.tests.test_studio import draft
|
||||
monkeypatch.setattr(jobs, 'executor', Immediate())
|
||||
monkeypatch.setattr(intelligence, 'ready', lambda: False)
|
||||
pid = client.post('/studio/import', files={'video':('input.mp4', source.read_bytes(), 'video/mp4')}).json()['project_id']
|
||||
saved = store.save_draft(pid, draft(), create=True)
|
||||
store.change(pid, lambda data: data['jobs'].append({'job_id':'existing', 'status':'completed', 'draft_id':saved['id']}))
|
||||
before = store.read(pid)
|
||||
monkeypatch.setattr(jobs, 'executor', RejectSubmission())
|
||||
from backend.core.database import SessionLocal
|
||||
from backend.models import Project
|
||||
with SessionLocal() as db:
|
||||
config = dict(db.get(Project, pid).processing_config)
|
||||
def rescreen():
|
||||
if route == 'plan':
|
||||
return client.put('/studio/'+pid+'/plan', json={'goal':'highlight'})
|
||||
return client.post('/studio/'+pid+'/analyze')
|
||||
response = rescreen()
|
||||
assert response.status_code == 422
|
||||
with SessionLocal() as db:
|
||||
assert db.get(Project, pid).processing_config == config
|
||||
assert store.read(pid) == before
|
||||
monkeypatch.setattr(jobs, 'executor', Immediate())
|
||||
assert rescreen().status_code == 200
|
||||
after = store.read(pid)
|
||||
assert after['analysis']['status'] == 'awaiting_confirmation'
|
||||
assert after['drafts'] == before['drafts'] and after['jobs'] == before['jobs']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtube.com/watch?si=abc&v=dQw4w9WgXcQ',
|
||||
'https://youtu.be/dQw4w9WgXcQ?si=abc',
|
||||
'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtube.com/shorts/dQw4w9WgXcQ',
|
||||
'https://youtube.com/live/dQw4w9WgXcQ',
|
||||
])
|
||||
def test_current_studio_accepts_shared_youtube_urls(client, source, monkeypatch, url):
|
||||
seen = []
|
||||
def download(pid, link, browser):
|
||||
seen.append(link)
|
||||
(store.directory(pid) / 'raw/input.mp4').write_bytes(source.read_bytes())
|
||||
monkeypatch.setattr(jobs, 'download', download)
|
||||
monkeypatch.setattr(jobs, 'executor', Immediate())
|
||||
monkeypatch.setattr(intelligence, 'ready', lambda: False)
|
||||
response = client.post('/studio/import', data={'url':url})
|
||||
assert response.status_code == 200
|
||||
assert seen == [url]
|
||||
assert store.read(response.json()['project_id'])['analysis']['status'] == 'awaiting_confirmation'
|
||||
|
||||
|
||||
def test_studio_content_preserves_structured_pipeline_failure(monkeypatch, tmp_path):
|
||||
from backend.tasks.processing import process_video_pipeline
|
||||
from backend.pipeline.failures import PipelineFailure
|
||||
class Result:
|
||||
def get(self):
|
||||
return {'success':False, 'error':'private hint', 'result':{'stage':'ANALYZE', 'error_code':'llm_not_configured'}}
|
||||
monkeypatch.setattr(process_video_pipeline, 'apply', lambda **kwargs: Result())
|
||||
with pytest.raises(PipelineFailure) as caught:
|
||||
jobs.run_content('p', tmp_path/'input.mp4')
|
||||
assert caught.value.code == 'llm_not_configured'
|
||||
|
||||
|
||||
def test_local_import_creates_project_thumbnail(client, source, monkeypatch):
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
from backend.core.database import SessionLocal
|
||||
from backend.models import Project
|
||||
monkeypatch.setattr(jobs, 'executor', Immediate())
|
||||
response = client.post('/studio/import', data={'goal': 'content'},
|
||||
files={'video': ('source.mp4', source.read_bytes(), 'video/mp4')})
|
||||
assert response.status_code == 200, response.text
|
||||
pid = response.json()['project_id']
|
||||
with SessionLocal() as db:
|
||||
thumbnail = db.get(Project, pid).thumbnail
|
||||
assert thumbnail.startswith('data:image/jpeg;base64,')
|
||||
Image.open(io.BytesIO(base64.b64decode(thumbnail.split(',', 1)[1]))).verify()
|
||||
assert client.get('/studio/' + pid).json()['analysis']['status'] == 'awaiting_confirmation'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
import subprocess
|
||||
import pytest
|
||||
from backend.tests.test_studio import root, source, client
|
||||
from backend.services.studio import preview
|
||||
from backend.utils.ffmpeg_utils import get_ffmpeg_path, get_ffprobe_path
|
||||
|
||||
|
||||
class Immediate:
|
||||
def submit(self, fn, *args):
|
||||
fn(*args)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(monkeypatch):
|
||||
monkeypatch.setattr(preview, '_states', {})
|
||||
monkeypatch.setattr(preview, '_executor', Immediate())
|
||||
|
||||
|
||||
def test_explicit_preview_transcodes_avi_preserves_source_and_serves_ranges(client, source):
|
||||
avi = source.with_suffix('.avi')
|
||||
subprocess.run([get_ffmpeg_path(), '-v', 'error', '-i', str(source), '-c:v', 'mpeg4',
|
||||
'-c:a', 'pcm_s16le', '-y', str(avi)], check=True, capture_output=True)
|
||||
source.unlink()
|
||||
original = avi.read_bytes()
|
||||
assert client.get('/studio/p1/source-preview').json()['status'] == 'idle'
|
||||
assert client.get('/studio/p1/source-preview/video').status_code == 404
|
||||
response = client.post('/studio/p1/source-preview')
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()['status'] == 'completed', response.text
|
||||
output = preview.ready_file('p1')
|
||||
streams = json.loads(subprocess.check_output([get_ffprobe_path(), '-v', 'error',
|
||||
'-show_streams', '-of', 'json', str(output)]))['streams']
|
||||
assert [s['codec_name'] for s in streams] == ['h264', 'aac']
|
||||
assert streams[0]['pix_fmt'] == 'yuv420p'
|
||||
assert avi.read_bytes() == original
|
||||
modified = output.stat().st_mtime_ns
|
||||
assert client.post('/studio/p1/source-preview').json()['status'] == 'completed'
|
||||
assert output.stat().st_mtime_ns == modified
|
||||
response = client.get('/studio/p1/source-preview/video', headers={'Range': 'bytes=0-99'})
|
||||
assert response.status_code == 206 and len(response.content) == 100
|
||||
assert response.headers['content-type'] == 'video/mp4'
|
||||
output.unlink()
|
||||
assert client.get('/studio/p1/source-preview').json()['status'] == 'idle'
|
||||
assert client.post('/studio/p1/source-preview').json()['status'] == 'completed'
|
||||
avi.write_bytes(original + b'changed')
|
||||
assert client.get('/studio/p1/source-preview').json()['status'] == 'idle'
|
||||
|
||||
|
||||
def test_timeout_is_retryable_and_partial_file_is_removed(source, monkeypatch):
|
||||
def fail(command, **kwargs):
|
||||
assert kwargs['timeout'] == 900
|
||||
from pathlib import Path
|
||||
Path(command[-1]).write_bytes(b'partial')
|
||||
raise subprocess.TimeoutExpired(command, 900)
|
||||
monkeypatch.setattr(preview.subprocess, 'run', fail)
|
||||
monkeypatch.setattr(preview, 'capture_studio_exception', lambda *a: None)
|
||||
assert preview.start('p1')['status'] == 'failed'
|
||||
assert not list((source.parent.parent / 'output' / 'preview').glob('*.mp4'))
|
||||
assert preview.start('p1')['status'] == 'failed'
|
||||
|
||||
|
||||
def test_duplicate_start_and_global_capacity(source, monkeypatch):
|
||||
calls = []
|
||||
class Queued:
|
||||
def submit(self, *args): calls.append(args)
|
||||
monkeypatch.setattr(preview, '_executor', Queued())
|
||||
assert preview.start('p1')['status'] == 'queued'
|
||||
assert preview.start('p1')['status'] == 'queued'
|
||||
assert len(calls) == 1
|
||||
source.write_bytes(source.read_bytes() + b'changed')
|
||||
with pytest.raises(ValueError): preview.start('p1')
|
||||
|
||||
|
||||
def test_dispatch_failure_does_not_leave_queued_state(source, monkeypatch):
|
||||
class Rejected:
|
||||
def submit(self, *args): raise RuntimeError('closed executor')
|
||||
monkeypatch.setattr(preview, '_executor', Rejected())
|
||||
with pytest.raises(ValueError): preview.start('p1')
|
||||
assert preview.status('p1')['status'] == 'idle'
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Exercise real CPython transport cleanup with a synthetic reset socket."""
|
||||
|
||||
import asyncio
|
||||
from asyncio.proactor_events import _ProactorBasePipeTransport
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.core import windows_asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport():
|
||||
sock = Mock(spec=["shutdown", "close", "fileno"])
|
||||
sock.fileno.return_value = 42
|
||||
item = _ProactorBasePipeTransport(Mock(), sock, Mock(), server=Mock())
|
||||
yield item, sock, item._protocol, item._server
|
||||
# Avoid destructor warnings in negative-path tests.
|
||||
item._sock = None
|
||||
|
||||
|
||||
def test_original_runtime_reproduces_incomplete_cleanup(transport):
|
||||
item, sock, protocol, server = transport
|
||||
sock.shutdown.side_effect = ConnectionResetError(10054, "synthetic peer reset")
|
||||
with pytest.raises(ConnectionResetError):
|
||||
item._call_connection_lost(None)
|
||||
protocol.connection_lost.assert_called_once_with(None)
|
||||
sock.close.assert_not_called()
|
||||
server._detach.assert_not_called()
|
||||
assert not item._called_connection_lost
|
||||
|
||||
|
||||
def test_reset_still_closes_and_detaches_once(transport):
|
||||
item, sock, protocol, server = transport
|
||||
sock.shutdown.side_effect = ConnectionResetError(10054, "synthetic peer reset")
|
||||
cause = ConnectionResetError("read failed")
|
||||
windows_asyncio._call_connection_lost(item, cause)
|
||||
windows_asyncio._call_connection_lost(item, cause)
|
||||
protocol.connection_lost.assert_called_once_with(cause)
|
||||
sock.close.assert_called_once()
|
||||
server._detach.assert_called_once_with(item)
|
||||
assert item._sock is None and item._server is None
|
||||
assert item._called_connection_lost
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["normal", "closed", "pipe"])
|
||||
def test_other_cleanup_paths(transport, kind):
|
||||
item, sock, protocol, server = transport
|
||||
if kind == "closed":
|
||||
sock.fileno.return_value = -1
|
||||
elif kind == "pipe":
|
||||
del sock.shutdown
|
||||
windows_asyncio._call_connection_lost(item, None)
|
||||
sock.close.assert_called_once()
|
||||
server._detach.assert_called_once_with(item)
|
||||
assert item._called_connection_lost
|
||||
if kind == "closed":
|
||||
sock.shutdown.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [RuntimeError("protocol failed"), ConnectionResetError("protocol reset")])
|
||||
def test_protocol_failures_remain_visible_and_cleanup_finishes(transport, error):
|
||||
item, sock, protocol, server = transport
|
||||
protocol.connection_lost.side_effect = error
|
||||
sock.shutdown.side_effect = ConnectionResetError("shutdown reset")
|
||||
with pytest.raises(type(error)) as caught:
|
||||
windows_asyncio._call_connection_lost(item, None)
|
||||
assert caught.value is error
|
||||
sock.close.assert_called_once()
|
||||
server._detach.assert_called_once_with(item)
|
||||
|
||||
|
||||
def test_unexpected_shutdown_failure_is_not_silenced(transport):
|
||||
item, sock, _, _ = transport
|
||||
sock.shutdown.side_effect = PermissionError("synthetic denial")
|
||||
with pytest.raises(PermissionError):
|
||||
windows_asyncio._call_connection_lost(item, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform,version,installed", [
|
||||
("win32", (3, 13), True), ("darwin", (3, 13), False),
|
||||
("linux", (3, 13), False), ("win32", (3, 14), False),
|
||||
])
|
||||
def test_installer_scope_and_idempotence(monkeypatch, platform, version, installed):
|
||||
original = _ProactorBasePipeTransport._call_connection_lost
|
||||
monkeypatch.setattr(_ProactorBasePipeTransport, "_call_connection_lost", original)
|
||||
monkeypatch.setattr(windows_asyncio.sys, "platform", platform)
|
||||
monkeypatch.setattr(windows_asyncio.sys, "version_info", version)
|
||||
assert windows_asyncio.install_windows_proactor_cleanup() is installed
|
||||
expected = windows_asyncio._call_connection_lost if installed else original
|
||||
assert _ProactorBasePipeTransport._call_connection_lost is expected
|
||||
assert windows_asyncio.install_windows_proactor_cleanup() is installed
|
||||
assert _ProactorBasePipeTransport._call_connection_lost is expected
|
||||
|
||||
|
||||
@pytest.mark.skipif(windows_asyncio.sys.platform != "win32", reason="Windows IOCP integration")
|
||||
def test_windows_real_reset_and_subprocess():
|
||||
"""Exercise actual Proactor sockets and retain subprocess support on Windows."""
|
||||
import socket
|
||||
import struct
|
||||
|
||||
async def run():
|
||||
loop = asyncio.get_running_loop()
|
||||
errors = []
|
||||
loop.set_exception_handler(lambda _loop, context: errors.append(context))
|
||||
connected = asyncio.Event()
|
||||
released = asyncio.Event()
|
||||
|
||||
class Protocol(asyncio.Protocol):
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
connected.set()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
released.set()
|
||||
|
||||
server = await loop.create_server(Protocol, "127.0.0.1", 0)
|
||||
try:
|
||||
for _ in range(20):
|
||||
connected.clear()
|
||||
released.clear()
|
||||
client = socket.socket()
|
||||
try:
|
||||
client.connect(server.sockets[0].getsockname())
|
||||
await asyncio.wait_for(connected.wait(), 5)
|
||||
client.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("HH", 1, 0))
|
||||
finally:
|
||||
client.close() # RST, not a graceful FIN
|
||||
await asyncio.wait_for(released.wait(), 5)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
windows_asyncio.sys.executable, "-c", "print('ok')", stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), 10)
|
||||
assert process.returncode == 0 and stdout.strip() == b"ok"
|
||||
finally:
|
||||
server.close()
|
||||
await asyncio.wait_for(server.wait_closed(), 5)
|
||||
assert not errors
|
||||
|
||||
original = _ProactorBasePipeTransport._call_connection_lost
|
||||
try:
|
||||
assert windows_asyncio.install_windows_proactor_cleanup()
|
||||
with asyncio.Runner(loop_factory=asyncio.ProactorEventLoop) as runner:
|
||||
runner.run(run())
|
||||
finally:
|
||||
_ProactorBasePipeTransport._call_connection_lost = original
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Issue #224: actual JPEG output and bounded metadata probing."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import pytest
|
||||
from backend.utils.thumbnail_generator import ThumbnailGenerator
|
||||
from backend.utils.ffmpeg_utils import get_ffmpeg_path
|
||||
from backend.services import publish_export
|
||||
|
||||
|
||||
@pytest.mark.parametrize('duration', [0.4, 2])
|
||||
def test_plain_h264_is_not_a_cover_and_thumbnail_is_real_jpeg(tmp_path, duration):
|
||||
video = tmp_path / '素材.mp4'
|
||||
subprocess.run([get_ffmpeg_path(), '-v', 'error', '-f', 'lavfi', '-i',
|
||||
f'color=c=red:s=160x90:d={duration}', '-c:v', 'libx264',
|
||||
'-pix_fmt', 'yuv420p', '-y', str(video)], check=True, capture_output=True)
|
||||
generator = ThumbnailGenerator()
|
||||
assert generator._extract_video_cover(video) is None
|
||||
thumbnail = generator.generate_thumbnail(video)
|
||||
assert thumbnail is not None
|
||||
with Image.open(thumbnail) as image:
|
||||
image.verify()
|
||||
|
||||
|
||||
def test_probe_timeout_is_bounded_and_becomes_invalid_media(monkeypatch):
|
||||
def stalled(command, **kwargs):
|
||||
assert kwargs['timeout'] == 20
|
||||
raise subprocess.TimeoutExpired(command, kwargs['timeout'])
|
||||
monkeypatch.setattr(publish_export.subprocess, 'check_output', stalled)
|
||||
assert publish_export._probe(Path('stalled.mp4')) == {}
|
||||
@@ -74,7 +74,7 @@ class ThumbnailGenerator:
|
||||
# 封面不存在,回退到默认时间点
|
||||
time_offset = 1.0
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
get_ffmpeg_path(),
|
||||
'-ss', str(time_offset),
|
||||
'-i', str(video_path),
|
||||
'-vframes', '1',
|
||||
@@ -100,7 +100,7 @@ class ThumbnailGenerator:
|
||||
]
|
||||
|
||||
logger.info(f"生成缩略图: {video_path} -> {output_path}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"缩略图生成成功: {output_path}")
|
||||
@@ -127,23 +127,32 @@ class ThumbnailGenerator:
|
||||
封面图片路径,如果不存在则返回None
|
||||
"""
|
||||
try:
|
||||
# 检查是否有嵌入的封面图片
|
||||
# Only an attached picture is a cover. A normal video stream must
|
||||
# follow the duration-based frame selection below (often not frame 0).
|
||||
info = self.get_video_info(video_path) or {}
|
||||
cover = next((stream for stream in info.get('streams', [])
|
||||
if stream.get('disposition', {}).get('attached_pic') == 1), None)
|
||||
if cover is None or not isinstance(cover.get('index'), int):
|
||||
return None
|
||||
ffmpeg_bin = get_ffmpeg_path()
|
||||
cmd = [
|
||||
ffmpeg_bin,
|
||||
'-i', str(video_path),
|
||||
'-map', f"0:{cover['index']}",
|
||||
'-an', # 禁用音频
|
||||
'-vcodec', 'copy', # 复制视频流
|
||||
'-vcodec', 'mjpeg', # Decode video to a real JPEG, never copy H.264/HEVC bytes
|
||||
'-f', 'image2',
|
||||
'-vframes', '1',
|
||||
'-y',
|
||||
str(video_path.parent / f"{video_path.stem}_cover.jpg")
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10)
|
||||
if result.returncode == 0:
|
||||
cover_path = video_path.parent / f"{video_path.stem}_cover.jpg"
|
||||
if cover_path.exists() and cover_path.stat().st_size > 0:
|
||||
with Image.open(cover_path) as image:
|
||||
image.verify()
|
||||
logger.info(f"成功提取视频封面: {cover_path}")
|
||||
return cover_path
|
||||
|
||||
@@ -204,7 +213,7 @@ class ThumbnailGenerator:
|
||||
optimal_time = duration * 0.05
|
||||
|
||||
# 确保时间点合理(至少1秒,最多不超过视频长度)
|
||||
optimal_time = max(1.0, min(optimal_time, duration - 1))
|
||||
optimal_time = max(0.0, min(optimal_time, max(0.0, duration - 0.05)))
|
||||
|
||||
logger.info(f"为视频 {video_path.name} 选择最佳时间点: {optimal_time}秒 (总时长: {duration}秒)")
|
||||
return optimal_time
|
||||
@@ -276,7 +285,7 @@ class ThumbnailGenerator:
|
||||
str(video_path)
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10)
|
||||
|
||||
if result.returncode == 0:
|
||||
import json
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 最新反馈排查 — 2026-09-27
|
||||
|
||||
基线:`origin/main` / `0241198c`(1.4.0)。反馈来自 1.3.3–1.3.5。
|
||||
代码在独立分支 `codex/recent-feedback-fixes`,未修改原工作目录。
|
||||
|
||||
## 结论
|
||||
|
||||
| Issue | 复现与处理 |
|
||||
| --- | --- |
|
||||
| [#217](https://github.com/zhouxiaoka/autoclip/issues/217)、[#198](https://github.com/zhouxiaoka/autoclip/issues/198) 时间线为空 | 复现同类确定性故障:3 个相邻 10 秒话题在 30 秒字幕中被全部丢弃。修复为先尝试与后续相邻段合并,再决定丢弃;仍遵守合并间隔与最大时长限制。 |
|
||||
| [#195](https://github.com/zhouxiaoka/autoclip/issues/195) 时间戳无法对齐 | 复现标准点号时间戳、无毫秒时间戳及 MM:SS 被拒绝。现在统一为 SRT 时间戳,并拒绝倒序、无交集及分钟/秒溢出的区间。同样可能影响 #198/#217。 |
|
||||
| [#197](https://github.com/zhouxiaoka/autoclip/issues/197) YouTube invalid link | 复现 Shorts、live、m.youtube、music.youtube 及 watch 的 v 参数不在首位时被前端拒绝。提取单一 URL 解析函数供校验和平台识别共用,支持这些格式,保留 B 站及既有 YouTube 格式。 |
|
||||
| [#200](https://github.com/zhouxiaoka/autoclip/issues/200)、[#181](https://github.com/zhouxiaoka/autoclip/issues/181) 未配置模型 | 反馈错误属于缺少 API Key / 服务地址的配置前置检查。已有失败与配置持久化测试通过;没有用户配置证据可确认是保存丢失等代码故障,此次未更改。 |
|
||||
| [#183](https://github.com/zhouxiaoka/autoclip/issues/183) 导入长期显示 5% | 主线已包含下载进度持久化修复。相关数据库与前端进度测试通过。无法凭此确认 15 小时未完成的真实下载原因。 |
|
||||
| [#182](https://github.com/zhouxiaoka/autoclip/issues/182) 短广告无法出片 | 相邻短片段误丢弃及时间戳格式修复同样适用,但不等于广告改写/混剪能力已满足;不足 20 秒的素材仍受原有时长策略限制。 |
|
||||
|
||||
## 验证
|
||||
|
||||
修复前:新增链接用例 6 个失败、时间线用例 7 个失败。
|
||||
修复后:
|
||||
|
||||
- 后端 55 个测试通过:`test_recent_feedback.py`、`test_quality.py`、`test_pipeline_failures.py`、`test_llm_provider_config.py`、`test_download_progress_persist.py`、`test_youtube_download_isolation.py`、`test_youtube_subtitle_fallback.py`。
|
||||
- 前端 `node --test tests/*.test.cjs`:129 个通过。
|
||||
- `npm run typecheck` 与 `npm run build` 通过。构建存在现有 bundle 大小提示。
|
||||
- 完整 step2 流程用合成字幕和固定模型响应验证,生成一个 30 秒候选及质量报告。
|
||||
- 边界覆盖:长静音间隔、最大时长、不到 20 秒的整条素材、越界/倒序时间戳、伪域名及非视频链接。
|
||||
|
||||
## 限制与后续验证
|
||||
|
||||
没有反馈者的视频、字幕、模型原始响应或 #197 的原始 URL,因此以上是相同失败条件的复现,不是对每条用户反馈根因的最终确认。
|
||||
未进行真实 Gemini/Ollama 调用、YouTube 在线下载或 Windows 安装包端到端验证。
|
||||
前端解析接口的网络错误目前仍可能显示通用链接错误,本次链接格式修复不覆盖网络、Cookies、区域限制或 yt-dlp 上游变化。
|
||||
未降低默认 20 秒最短时长,未自动把任意短素材整段返回以伪装成切片成功。
|
||||
未发布版本、关闭 issue 或向反馈者发送消息。
|
||||
|
||||
|
||||
## 后续项目审查补充
|
||||
|
||||
1.4 当前入口已经改为 Studio 导入,`BilibiliDownload` 未被其他组件引用。因此 #197 的修复范围是保留的旧组件(对应旧版报错路径),不代表新 Studio 入口存在相同校验缺陷。新的项目审查与独立复现结果见 [PROJECT_REVIEW_2026-09-27.md](PROJECT_REVIEW_2026-09-27.md)。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 最新反馈排查(2026-09-28,待发布)
|
||||
|
||||
目标为新出现的 [#224](https://github.com/zhouxiaoka/autoclip/issues/224) 与 [#225](https://github.com/zhouxiaoka/autoclip/issues/225)。修复追加到 `codex/recent-feedback-fixes` / PR #221;不修改 #185,不自动回复或关闭反馈。
|
||||
|
||||
## #224:Windows 1.4.0 升级、导入和预览
|
||||
|
||||
### 已复现并修复
|
||||
|
||||
- **缩略图不是有效图片**:用真实 H.264 MP4 调旧 `_extract_video_cover`,返回成功但 PIL 无法识别。原先 `-vcodec copy` 将压缩视频包写到 `.jpg`。现在只将 attached_pic 当嵌入封面并解码为 JPEG,普通视频走按时长选帧;修复短于 1 秒素材的越界选帧,统一 UTF-8 解码及内置 ffmpeg 路径。
|
||||
- **本地导入漏缩略图**:Studio URL 下载会写 Project.thumbnail,但本地上传不做。现在后台筛查时补齐,失败不阻断方案确认。真实导入 API 测试验证数据库内是可解码 JPEG。
|
||||
- **探测可能无限等待**:Studio 复用的 `_probe` 没有 timeout。模拟 ffprobe 停滞已覆盖,现在最多 20 秒后返回无效媒体,进入已有失败状态,而非一直等待。这只证明修复了一个可挂起路径,不证明用户卡住的所有原因相同。
|
||||
- **原片格式未保证浏览器兼容**:Studio 编辑器直接播放原片,AVI/MKV/HEVC 等素材不能保证被 WebView 解码。新增明确点击后才运行的兼容预览:独立本地 H.264/yuv420p + AAC MP4,不改原片、不调用模型。单任务、2 编码线程、最长 15 分钟、最高 1280×720,失败可重试,按源文件 stat 缓存,原子落盘;真实 AVI 测试验证输出编码、Range 206、缓存复用/失效与原片字节保持。
|
||||
- **Windows 后台进程残留**:旧 Rust stop 只终止 Python 父进程;托盘退出未统一清理。新增 Windows Job Object,非继承句柄随桌面进程关闭,管理后端及后续子进程;Tauri Exit 统一 stop,停止已退出后端也清理残留。CI 回归包含“杀父进程后孙进程仍在”的旧行为与 Job 关闭后完整清理。原理见 [Microsoft Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)。
|
||||
|
||||
### 尚未证明的范围
|
||||
|
||||
没有报告者的素材、编解码信息和安装日志,不能把以上复现等同于用户整条故障链。Windows 安装包在真实机器上的原位升级、WebView2 播放及长素材转码仍需冒烟。兼容预览入口始终可手动展开,播放器报告错误时自动展开,便于处理“有声音但黑屏”而浏览器未报告错误的情况。
|
||||
|
||||
旧安装包已残留的进程不会被新代码追溯接管。遇到 `_asyncio.pyd` 正被占用,应完整退出旧应用,必要时重启 Windows 后再安装;不要跳过文件覆盖或批量结束其他软件的 Python 进程。
|
||||
|
||||
## #225:Windows 1.3.3 Whisper 未安装
|
||||
|
||||
反馈正文明确为运行时未安装,尚无证据是新的程序缺陷。现有测试已验证未安装时抛出可读的配置错误,提示「设置 → 转写」或导入 SRT,并将安装失败与未安装分开。本轮未自动下载运行时或模型,不将配置缺失伪装成成功;#221 已有结构化 warning 分类。需要用户安装运行时及模型,或提供字幕后重试。
|
||||
|
||||
## 验证
|
||||
|
||||
- 媒体/导入/Whisper 定向:72 passed;兼容预览:4 passed。
|
||||
- 前端 136 项测试、typecheck、lint、build 通过。
|
||||
- macOS `cargo check` 通过(指定现有 MacOSX15.4 SDK;默认 SDK 27 与本机 linker 不兼容)。
|
||||
- 后端全量 639 passed、1 skipped(macOS 跳过 Windows IOCP);末次缓存删除恢复边界单独 4 项回归通过。
|
||||
- [Windows CI](https://github.com/zhouxiaoka/autoclip/actions/runs/36368556991) 在 `29342b56` 上通过:13 项 IOCP 测试、2 项真实进程树测试、完整桌面 `cargo check`。编译使用资源占位目录,不是安装包验收;后续改动仅为兼容预览缓存恢复、UI 手动入口与文档。
|
||||
- [PR CI](https://github.com/zhouxiaoka/autoclip/actions/runs/36368559552) 的后端、前端、Docker 冒烟均通过。
|
||||
- 本轮没有发布安装包或关闭 issue;线上旧版不会因 PR 更新而自动修复。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 项目审查 — 2026-09-27
|
||||
|
||||
审查基线:`485ad01a`,分支 `codex/recent-feedback-fixes`,基于 `main@0241198c`。
|
||||
此前反馈修复已提交并推送;以下为审查时的发现,三项现已修复,见文末修复验收补充。本次聚焦导入、分析重试、Studio 状态与导出、测试和发布记录,不是全仓逐行或安全审计。
|
||||
|
||||
## 需要优先修复的发现
|
||||
|
||||
### P1:当前分析失败仍返回上次时间线
|
||||
|
||||
位置:`backend/pipeline/step2_timeline.py:187–194`。
|
||||
|
||||
提取结束使用 `glob("*.json")` 读取整个结果目录。失败/缺失字幕块时不会覆盖或排除上次 `chunk_N.json`,因此对同一项目重新分析时,本轮模型失败仍可能返回旧话题,继续评分与出片,掩盖实际失败。当前大纲不包含的旧块也会被读入。
|
||||
|
||||
已复现:先写入标题为 `STALE PREVIOUS RUN` 的 0–30 秒结果;本次模型返回空字符串;`extract_timeline` 仍返回这条旧标题,而不是空结果。
|
||||
|
||||
建议:只汇总本次成功解析的块,在内存中收集结果或使用本次运行清单。历史文件可保留作诊断,但不能参与本次结果;补充“本次全失败”和“当前块数量减少”的回归。
|
||||
|
||||
### P2:智能导入提交失败后卡在运行中,无法重试
|
||||
|
||||
位置:`backend/services/studio/jobs.py:158–165`。
|
||||
|
||||
`inspect_project` 先把 analysis 写为 running,再调用 `executor.submit`。如果提交抛错(例如线程创建失败、executor 已关闭),没有恢复状态。本进程中 `store.read` 不会把相同 INSTANCE 的运行状态认定为中断,后续重试与修改方案都会被拦。导入 API 虽会把数据库项目标为 failed,但不能修复 Studio JSON 状态。
|
||||
|
||||
已复现:让 executor.submit 抛 RuntimeError;读取 analysis 仍为 running;再次 inspect_project 抛“当前任务正在运行”。不涉及付费模型调用。
|
||||
|
||||
建议:参考已有 confirm_project/export 的提交失败处理,把状态预留与提交放在同一锁内,失败时恢复原方案或写明确可重试的 failed;补充首次导入及重新推荐两条 API 回归。
|
||||
|
||||
### P2:命中旧原始响应缓存却不解析
|
||||
|
||||
位置:`backend/pipeline/step2_timeline.py:105–111`。
|
||||
|
||||
`chunk_N.txt` 存在时只读取 raw_response,解析与写入结果全部位于 else 分支。有原始响应但缺少解析结果文件时,会跳过模型调用却返回空时间线;如果同时有旧结果,又会落入上面的残留结果问题。
|
||||
|
||||
已复现:只放入一份合法的 0–30 秒 JSON 原始缓存,不放结果文件;模型桩禁止请求;最终输出为空。
|
||||
|
||||
适用范围:需要旧缓存或手工恢复的 `chunk_N.txt`;当前常规请求写的是 attempt 文件,此缺陷不代表每次新导入都会触发。
|
||||
|
||||
建议:缓存读取与模型调用共用解析/校验路径;缓存失败时明确处理,不将文件存在视为已有有效结果。与第一项同批修复。
|
||||
|
||||
## 项目现状与维护补项
|
||||
|
||||
- 后端完整测试 **600 passed**(33 条警告,90.15 秒),包括现有真实 FFmpeg 用例。前端上一轮 **129 passed**、类型检查和生产构建通过,本轮补跑 ESLint 通过。
|
||||
- 三个发现用独立临时 pytest 用例验证,**3/3 成功复现缺陷**。这不是“修复后通过”,这些验证不在产品回归套件里,避免将错误行为固化为成功标准。完整测试套件目前缺少这些场景。
|
||||
- `HANDOFF.md` 自称当前状态的单一事实来源,顶部仍停在 v1.3.3;实际代码版本已是 v1.4.0。应将当期事实统一到新版发布/验收记录,历史过程单独保留。
|
||||
- 1.4 当前入口使用 Studio 导入。仓库中已没有对 `BilibiliDownload` 组件的引用;上一轮 #197 的链接修复解决的是保留的旧组件校验,不应声称修复了新 Studio 入口。旧组件的请求失败一律显示“请输入正确的视频链接”也仍存在,但当前不可达,优先级低于上述在用链路。
|
||||
- 新 Studio 的链接下载没有复用旧 YouTube 字幕下载逻辑;默认字幕路线可能需要额外 Whisper 转写。建议补一条“有网站字幕的 URL → 字幕方案 → 出片”的端到端用例,再判断是否统一两套下载入口。本轮未进行真实在线视频下载,不把这一点记录为已复现线上故障。
|
||||
- 后端 Ruff 在 CI 中仍 `continue-on-error`,通过 CI 不代表静态检查通过。建议先收敛关键运行错误规则,再逐步清理风格债务。
|
||||
- macOS 原生与升级/回退已有受控证据;Windows 真机安装/出片、多游戏完整观感与推广差异化仍是发布记录里的未验收范围。不能用单测或构建成功代替。
|
||||
|
||||
## 建议顺序
|
||||
|
||||
1. 时间线本次结果隔离 + 缓存统一解析,一组提交。
|
||||
2. 导入任务提交失败恢复,单独提交。
|
||||
3. 补齐当前 Studio URL 字幕链路验收,再清理不可达旧入口与同步状态文档。
|
||||
|
||||
本轮未修改这些新发现对应的业务逻辑,未合并 main、发布版本或修改 issue 状态。
|
||||
|
||||
|
||||
## 修复验收补充(2026-09-27,同一分支)
|
||||
|
||||
三项问题均已修复:
|
||||
|
||||
- 时间线仅汇总本次解析成功的块,旧结果/无关字幕块不参与结果与校正;旧文件保留用于诊断。
|
||||
- 缓存和新响应共用校验路径。合法原始缓存产生候选;损坏/空缓存不触发隐式模型请求。
|
||||
- 智能导入在锁内预留状态并提交任务;首次提交失败写可重试状态,已有方案则恢复原状态。修改方案提交失败还会恢复数据库偏好。已有草稿、成片和素材保留。
|
||||
|
||||
新增 15 项后端回归覆盖上述故障、部分块失败、缓存、首次导入/再次分析/修改方案失败后显式重试,以及 6 类当前 Studio YouTube 分享链接。链接测试替代了实际下载,不代表网络下载验收。
|
||||
|
||||
最终验证:后端完整 **615 passed**(33 条警告,86.00 秒);前端 **129 passed**、TypeScript、ESLint、生产构建通过。现有八语系统消息测试已加入新增提示并单独复验 **2 passed**。构建体积提示及 Python 弃用警告仍为现有事项。
|
||||
|
||||
`HANDOFF.md` 已更新为 1.4 当前状态,旧内容标为历史快照;`CHANGELOG.md` 的未发布节记录了本轮修复。未合并主线或发布安装包,未进行真实 YouTube 下载、付费模型调用或 Windows 真机验收。
|
||||
@@ -0,0 +1,68 @@
|
||||
# AutoClip 1.4 PostHog / Sentry 审查与补齐方案
|
||||
|
||||
日期:2026-09-27;代码基线:`13d25ad2`。范围:已读取代码、执行离线验证,并只读检查现有 PostHog 看板和 Sentry 项目。以下矩阵保留审查时的缺口;本轮已按清单实施,当前契约、线上链接和验收限制见 [Studio 监控实施记录](analytics/STUDIO_MONITORING.md)。
|
||||
|
||||
## 结论
|
||||
|
||||
两套系统都需要更新,但不需要重装 SDK。PostHog 负责产品使用、流程转化与结果;Sentry 负责工程异常与定位。现有隐私开关、字段过滤、release/source maps 保留。首要任务是覆盖在用的 Studio 流程,而不是继续给不可达旧组件补事件。
|
||||
|
||||
## 覆盖矩阵
|
||||
|
||||
| 用户动作/阶段 | 现有覆盖 | 缺口与调整 |
|
||||
| --- | --- | --- |
|
||||
| 首页、确认页、编辑器 | 有页面事件 | `routeName` 把 `/import/:id` 和 `/project/:id/studio/:draftId` 都记作 `/other`;需新增固定路由名,不能发送真实 ID |
|
||||
| 视频导入 | `studio_import_requested/accepted/request_failed` | 未区分文件/YouTube/B站、是否自带字幕;accepted 只是请求受理,不能算素材下载或制作成功 |
|
||||
| 推荐完成 | `studio_screen_finished` | 只有 recommended/manual_fallback/failed;`mode=local` 的正常字幕推荐被算 manual_fallback。应分别保留 ai/local/manual/fallback,并记录有无可用字幕的有限枚举 |
|
||||
| 修改方案、重新识别 | 未接 observeStudioOperation | `correctPlan/analyze` 无请求结果事件,也不重新登记观察;首次失败后的恢复漏报。补独立 attempt 生命周期,不能把旧 plan 当本次完成 |
|
||||
| 确认制作 | `studio_confirm_*` | 没有字幕/视觉路线、content/highlight/promo 选择、自动/显式画幅等有限属性,无法判断新能力采用情况 |
|
||||
| 制作结果 | `studio_production_finished` | 只有 completed/failed;高光成功但推广失败仍是整体 failed,无法统计部分成功。需后端提供本次分目标结果、候选数量及耗时,不从历史草稿总数推测 |
|
||||
| 草稿打开、保存、复制 | 无业务事件 | 不知道用户是否进入编辑、修改或复用;记录动作结果即可,不记录每次按键、字幕、标题、提示词 |
|
||||
| AI 文案改写 | 无业务事件 | 有额外模型调用但缺采用/失败口径;记录请求结果、耗时、固定错误分类,不记录输入输出正文 |
|
||||
| 导出渲染 | `studio_export_*` 与 finished | 有最小闭环,但缺制作类型、画幅、字幕/模板有限枚举、渲染耗时和安全错误码 |
|
||||
| 下载成片 | 只有 `studio_download_requested` | 桌面保存成功/失败均无事件。原生保存已有返回结果,可上报 saved/failed;浏览器普通链接只能报 requested,不能声称写盘成功 |
|
||||
| 视觉配置与连接测试 | 未接旧 provider_test_* | `VisionSettings` 直接调用接口;应区分 text/vision 配置与连通结果,不发送 key、base_url 或任意自定义模型字符串 |
|
||||
| 社交发布 | 保留旧发布/发布导出能力 | `publish_export` 是渲染,不是上传社交平台成功。需单独核验发布请求/平台受理/最终结果,Studio 来源仅用固定 source_type |
|
||||
|
||||
## 已确认的实现问题
|
||||
|
||||
1. **P1:Studio 后台异常监控缺口。** `backend/services/studio/jobs.py` 的 `_inspect/_render/_produce_selected` 捕获异常后保存失败状态;多处仅 warning,部分只写 JSON。没有显式 capture_exception。当前 Sentry logging 仅接 ERROR,before_send 又丢弃无异常栈的纯日志,不能依赖“记过日志就会上报”。这些异常不会冒泡到 FastAPI 全局异常处理器。需在异步任务的终态失败边界统一上报;同一次失败只报一次,避免共享分析被两个目标重复报告。
|
||||
2. **P1:当前业务看板漏掉新链路。** 线上 `MjbdVzZb` 的 SQL 与 `docs/analytics/business_signals.sql` 一致,IN 列表没有 studio_*。该图即使持续收旧事件,也不能代表 1.4 使用情况。线上说明仍写“代码待发布验收”,需要更新为实际验收状态。
|
||||
3. **P1:价值终点漏报。** `StudioDownloadLink.tsx` 在原生保存前报 requested,成功与 catch 分支没有结果事件。不能用点击数作为用户取得成片数。
|
||||
4. **P2:当前统计存在误分类及信息不足。** 离线执行实际 WorkflowTracker,确认 local 推荐得到 manual_fallback、新页面得到 /other;制作只有整体失败,重试不登记。现有请求事件没有 attempt 关联,终态只有 outcome,不能可靠计算任务级转化/耗时。
|
||||
5. **P2:直接增加 Sentry tags 不会生效。** 后端 before_send 仅保留已分类旧导入异常的 import_failure;前端只保留 app_locale。离线测试加入 phase/runtime 后,后端过滤结果无 tags。必须同步修改“有限枚举白名单”,而不是放行任意 context。
|
||||
6. **P2:线上噪声与发布维度。** 已通过 Sentry 确认有 1.4.0 后端事件,但同时存在高频 ConnectionResetError。需先区分客户端正常断连与业务失败,不能直接全部屏蔽,也不能用它替代出片失败率。后端 environment=desktop/web,前端=production/development,跨端看板需要显式映射,后端另补受控构建环境标记。
|
||||
|
||||
## 建议事件与属性契约
|
||||
|
||||
新增/扩展固定命名的 `studio_rescreen_*`、`studio_draft_save_*`、`studio_draft_duplicate_*`、`studio_rewrite_*`、`studio_download_saved/failed`、`vision_provider_test_*`。延续现有 import/confirm/export 请求生命周期,明确 accepted 与 finished 的区别。页面打开可用规范化路由,无需重复加每个按钮点击。
|
||||
|
||||
有限属性:source_type、analysis_mode、goal 或固定目标组合、recommendation_mode、outcome、error_code、duration_ms、候选/草稿数量、aspect、subtitle_enabled、固定模板名、runtime、app_version、build_environment。
|
||||
|
||||
- 多目标制作结果按本次运行保存 `requested/succeeded/failed` 目标集合,整体采用 success/partial/failed;不改变已有用户内容或任务执行策略。
|
||||
- 区分真实后端执行耗时与 UI 首次观察耗时,不用轮询间隔伪造精确执行时间。
|
||||
- 新增重试要有本地 attempt 边界。现有外发策略不发送真实项目/计划/任务 ID;如需精确跨阶段任务漏斗,设计单独随机、短生命周期的遥测 ID 并明确契约,不能偷偷复用内部 ID。未具备可靠关联前,只提供事件数与设备数信号,不宣称精确端到端任务转化。
|
||||
- 终态本地去重及传输 insert_id 需要一起设计,避免重启补观察、SDK 重送、重复导出受理造成重复计数。
|
||||
- 采用独立的新版 Studio 契约标记,例如 `studio_schema_version=1`;原版缺此属性。更改 outcome 语义后不与旧事件直接混算,旧 1.3 图表保留为历史。
|
||||
|
||||
Sentry 建议保留经过枚举验证的 area/phase/analysis_mode/goal/error_code/runtime/build_environment。错误栈继续脱敏。配置缺失、用户取消、无合适候选与内部异常分开归组;正常“没有字幕”不应触发高优先级工程事故告警。为前端已捕获的保存/改写/原生下载故障补对应安全分类,但避免前后端对同一请求重复报错。
|
||||
|
||||
不新增视频/字幕/提示词正文、文件路径、来源 URL、密钥、模型回答、录屏或自动 DOM 捕获。不因使用视觉/LLM 能力就默认开启 AI 会话内容追踪。沿用独立统计/崩溃开关,关闭后进行中任务不得补报。
|
||||
|
||||
## 看板安排
|
||||
|
||||
1. 保留现有 1.3 历史基线;为 1.4 单独建 Studio 业务信号/漏斗视图,按 app_version、runtime、契约版本筛选。
|
||||
2. 新能力采用:字幕/视觉,各制作类型,人工调整与改写。
|
||||
3. 失败与恢复:初筛/制作/渲染/下载各阶段的固定错误分类及重试结果。
|
||||
4. 价值终点:渲染完成与桌面保存完成分开;浏览器下载意图单列。发布上传另列。
|
||||
5. Sentry 工程健康:版本回归、未预期后台异常、阶段分布;常见配置问题与高频连接噪声单列。
|
||||
6. UI 关闭、观察列表 TTL/容量、统计关闭导致的缺失继续明示;没有收到事件不等于没有使用。
|
||||
|
||||
## 实施优先级与验收
|
||||
|
||||
**第一批:补可见性。** Studio 后台错误安全上报及白名单、桌面保存结果、重试登记、local 推荐分类和规范化路由;同时准备新版看板查询。
|
||||
|
||||
**第二批:补决策指标。** 分析路线/目标/本次部分成功、编辑/改写/视觉模型设置,以及社交发布链路。
|
||||
|
||||
**第三批:正式包验收。** 同一次生产构建,在明确测试标记下对照操作与两平台实际接收:正常导入、缺模型、网络失败、部分成功、重试、编辑、导出、原生保存成功/失败;重复轮询与重启只产生正确次数,隐私关闭后不收数。先验 macOS,Windows 真机覆盖仍需明确跟进。
|
||||
|
||||
实施进度:Studio 事件、后台错误捕获、Sentry 标签白名单及新版 PostHog 图表已更新;通过真实隔离应用验证两平台接收。未发布新包,未改通知规则,原生双平台正式包验收仍待完成,详见实施记录。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Windows connection reset 修复(待发布)
|
||||
|
||||
日期:2026-09-27;问题:[PYTHON-FASTAPI-3](https://autoclip-ts.sentry.io/issues/PYTHON-FASTAPI-3)。
|
||||
|
||||
## 证据与范围
|
||||
|
||||
正式后端 `autoclip-backend@1.4.0` 最近 7 天查询快照为 121 次,均为同一错误组、desktop 环境;该组跨版本累计约 9,079 次,不能将累计数当成 1.4.0 的次数或用户数。验收版本 `1.4.0-telemetry-validation` 不在此统计中。数据会继续变化。
|
||||
|
||||
代表事件堆栈为 `events.py:_run` → `proactor_events.py:165:_call_connection_lost`。桌面包固定 CPython 3.13.13;同一标准库实现中第 165 行调用 socket.shutdown。原实现若在这里抛出 ConnectionResetError,会跳过 close、server._detach 和 _called_connection_lost 标记。测试调用真实标准库 transport 配合模拟 reset socket,已复现这一清理中断。
|
||||
|
||||
尚不能由脱敏堆栈确定具体请求、用户操作、影响人数或业务任务失败率,也不能把模拟复现视为已复现原用户完整操作。
|
||||
|
||||
## 修复
|
||||
|
||||
在桌面应用创建时,仅对 Windows CPython 3.13 安装一个兼容补丁,保持原 Proactor 清理顺序,只容忍 shutdown 本身的 ConnectionResetError,随后执行关闭和解绑。协议回调错误(包括 ConnectionResetError)及其他 shutdown 错误仍抛出。保留 Proactor 与异步子进程能力,未修改 Sentry 过滤规则。
|
||||
|
||||
补丁依赖 CPython 私有 transport 方法,升级打包 Python 时必须重新审查;3.14 等其他版本不安装此补丁。参考 [CPython 标准库实现](https://github.com/python/cpython/blob/v3.13.13/Lib/asyncio/proactor_events.py)。
|
||||
|
||||
## 验证与发布前检查
|
||||
|
||||
- 本地后端全量:631 passed、1 skipped(Windows IOCP 集成测试在 macOS 跳过),33 条既有弃用告警;隔离数据目录,禁用 Sentry DSN。定向测试 12 passed。
|
||||
- 覆盖原实现清理中断、修复后关闭/解绑/幂等、正常/已关闭 socket/pipe、协议异常保留、未知异常保留、平台与版本范围。
|
||||
- 独立 Windows CI 使用与安装包相同的 Python 版本 3.13.13(GitHub Actions 分发,非最终便携安装包):20 次真实 TCP RST、服务器关闭完成、无事件循环未处理异常,以及异步子进程输出。[CI run 36308742972](https://github.com/zhouxiaoka/autoclip/actions/runs/36308742972) 在修复提交 `c6979acf` 上 **13 passed,无跳过**。
|
||||
- 发布前仍需 Windows 安装包冒烟:启动、导入/制作、取消与退出、重新打开;CI 不能替代 Tauri/WebView2 真机链路。
|
||||
- 小版本发布后按新 release 观察此错误是否复发,并对照 Studio 失败事件。当前没有发布、打 tag 或手动关闭 Sentry issue。
|
||||
@@ -0,0 +1,59 @@
|
||||
# Studio 1.4 监控契约与验收
|
||||
|
||||
2026-09-27,分支 `codex/recent-feedback-fixes`。代码已实施,尚未合入或发布;已安装的 1.4.0 不会自动获得这些改动。
|
||||
|
||||
## PostHog
|
||||
|
||||
- 新看板:[1.4 Studio 使用与交付](https://us.posthog.com/project/450605/dashboard/2140564)。
|
||||
- [业务信号](https://us.posthog.com/project/450605/insights/AnApzd81):新契约、production;旧 1.3 图表数据不覆盖;旧看板说明已链接新看板并澄清历史“代码待发布”字样。
|
||||
- [来源与能力采用](https://us.posthog.com/project/450605/insights/Tiw4nBRZ):文件/链接来源、字幕/视觉路线、制作目标与推荐模式。
|
||||
- [结果与耗时](https://us.posthog.com/project/450605/insights/zbfS3Va8):渲染/保存/发布结果、每次制作产出和执行耗时。
|
||||
- [验收收数](https://us.posthog.com/project/450605/insights/nsKnzRm4):validation 单列,不能加入生产转化率。
|
||||
- 同目录 SQL 为可审查的查询来源。滚动 7 日;SQL 固定日期范围,不受看板日期覆盖器影响。
|
||||
|
||||
所有新增事件保留 `schema_version=2`,另带 `studio_schema_version=1`。`app_version` 来自构建版本,`analytics_environment` 区分 production/development/validation。只有显式 `VITE_TELEMETRY_VALIDATION=true` 的验收进程才使用 validation。
|
||||
|
||||
| 事件 | 语义 |
|
||||
| --- | --- |
|
||||
| `studio_import_*` | 导入请求、受理、请求失败;固定来源与是否自带字幕 |
|
||||
| `studio_plan_update_*` / `studio_rescreen_*` | 修改方案或重新识别,并替换该项目本地观察记录 |
|
||||
| `studio_confirm_*` | 确认制作;记录分析路线、目标布尔值、画幅 |
|
||||
| `studio_screen_finished` | 初筛终态;local/ai 为 recommended,manual/fallback 分开;旧 plan 不算正在重试的完成;复用页面快照避免快速确认漏报,旧在途响应不能完成新 attempt |
|
||||
| `studio_production_finished` | completed/partial/failed,本次 requested/succeeded/failed 目标、结果数量、后端耗时 |
|
||||
| `studio_draft_create/save/duplicate_*` | 用户显式编辑动作;不记录按键、字幕、标题或镜头正文 |
|
||||
| `studio_rewrite_*` | 改写接口结果和请求耗时;不上传提示词/模型回答 |
|
||||
| `studio_export_*` / `studio_export_finished` | 导出受理与渲染终态分开;画幅、固定模板、字幕开关和渲染耗时 |
|
||||
| `studio_download_requested/saved/failed` | web 只有 requested;native 只有实际保存非空文件成功才 saved |
|
||||
| `vision_provider_save/test_*` | 视觉设置保存和测试结果;不上传模型名、地址、key |
|
||||
| `studio_analysis_preferences_*` | 字幕/视觉偏好及快速视觉判断开关 |
|
||||
| `social_publish_*` / `social_publish_finished` | 发布渠道受理与平台结果分开;固定 source_type=studio/legacy;scheduled/inbox/unknown 不算 completed |
|
||||
|
||||
`*` 表示 requested / accepted / request_failed。同步保存/改写/测试的 accepted 表示接口成功返回;导入/确认/导出的 accepted 只表示后台受理。`social_publish_finished` 每个网关在当前发布页面的观察期只报一次;关页后不做后台补报,排期之后是否真的发出需看平台记录。
|
||||
|
||||
页面路由仅发送 `/import/:id`、`/project/:id/studio/:draftId`、`/project/:id/publish/:clipId` 等固定模板。所有 Studio 属性通过枚举/数字/布尔白名单。项目、草稿、计划、作业 ID 留在本地;终态 `$insert_id` 是独立随机观察 token,保持重启去重,不能关联内部 ID,也不能用于跨阶段精确任务漏斗。同一不可变导出作业重复受理不重复计终态。
|
||||
|
||||
制作 `result_count` 仅为本次内容片段/草稿产出数,不是历史草稿数或已渲染文件数。`duration_ms` 为后台执行耗时,不含排队;请求事件另用 `request_duration_ms`。部分失败保留原有后端 `status=failed`,新增 `outcome=partial`,避免改变产品恢复流程。
|
||||
|
||||
观察列表仍受 UI 在线、7 日 TTL、50 项容量和隐私选择约束。新契约没有数据不能推断无人使用,也不能回填旧版本的缺失字段。
|
||||
|
||||
## Sentry
|
||||
|
||||
捕获边界覆盖 Studio 初筛、快速视觉推荐降级、制作、渲染、任务提交、文案改写和视觉连接测试;原生保存故障由前端上报。HTTP 请求的工程异常由后端负责,前端不重复上报同一次保存/改写请求。
|
||||
|
||||
保留固定标签 area=studio、phase、analysis_mode、goal、error_code、runtime、app_mode、build_environment。后端保留 desktop/web 的 environment,桌面启动器显式注入 production/development 的 build_environment,其他部署可设置 `AUTOCLIP_BUILD_ENVIRONMENT`,未设置时 unknown。前端 environment 延续 production/development。
|
||||
|
||||
ValueError 校验、素材缺失、视觉鉴权/限流/拒绝为 warning;其他工程异常保留 error。此分类依据异常类型及受控代码,不解析或上传异常正文。内容管线保留 llm_not_configured、字幕/转写、timeline_empty 等结构化失败码并按 warning 分类,不再包装成无分类的 RuntimeError。普通 ValueError 只能归为 validation;旧导入的 typed failure 分类继续保留。没有屏蔽 ConnectionResetError,也没有调整现有通知接收人或阈值。
|
||||
|
||||
- [Studio 工程异常](https://autoclip-ts.sentry.io/issues/views/226393/)
|
||||
- [Studio 配置/素材警告](https://autoclip-ts.sentry.io/issues/views/226394/)
|
||||
- [本轮验收异常](https://autoclip-ts.sentry.io/issues/PYTHON-FASTAPI-17)
|
||||
|
||||
`before_send` 继续去掉正文、变量、请求、上下文和面包屑,保留文件名/函数/行号。每次发送重新检查崩溃报告开关。共享分析错误在一次多目标制作中只显式捕获一次;监控异常不影响主操作。
|
||||
|
||||
## 验收及限制
|
||||
|
||||
使用独立 `/private/tmp` 数据目录、真实应用工厂与 Vite 页面。仅加载已有监控 DSN/公开项目 key,没有复制用户素材、模型配置或发布账号。用自制损坏 MP4 从真实首页导入:Sentry 收到 PYTHON-FASTAPI-17,栈可读为 jobs.py `_inspect` → planning.py `recommend`;area/phase/build_environment/telemetry_test 标签保留,正文脱敏。PostHog 的 validation 查询实际收到导入受理、修改方案、初筛失败/正常推荐、确认制作和制作失败事件;最终制作失败带 llm_not_configured。替换为自制纯色视频和测试字幕后,重新识别恢复到正常字幕推荐;确认制作后触发真实缺模型配置失败。最初该失败误归 RuntimeError,修复结构化错误码传递后再验,Sentry 收到 [PYTHON-FASTAPI-19](https://autoclip-ts.sentry.io/issues/PYTHON-FASTAPI-19),warning、phase=production、error_code=llm_not_configured。中间的 PYTHON-FASTAPI-18 为测试样本,两个保存视图均排除测试标记。
|
||||
|
||||
自动化验证:全量后端 619 项通过;最后目标计数顺序调整另跑相关 58 项通过。前端 136 项通过,typecheck/lint/build 通过。构建仍有原有大 chunk 提示。
|
||||
|
||||
没有发布新安装包。原生桌面保存已验证代码与回归桩,未在本轮重打 macOS/Windows 正式包;多目标部分成功、隐私关闭与重复观察通过离线回归。未向真实社交账号投稿,也未进行付费模型验收。Sentry 页面首次加载报错,重试后恢复;已保存工程异常与配置/素材警告两个 Issue View,排除 telemetry_test=true。未创建通知规则。
|
||||
@@ -0,0 +1,13 @@
|
||||
SELECT event, properties.source_type AS source_type,
|
||||
properties.analysis_mode AS analysis_mode, properties.goal_content AS content,
|
||||
properties.goal_highlight AS highlight, properties.goal_promo AS promo,
|
||||
properties.recommendation_mode AS recommendation_mode,
|
||||
count() AS events, uniq(distinct_id) AS devices
|
||||
FROM events
|
||||
WHERE timestamp >= now() - INTERVAL 7 DAY
|
||||
AND properties.studio_schema_version = 1
|
||||
AND properties.analytics_environment = 'production'
|
||||
AND event IN ('studio_import_accepted', 'studio_confirm_accepted', 'studio_screen_finished',
|
||||
'studio_rewrite_accepted', 'studio_draft_duplicate_accepted', 'social_publish_accepted')
|
||||
GROUP BY event, source_type, analysis_mode, content, highlight, promo, recommendation_mode
|
||||
ORDER BY events DESC LIMIT 100
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Event/device signals, not a task-correlated funnel. Legacy schema is excluded.
|
||||
SELECT event, properties.analytics_environment AS environment,
|
||||
properties.app_version AS app_version, properties.runtime AS runtime,
|
||||
properties.outcome AS outcome, properties.analysis_mode AS analysis_mode,
|
||||
count() AS events, uniq(distinct_id) AS devices
|
||||
FROM events
|
||||
WHERE timestamp >= now() - INTERVAL 7 DAY
|
||||
AND properties.studio_schema_version = 1
|
||||
AND properties.analytics_environment = 'production'
|
||||
GROUP BY event, environment, app_version, runtime, outcome, analysis_mode
|
||||
ORDER BY events DESC LIMIT 100
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Duration is backend execution time only; null/missing values are excluded by avg.
|
||||
SELECT event, properties.outcome AS outcome, properties.error_code AS error_code,
|
||||
properties.analysis_mode AS analysis_mode,
|
||||
count() AS events, uniq(distinct_id) AS devices,
|
||||
avg(toFloat(properties.duration_ms)) AS avg_duration_ms,
|
||||
sum(toFloat(properties.succeeded_count)) AS succeeded_goals,
|
||||
sum(toFloat(properties.failed_count)) AS failed_goals,
|
||||
sum(toFloat(properties.result_count)) AS outputs
|
||||
FROM events
|
||||
WHERE timestamp >= now() - INTERVAL 7 DAY
|
||||
AND properties.studio_schema_version = 1
|
||||
AND properties.analytics_environment = 'production'
|
||||
AND event IN ('studio_screen_finished', 'studio_production_finished',
|
||||
'studio_export_finished', 'studio_download_saved', 'studio_download_failed', 'social_publish_finished')
|
||||
GROUP BY event, outcome, error_code, analysis_mode
|
||||
ORDER BY events DESC LIMIT 100
|
||||
@@ -0,0 +1,9 @@
|
||||
SELECT event, properties.analytics_environment AS environment,
|
||||
properties.app_version AS app_version, properties.outcome AS outcome,
|
||||
properties.error_code AS error_code, count() AS events, uniq(distinct_id) AS devices
|
||||
FROM events
|
||||
WHERE timestamp >= now() - INTERVAL 7 DAY
|
||||
AND properties.studio_schema_version = 1
|
||||
AND properties.analytics_environment = 'validation'
|
||||
GROUP BY event, environment, app_version, outcome, error_code
|
||||
ORDER BY event LIMIT 100
|
||||
@@ -34,7 +34,8 @@ export function captureBusinessEvent(name: string, properties: Properties = {}):
|
||||
try {
|
||||
return posthog.capture(name, {
|
||||
schema_version: 2,
|
||||
analytics_environment: import.meta.env.DEV ? 'development' : 'production',
|
||||
app_version: import.meta.env.VITE_APP_VERSION || 'unknown',
|
||||
analytics_environment: import.meta.env.VITE_TELEMETRY_VALIDATION === 'true' ? 'validation' : import.meta.env.DEV ? 'development' : 'production',
|
||||
runtime: '__TAURI_INTERNALS__' in window ? 'desktop' : 'web',
|
||||
entrypoint: 'ui',
|
||||
app_locale: typeof document !== 'undefined' ? document.documentElement.lang : 'unknown',
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { captureBusinessEvent } from './posthog'
|
||||
import { workflow } from './observer'
|
||||
import { errorCode } from './workflow'
|
||||
import { errorCode, safeStudioProperties, type Properties, type StudioSnapshot } from './workflow'
|
||||
|
||||
/** Only fixed event names, HTTP error categories and elapsed milliseconds leave the app.
|
||||
/** Only allowlisted categories, booleans, counts and elapsed milliseconds leave the app.
|
||||
* No project/job/operation IDs, URLs, filenames, content or model output are captured. */
|
||||
export async function observeStudioOperation<T>(
|
||||
name: 'studio_import' | 'studio_confirm' | 'studio_export',
|
||||
action: () => Promise<T>, accepted: (result: T) => void,
|
||||
name: 'studio_import' | 'studio_confirm' | 'studio_export' | 'studio_rescreen' | 'studio_plan_update' | 'studio_draft_create' | 'studio_draft_save' | 'studio_draft_duplicate' | 'studio_rewrite' | 'studio_analysis_preferences' | 'vision_provider_test' | 'vision_provider_save' | 'social_publish',
|
||||
action: () => Promise<T>, accepted: (result: T) => void = () => {}, properties: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const props = safeStudioProperties(properties)
|
||||
const started = Date.now(), generation = workflow.generation()
|
||||
const enabled = workflow.active(generation)
|
||||
if (enabled) captureBusinessEvent(`${name}_requested`, {})
|
||||
if (enabled) captureBusinessEvent(`${name}_requested`, props)
|
||||
try {
|
||||
const result = await action()
|
||||
if (enabled && workflow.active(generation)) {
|
||||
captureBusinessEvent(`${name}_accepted`, { request_duration_ms: Date.now() - started })
|
||||
captureBusinessEvent(`${name}_accepted`, { ...props, request_duration_ms: Date.now() - started })
|
||||
try { accepted(result) } catch { /* local watch failures never change the API result */ }
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (enabled && workflow.active(generation)) captureBusinessEvent(`${name}_request_failed`, {
|
||||
error_code: errorCode(error), request_duration_ms: Date.now() - started,
|
||||
...props, error_code: errorCode(error), request_duration_ms: Date.now() - started,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -28,5 +29,71 @@ export async function observeStudioOperation<T>(
|
||||
|
||||
/** Navigation intent only; no claim about successful disk writes. */
|
||||
export function studioDownloadRequested() {
|
||||
captureBusinessEvent('studio_download_requested', {})
|
||||
captureBusinessEvent('studio_download_requested', safeStudioProperties({ download_mode: 'browser' }))
|
||||
}
|
||||
|
||||
export async function observeStudioDownload<T>(action: () => Promise<T>): Promise<T> {
|
||||
const generation = workflow.generation(), started = Date.now()
|
||||
const enabled = workflow.active(generation)
|
||||
const props = safeStudioProperties({ download_mode: 'native' })
|
||||
const emit = (name: string, result: Properties = {}) => {
|
||||
if (enabled && workflow.active(generation)) captureBusinessEvent(name, { ...props, ...result })
|
||||
}
|
||||
emit('studio_download_requested')
|
||||
try {
|
||||
const result = await action()
|
||||
emit('studio_download_saved', { duration_ms: Date.now() - started })
|
||||
return result
|
||||
} catch (error) {
|
||||
emit('studio_download_failed', { duration_ms: Date.now() - started, error_code: errorCode(error) })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function studioImportProperties(body: FormData): Record<string, unknown> {
|
||||
let source_type = 'file'
|
||||
const url = body.get?.('url')
|
||||
if (typeof url === 'string' && url) {
|
||||
source_type = 'other_url'
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
if (host === 'youtu.be' || host === 'youtube.com' || host.endsWith('.youtube.com')) source_type = 'youtube'
|
||||
else if (host === 'b23.tv' || host === 'bilibili.com' || host.endsWith('.bilibili.com')) source_type = 'bilibili'
|
||||
} catch { /* invalid input remains an enum; URL is never captured */ }
|
||||
}
|
||||
return { source_type, has_subtitle: !!body.get?.('subtitle'), goal: body.get?.('goal'), aspect: body.get?.('aspect') || 'auto' }
|
||||
}
|
||||
|
||||
export function studioGoals(goals: string[]): Properties {
|
||||
return { goal_content: goals.includes('content'), goal_highlight: goals.includes('highlight'), goal_promo: goals.includes('promo') }
|
||||
}
|
||||
|
||||
/** Called only for a submission enrolled in this mounted publish page. */
|
||||
export function socialPublishObserved(generation: number, properties: Record<string, unknown>) {
|
||||
if (workflow.active(generation)) captureBusinessEvent('social_publish_finished', safeStudioProperties(properties))
|
||||
}
|
||||
|
||||
export function socialPublishOutcome(status: string, scheduled: boolean, results: {success: boolean; skipped?: boolean; fallback_to_inbox?: boolean}[]): string {
|
||||
if (status === 'failed') return 'failed'
|
||||
const failed = results.some(r => !r.success && !r.skipped)
|
||||
const success = results.some(r => r.success && !r.skipped)
|
||||
if (failed) return success ? 'partial' : 'failed'
|
||||
if (scheduled) return 'scheduled'
|
||||
if (results.some(r => r.fallback_to_inbox)) return 'inbox'
|
||||
// A completed gateway with no platform evidence is not proof of publishing.
|
||||
return success ? 'completed' : 'unknown'
|
||||
}
|
||||
|
||||
/** Reuse the snapshot already shown by the UI; quick confirmations must not
|
||||
* overwrite screening results before the background observer's next poll. */
|
||||
export async function observeStudioWorkspace<T extends StudioSnapshot>(projectId: string, action: () => Promise<T>): Promise<T> {
|
||||
const generation = workflow.generation(), enabled = workflow.active(generation)
|
||||
const watches = enabled ? workflow.list().filter(watch => watch.kind.startsWith('studio-') && (watch.projectId || watch.id) === projectId) : []
|
||||
const snapshot = await action()
|
||||
if (enabled && workflow.active(generation)) {
|
||||
try {
|
||||
for (const watch of watches) workflow.observeStudio(watch, snapshot)
|
||||
} catch { /* telemetry cannot prevent the editor from opening */ }
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
/** Versioned business telemetry. Only explicit UI operations enroll projects. */
|
||||
export type Properties = Record<string, string | number | boolean | null | undefined>
|
||||
export type Watch = { kind: 'project' | 'export' | 'bilibili' | 'youtube' | 'studio-screen' | 'studio-production' | 'studio-export'; id: string; projectId?: string; since: number; seen: string[]; settled?: boolean }
|
||||
export type Watch = { kind: 'project' | 'export' | 'bilibili' | 'youtube' | 'studio-screen' | 'studio-production' | 'studio-export'; id: string; projectId?: string; since: number; seen: string[]; settled?: boolean; properties?: Properties; token?: string }
|
||||
export const WORKFLOW_KEY = 'autoclip.analytics.workflow.v2'
|
||||
const TTL = 7 * 86400000
|
||||
const LIMIT = 50
|
||||
|
||||
/** Explicit property contract: never spread user/model data into a payload. */
|
||||
export function safeStudioProperties(value: Record<string, unknown> | null = {}): Properties {
|
||||
const input = value && typeof value === 'object' ? value : {}
|
||||
const out: Properties = { studio_schema_version: 1 }
|
||||
const enums: Record<string, string[]> = {
|
||||
source_type: ['file', 'youtube', 'bilibili', 'other_url', 'visual_event', 'content_clip', 'studio', 'legacy'],
|
||||
analysis_mode: ['subtitle', 'visual', 'auto'], goal: ['content', 'highlight', 'promo', 'auto'],
|
||||
aspect: ['original', 'portrait', 'landscape', 'auto'],
|
||||
recommendation_mode: ['ai', 'local', 'manual', 'fallback'],
|
||||
subtitle_status: ['available', 'missing', 'invalid', 'unreadable', 'too_large'],
|
||||
outcome: ['completed', 'failed', 'partial', 'recommended', 'manual', 'fallback', 'scheduled', 'inbox', 'unknown'],
|
||||
download_mode: ['native', 'browser'], gateway: ['bilibili', 'upload-post'],
|
||||
title_style: ['plain', 'impact', 'card', 'comic', 'neon', 'arena', 'editorial', 'pixel', 'frosted'],
|
||||
}
|
||||
for (const [key, allowed] of Object.entries(enums)) {
|
||||
if (typeof input[key] === 'string' && allowed.includes(input[key] as string)) out[key] = input[key] as string
|
||||
}
|
||||
for (const key of ['subtitle_enabled', 'has_subtitle', 'goal_content', 'goal_highlight', 'goal_promo', 'allow_visual_screening', 'scheduled', ...['requested', 'succeeded', 'failed'].flatMap(p => ['content', 'highlight', 'promo'].map(g => `${p}_${g}`))]) {
|
||||
if (typeof input[key] === 'boolean') out[key] = input[key] as boolean
|
||||
}
|
||||
for (const key of ['duration_ms', 'request_duration_ms', 'result_count', 'requested_count', 'succeeded_count', 'failed_count']) {
|
||||
if (typeof input[key] === 'number' && Number.isFinite(input[key]) && (input[key] as number) >= 0) out[key] = input[key] as number
|
||||
}
|
||||
for (const prefix of ['requested', 'succeeded', 'failed']) {
|
||||
const goals = input[`${prefix}_goals`]
|
||||
if (Array.isArray(goals)) {
|
||||
const valid = ['content', 'highlight', 'promo'].filter(g => goals.includes(g))
|
||||
out[`${prefix}_count`] = valid.length
|
||||
for (const goal of ['content', 'highlight', 'promo']) out[`${prefix}_${goal}`] = valid.includes(goal)
|
||||
}
|
||||
}
|
||||
if (typeof input.error_code === 'string' && /^(http_[45][0-9]{2}|network|timeout|unknown|validation|missing_resource|unexpected|connection|authentication|rate_limited|provider_error|invalid_response|output_truncated|refused|multiple|llm_not_configured|whisper_not_installed|whisper_install_failed|transcription_empty|subtitle_setup|timeline_empty)$/.test(input.error_code)) out.error_code = input.error_code
|
||||
return out
|
||||
}
|
||||
|
||||
export function errorCode(error: unknown): string {
|
||||
const e = error as { code?: string; response?: { status?: number } } | undefined
|
||||
if (e?.response?.status) return `http_${e.response.status}`
|
||||
const status = e?.response?.status
|
||||
if (typeof status === 'number' && Number.isInteger(status) && status >= 400 && status <= 599) return `http_${status}`
|
||||
if (e?.code === 'ECONNABORTED' || e?.code === 'ETIMEDOUT') return 'timeout'
|
||||
if (e?.code === 'ERR_NETWORK') return 'network'
|
||||
return 'unknown'
|
||||
@@ -21,6 +57,9 @@ export function utcMillis(value?: string | null): number | undefined {
|
||||
|
||||
export function routeName(path: string): string {
|
||||
const p = path.split(/[?#]/)[0]
|
||||
if (/^\/project\/[^/]+\/publish(?:\/[^/]+)?\/?$/.test(p)) return '/project/:id/publish/:clipId'
|
||||
if (/^\/import\/[^/]+\/?$/.test(p)) return '/import/:id'
|
||||
if (/^\/project\/[^/]+\/studio\/[^/]+\/?$/.test(p)) return '/project/:id/studio/:draftId'
|
||||
if (/^\/project\/[^/]+\/?$/.test(p)) return '/project/:id'
|
||||
return ['/', '/settings'].includes(p) ? p : '/other'
|
||||
}
|
||||
@@ -31,9 +70,9 @@ export interface TaskSnapshot {
|
||||
}
|
||||
|
||||
export interface StudioSnapshot {
|
||||
plan?: { id: string; mode?: string }
|
||||
analysis?: { status: string } | null
|
||||
jobs?: { job_id: string; status: string }[]
|
||||
plan?: { id: string; mode?: string; confirmed_analysis?: string; recommended_analysis?: string; local_evidence?: { subtitle_status?: string } }
|
||||
analysis?: { status: string; outcome?: string; duration_ms?: number; error_code?: string; requested_goals?: string[]; succeeded_goals?: string[]; failed_goals?: string[]; result_count?: number } | null
|
||||
jobs?: { job_id: string; status: string; duration_ms?: number; error_code?: string }[]
|
||||
}
|
||||
|
||||
/** Storage and capture are injected so offline/privacy/replay behavior is testable. */
|
||||
@@ -74,42 +113,49 @@ export class WorkflowTracker {
|
||||
this.prune()
|
||||
return [...this.watches]
|
||||
}
|
||||
watch(kind: Watch['kind'], id: string, projectId?: string, since = this.now()): void {
|
||||
watch(kind: Watch['kind'], id: string, projectId?: string, since = this.now(), properties: Properties = {}, restart = false): void {
|
||||
if (!this.enabled() || !id) return
|
||||
this.prune()
|
||||
const existing = this.watches.find(w => w.kind === kind && w.id === id)
|
||||
if (existing && !existing.settled) return
|
||||
if (existing && (!existing.settled || kind === 'studio-export') && !restart) return
|
||||
if (existing) this.watches = this.watches.filter(w => w !== existing)
|
||||
this.watches.push({ kind, id, projectId, since, seen: [] })
|
||||
this.watches.push({ kind, id, projectId, since, seen: [], properties: safeStudioProperties(properties), token: `${this.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` })
|
||||
this.prune()
|
||||
this.persist()
|
||||
}
|
||||
emitOnce(w: Watch, key: string, event: string, properties: Properties): void {
|
||||
if (!this.enabled() || !this.watches.includes(w) || w.seen.includes(key) || w.seen.length >= 2000) return
|
||||
if (this.capture(event, w.kind.startsWith('studio-') ? { outcome: properties.outcome } : { ...properties, $insert_id: `autoclip-v2:${w.kind}:${w.id}:${key}` })) {
|
||||
if (this.capture(event, w.kind.startsWith('studio-') ? { ...safeStudioProperties(w.properties), ...safeStudioProperties(properties), ...(w.token && /^[a-z0-9-]{10,100}$/.test(w.token) ? { $insert_id: `studio-v1:${w.token}:${key}` } : {}) } : { ...properties, $insert_id: `autoclip-v2:${w.kind}:${w.id}:${key}` })) {
|
||||
w.seen.push(key)
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
/** IDs remain in local watches only; external payload is a fixed outcome enum. */
|
||||
/** IDs remain in local watches only; external payload follows the versioned aggregate contract. */
|
||||
observeStudio(w: Watch, snapshot: StudioSnapshot): void {
|
||||
if (w.settled) return
|
||||
let event: string | undefined
|
||||
let outcome: string | undefined
|
||||
let details: Record<string, unknown> = {}
|
||||
if (w.kind === 'studio-export') {
|
||||
const job = snapshot.jobs?.find(j => j.job_id === w.id)
|
||||
if (job && ['completed', 'failed'].includes(job.status)) {
|
||||
event = 'studio_export_finished'; outcome = job.status
|
||||
event = 'studio_export_finished'; outcome = job.status; details = { duration_ms: job.duration_ms, error_code: job.error_code }
|
||||
}
|
||||
} else if (w.kind === 'studio-screen') {
|
||||
if (snapshot.plan?.id) { event = 'studio_screen_finished'; outcome = snapshot.plan.mode === 'ai' ? 'recommended' : 'manual_fallback' }
|
||||
else if (snapshot.analysis?.status === 'failed') { event = 'studio_screen_finished'; outcome = 'failed' }
|
||||
details = { duration_ms: snapshot.analysis?.duration_ms, error_code: snapshot.analysis?.error_code }
|
||||
if (snapshot.analysis?.status === 'failed') { event = 'studio_screen_finished'; outcome = 'failed' }
|
||||
else if (snapshot.analysis?.status === 'awaiting_confirmation' && snapshot.plan?.id) {
|
||||
event = 'studio_screen_finished'
|
||||
outcome = ['ai', 'local'].includes(snapshot.plan.mode || '') ? 'recommended' : snapshot.plan.mode
|
||||
details = { ...details, recommendation_mode: snapshot.plan.mode, analysis_mode: snapshot.plan.recommended_analysis, subtitle_status: snapshot.plan.local_evidence?.subtitle_status }
|
||||
}
|
||||
} else if (w.kind === 'studio-production' && snapshot.plan?.id === w.id &&
|
||||
['completed', 'failed'].includes(snapshot.analysis?.status || '')) {
|
||||
event = 'studio_production_finished'; outcome = snapshot.analysis!.status
|
||||
event = 'studio_production_finished'; outcome = snapshot.analysis!.outcome || snapshot.analysis!.status
|
||||
details = { ...snapshot.analysis, analysis_mode: snapshot.plan.confirmed_analysis }
|
||||
}
|
||||
if (event) {
|
||||
this.emitOnce(w, 'finished', event, { outcome })
|
||||
this.emitOnce(w, 'finished', event, safeStudioProperties({ ...details, outcome }))
|
||||
if (w.seen.includes('finished')) { w.settled = true; this.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { projectApi, bilibiliApi, VideoCategory, BilibiliDownloadTask } from '..
|
||||
import { useProjectStore } from '../store/useProjectStore'
|
||||
import { applyCategoryResponse } from '../utils/videoCategories'
|
||||
import VideoCategoryPicker from './VideoCategoryPicker'
|
||||
import { getVideoType, validateVideoUrl } from '../utils/videoUrl'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -73,50 +74,6 @@ const BilibiliDownload: React.FC<BilibiliDownloadProps> = ({ onDownloadSuccess }
|
||||
}
|
||||
}, [pollingInterval])
|
||||
|
||||
const validateVideoUrl = (url: string): boolean => {
|
||||
const bilibiliPatterns = [
|
||||
/^https?:\/\/www\.bilibili\.com\/video\/[Bb][Vv][0-9A-Za-z]+/,
|
||||
/^https?:\/\/bilibili\.com\/video\/[Bb][Vv][0-9A-Za-z]+/,
|
||||
/^https?:\/\/b23\.tv\/[0-9A-Za-z]+/,
|
||||
/^https?:\/\/www\.bilibili\.com\/video\/av\d+/,
|
||||
/^https?:\/\/bilibili\.com\/video\/av\d+/
|
||||
]
|
||||
|
||||
const youtubePatterns = [
|
||||
/^https?:\/\/(www\.)?youtube\.com\/watch\?v=[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/youtu\.be\/[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/(www\.)?youtube\.com\/embed\/[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/(www\.)?youtube\.com\/v\/[a-zA-Z0-9_-]+/
|
||||
]
|
||||
|
||||
return bilibiliPatterns.some(pattern => pattern.test(url)) ||
|
||||
youtubePatterns.some(pattern => pattern.test(url))
|
||||
}
|
||||
|
||||
const getVideoType = (url: string): 'bilibili' | 'youtube' | null => {
|
||||
const bilibiliPatterns = [
|
||||
/^https?:\/\/www\.bilibili\.com\/video\/[Bb][Vv][0-9A-Za-z]+/,
|
||||
/^https?:\/\/bilibili\.com\/video\/[Bb][Vv][0-9A-Za-z]+/,
|
||||
/^https?:\/\/b23\.tv\/[0-9A-Za-z]+/,
|
||||
/^https?:\/\/www\.bilibili\.com\/video\/av\d+/,
|
||||
/^https?:\/\/bilibili\.com\/video\/av\d+/
|
||||
]
|
||||
|
||||
const youtubePatterns = [
|
||||
/^https?:\/\/(www\.)?youtube\.com\/watch\?v=[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/youtu\.be\/[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/(www\.)?youtube\.com\/embed\/[a-zA-Z0-9_-]+/,
|
||||
/^https?:\/\/(www\.)?youtube\.com\/v\/[a-zA-Z0-9_-]+/
|
||||
]
|
||||
|
||||
if (bilibiliPatterns.some(pattern => pattern.test(url))) {
|
||||
return 'bilibili'
|
||||
} else if (youtubePatterns.some(pattern => pattern.test(url))) {
|
||||
return 'youtube'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const parseVideoInfo = async () => {
|
||||
if (!url.trim()) {
|
||||
setError(t("请输入正确的视频链接"))
|
||||
|
||||
@@ -54,7 +54,7 @@ export function initSentry(): void {
|
||||
type: event.type, event_id: event.event_id, timestamp: event.timestamp, platform: event.platform,
|
||||
level: event.level, release: event.release, environment: event.environment,
|
||||
sdk: event.sdk, debug_meta: event.debug_meta,
|
||||
tags: { app_locale: typeof document !== "undefined" ? document.documentElement.lang : "unknown" },
|
||||
tags: { ...(event.tags?.area === 'studio' && event.tags?.phase === 'native_download' ? { area: 'studio', phase: 'native_download' } : {}), app_locale: typeof document !== "undefined" ? document.documentElement.lang : "unknown" },
|
||||
exception: { values: event.exception?.values?.map(value => ({
|
||||
type: value.type, value: '[message omitted for privacy]',
|
||||
stacktrace: { frames: value.stacktrace?.frames?.map(frame => ({
|
||||
@@ -84,3 +84,15 @@ export function captureException(error: unknown): void {
|
||||
if (!initialized || !isCrashReportsEnabled()) return
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
/** Native disk failures have no backend exception; report them once here. */
|
||||
export function captureStudioException(error: unknown, phase: 'native_download'): void {
|
||||
if (!initialized || !isCrashReportsEnabled()) return
|
||||
try {
|
||||
Sentry.withScope(scope => {
|
||||
scope.setTag('area', 'studio')
|
||||
scope.setTag('phase', phase)
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
} catch { /* monitoring must not break the download recovery UI */ }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { message } from 'antd'
|
||||
import { studioDownloadRequested } from '../../analytics/studio'
|
||||
import { studioDownloadRequested, observeStudioDownload } from '../../analytics/studio'
|
||||
import { captureStudioException } from '../../desktop/sentry'
|
||||
import { studioApi } from './api'
|
||||
import { isDesktopDownload, saveStudioExport } from './nativeDownload'
|
||||
|
||||
@@ -19,11 +20,11 @@ export default function StudioDownloadLink({ projectId, jobId, className = 'stud
|
||||
if (pending.current) return
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
studioDownloadRequested()
|
||||
try {
|
||||
await saveStudioExport(projectId, jobId)
|
||||
await observeStudioDownload(() => saveStudioExport(projectId, jobId))
|
||||
message.success(t('已保存到下载文件夹'))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
captureStudioException(error, 'native_download')
|
||||
message.error(t('下载失败,请稍后重试'))
|
||||
} finally {
|
||||
pending.current = false
|
||||
|
||||
@@ -4,7 +4,7 @@ import StudioDownloadLink from './StudioDownloadLink'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Btn, Dialog, ProgressLine, Row, fmtDuration } from '../../ui'
|
||||
import { studioApi, errorText } from './api'
|
||||
import { studioApi, errorText, type SourcePreview } from './api'
|
||||
import { useWorkspace } from './useWorkspace'
|
||||
import { Draft, Scene, languages, draftDuration, draftError, moveScene, applyCandidate, portraitDesign } from './types'
|
||||
import CandidatePicker from './CandidatePicker'
|
||||
@@ -34,6 +34,29 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
|
||||
const [showVariant, setShowVariant] = useState(false)
|
||||
const [showExport, setShowExport] = useState(false)
|
||||
const [showRendered, setShowRendered] = useState(false)
|
||||
const [playbackError, setPlaybackError] = useState(false)
|
||||
const [sourcePreview, setSourcePreview] = useState<SourcePreview>({status: 'idle'})
|
||||
useEffect(() => {
|
||||
if (!['queued', 'running'].includes(sourcePreview.status)) return
|
||||
let cancelled = false
|
||||
const timer = window.setInterval(() => {
|
||||
studioApi.previewStatus(projectId).then(state => {
|
||||
if (cancelled) return
|
||||
setSourcePreview(state)
|
||||
if (state.status === 'completed') setPlaybackError(false)
|
||||
}).catch(error => { if (!cancelled) setSourcePreview({status:'failed', error:errorText(error)}) })
|
||||
}, 1500)
|
||||
return () => { cancelled = true; window.clearInterval(timer) }
|
||||
}, [projectId, sourcePreview.status])
|
||||
const preparePreview = async () => {
|
||||
setSourcePreview({status:'queued'})
|
||||
try {
|
||||
const state = await studioApi.preparePreview(projectId)
|
||||
setSourcePreview(state)
|
||||
if (state.status === 'completed') setPlaybackError(false)
|
||||
} catch(error) { setSourcePreview({status:'failed', error:errorText(error)}) }
|
||||
}
|
||||
|
||||
const [suggestion, setSuggestion] = useState<Draft | null>(null)
|
||||
const [undo, setUndo] = useState<Draft | null>(null)
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
@@ -86,7 +109,7 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
|
||||
const render = async () => {
|
||||
await perform('render', async () => {
|
||||
const savedDraft = await save()
|
||||
await studioApi.export(projectId, savedDraft.id, savedDraft.revision)
|
||||
await studioApi.export(projectId, savedDraft.id, savedDraft.revision, savedDraft)
|
||||
refresh(); setNotice("渲染已开始,可以离开页面,之后在导出记录查看")
|
||||
})
|
||||
}
|
||||
@@ -101,10 +124,16 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
|
||||
<button className="ac-back" onClick={() => navigate(`/project/${projectId}`)}>{t("‹ 返回项目")}</button>
|
||||
<header className="studio-row studio-editor-head"><div><h1 className="ac-title">{draft.title}</h1><span className="studio-muted">{dirty ? t("有修改未保存 · 本机暂存") : t("草稿已保存")} · V{draft.revision} · {fmtDuration(draftDuration(draft))}</span></div><div className="studio-actions"><Btn disabled={!!busy} onClick={() => setShowVariant(true)}>{t("另存为新版本")}</Btn><Btn disabled={!!busy || !dirty} loading={busy==='save'} onClick={() => perform('save', async () => {await save()})}>{t("保存草稿")}</Btn><Btn variant="cta" disabled={!!busy} onClick={() => setShowExport(true)}>{t("导出成片")}</Btn></div></header>
|
||||
{loadError && <p className="studio-error">{t("任务状态暂时无法更新:")}{t(loadError)}</p>}
|
||||
{!showRendered && (sourcePreview.status !== 'completed' || playbackError) && <details open={playbackError || ['queued', 'running', 'failed'].includes(sourcePreview.status)} className="studio-muted">
|
||||
<summary>{t('生成兼容预览')}</summary>
|
||||
<p>{t('原片无法播放时,可生成兼容预览;原片不变,不调用模型。')}</p>
|
||||
{sourcePreview.error && <p className="studio-error">{t(sourcePreview.error)}</p>}
|
||||
<Btn disabled={['queued', 'running'].includes(sourcePreview.status)} onClick={preparePreview}>{t(['queued', 'running'].includes(sourcePreview.status) ? '正在生成兼容预览,长视频可能需要几分钟…' : '生成兼容预览')}</Btn>
|
||||
</details>}
|
||||
<fieldset disabled={!!busy} className="studio-fieldset">
|
||||
<div className="studio-editor-grid"><section><div className={`studio-stage studio-stage--${draft.aspect}`}>
|
||||
<div className="studio-video-frame" style={{aspectRatio: draft.aspect==='portrait'?'9/16':draft.aspect==='landscape'?'16/9':undefined}}>
|
||||
<video ref={video} controls preload="metadata" muted={!draft.original_audio} src={showRendered && previewUrl ? previewUrl : studioApi.source(projectId)} style={{objectFit: showRendered || draft.layout!=='crop'?'contain':'cover', objectPosition:`${(draft.crop_x ?? .5)*100}% 50%`}} onLoadedMetadata={() => { if(video.current && scene && !showRendered) { setSourceDuration(video.current.duration); video.current.currentTime=scene.start } }} onTimeUpdate={() => {const v=video.current; if(v && scene && !showRendered && v.currentTime>=scene.end) {v.pause();v.currentTime=scene.start}}} />
|
||||
<video ref={video} controls preload="metadata" muted={!draft.original_audio} onError={() => setPlaybackError(true)} onLoadedData={() => setPlaybackError(false)} src={showRendered && previewUrl ? previewUrl : sourcePreview.status === 'completed' && sourcePreview.version ? studioApi.compatibleSource(projectId, sourcePreview.version) : studioApi.source(projectId)} style={{objectFit: showRendered || draft.layout!=='crop'?'contain':'cover', objectPosition:`${(draft.crop_x ?? .5)*100}% 50%`}} onLoadedMetadata={() => { if(video.current && scene && !showRendered) { setSourceDuration(video.current.duration); video.current.currentTime=scene.start } }} onTimeUpdate={() => {const v=video.current; if(v && scene && !showRendered && v.currentTime>=scene.end) {v.pause();v.currentTime=scene.start}}} />
|
||||
{!showRendered && selected===0 && draft.hook && !artworkStyle && <div className={`studio-hook studio-hook--${draft.title_style || 'plain'}`}>{draft.hook}</div>}
|
||||
{!showRendered && selected===0 && draft.hook && artworkStyle && <TitleArtwork projectId={projectId} draft={draft}/>}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,6 @@ export default function StudioResults({ project, children, onCreateCollection, o
|
||||
<details className="studio-details studio-source-details"><summary>{t("原素材")}</summary><video controls preload="none" className="studio-source-video" src={studioApi.source(project.id)}/></details>
|
||||
<ExportHistory projectId={project.id} jobs={workspace.jobs} open={history} onClose={()=>setHistory(false)}/>
|
||||
<Dialog open={!!preview} title={preview?.label||t("原片")} onClose={()=>setPreview(null)}>{preview&&<video key={preview.id} controls preload="metadata" className="studio-source-video" src={`${studioApi.source(project.id)}#t=${preview.start},${preview.end}`}/>}<p className="studio-muted">{preview?.evidence}</p></Dialog>
|
||||
<Dialog open={!!exporting} title={t("导出成片")} onClose={()=>!busy&&setExporting(null)} description={t("按当前草稿设置渲染,完成后在导出记录下载。")} footer={<div className="studio-actions"><Btn disabled={!!busy} onClick={()=>setExporting(null)}>{t("关闭")}</Btn><Btn variant="cta" disabled={!!busy} loading={busy==='export'} onClick={()=>exporting&&act('export',async()=>{await studioApi.export(project.id,exporting.id,exporting.revision);setExporting(null);setHistory(true)})}>{t("确认导出")}</Btn></div>}><p>{exporting?.title}</p><p className="studio-muted">{exporting?.aspect==='portrait'?t("9:16 竖屏"):exporting?.aspect==='landscape'?t("16:9 横屏"):t("原画幅")} · MP4 · 30 fps</p>{actionError&&<p className="studio-error">{actionError}</p>}</Dialog>
|
||||
<Dialog open={!!exporting} title={t("导出成片")} onClose={()=>!busy&&setExporting(null)} description={t("按当前草稿设置渲染,完成后在导出记录下载。")} footer={<div className="studio-actions"><Btn disabled={!!busy} onClick={()=>setExporting(null)}>{t("关闭")}</Btn><Btn variant="cta" disabled={!!busy} loading={busy==='export'} onClick={()=>exporting&&act('export',async()=>{await studioApi.export(project.id,exporting.id,exporting.revision, exporting);setExporting(null);setHistory(true)})}>{t("确认导出")}</Btn></div>}><p>{exporting?.title}</p><p className="studio-muted">{exporting?.aspect==='portrait'?t("9:16 竖屏"):exporting?.aspect==='landscape'?t("16:9 横屏"):t("原画幅")} · MP4 · 30 fps</p>{actionError&&<p className="studio-error">{actionError}</p>}</Dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { t } from '../../i18n'
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../../services/api'
|
||||
import { observeStudioOperation } from '../../analytics/studio'
|
||||
import axios from 'axios'
|
||||
import { Btn, Row, Section } from '../../ui'
|
||||
import { errorText } from './api'
|
||||
@@ -22,10 +23,10 @@ export default function VisionSettings() {
|
||||
const body = { base_url: config.base_url.trim(), model: config.model.trim(), timeout: config.timeout, api_key: key.trim() || null, clear_key: clearKey }
|
||||
try {
|
||||
if (action === 'save') {
|
||||
const { data: result } = await axios.put<Config>(`${api.defaults.baseURL}/studio/vision-settings`, body)
|
||||
const { data: result } = await observeStudioOperation('vision_provider_save', () => axios.put<Config>(`${api.defaults.baseURL}/studio/vision-settings`, body))
|
||||
setConfig(result); setKey(''); setClearKey(false); setNotice("视觉模型配置已保存")
|
||||
} else {
|
||||
await axios.post(`${api.defaults.baseURL}/studio/vision-settings/test`, body, { timeout: (config.timeout + 10) * 1000 })
|
||||
await observeStudioOperation('vision_provider_test', () => axios.post(`${api.defaults.baseURL}/studio/vision-settings/test`, body, { timeout: (config.timeout + 10) * 1000 }))
|
||||
setNotice("连接与图片理解测试通过。若修改了配置,请保存后生效")
|
||||
}
|
||||
} catch(e) { setError(t(errorText(e))) } finally { setBusy('') }
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { observeStudioOperation } from '../../analytics/studio'
|
||||
import { observeStudioOperation, studioImportProperties, studioGoals, observeStudioWorkspace } from '../../analytics/studio'
|
||||
import { workflow } from '../../analytics/observer'
|
||||
import api from '../../services/api'
|
||||
import { Draft, Workspace, RenderJob, Language, CandidateList, ImportOptions, Goal, AnalysisMode, AnalysisPreferences } from './types'
|
||||
export type SourcePreview = { status: 'idle' | 'queued' | 'running' | 'completed' | 'failed'; version?: string; error?: string }
|
||||
export const studioApi = {
|
||||
preparePreview: (pid: string): Promise<SourcePreview> => api.post(`/studio/${pid}/source-preview`),
|
||||
previewStatus: (pid: string): Promise<SourcePreview> => api.get(`/studio/${pid}/source-preview`),
|
||||
compatibleSource: (pid: string, version: string) => `${api.defaults.baseURL}/studio/${pid}/source-preview/video?v=${version}`,
|
||||
analysisPreferences: (): Promise<AnalysisPreferences> => api.get('/studio/analysis-preferences'),
|
||||
saveAnalysisPreferences: (body: AnalysisPreferences): Promise<AnalysisPreferences> => api.put('/studio/analysis-preferences', body),
|
||||
saveAnalysisPreferences: (body: AnalysisPreferences): Promise<AnalysisPreferences> => observeStudioOperation('studio_analysis_preferences', () => api.put('/studio/analysis-preferences', body), undefined, { analysis_mode: body.analysis_mode, allow_visual_screening: body.allow_visual_screening }),
|
||||
source: (pid: string) => `${api.defaults.baseURL}/studio/${pid}/source`,
|
||||
capabilities: (): Promise<{ visual_analysis: boolean; visual_model: string }> => api.get('/studio/capabilities'),
|
||||
get: (pid: string, signal?: AbortSignal): Promise<Workspace> => api.get(`/studio/${pid}`, { signal }),
|
||||
get: (pid: string, signal?: AbortSignal): Promise<Workspace> => observeStudioWorkspace(pid, () => api.get(`/studio/${pid}`, { signal })),
|
||||
titleThumbnail: (style: string, version = 6) => `${api.defaults.baseURL}/studio/title-presets/${style}/thumbnail?v=${version}`,
|
||||
titlePreview: (pid: string, draft: Draft, signal?: AbortSignal, layer = 'artwork'): Promise<Blob> => api.post(`/studio/${pid}/title-preview?layer=${layer}`, draft, {responseType:'blob', signal}),
|
||||
candidates: (pid: string, signal?: AbortSignal): Promise<CandidateList> => api.get(`/studio/${pid}/candidates`, { signal }),
|
||||
import: (body: FormData): Promise<{ project_id: string }> => observeStudioOperation('studio_import', () => api.post('/studio/import', body, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }), (result: { project_id: string }) => workflow.watch('studio-screen', result.project_id)),
|
||||
confirmPlan: (pid: string, planId: string, goals: Goal[], options: ImportOptions, analysisMode: AnalysisMode) => observeStudioOperation('studio_confirm', () => api.post(`/studio/${pid}/start`, {plan_id:planId, goals, analysis_mode:analysisMode, language:options.language, aspect:options.aspect, duration:options.duration}), () => workflow.watch('studio-production', planId, pid)),
|
||||
correctPlan: (pid: string, body: ImportOptions) => api.put(`/studio/${pid}/plan`, body),
|
||||
analyze: (pid: string) => api.post(`/studio/${pid}/analyze`),
|
||||
create: (pid: string, clip_ids: string[], title: string, reuse_existing = false): Promise<Draft> => api.post(`/studio/${pid}/drafts`, { clip_ids, title, reuse_existing }),
|
||||
eventDraft: (pid: string, id: string): Promise<Draft> => api.post(`/studio/${pid}/events/${id}/draft`),
|
||||
save: (pid: string, draft: Draft): Promise<Draft> => api.put(`/studio/${pid}/drafts/${draft.id}`, draft),
|
||||
duplicate: (pid: string, draft: Draft, title: string, language: Language): Promise<Draft> => api.post(`/studio/${pid}/drafts/${draft.id}/duplicate`, { draft, title, language }),
|
||||
rewrite: (pid: string, draft: Draft, instruction: string): Promise<Draft> => api.post(`/studio/${pid}/rewrite`, { draft, instruction }, { timeout: 310000 }),
|
||||
export: (pid: string, draft: string, revision: number): Promise<RenderJob> => observeStudioOperation('studio_export', () => api.post(`/studio/${pid}/drafts/${draft}/export`, { revision }), (job: RenderJob) => workflow.watch('studio-export', job.job_id, pid)),
|
||||
import: (body: FormData): Promise<{ project_id: string }> => observeStudioOperation('studio_import', () => api.post('/studio/import', body, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }), (result: { project_id: string }) => workflow.watch('studio-screen', result.project_id, undefined, undefined, studioImportProperties(body) as Record<string, string | boolean>), studioImportProperties(body)),
|
||||
confirmPlan: (pid: string, planId: string, goals: Goal[], options: ImportOptions, analysisMode: AnalysisMode) => observeStudioOperation('studio_confirm', () => api.post(`/studio/${pid}/start`, {plan_id:planId, goals, analysis_mode:analysisMode, language:options.language, aspect:options.aspect, duration:options.duration}), () => workflow.watch('studio-production', planId, pid, undefined, { analysis_mode: analysisMode, ...studioGoals(goals) }), { analysis_mode: analysisMode, ...studioGoals(goals), aspect: options.aspect || 'auto' }),
|
||||
correctPlan: (pid: string, body: ImportOptions) => observeStudioOperation('studio_plan_update', () => api.put(`/studio/${pid}/plan`, body), () => workflow.watch('studio-screen', pid, undefined, undefined, {}, true), {goal: body.goal, aspect: body.aspect || 'auto'}),
|
||||
analyze: (pid: string) => observeStudioOperation('studio_rescreen', () => api.post(`/studio/${pid}/analyze`), () => workflow.watch('studio-screen', pid, undefined, undefined, {}, true)),
|
||||
create: (pid: string, clip_ids: string[], title: string, reuse_existing = false): Promise<Draft> => observeStudioOperation('studio_draft_create', () => api.post(`/studio/${pid}/drafts`, { clip_ids, title, reuse_existing }), undefined, {source_type: 'content_clip'}),
|
||||
eventDraft: (pid: string, id: string): Promise<Draft> => observeStudioOperation('studio_draft_create', () => api.post(`/studio/${pid}/events/${id}/draft`), undefined, {source_type: 'visual_event'}),
|
||||
save: (pid: string, draft: Draft): Promise<Draft> => observeStudioOperation('studio_draft_save', () => api.put(`/studio/${pid}/drafts/${draft.id}`, draft), undefined, { aspect: draft.aspect, subtitle_enabled: draft.subtitles, title_style: draft.title_style }),
|
||||
duplicate: (pid: string, draft: Draft, title: string, language: Language): Promise<Draft> => observeStudioOperation('studio_draft_duplicate', () => api.post(`/studio/${pid}/drafts/${draft.id}/duplicate`, { draft, title, language }), undefined, { aspect: draft.aspect, subtitle_enabled: draft.subtitles, title_style: draft.title_style }),
|
||||
rewrite: (pid: string, draft: Draft, instruction: string): Promise<Draft> => observeStudioOperation('studio_rewrite', () => api.post(`/studio/${pid}/rewrite`, { draft, instruction }, { timeout: 310000 }), undefined, { aspect: draft.aspect, subtitle_enabled: draft.subtitles, title_style: draft.title_style }),
|
||||
export: (pid: string, draft: string, revision: number, settings?: Pick<Draft, 'aspect' | 'subtitles' | 'title_style'>): Promise<RenderJob> => observeStudioOperation('studio_export', () => api.post(`/studio/${pid}/drafts/${draft}/export`, { revision }), (job: RenderJob) => workflow.watch('studio-export', job.job_id, pid, undefined, { aspect: settings?.aspect, subtitle_enabled: settings?.subtitles, title_style: settings?.title_style }), { aspect: settings?.aspect, subtitle_enabled: settings?.subtitles, title_style: settings?.title_style }),
|
||||
thumbnail: (pid: string, draft: string, revision: number, job?: string) => `${api.defaults.baseURL}/studio/${pid}/drafts/${draft}/thumbnail?revision=${revision}${job ? `&job_id=${job}` : ''}`,
|
||||
video: (pid: string, jid: string, download = false) => `${api.defaults.baseURL}/studio/${pid}/exports/${jid}/video${download ? '?download=true' : ''}`,
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Subtitle promos make one additional text-model request for copy, billed by your provider. Review before use.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Usable subtitles found. Semantic editing is suggested; content quality has not been assessed and no model was called.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "No subtitles found. Transcription or supplied subtitles are needed; usable speech is not yet confirmed. You can select output types manually.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Subtitles did not pass the quick check, so nothing is preselected. Check the subtitles or select manually; the source is preserved."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Subtitles did not pass the quick check, so nothing is preselected. Check the subtitles or select manually; the source is preserved.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "Could not start the import. Please retry; your source and existing exports are preserved.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "If the source cannot play, create a compatible preview. The original stays unchanged; no AI is used.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "Preparing preview; long videos may take a few minutes…",
|
||||
"生成兼容预览": "Create compatible preview"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Las promociones con subtítulos hacen una petición adicional al modelo de texto, facturada por el proveedor. Revisa antes de usar.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Se encontraron subtítulos utilizables. Se sugiere edición semántica; no se evaluó la calidad ni se llamó a un modelo.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "No hay subtítulos. Se necesita transcripción o un archivo; aún no se confirmó habla utilizable. Puedes elegir tipos manualmente.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Los subtítulos no pasaron la revisión rápida; nada se preseleccionó. Revísalos o elige manualmente; se conserva el original."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Los subtítulos no pasaron la revisión rápida; nada se preseleccionó. Revísalos o elige manualmente; se conserva el original.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "No se pudo iniciar la importación. Inténtalo de nuevo; el original y las exportaciones existentes se conservan.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "Si el original no se reproduce, crea una vista previa compatible. No se modifica el original ni se usa IA.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "Preparando vista previa; puede tardar unos minutos…",
|
||||
"生成兼容预览": "Crear vista previa compatible"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Les promos basées sur les sous-titres font un appel texte supplémentaire, facturé par le fournisseur. Relisez avant utilisation.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Sous-titres exploitables détectés. Montage sémantique suggéré ; qualité non évaluée, aucun modèle appelé.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Aucun sous-titre trouvé. Transcription ou fichier requis ; parole exploitable non confirmée. Vous pouvez choisir les types manuellement.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Les sous-titres n’ont pas passé la vérification rapide ; aucune présélection. Vérifiez-les ou choisissez manuellement ; la source est conservée."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Les sous-titres n’ont pas passé la vérification rapide ; aucune présélection. Vérifiez-les ou choisissez manuellement ; la source est conservée.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "Impossible de démarrer l’importation. Réessayez ; la source et les exports existants sont conservés.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "Si la source ne se lit pas, créez un aperçu compatible. Original inchangé, sans IA.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "Préparation de l’aperçu ; cela peut prendre quelques minutes…",
|
||||
"生成兼容预览": "Créer un aperçu compatible"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕プロモーションは文案生成のため文字モデルを追加で1回呼び出します。提供元の料金が適用されます。使用前に確認してください。",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "利用可能な字幕を検出しました。意味に基づく編集を推奨します。品質は未評価で、モデルは呼び出していません。",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "字幕がありません。文字起こしまたは字幕ファイルが必要です。利用可能な音声は未確認です。制作タイプを手動で選べます。",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕が簡易チェックを通過しなかったため、自動選択していません。字幕の確認または手動選択ができます。元の素材は保持しています。"
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕が簡易チェックを通過しなかったため、自動選択していません。字幕の確認または手動選択ができます。元の素材は保持しています。",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "インポートを開始できませんでした。再試行してください。元の素材と既存の書き出しは保持されています。",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "原動画を再生できない場合は互換プレビューを作成できます。原本は変更せず、AIは使用しません。",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "互換プレビューを生成中。長い動画は数分かかる場合があります…",
|
||||
"生成兼容预览": "互換プレビューを作成"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "자막 홍보는 문구 생성을 위해 텍스트 모델을 한 번 추가 호출하며 제공업체 요금이 적용됩니다. 사용 전에 검토하세요.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "사용 가능한 자막을 찾았습니다. 의미 기반 편집을 제안합니다. 품질은 평가하지 않았으며 모델을 호출하지 않았습니다.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "자막이 없습니다. 전사 또는 자막 파일이 필요합니다. 사용 가능한 음성은 아직 확인되지 않았습니다. 제작 유형을 직접 선택할 수 있습니다.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "자막이 빠른 검사를 통과하지 않아 자동 선택하지 않았습니다. 자막을 확인하거나 직접 선택하세요. 원본은 보존됩니다."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "자막이 빠른 검사를 통과하지 않아 자동 선택하지 않았습니다. 자막을 확인하거나 직접 선택하세요. 원본은 보존됩니다.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "가져오기를 시작하지 못했습니다. 다시 시도해 주세요. 원본과 기존 내보내기는 보존됩니다.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "원본을 재생할 수 없다면 호환 미리보기를 만드세요. 원본은 변경되지 않으며 AI를 사용하지 않습니다.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "미리보기 생성 중. 긴 동영상은 몇 분 걸릴 수 있습니다…",
|
||||
"生成兼容预览": "호환 미리보기 생성"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Promoções com legendas fazem uma chamada extra ao modelo de texto, cobrada pelo provedor. Revise antes de usar.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Legendas utilizáveis encontradas. Sugere-se edição semântica; qualidade não avaliada e nenhum modelo chamado.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Sem legendas. É necessária transcrição ou arquivo; fala utilizável ainda não confirmada. Você pode escolher os tipos manualmente.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "As legendas não passaram na verificação rápida; nada foi pré-selecionado. Verifique ou escolha manualmente; o original foi preservado."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "As legendas não passaram na verificação rápida; nada foi pré-selecionado. Verifique ou escolha manualmente; o original foi preservado.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "Não foi possível iniciar a importação. Tente novamente; o original e as exportações existentes foram preservados.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "Se o original não reproduzir, crie uma prévia compatível. O original não muda e nenhuma IA é usada.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "Preparando prévia; vídeos longos podem levar alguns minutos…",
|
||||
"生成兼容预览": "Criar prévia compatível"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Промо по субтитрам делает дополнительный запрос к текстовой модели для текста. Оплата по тарифу провайдера; проверьте перед использованием.",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Найдены пригодные субтитры. Предлагается смысловой монтаж; качество не оценивалось, модель не вызывалась.",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Субтитры не найдены. Нужна транскрипция или файл; наличие пригодной речи не подтверждено. Типы можно выбрать вручную.",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Субтитры не прошли быструю проверку, поэтому ничего не выбрано. Проверьте их или выберите вручную; оригинал сохранён."
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Субтитры не прошли быструю проверку, поэтому ничего не выбрано. Проверьте их или выберите вручную; оригинал сохранён.",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "Не удалось запустить импорт. Повторите попытку; исходный материал и готовые файлы сохранены.",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "Если исходное видео не воспроизводится, создайте совместимый предпросмотр. Оригинал не меняется, ИИ не используется.",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "Подготовка предпросмотра может занять несколько минут…",
|
||||
"生成兼容预览": "Создать совместимый предпросмотр"
|
||||
}
|
||||
|
||||
@@ -1118,5 +1118,9 @@
|
||||
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。",
|
||||
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。",
|
||||
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。",
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。"
|
||||
"字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。",
|
||||
"导入任务未能启动,请重试;原素材与已有成片已保留": "导入任务未能启动,请重试;原素材与已有成片已保留",
|
||||
"原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "原片无法播放时,可生成兼容预览;原片不变,不调用模型。",
|
||||
"正在生成兼容预览,长视频可能需要几分钟…": "正在生成兼容预览,长视频可能需要几分钟…",
|
||||
"生成兼容预览": "生成兼容预览"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { socialPublishObserved, socialPublishOutcome } from '../analytics/studio'
|
||||
import { workflow } from '../analytics/observer'
|
||||
import i18n, { t } from '../i18n'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
@@ -246,6 +248,9 @@ const PublishClipPage: React.FC = () => {
|
||||
setPhase('running')
|
||||
setPercent(12)
|
||||
const tracks: Track[] = []
|
||||
const telemetryGeneration = workflow.generation()
|
||||
const telemetryEnabled = workflow.active(telemetryGeneration)
|
||||
const observed = new Set<string>()
|
||||
try {
|
||||
if (overseas.length) {
|
||||
const started = await uploadPostApi.start(projectId, clipId, {
|
||||
@@ -301,6 +306,13 @@ const PublishClipPage: React.FC = () => {
|
||||
: await bilibiliApi.job(track.jobId)
|
||||
if (runId.current !== session) return
|
||||
const settled = settleJob(job, later)
|
||||
if (!settled.pending && !observed.has(track.jobId)) {
|
||||
observed.add(track.jobId)
|
||||
if (telemetryEnabled) socialPublishObserved(telemetryGeneration, {
|
||||
source_type: studioJobId ? 'studio' : 'legacy', gateway: track.kind,
|
||||
outcome: socialPublishOutcome(job.status, settled.scheduled, settled.results),
|
||||
})
|
||||
}
|
||||
if (settled.pending) pending = true
|
||||
if (settled.failed) failed = true
|
||||
if (settled.scheduled) scheduled = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeStudioOperation } from '../analytics/studio'
|
||||
import api from '../services/api'
|
||||
import type { PlatformResult } from './uploadPostApi'
|
||||
|
||||
@@ -33,6 +34,6 @@ export const bilibiliApi = {
|
||||
api.put('/publish/bilibili/config', body),
|
||||
clearConfig: (): Promise<BilibiliConfigView & { ok?: boolean }> => api.delete('/publish/bilibili/config'),
|
||||
start: (projectId: string, clipId: string, body: BilibiliPublishBody): Promise<{ ok: boolean; job_id: string; status: string }> =>
|
||||
api.post(`/publish/bilibili/${projectId}/clips/${clipId}`, body),
|
||||
observeStudioOperation('social_publish', () => api.post(`/publish/bilibili/${projectId}/clips/${clipId}`, body), undefined, { source_type: /^studio-[a-f0-9]{32}$/.test(clipId) ? 'studio' : 'legacy', gateway: 'bilibili', scheduled: !!body.scheduled_date }),
|
||||
job: (jobId: string): Promise<BilibiliJobView> => api.get(`/publish/bilibili/jobs/${jobId}`),
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeStudioOperation } from '../analytics/studio'
|
||||
import api from '../services/api'
|
||||
|
||||
export interface UploadPostConfigView {
|
||||
@@ -67,7 +68,7 @@ export const uploadPostApi = {
|
||||
clearConfig: (): Promise<{ ok: boolean; configured: boolean }> => api.delete('/publish/upload-post/config'),
|
||||
profiles: (): Promise<{ profiles: UploadPostProfile[] }> => api.get('/publish/upload-post/profiles'),
|
||||
start: (projectId: string, clipId: string, body: PublishClipBody): Promise<{ ok: boolean; job_id: string; status: string }> =>
|
||||
api.post(`/publish/upload-post/${projectId}/clips/${clipId}`, body),
|
||||
observeStudioOperation('social_publish', () => api.post(`/publish/upload-post/${projectId}/clips/${clipId}`, body), undefined, { source_type: /^studio-[a-f0-9]{32}$/.test(clipId) ? 'studio' : 'legacy', gateway: 'upload-post', scheduled: !!body.scheduled_date }),
|
||||
job: (jobId: string): Promise<PublishJobView> => api.get(`/publish/upload-post/jobs/${jobId}`),
|
||||
records: (projectId: string): Promise<{ records: PublishRecord[] }> => api.get(`/publish/upload-post/${projectId}/records`),
|
||||
cancel: (projectId: string, requestId: string): Promise<{ ok: boolean; status: string }> =>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Classify supported video links using their host, path and query independently. */
|
||||
export function getVideoType(value: string): 'bilibili' | 'youtube' | null {
|
||||
let url: URL
|
||||
try { url = new URL(value.trim()) } catch { return null }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) return null
|
||||
const host = url.hostname.toLowerCase()
|
||||
const path = url.pathname
|
||||
if (['bilibili.com', 'www.bilibili.com'].includes(host) &&
|
||||
/^\/video\/(?:BV[0-9a-z]+|av\d+)\/?$/i.test(path)) return 'bilibili'
|
||||
if (host === 'b23.tv' && /^\/[0-9a-z]+\/?$/i.test(path)) return 'bilibili'
|
||||
if (host === 'youtu.be' && /^\/[a-zA-Z0-9_-]+\/?$/.test(path)) return 'youtube'
|
||||
if (['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'].includes(host)) {
|
||||
if (path === '/watch' && /^[a-zA-Z0-9_-]+$/.test(url.searchParams.get('v') || '')) return 'youtube'
|
||||
if (/^\/(?:shorts|live|embed|v)\/[a-zA-Z0-9_-]+\/?$/.test(path)) return 'youtube'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const validateVideoUrl = (value: string): boolean => getVideoType(value) !== null
|
||||
@@ -93,6 +93,7 @@ test('watch retention and capacity bounded', () => {
|
||||
test('errors and routes contain no original secrets, paths or search parameters', () => {
|
||||
assert.equal(core.routeName('/project/private-id?token=secret'), '/project/:id')
|
||||
assert.equal(core.routeName('/other/secret'), '/other')
|
||||
assert.equal(core.errorCode({response:{status:'private'}}), 'unknown')
|
||||
assert.equal(core.errorCode({ message: 'sk-secret /Users/person.mp4', response: { status: 401 } }), 'http_401')
|
||||
assert.equal(core.errorCode({ message: 'secret' }), 'unknown')
|
||||
})
|
||||
@@ -185,7 +186,7 @@ test('Studio phases dedupe locally across restart without sending internal IDs o
|
||||
const recovered=new core.WorkflowTracker(s.storage,()=>true,s.capture,()=>NOW)
|
||||
recovered.observeStudio(recovered.list()[0],snapshot)
|
||||
assert.equal(s.events.length,1)
|
||||
assert.deepEqual(Object.keys(s.events[0].props),['outcome'])
|
||||
assert.equal(s.events[0].props.studio_schema_version,1)
|
||||
assert.equal(s.events[0].props.outcome,'failed')
|
||||
assert.equal(JSON.stringify(s.events).includes('secret'),false)
|
||||
s.tracker.watch('studio-export','secret-job','secret-project')
|
||||
@@ -196,11 +197,11 @@ test('Studio phases dedupe locally across restart without sending internal IDs o
|
||||
assert.equal(s.events.length,2)
|
||||
})
|
||||
test('Studio screening distinguishes recommendations, manual fallback and import failure',()=>{
|
||||
for (const [snapshot,outcome] of [[{plan:{id:'plan',mode:'ai'}},'recommended'],[{plan:{id:'plan',mode:'fallback'}},'manual_fallback'],[{analysis:{status:'failed'}},'failed']]) {
|
||||
for (const [snapshot,outcome] of [[{plan:{id:'plan',mode:'ai'},analysis:{status:'awaiting_confirmation'}},'recommended'],[{plan:{id:'plan',mode:'fallback'},analysis:{status:'awaiting_confirmation'}},'fallback'],[{analysis:{status:'failed'}},'failed']]) {
|
||||
const s=setup();s.tracker.watch('studio-screen','private-project')
|
||||
s.tracker.observeStudio(s.tracker.list()[0],snapshot)
|
||||
assert.equal(s.events[0].props.outcome,outcome)
|
||||
assert.deepEqual(Object.keys(s.events[0].props),['outcome'])
|
||||
assert.equal(s.events[0].props.studio_schema_version,1)
|
||||
}
|
||||
})
|
||||
test('actual Studio API enrolls accepted work and sends aggregate-only telemetry',async()=>{
|
||||
@@ -219,7 +220,7 @@ test('actual Studio API enrolls accepted work and sends aggregate-only telemetry
|
||||
assert.equal(s.events.length,6)
|
||||
assert.equal(JSON.stringify(s.events).includes('private'),false)
|
||||
assert.equal(JSON.stringify(s.events).includes('secret'),false)
|
||||
for(const e of s.events) assert.ok(Object.keys(e.props).every(k=>k==='request_duration_ms'))
|
||||
for(const e of s.events) assert.ok(Object.keys(e.props).every(k=>['studio_schema_version','request_duration_ms','source_type','has_subtitle','aspect','goal_content','goal_highlight','goal_promo'].includes(k)))
|
||||
s.enable(false)
|
||||
await api.import({})
|
||||
assert.equal(s.events.length,6)
|
||||
@@ -236,3 +237,71 @@ test('Studio aggregate requests retain failures and respect consent changes in f
|
||||
assert.equal(accepted,false)
|
||||
assert.equal(s.events.length,3)
|
||||
})
|
||||
|
||||
test('Studio allowlist rejects arbitrary values and preserves per-run partial counts',()=>{
|
||||
const props=core.safeStudioProperties({source_type:'secret',analysis_mode:'visual',title:'private',duration_ms:-1,goal:'private',requested_goals:['highlight','promo'],succeeded_goals:['highlight'],failed_goals:['promo'],error_code:'http_secret'})
|
||||
assert.equal(props.requested_count,2);assert.equal(props.failed_promo,true)
|
||||
assert.equal(JSON.stringify(props).includes('secret'),false);assert.equal(JSON.stringify(props).includes('private'),false)
|
||||
const s=setup();s.tracker.watch('studio-production','plan','project')
|
||||
s.tracker.observeStudio(s.tracker.list()[0],{plan:{id:'plan',confirmed_analysis:'visual'},analysis:{status:'failed',outcome:'partial',requested_goals:['highlight','promo'],succeeded_goals:['highlight'],failed_goals:['promo'],result_count:2,duration_ms:321}})
|
||||
const p=s.events[0].props;assert.equal(p.outcome,'partial');assert.equal(p.succeeded_highlight,true);assert.equal(p.failed_count,1);assert.equal(p.duration_ms,321);assert.equal(p.analysis_mode,'visual')
|
||||
})
|
||||
test('rescreen ignores an old plan while running and replaces the old local watch',()=>{
|
||||
const s=setup();s.tracker.watch('studio-screen','p');const old=s.tracker.list()[0]
|
||||
s.tracker.watch('studio-screen','p',undefined,undefined,{},true);const current=s.tracker.list()[0]
|
||||
s.tracker.observeStudio(old,{plan:{id:'old',mode:'ai'},analysis:{status:'awaiting_confirmation'}})
|
||||
s.tracker.observeStudio(current,{plan:{id:'old',mode:'ai'},analysis:{status:'running'}})
|
||||
assert.equal(s.events.length,0)
|
||||
s.tracker.observeStudio(current,{plan:{id:'new',mode:'local',local_evidence:{subtitle_status:'available'}},analysis:{status:'awaiting_confirmation'}})
|
||||
assert.equal(s.events[0].props.outcome,'recommended');assert.equal(s.events[0].props.recommendation_mode,'local')
|
||||
assert.equal(core.routeName('/import/private?token=secret'),'/import/:id')
|
||||
assert.equal(core.routeName('/project/private/studio/secret'),'/project/:id/studio/:draftId')
|
||||
})
|
||||
test('immutable export repeated acceptance does not recount its completion',()=>{
|
||||
const s=setup();s.tracker.watch('studio-export','j','p');const w=s.tracker.list()[0]
|
||||
s.tracker.observeStudio(w,{jobs:[{job_id:'j',status:'completed',duration_ms:20}]})
|
||||
const insert=s.events[0].props.$insert_id
|
||||
s.tracker.watch('studio-export','j','p');s.tracker.observeStudio(s.tracker.list()[0],{jobs:[{job_id:'j',status:'completed'}]})
|
||||
assert.equal(s.events.length,1);assert.ok(insert.startsWith('studio-v1:'))
|
||||
})
|
||||
test('native download emits saved or failed, and opt-out in flight suppresses results',async()=>{
|
||||
const s=setup();const aggregate=load('studio',{'./posthog':{captureBusinessEvent:s.capture},'./observer':{workflow:s.tracker},'./workflow':core})
|
||||
await aggregate.observeStudioDownload(async()=>42)
|
||||
assert.equal(s.events[1].event,'studio_download_saved')
|
||||
await assert.rejects(aggregate.observeStudioDownload(async()=>{throw Error('private filename')}))
|
||||
assert.equal(s.events[3].event,'studio_download_failed');assert.equal(JSON.stringify(s.events).includes('private'),false)
|
||||
let finish;const pending=aggregate.observeStudioDownload(()=>new Promise(r=>finish=r));s.tracker.clear();finish();await pending
|
||||
assert.equal(s.events.length,5)
|
||||
})
|
||||
|
||||
test('social publishing distinguishes scheduling and inbox acceptance from published content',()=>{
|
||||
const s=setup();const aggregate=load('studio',{'./posthog':{captureBusinessEvent:s.capture},'./observer':{workflow:s.tracker},'./workflow':core})
|
||||
assert.equal(aggregate.socialPublishOutcome('scheduled',true,[]),'scheduled')
|
||||
assert.equal(aggregate.socialPublishOutcome('submitted',false,[{success:true,fallback_to_inbox:true}]),'inbox')
|
||||
assert.equal(aggregate.socialPublishOutcome('completed',false,[]),'unknown')
|
||||
assert.equal(aggregate.socialPublishOutcome('completed',false,[{success:true},{success:false}]),'partial')
|
||||
assert.equal(aggregate.socialPublishOutcome('completed',false,[{success:false}]),'failed')
|
||||
aggregate.socialPublishObserved(s.tracker.generation(),{source_type:'studio',gateway:'bilibili',outcome:'completed',url:'private'})
|
||||
assert.equal(s.events[0].props.source_type,'studio');assert.equal(JSON.stringify(s.events).includes('private'),false)
|
||||
const gen=s.tracker.generation();s.tracker.clear();aggregate.socialPublishObserved(gen,{outcome:'completed'});assert.equal(s.events.length,1)
|
||||
})
|
||||
|
||||
test('UI workspace snapshots capture fast screening before confirm and never enroll historical projects',async()=>{
|
||||
const s=setup();const aggregate=load('studio',{'./posthog':{captureBusinessEvent:s.capture},'./observer':{workflow:s.tracker},'./workflow':core})
|
||||
const snapshot={plan:{id:'plan',mode:'local'},analysis:{status:'awaiting_confirmation'}}
|
||||
await aggregate.observeStudioWorkspace('p',async()=>snapshot);assert.equal(s.events.length,0)
|
||||
s.tracker.watch('studio-screen','p')
|
||||
assert.equal(await aggregate.observeStudioWorkspace('p',async()=>snapshot),snapshot)
|
||||
assert.equal(s.events.length,1);assert.equal(s.events[0].props.recommendation_mode,'local')
|
||||
await aggregate.observeStudioWorkspace('p',async()=>snapshot);assert.equal(s.events.length,1)
|
||||
assert.equal(core.safeStudioProperties(null).studio_schema_version,1)
|
||||
})
|
||||
|
||||
test('late workspace response cannot settle a newer rescreen attempt',async()=>{
|
||||
const s=setup();const aggregate=load('studio',{'./posthog':{captureBusinessEvent:s.capture},'./observer':{workflow:s.tracker},'./workflow':core})
|
||||
s.tracker.watch('studio-screen','p');let finish
|
||||
const pending=aggregate.observeStudioWorkspace('p',()=>new Promise(r=>finish=r))
|
||||
s.tracker.watch('studio-screen','p',undefined,undefined,{},true)
|
||||
finish({plan:{id:'old',mode:'ai'},analysis:{status:'awaiting_confirmation'}});await pending
|
||||
assert.equal(s.events.length,0)
|
||||
})
|
||||
|
||||
@@ -35,7 +35,8 @@ function link({ desktop = true, save } = {}) {
|
||||
'react/jsx-runtime': { jsx: (_type,props)=>props },
|
||||
'react-i18next': { useTranslation: ()=>({t:key=>key}) },
|
||||
antd: { message: { success:()=>calls.push('success'),error:()=>calls.push('error') } },
|
||||
'../../analytics/studio': { studioDownloadRequested:()=>calls.push('intent') },
|
||||
'../../analytics/studio': { studioDownloadRequested:()=>calls.push('intent'), observeStudioDownload:async action=>{calls.push('intent');return action()} },
|
||||
'../../desktop/sentry': { captureStudioException:()=>{} },
|
||||
'./api': { studioApi:{video:()=>'/immutable.mp4?download=true'} },
|
||||
'./nativeDownload': { isDesktopDownload:()=>desktop,saveStudioExport:save|| (async()=>{}) },
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@ test('export history renders localized server warnings/restart errors in all eig
|
||||
}
|
||||
})
|
||||
test('all backend processing stages have translations in eight catalogs',()=>{
|
||||
const stages=["制作任务未能启动,请重试确认;原素材与已有成片已保留",'导出任务未能启动,请重试;已有成片已保留','下载素材','理解画面','准备素材','快速判断适合的制作类型','开始制作所选内容','制作内容切片','制作精彩高光','制作推广成片','扫描画面,寻找候选高光','复核首选高光的起止边界','组织推广开头与成片草稿','整理高光成片草稿']
|
||||
const stages=["导入任务未能启动,请重试;原素材与已有成片已保留","制作任务未能启动,请重试确认;原素材与已有成片已保留",'导出任务未能启动,请重试;已有成片已保留','下载素材','理解画面','准备素材','快速判断适合的制作类型','开始制作所选内容','制作内容切片','制作精彩高光','制作推广成片','扫描画面,寻找候选高光','复核首选高光的起止边界','组织推广开头与成片草稿','整理高光成片草稿']
|
||||
for(const lang of langs)for(const stage of stages){assert.ok(catalogs[lang][stage],lang+stage);if(lang!=='zh')assert.notEqual(catalogs[lang][stage],stage)}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const helper = path.join(__dirname, '../src/utils/videoUrl.ts')
|
||||
const source = fs.readFileSync(helper, 'utf8')
|
||||
const moduleValue = { exports: {} }
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { module: moduleValue, exports: moduleValue.exports, URL })
|
||||
const { getVideoType } = moduleValue.exports
|
||||
for (const url of [
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtu.be/dQw4w9WgXcQ?si=abc',
|
||||
'https://youtube.com/shorts/dQw4w9WgXcQ',
|
||||
'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://music.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtube.com/watch?si=abc&v=dQw4w9WgXcQ',
|
||||
'https://youtube.com/live/dQw4w9WgXcQ',
|
||||
'https://youtube.com/embed/dQw4w9WgXcQ',
|
||||
'https://youtube.com/v/dQw4w9WgXcQ',
|
||||
' https://youtu.be/dQw4w9WgXcQ ',
|
||||
]) test(url, () => assert.equal(getVideoType(url), 'youtube'))
|
||||
for (const url of ['https://www.bilibili.com/video/BV1xx411c7mu', 'https://bilibili.com/video/av123', 'https://b23.tv/Ab123'])
|
||||
test(url, () => assert.equal(getVideoType(url), 'bilibili'))
|
||||
for (const url of ['https://youtube.com.evil.test/watch?v=x', 'https://evil.test/youtube.com/watch?v=x', 'javascript:alert(1)', 'https://youtube.com/watch?si=abc', 'https://youtube.com/playlist?list=x', 'https://youtu.be/', 'https://youtube.com/shorts/', 'not a URL'])
|
||||
test(url, () => assert.equal(getVideoType(url), null))
|
||||
Generated
+1
@@ -126,6 +126,7 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -28,3 +28,6 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61.0", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"] }
|
||||
|
||||
@@ -30,6 +30,8 @@ impl Default for BackendStatus {
|
||||
pub struct BackendManager {
|
||||
status: Arc<Mutex<BackendStatus>>,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)]
|
||||
job: Mutex<Option<crate::windows_job::BackendJob>>,
|
||||
}
|
||||
|
||||
struct BackendLaunch {
|
||||
@@ -43,6 +45,8 @@ impl BackendManager {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(BackendStatus::default())),
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +66,10 @@ impl BackendManager {
|
||||
.stderr(Stdio::piped())
|
||||
.env("AUTOCLIP_DESKTOP_MODE", "true")
|
||||
.env("AUTOCLIP_MODE", "desktop")
|
||||
.env(
|
||||
"AUTOCLIP_BUILD_ENVIRONMENT",
|
||||
if cfg!(debug_assertions) { "development" } else { "production" },
|
||||
)
|
||||
// Keep signed bundle resources immutable, including Python startup imports.
|
||||
.env("PYTHONDONTWRITEBYTECODE", "1")
|
||||
// Single source of truth for the version the backend reports in /settings.
|
||||
@@ -117,7 +125,21 @@ impl BackendManager {
|
||||
}
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(child) => {
|
||||
#[allow(unused_mut)]
|
||||
Ok(mut child) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Own the complete process tree, including Celery/ffmpeg.
|
||||
// Closing the app handle (even on a crash) releases DLL locks.
|
||||
match crate::windows_job::BackendJob::attach(&child) {
|
||||
Ok(job) => *self.job.lock().unwrap() = Some(job),
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("无法管理后端进程生命周期: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pid = child.id();
|
||||
let start_time = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -152,14 +174,23 @@ impl BackendManager {
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
let mut status = self.status.lock().unwrap();
|
||||
if !status.is_running {
|
||||
return Err("后端服务未运行".to_string());
|
||||
}
|
||||
|
||||
let mut process = self.process.lock().unwrap();
|
||||
#[cfg(windows)]
|
||||
let terminated_tree = {
|
||||
let job = self.job.lock().unwrap().take();
|
||||
let owned = job.is_some();
|
||||
drop(job);
|
||||
owned
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let terminated_tree = false;
|
||||
if let Some(mut child) = process.take() {
|
||||
if let Err(e) = child.kill() {
|
||||
return Err(format!("停止后端服务失败: {}", e));
|
||||
if !terminated_tree && child.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||
if let Err(error) = child.kill() {
|
||||
// Retain ownership if termination failed so it can be retried.
|
||||
*process = Some(child);
|
||||
return Err(format!("停止后端服务失败: {error}"));
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::tray::setup_system_tray;
|
||||
use tauri::Manager;
|
||||
|
||||
mod backend_manager;
|
||||
#[cfg(windows)]
|
||||
mod windows_job;
|
||||
mod commands;
|
||||
mod tray;
|
||||
|
||||
@@ -54,6 +56,11 @@ pub fn run() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
let _ = app.state::<BackendManager>().stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! A non-inheritable job handle ties the entire backend tree to the desktop app.
|
||||
use std::io;
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::process::Child;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
pub struct BackendJob(HANDLE);
|
||||
// The owned kernel handle can be moved between threads; access is mutex protected.
|
||||
unsafe impl Send for BackendJob {}
|
||||
|
||||
impl BackendJob {
|
||||
pub fn attach(child: &Child) -> io::Result<Self> {
|
||||
unsafe {
|
||||
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let job = Self(handle);
|
||||
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&limits as *const _ as *const _,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
) == 0
|
||||
|| AssignProcessToJobObject(handle, child.as_raw_handle()) == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackendJob {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn dropping_job_terminates_parent_and_grandchild() {
|
||||
check_tree(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_cleans_descendant_even_after_parent_exits() {
|
||||
check_tree(true);
|
||||
}
|
||||
|
||||
fn check_tree(kill_parent_first: bool) {
|
||||
// Gate descendant creation until the parent has joined the job.
|
||||
let script = "import sys,subprocess,time; sys.stdin.readline(); p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); print(p.pid,flush=True); time.sleep(60)";
|
||||
let mut child = Command::new("python")
|
||||
.args(["-c", script])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let job = BackendJob::attach(&child).unwrap();
|
||||
child.stdin.take().unwrap().write_all(b"go\n").unwrap();
|
||||
let mut pid = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut pid)
|
||||
.unwrap();
|
||||
let descendant =
|
||||
unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid.trim().parse().unwrap()) };
|
||||
assert!(!descendant.is_null());
|
||||
if kill_parent_first {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert_eq!(
|
||||
unsafe { WaitForSingleObject(descendant, 100) },
|
||||
258,
|
||||
"killing only the parent leaves the descendant alive"
|
||||
);
|
||||
}
|
||||
drop(job);
|
||||
let ended = unsafe { WaitForSingleObject(descendant, 5000) };
|
||||
unsafe {
|
||||
CloseHandle(descendant);
|
||||
}
|
||||
assert_eq!(ended, 0, "grandchild must exit when the job closes");
|
||||
assert_eq!(unsafe { WaitForSingleObject(child.as_raw_handle(), 5000) }, 0);
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "autoclip-windows-job-test"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "../../src/windows_job.rs"
|
||||
|
||||
[dependencies]
|
||||
windows-sys = { version = "=0.61.0", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"] }
|
||||
Reference in New Issue
Block a user