diff --git a/.github/workflows/windows-asyncio.yml b/.github/workflows/windows-asyncio.yml new file mode 100644 index 00000000..ecbc109c --- /dev/null +++ b/.github/workflows/windows-asyncio.yml @@ -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 '' + - name: Compile desktop integration (not an installer build) + run: cargo check --manifest-path src-tauri/Cargo.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6fd7fb..380c90b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ ## [未发布] -_(本周尚无改动)_ +### 修复 +- 时间线重试只使用本次有效结果,避免失败后误用上次候选;原始响应缓存现在会正常解析,无效缓存不会自动触发模型请求。 +- 智能导入后台任务提交失败后可明确重试,已有方案、草稿和成片保留;修改方案失败时同步恢复偏好。 +- 时间线兼容常见时间戳格式;相邻短片段在丢弃前尝试合并,保留既有时长限制。 +- 保留的旧导入组件支持更多 YouTube 分享链接格式;当前 Studio 入口增加独立链接回归覆盖。 ## [1.4.0] - 2026-09-27 diff --git a/HANDOFF.md b/HANDOFF.md index 890ca127..9ff3d963 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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) + +以下发布版本、入口与待办仅记录当时状态;发生冲突时以上方当前状态为准。 --- diff --git a/backend/api/v1/studio.py b/backend/api/v1/studio.py index 8f895490..76056cf8 100644 --- a/backend/api/v1/studio.py +++ b/backend/api/v1/studio.py @@ -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 diff --git a/backend/app_factory.py b/backend/app_factory.py index ab467388..a0c23d63 100644 --- a/backend/app_factory.py +++ b/backend/app_factory.py @@ -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) diff --git a/backend/core/sentry_setup.py b/backend/core/sentry_setup.py index 9dd3495c..df7f7cb5 100644 --- a/backend/core/sentry_setup.py +++ b/backend/core/sentry_setup.py @@ -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 diff --git a/backend/core/windows_asyncio.py b/backend/core/windows_asyncio.py new file mode 100644 index 00000000..b4df501a --- /dev/null +++ b/backend/core/windows_asyncio.py @@ -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 diff --git a/backend/pipeline/quality.py b/backend/pipeline/quality.py index 239170e6..04424800 100644 --- a/backend/pipeline/quality.py +++ b/backend/pipeline/quality.py @@ -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 diff --git a/backend/pipeline/step2_timeline.py b/backend/pipeline/step2_timeline.py index dc7ec0ed..ab9537d8 100644 --- a/backend/pipeline/step2_timeline.py +++ b/backend/pipeline/step2_timeline.py @@ -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格式 diff --git a/backend/services/publish_export.py b/backend/services/publish_export.py index 1e5ab5e8..b94c622e 100644 --- a/backend/services/publish_export.py +++ b/backend/services/publish_export.py @@ -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 { diff --git a/backend/services/studio/jobs.py b/backend/services/studio/jobs.py index 91025313..2a337f7a 100644 --- a/backend/services/studio/jobs.py +++ b/backend/services/studio/jobs.py @@ -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 diff --git a/backend/services/studio/planning.py b/backend/services/studio/planning.py index b35f77b9..c3d79764 100644 --- a/backend/services/studio/planning.py +++ b/backend/services/studio/planning.py @@ -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' diff --git a/backend/services/studio/preview.py b/backend/services/studio/preview.py new file mode 100644 index 00000000..3b0d4716 --- /dev/null +++ b/backend/services/studio/preview.py @@ -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) diff --git a/backend/tests/test_recent_feedback.py b/backend/tests/test_recent_feedback.py new file mode 100644 index 00000000..4d741ba5 --- /dev/null +++ b/backend/tests/test_recent_feedback.py @@ -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'] diff --git a/backend/tests/test_sentry_setup.py b/backend/tests/test_sentry_setup.py index 6730b1c9..a4f46631 100644 --- a/backend/tests/test_sentry_setup.py +++ b/backend/tests/test_sentry_setup.py @@ -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) diff --git a/backend/tests/test_smart_import.py b/backend/tests/test_smart_import.py index d06d2423..cf4f674a 100644 --- a/backend/tests/test_smart_import.py +++ b/backend/tests/test_smart_import.py @@ -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' diff --git a/backend/tests/test_studio_preview.py b/backend/tests/test_studio_preview.py new file mode 100644 index 00000000..cd180315 --- /dev/null +++ b/backend/tests/test_studio_preview.py @@ -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' diff --git a/backend/tests/test_windows_asyncio.py b/backend/tests/test_windows_asyncio.py new file mode 100644 index 00000000..d9d67c3f --- /dev/null +++ b/backend/tests/test_windows_asyncio.py @@ -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 diff --git a/backend/tests/test_windows_media_feedback.py b/backend/tests/test_windows_media_feedback.py new file mode 100644 index 00000000..2474006d --- /dev/null +++ b/backend/tests/test_windows_media_feedback.py @@ -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')) == {} diff --git a/backend/utils/thumbnail_generator.py b/backend/utils/thumbnail_generator.py index d638c68e..ad420d4f 100644 --- a/backend/utils/thumbnail_generator.py +++ b/backend/utils/thumbnail_generator.py @@ -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 diff --git a/docs/FEEDBACK_TRIAGE_2026-09-27.md b/docs/FEEDBACK_TRIAGE_2026-09-27.md new file mode 100644 index 00000000..d903f6f1 --- /dev/null +++ b/docs/FEEDBACK_TRIAGE_2026-09-27.md @@ -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)。 diff --git a/docs/FEEDBACK_TRIAGE_2026-09-28.md b/docs/FEEDBACK_TRIAGE_2026-09-28.md new file mode 100644 index 00000000..72dd9314 --- /dev/null +++ b/docs/FEEDBACK_TRIAGE_2026-09-28.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 更新而自动修复。 diff --git a/docs/PROJECT_REVIEW_2026-09-27.md b/docs/PROJECT_REVIEW_2026-09-27.md new file mode 100644 index 00000000..6704db6e --- /dev/null +++ b/docs/PROJECT_REVIEW_2026-09-27.md @@ -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 真机验收。 diff --git a/docs/TELEMETRY_AUDIT_1_4.md b/docs/TELEMETRY_AUDIT_1_4.md new file mode 100644 index 00000000..2d06f3d1 --- /dev/null +++ b/docs/TELEMETRY_AUDIT_1_4.md @@ -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 图表已更新;通过真实隔离应用验证两平台接收。未发布新包,未改通知规则,原生双平台正式包验收仍待完成,详见实施记录。 diff --git a/docs/WINDOWS_CONNECTION_RESET_FIX.md b/docs/WINDOWS_CONNECTION_RESET_FIX.md new file mode 100644 index 00000000..c7c51b7a --- /dev/null +++ b/docs/WINDOWS_CONNECTION_RESET_FIX.md @@ -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。 diff --git a/docs/analytics/STUDIO_MONITORING.md b/docs/analytics/STUDIO_MONITORING.md new file mode 100644 index 00000000..15c39f48 --- /dev/null +++ b/docs/analytics/STUDIO_MONITORING.md @@ -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。未创建通知规则。 diff --git a/docs/analytics/studio_adoption.sql b/docs/analytics/studio_adoption.sql new file mode 100644 index 00000000..7cef7531 --- /dev/null +++ b/docs/analytics/studio_adoption.sql @@ -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 diff --git a/docs/analytics/studio_business.sql b/docs/analytics/studio_business.sql new file mode 100644 index 00000000..6b207764 --- /dev/null +++ b/docs/analytics/studio_business.sql @@ -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 diff --git a/docs/analytics/studio_results.sql b/docs/analytics/studio_results.sql new file mode 100644 index 00000000..df3c77b1 --- /dev/null +++ b/docs/analytics/studio_results.sql @@ -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 diff --git a/docs/analytics/studio_validation.sql b/docs/analytics/studio_validation.sql new file mode 100644 index 00000000..f8a692e5 --- /dev/null +++ b/docs/analytics/studio_validation.sql @@ -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 diff --git a/frontend/src/analytics/posthog.ts b/frontend/src/analytics/posthog.ts index 8e366625..7b9d17ae 100644 --- a/frontend/src/analytics/posthog.ts +++ b/frontend/src/analytics/posthog.ts @@ -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', diff --git a/frontend/src/analytics/studio.ts b/frontend/src/analytics/studio.ts index dc112b6d..72b34976 100644 --- a/frontend/src/analytics/studio.ts +++ b/frontend/src/analytics/studio.ts @@ -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( - name: 'studio_import' | 'studio_confirm' | 'studio_export', - action: () => Promise, 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, accepted: (result: T) => void = () => {}, properties: Record = {}, ): Promise { + 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( /** 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(action: () => Promise): Promise { + 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 { + 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) { + 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(projectId: string, action: () => Promise): Promise { + 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 } diff --git a/frontend/src/analytics/workflow.ts b/frontend/src/analytics/workflow.ts index 61ab9399..8cad21e8 100644 --- a/frontend/src/analytics/workflow.ts +++ b/frontend/src/analytics/workflow.ts @@ -1,13 +1,49 @@ /** Versioned business telemetry. Only explicit UI operations enroll projects. */ export type Properties = Record -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 | null = {}): Properties { + const input = value && typeof value === 'object' ? value : {} + const out: Properties = { studio_schema_version: 1 } + const enums: Record = { + 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 = {} 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() } } } diff --git a/frontend/src/components/BilibiliDownload.tsx b/frontend/src/components/BilibiliDownload.tsx index 88f913b3..4c3300ab 100644 --- a/frontend/src/components/BilibiliDownload.tsx +++ b/frontend/src/components/BilibiliDownload.tsx @@ -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 = ({ 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("请输入正确的视频链接")) diff --git a/frontend/src/desktop/sentry.ts b/frontend/src/desktop/sentry.ts index c8f4f5c0..b7b7a55c 100644 --- a/frontend/src/desktop/sentry.ts +++ b/frontend/src/desktop/sentry.ts @@ -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 */ } +} diff --git a/frontend/src/features/studio/StudioDownloadLink.tsx b/frontend/src/features/studio/StudioDownloadLink.tsx index 49a77ec9..b3f77bfe 100644 --- a/frontend/src/features/studio/StudioDownloadLink.tsx +++ b/frontend/src/features/studio/StudioDownloadLink.tsx @@ -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 diff --git a/frontend/src/features/studio/StudioEditor.tsx b/frontend/src/features/studio/StudioEditor.tsx index ca1bcc03..60617a73 100644 --- a/frontend/src/features/studio/StudioEditor.tsx +++ b/frontend/src/features/studio/StudioEditor.tsx @@ -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({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(null) const [undo, setUndo] = useState(null) const video = useRef(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 })

{draft.title}

{dirty ? t("有修改未保存 · 本机暂存") : t("草稿已保存")} · V{draft.revision} · {fmtDuration(draftDuration(draft))}
setShowVariant(true)}>{t("另存为新版本")} perform('save', async () => {await save()})}>{t("保存草稿")} setShowExport(true)}>{t("导出成片")}
{loadError &&

{t("任务状态暂时无法更新:")}{t(loadError)}

} + {!showRendered && (sourcePreview.status !== 'completed' || playbackError) &&
+ {t('生成兼容预览')} +

{t('原片无法播放时,可生成兼容预览;原片不变,不调用模型。')}

+ {sourcePreview.error &&

{t(sourcePreview.error)}

} + {t(['queued', 'running'].includes(sourcePreview.status) ? '正在生成兼容预览,长视频可能需要几分钟…' : '生成兼容预览')} +
}
-
diff --git a/frontend/src/features/studio/StudioResults.tsx b/frontend/src/features/studio/StudioResults.tsx index 17798bdc..8e990607 100644 --- a/frontend/src/features/studio/StudioResults.tsx +++ b/frontend/src/features/studio/StudioResults.tsx @@ -47,6 +47,6 @@ export default function StudioResults({ project, children, onCreateCollection, o
{t("原素材")}
setHistory(false)}/> setPreview(null)}>{preview&& - !busy&&setExporting(null)} description={t("按当前草稿设置渲染,完成后在导出记录下载。")} footer={
setExporting(null)}>{t("关闭")}exporting&&act('export',async()=>{await studioApi.export(project.id,exporting.id,exporting.revision);setExporting(null);setHistory(true)})}>{t("确认导出")}
}>

{exporting?.title}

{exporting?.aspect==='portrait'?t("9:16 竖屏"):exporting?.aspect==='landscape'?t("16:9 横屏"):t("原画幅")} · MP4 · 30 fps

{actionError&&

{actionError}

}
+ !busy&&setExporting(null)} description={t("按当前草稿设置渲染,完成后在导出记录下载。")} footer={
setExporting(null)}>{t("关闭")}exporting&&act('export',async()=>{await studioApi.export(project.id,exporting.id,exporting.revision, exporting);setExporting(null);setHistory(true)})}>{t("确认导出")}
}>

{exporting?.title}

{exporting?.aspect==='portrait'?t("9:16 竖屏"):exporting?.aspect==='landscape'?t("16:9 横屏"):t("原画幅")} · MP4 · 30 fps

{actionError&&

{actionError}

}
} diff --git a/frontend/src/features/studio/VisionSettings.tsx b/frontend/src/features/studio/VisionSettings.tsx index 10f86d62..8f320d76 100644 --- a/frontend/src/features/studio/VisionSettings.tsx +++ b/frontend/src/features/studio/VisionSettings.tsx @@ -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(`${api.defaults.baseURL}/studio/vision-settings`, body) + const { data: result } = await observeStudioOperation('vision_provider_save', () => axios.put(`${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('') } diff --git a/frontend/src/features/studio/api.ts b/frontend/src/features/studio/api.ts index c60bf89c..5c9eb5bf 100644 --- a/frontend/src/features/studio/api.ts +++ b/frontend/src/features/studio/api.ts @@ -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 => api.post(`/studio/${pid}/source-preview`), + previewStatus: (pid: string): Promise => api.get(`/studio/${pid}/source-preview`), + compatibleSource: (pid: string, version: string) => `${api.defaults.baseURL}/studio/${pid}/source-preview/video?v=${version}`, analysisPreferences: (): Promise => api.get('/studio/analysis-preferences'), - saveAnalysisPreferences: (body: AnalysisPreferences): Promise => api.put('/studio/analysis-preferences', body), + saveAnalysisPreferences: (body: AnalysisPreferences): Promise => 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 => api.get(`/studio/${pid}`, { signal }), + get: (pid: string, signal?: AbortSignal): Promise => 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 => api.post(`/studio/${pid}/title-preview?layer=${layer}`, draft, {responseType:'blob', signal}), candidates: (pid: string, signal?: AbortSignal): Promise => 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 => api.post(`/studio/${pid}/drafts`, { clip_ids, title, reuse_existing }), - eventDraft: (pid: string, id: string): Promise => api.post(`/studio/${pid}/events/${id}/draft`), - save: (pid: string, draft: Draft): Promise => api.put(`/studio/${pid}/drafts/${draft.id}`, draft), - duplicate: (pid: string, draft: Draft, title: string, language: Language): Promise => api.post(`/studio/${pid}/drafts/${draft.id}/duplicate`, { draft, title, language }), - rewrite: (pid: string, draft: Draft, instruction: string): Promise => api.post(`/studio/${pid}/rewrite`, { draft, instruction }, { timeout: 310000 }), - export: (pid: string, draft: string, revision: number): Promise => 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), 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 => 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 => observeStudioOperation('studio_draft_create', () => api.post(`/studio/${pid}/events/${id}/draft`), undefined, {source_type: 'visual_event'}), + save: (pid: string, draft: Draft): Promise => 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 => 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 => 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): Promise => 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' : ''}`, } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b37ae573..aec6dc0b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 753ab582..be16287b 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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" } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 70a704f8..33b67ad0 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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" } diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 15a35164..459dfa7c 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -1118,5 +1118,9 @@ "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕プロモーションは文案生成のため文字モデルを追加で1回呼び出します。提供元の料金が適用されます。使用前に確認してください。", "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "利用可能な字幕を検出しました。意味に基づく編集を推奨します。品質は未評価で、モデルは呼び出していません。", "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "字幕がありません。文字起こしまたは字幕ファイルが必要です。利用可能な音声は未確認です。制作タイプを手動で選べます。", - "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕が簡易チェックを通過しなかったため、自動選択していません。字幕の確認または手動選択ができます。元の素材は保持しています。" + "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕が簡易チェックを通過しなかったため、自動選択していません。字幕の確認または手動選択ができます。元の素材は保持しています。", + "导入任务未能启动,请重试;原素材与已有成片已保留": "インポートを開始できませんでした。再試行してください。元の素材と既存の書き出しは保持されています。", + "原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "原動画を再生できない場合は互換プレビューを作成できます。原本は変更せず、AIは使用しません。", + "正在生成兼容预览,长视频可能需要几分钟…": "互換プレビューを生成中。長い動画は数分かかる場合があります…", + "生成兼容预览": "互換プレビューを作成" } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 3e5d2146..109f7e61 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -1118,5 +1118,9 @@ "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "자막 홍보는 문구 생성을 위해 텍스트 모델을 한 번 추가 호출하며 제공업체 요금이 적용됩니다. 사용 전에 검토하세요.", "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "사용 가능한 자막을 찾았습니다. 의미 기반 편집을 제안합니다. 품질은 평가하지 않았으며 모델을 호출하지 않았습니다.", "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "자막이 없습니다. 전사 또는 자막 파일이 필요합니다. 사용 가능한 음성은 아직 확인되지 않았습니다. 제작 유형을 직접 선택할 수 있습니다.", - "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "자막이 빠른 검사를 통과하지 않아 자동 선택하지 않았습니다. 자막을 확인하거나 직접 선택하세요. 원본은 보존됩니다." + "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "자막이 빠른 검사를 통과하지 않아 자동 선택하지 않았습니다. 자막을 확인하거나 직접 선택하세요. 원본은 보존됩니다.", + "导入任务未能启动,请重试;原素材与已有成片已保留": "가져오기를 시작하지 못했습니다. 다시 시도해 주세요. 원본과 기존 내보내기는 보존됩니다.", + "原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "원본을 재생할 수 없다면 호환 미리보기를 만드세요. 원본은 변경되지 않으며 AI를 사용하지 않습니다.", + "正在生成兼容预览,长视频可能需要几分钟…": "미리보기 생성 중. 긴 동영상은 몇 분 걸릴 수 있습니다…", + "生成兼容预览": "호환 미리보기 생성" } diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 9465a14b..3c58c085 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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" } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index bec8f8cc..287b6af2 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1118,5 +1118,9 @@ "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Промо по субтитрам делает дополнительный запрос к текстовой модели для текста. Оплата по тарифу провайдера; проверьте перед использованием.", "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Найдены пригодные субтитры. Предлагается смысловой монтаж; качество не оценивалось, модель не вызывалась.", "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Субтитры не найдены. Нужна транскрипция или файл; наличие пригодной речи не подтверждено. Типы можно выбрать вручную.", - "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Субтитры не прошли быструю проверку, поэтому ничего не выбрано. Проверьте их или выберите вручную; оригинал сохранён." + "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "Субтитры не прошли быструю проверку, поэтому ничего не выбрано. Проверьте их или выберите вручную; оригинал сохранён.", + "导入任务未能启动,请重试;原素材与已有成片已保留": "Не удалось запустить импорт. Повторите попытку; исходный материал и готовые файлы сохранены.", + "原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "Если исходное видео не воспроизводится, создайте совместимый предпросмотр. Оригинал не меняется, ИИ не используется.", + "正在生成兼容预览,长视频可能需要几分钟…": "Подготовка предпросмотра может занять несколько минут…", + "生成兼容预览": "Создать совместимый предпросмотр" } diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 35bc830e..1a72000c 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -1118,5 +1118,9 @@ "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。", "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。", "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。", - "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。" + "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。": "字幕未通过快速检查,暂不自动勾选;请检查字幕或手动选择,原素材已保留。", + "导入任务未能启动,请重试;原素材与已有成片已保留": "导入任务未能启动,请重试;原素材与已有成片已保留", + "原片无法播放时,可生成兼容预览;原片不变,不调用模型。": "原片无法播放时,可生成兼容预览;原片不变,不调用模型。", + "正在生成兼容预览,长视频可能需要几分钟…": "正在生成兼容预览,长视频可能需要几分钟…", + "生成兼容预览": "生成兼容预览" } diff --git a/frontend/src/pages/PublishClipPage.tsx b/frontend/src/pages/PublishClipPage.tsx index c391af0c..5ef9d830 100644 --- a/frontend/src/pages/PublishClipPage.tsx +++ b/frontend/src/pages/PublishClipPage.tsx @@ -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() 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 diff --git a/frontend/src/publish/bilibiliApi.ts b/frontend/src/publish/bilibiliApi.ts index 1ac16ea1..afff4de9 100644 --- a/frontend/src/publish/bilibiliApi.ts +++ b/frontend/src/publish/bilibiliApi.ts @@ -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 => 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 => api.get(`/publish/bilibili/jobs/${jobId}`), } diff --git a/frontend/src/publish/uploadPostApi.ts b/frontend/src/publish/uploadPostApi.ts index fc9aaa0a..43670396 100644 --- a/frontend/src/publish/uploadPostApi.ts +++ b/frontend/src/publish/uploadPostApi.ts @@ -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 => 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 }> => diff --git a/frontend/src/utils/videoUrl.ts b/frontend/src/utils/videoUrl.ts new file mode 100644 index 00000000..593a77ef --- /dev/null +++ b/frontend/src/utils/videoUrl.ts @@ -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 diff --git a/frontend/tests/analytics.test.cjs b/frontend/tests/analytics.test.cjs index 8d74d6b9..1f8df364 100644 --- a/frontend/tests/analytics.test.cjs +++ b/frontend/tests/analytics.test.cjs @@ -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) +}) diff --git a/frontend/tests/studio-native-download.test.cjs b/frontend/tests/studio-native-download.test.cjs index 5ebf62d6..36f787af 100644 --- a/frontend/tests/studio-native-download.test.cjs +++ b/frontend/tests/studio-native-download.test.cjs @@ -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()=>{}) }, } diff --git a/frontend/tests/studio-system-messages.test.cjs b/frontend/tests/studio-system-messages.test.cjs index b1768316..0609c7c7 100644 --- a/frontend/tests/studio-system-messages.test.cjs +++ b/frontend/tests/studio-system-messages.test.cjs @@ -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)} }) diff --git a/frontend/tests/video-url.test.cjs b/frontend/tests/video-url.test.cjs new file mode 100644 index 00000000..dd1df346 --- /dev/null +++ b/frontend/tests/video-url.test.cjs @@ -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)) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 788be891..baad1534 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -126,6 +126,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-updater", "tokio", + "windows-sys 0.61.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9b1cccef..126aceb5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/backend_manager.rs b/src-tauri/src/backend_manager.rs index 7d1c5624..43cbbd19 100644 --- a/src-tauri/src/backend_manager.rs +++ b/src-tauri/src/backend_manager.rs @@ -30,6 +30,8 @@ impl Default for BackendStatus { pub struct BackendManager { status: Arc>, process: Arc>>, + #[cfg(windows)] + job: Mutex>, } 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(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4813dabd..e53f4f96 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::().stop(); + } + }); } diff --git a/src-tauri/src/windows_job.rs b/src-tauri/src/windows_job.rs new file mode 100644 index 00000000..53df0277 --- /dev/null +++ b/src-tauri/src/windows_job.rs @@ -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 { + 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::() 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(); + } +} diff --git a/src-tauri/tests/windows-job/Cargo.toml b/src-tauri/tests/windows-job/Cargo.toml new file mode 100644 index 00000000..5897dac9 --- /dev/null +++ b/src-tauri/tests/windows-job/Cargo.toml @@ -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"] }