@remixmate/cli 0.9.44 → 0.9.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scenes": {
36
36
  "type": "number",
37
- "description": "Scene count"
37
+ "description": "Scene count. Only applies to templates with capabilities.sceneStrategy=arc (the default); single / fixed / countdown templates own their own count. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20) — a clamp is reported on stderr and is NOT an error, so always read the real count back from the returned DSL (scenes[].length) before describing the script to the user. For a template that maps one scene per source page (e.g. ppt-to-video), pass the page count you saw in doc_parse's summary."
38
38
  },
39
39
  "voice_id": {
40
40
  "type": "string",
@@ -48,6 +48,10 @@
48
48
  "type": "string",
49
49
  "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
50
50
  },
51
+ "source_document_url": {
52
+ "type": "string",
53
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). **Must** pass it for such a template: it is recorded at meta.sourceDocument, and prepare_video_assets then fetches the layout itself and fills every scene's customPayload.slide. Pass the same URL you gave doc_parse. You are NOT expected to author or even read that layout data — one page is hundreds of KB, so it never travels through the conversation; without this reference the DSL is rejected downstream after you have already written all the narration."
54
+ },
51
55
  "headline": {
52
56
  "type": "string",
53
57
  "description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
@@ -114,6 +118,7 @@
114
118
  "voice_id",
115
119
  "speed",
116
120
  "template_id",
121
+ "source_document_url",
117
122
  "headline",
118
123
  "subheadline",
119
124
  "carousel_items",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V15",
5
+ "version": "V16",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -33,6 +33,10 @@
33
33
  "type": "string",
34
34
  "description": "Template id. When provided, the script invokes template-registry internally; no separate binding step is needed."
35
35
  },
36
+ "source_document_url": {
37
+ "type": "string",
38
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). Normally unnecessary: gen_script already recorded it at meta.sourceDocument when you passed source_document_url there, and the cached skeleton carries it through. Pass it here only to recover when the skeleton lacks the reference — it saves a full gen_script round trip. The layout data is fetched here and written into every scene's customPayload.slide; you neither author nor see it."
39
+ },
36
40
  "binding_json": {
37
41
  "type": "string",
38
42
  "description": "TemplateBinding JSON as an inline string (alternative to template_id; no file needed)."
@@ -2,6 +2,6 @@
2
2
  "skillName": "prepare-video-assets",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "476",
5
- "version": "V1",
5
+ "version": "V3",
6
6
  "skillDescription": "Asset-preparation skill: resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a `job_id` for the subsequent `render_video` call.\n\nUse this skill as soon as the user mentions any of these intents:\n- Generate / prepare video assets, resolve assets, render-ready\n- \"Make me a video about X\" (the agent calls gen_script → prepare_video_assets → render_video)\n- Regenerate one asset (image / audio) for a specific scene\n\nNext step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.\n\n⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn."
7
7
  }
@@ -63,6 +63,7 @@ from video_dsl.runtime.timeline_compiler import (
63
63
  split_subtitle,
64
64
  split_subtitle_from_lines,
65
65
  segment_narration,
66
+ assign_sentence_indices,
66
67
  )
67
68
 
68
69
  # 字幕末尾标点去除(与 timeline_compiler 中的 _strip_trailing_punct 同逻辑)
@@ -507,6 +508,82 @@ def validate_dsl(dsl: dict) -> list:
507
508
  return errors_as_strings([e for e in found if getattr(e, "severity", "error") == "error"])
508
509
 
509
510
 
511
+ def _hydrate_dsl_payload(
512
+ dsl: dict, template_id: str | None, source_document_url: str | None
513
+ ) -> None:
514
+ """按模板声明把源文件的每一页填进 ``customPayload``(就地改 dsl)。
515
+
516
+ 只对声明了 ``capabilities.payloadHydration`` 的模板生效(目前 ppt-to-video);
517
+ 其余模板这里是零成本 no-op —— 声明驱动,不写 ``if template_id == X``(AGENTS.md)。
518
+
519
+ 失败一律 exit 2 并把「下一步做什么」打全:这条路上所有失败形态的共同点是
520
+ *数据源没通*,而历史报错说的是"字段没填",两者要采取的行动完全不同。
521
+
522
+ 共享模块导入失败(裁剪安装 / 单测环境)时静默跳过:那时 payloadRequirements
523
+ 的校验会照常报必填缺失,行为退回改动前,不会更糟。
524
+ """
525
+ try:
526
+ from payload_hydrator import HydrationError, hydrate_dsl # noqa: E402
527
+ except ImportError:
528
+ return
529
+
530
+ try:
531
+ from registry_loader import get_template # noqa: E402
532
+ template = get_template(template_id) if template_id else None
533
+ except Exception: # noqa: BLE001 - registry 不可达时退回"不注入",由校验兜底报错
534
+ template = None
535
+ if not template:
536
+ return
537
+
538
+ try:
539
+ report = hydrate_dsl(
540
+ dsl,
541
+ template,
542
+ override_url=source_document_url,
543
+ log=lambda msg: LogPrint(f" {msg}", file=sys.stderr),
544
+ )
545
+ except HydrationError as exc:
546
+ LogPrint(f"❌ {exc}", file=sys.stderr)
547
+ sys.exit(2)
548
+
549
+ for line in report:
550
+ LogPrint(f" {line}", file=sys.stderr)
551
+
552
+
553
+ def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
554
+ """动作条数与旁白句数不等时出声。
555
+
556
+ 为什么值得单独打一条:这一类缺陷**渲得出来**。它不报错、不退非零、renderJob
557
+ completed、漏斗记 rendered —— 2026-09-23 线上那条鸡兔同笼成片就是这样交付的,
558
+ 10 步板面配 10 句旁白,听起来该一一对应,实际板面在音频中点就走完了。
559
+ 既然成片本身不会告状,那就必须有人在这里说一句。
560
+
561
+ 只警告不阻断:步数少于句数有时是作者的本意(末尾一句总结不需要新动作)。
562
+ 真正需要拦的是"作者以为一一对应、实际不是",而那个判断只有作者做得了 ——
563
+ 所以这里给的是两个数字,不是一个结论。
564
+ """
565
+ steps = ((entry.get("props") or {}).get("steps")
566
+ or (entry.get("props") or {}).get("beats"))
567
+ if not isinstance(steps, list) or not steps:
568
+ return
569
+ sentences = (sentence_indices[-1] + 1) if sentence_indices else 0
570
+ if sentences == 0 or len(steps) == sentences:
571
+ return
572
+
573
+ scene_id = entry.get("sceneId", "?")
574
+ tail = (
575
+ f"末尾 {sentences - len(steps)} 句没有对应动作,画面会在那之前就走完并停住"
576
+ if len(steps) < sentences
577
+ else f"多出的 {len(steps) - sentences} 个动作会挤在最后一句上"
578
+ )
579
+ LogPrint(
580
+ f"⚠️ scene {scene_id}: {len(steps)} 个讲解动作 vs 旁白 {sentences} 句 —— {tail}。\n"
581
+ f" 本模板的动作按**句**对齐(第 i 个动作 = 第 i 句的起点)。要一一对应就让两者等数;"
582
+ f" 要刻意不等,忽略这条即可。",
583
+ file=sys.stderr,
584
+ )
585
+
586
+
510
587
  def resolve_dimensions(dsl: dict) -> tuple:
511
588
  ratio = dsl.get("global", {}).get("aspectRatio", "16:9")
512
589
  resolution = dsl.get("global", {}).get("resolution", "1080p")
@@ -1381,14 +1458,24 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1381
1458
 
1382
1459
  tts_subs = narration_asset.get("ttsSubtitles", [])
1383
1460
  if tts_subs:
1461
+ # 句归属必须在 _strip_subtitle_trailing_punct **之前**算:
1462
+ # 判据就是句末标点,去掉之后所有段都会被认成同一句。
1463
+ # 这一层是「第 i 个动作对齐第 i 句」类模板(math-principles-expl
1464
+ # 的 steps、ppt-to-video 的 beats)唯一能拿到句边界的地方 ——
1465
+ # TTS 行是按停顿切的,段数远多于句数。
1466
+ sentence_indices = assign_sentence_indices(
1467
+ [s.get("text") or "" for s in tts_subs]
1468
+ )
1384
1469
  entry["subtitleSegments"] = [
1385
1470
  {
1386
1471
  "text": _strip_subtitle_trailing_punct(s["text"].strip()),
1387
1472
  "startFrame": current_frame + int(s["timeBegin"] / 1000.0 * fps),
1388
1473
  "endFrame": current_frame + int(s["timeEnd"] / 1000.0 * fps),
1474
+ "sentenceIndex": sentence_indices[i],
1389
1475
  }
1390
- for s in tts_subs
1476
+ for i, s in enumerate(tts_subs)
1391
1477
  ]
1478
+ _warn_step_sentence_mismatch(entry, sentence_indices)
1392
1479
  else:
1393
1480
  # No TTS timestamps — re-distribute existing subtitle
1394
1481
  # lines across the REAL audio duration, by character
@@ -2177,6 +2264,13 @@ Examples:
2177
2264
  parser.add_argument("--dsl", help="Input DSL file path")
2178
2265
  parser.add_argument("--dsl-json", default=None, help="DSL JSON as an inline string (replaces --dsl; no file needed; preferred for multi-user concurrent flows)")
2179
2266
  parser.add_argument("--template-id", default=None, help="Template id (required unless --render-plan / --job-id is used)")
2267
+ parser.add_argument(
2268
+ "--source-document-url", default=None,
2269
+ help="Source document URL (.pptx) for templates that build their per-scene payload from a "
2270
+ "document (capabilities.payloadHydration, e.g. ppt-to-video). Overrides "
2271
+ "meta.sourceDocument in the DSL. The layout data is fetched here and written into "
2272
+ "every scene's customPayload — it is deliberately never routed through the model.",
2273
+ )
2180
2274
  parser.add_argument("--render-plan", help="Existing RenderPlan file path")
2181
2275
  parser.add_argument("--resolve-only", action="store_true", help="Only resolve assets; do not run the render")
2182
2276
  parser.add_argument("--skip-asset-resolve", action="store_true", help="Skip asset resolution")
@@ -2365,6 +2459,18 @@ Examples:
2365
2459
  )
2366
2460
  sys.exit(2)
2367
2461
 
2462
+ # ── 按引用注入模板声明的大 payload(必须在校验之前)─────────────
2463
+ #
2464
+ # ppt-to-video 这类模板的画面数据(customPayload.slide:一整页的节点树、
2465
+ # 坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2466
+ # 而它体积大到不能过模型(一页几百 KB,23 页好几 MB)。所以 DSL 里只带一个
2467
+ # 引用(meta.sourceDocument),在这里把全量取回来填进每个场景。
2468
+ #
2469
+ # 为什么必须在 validate_dsl 之前:那道校验读的就是 capabilities.payloadRequirements
2470
+ # 的必填项。放在后面 = 每次都被自己还没填的字段拒掉(2026-09-23 线上
2471
+ # conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
2472
+ _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2473
+
2368
2474
  errors = validate_dsl(dsl)
2369
2475
  if errors:
2370
2476
  LogPrint("❌ DSL validation failed:", file=sys.stderr)
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V25",
5
+ "version": "V27",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """Shared document extraction client — ab-render ``/extract`` submit + poll.
3
+
4
+ 为什么这段逻辑住在共享目录而不是 doc-parse skill 里:**有两个消费方,而且它们要的
5
+ 形态相反**。
6
+
7
+ doc-parse(给模型看) → 摘要进 stdout,全量落盘。layout 层一页带内嵌图标可达
8
+ 几百 KB,进对话正文会撑爆上下文。
9
+ payload_hydrator(给机器用)→ 要的就是全量 layout,直接填进 DSL 的 customPayload。
10
+
11
+ 第二个消费方以前不存在,于是 ppt-to-video 是这样断的:抽取结果只有落盘一条出路,而
12
+ 模型在 ab-agent 里**没有任何文件读取工具**(``mcp-tools.ts`` 的 ALLOWED_TOOL_NAMES 刻意
13
+ 只放行 MCP skill,内置 Read/Bash 全部排除,那是对的——bypassPermissions 下放开读写等于
14
+ 把 .env 里的 PRIV_TOKEN 交出去)。模型能看到「完整结果已写入 ./source.pptx.layout.json」,
15
+ 然后无论如何也读不到它。2026-09-23 线上那条会话在这里试了 ``web_read file://``,被
16
+ 「--url 只接受 http/https」打回,接着整条链路就没有第二条路了。
17
+
18
+ 结论:**layout 数据不该过模型**。它从 ab-render 出来,直接进 DSL,中间不经过上下文。
19
+ 这个模块就是那条直连的通道。
20
+
21
+ 环境变量(由 ab-agent 注入,与 doc-parse skill 同一套):
22
+ RENDER_API_URL / REMOTION_RENDER_API_URL ab-render 地址
23
+ PRIV_TOKEN ab-api 私有 token(端点拒绝匿名调用)
24
+ CONVERSATION_ID 留痕用,可选
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from typing import Callable, Optional
36
+
37
+ DEFAULT_RENDER_API_URL = "https://api-render.remixmate.ai"
38
+
39
+ # 端点是异步任务制:submit 拿 taskId,再轮询 extractStatus。
40
+ DEFAULT_MAX_WAIT_SEC = 600
41
+ DEFAULT_POLL_INTERVAL_SEC = 2
42
+
43
+ SUPPORTED_KINDS = ("pptx",)
44
+
45
+
46
+ class DocExtractError(RuntimeError):
47
+ """抽取失败。
48
+
49
+ message 直接面向调用方的 stderr —— ab-render 的抽取错误本来就是人话
50
+ (「不是 zip」「超页数上限」「.ppt 需另存为 .pptx」),再包一层只会让它更远。
51
+ """
52
+
53
+
54
+ def render_api_url() -> str:
55
+ return (
56
+ os.environ.get("RENDER_API_URL")
57
+ or os.environ.get("REMOTION_RENDER_API_URL")
58
+ or DEFAULT_RENDER_API_URL
59
+ ).rstrip("/")
60
+
61
+
62
+ def _api_post(path: str, body: dict, timeout: int = 30) -> dict:
63
+ url = f"{render_api_url()}{path}"
64
+ headers = {
65
+ "Content-Type": "application/json",
66
+ "X-Priv-Token": os.environ.get("PRIV_TOKEN", ""),
67
+ }
68
+ conversation_id = os.environ.get("CONVERSATION_ID", "")
69
+ if conversation_id:
70
+ headers["x-conversation-id"] = conversation_id
71
+
72
+ req = urllib.request.Request(
73
+ url, data=json.dumps(body).encode(), headers=headers, method="POST"
74
+ )
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 - fixed https base
77
+ return json.loads(resp.read())
78
+ except urllib.error.HTTPError as exc:
79
+ detail = exc.read().decode(errors="replace")
80
+ raise DocExtractError(f"HTTP {exc.code} from {path}: {detail}") from exc
81
+ except urllib.error.URLError as exc:
82
+ raise DocExtractError(f"cannot reach ab-render at {url}: {exc.reason}") from exc
83
+
84
+
85
+ def extract_document(
86
+ url: str,
87
+ *,
88
+ kind: str = "pptx",
89
+ fields: str | list[str] = "content,layout",
90
+ slide: Optional[int] = None,
91
+ max_wait_sec: int = DEFAULT_MAX_WAIT_SEC,
92
+ poll_interval_sec: int = DEFAULT_POLL_INTERVAL_SEC,
93
+ log: Optional[Callable[[str], None]] = None,
94
+ ) -> dict:
95
+ """Submit + poll one extraction; return the endpoint's ``result`` dict.
96
+
97
+ ``log`` 收到的是进度行,调用方自己决定丢到 stderr 还是丢掉。这里**不直接写
98
+ stdout** —— doc-parse 的 ``--json-output`` 管道模式要求 stdout 只有那一份 JSON。
99
+ """
100
+ emit = log or (lambda _msg: None)
101
+
102
+ if not os.environ.get("PRIV_TOKEN"):
103
+ raise DocExtractError(
104
+ "PRIV_TOKEN env var is not configured — the /extract endpoint refuses anonymous calls."
105
+ )
106
+ if kind not in SUPPORTED_KINDS:
107
+ raise DocExtractError(
108
+ f"unsupported document kind '{kind}' (supported: {', '.join(SUPPORTED_KINDS)})"
109
+ )
110
+ if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
111
+ raise DocExtractError(
112
+ f"document url must be http/https, got {url!r}. "
113
+ "Pass the file URL itself (what the asset library returns), not a page or share link."
114
+ )
115
+
116
+ field_list = (
117
+ [f.strip() for f in fields.split(",") if f.strip()]
118
+ if isinstance(fields, str)
119
+ else [str(f).strip() for f in fields if str(f).strip()]
120
+ )
121
+
122
+ body: dict = {"documentUrl": url, "kind": kind, "fields": field_list}
123
+ if slide is not None:
124
+ body["slide"] = slide
125
+
126
+ emit(f"[doc-extract] submitting: {url[:80]}")
127
+ resp = _api_post("/extract", body)
128
+ if resp.get("code") != 0:
129
+ raise DocExtractError(f"submission failed: {resp.get('msg', 'unknown error')}")
130
+
131
+ task_id = ((resp.get("data") or {}).get("taskId")) or ""
132
+ if not task_id:
133
+ raise DocExtractError(f"/extract did not return a taskId: {resp}")
134
+ emit(f"[doc-extract] task accepted, taskId={task_id}")
135
+
136
+ elapsed = 0
137
+ while elapsed < max_wait_sec:
138
+ time.sleep(poll_interval_sec)
139
+ elapsed += poll_interval_sec
140
+
141
+ try:
142
+ status_resp = _api_post("/extractStatus", {"taskId": task_id})
143
+ except DocExtractError as exc:
144
+ # 网络抖动不是任务失败:任务还在服务端跑着,重试轮询。
145
+ emit(f"[doc-extract] polling error (will retry): {exc}")
146
+ continue
147
+
148
+ if status_resp.get("code") != 0:
149
+ emit(f"[doc-extract] polling error code (will retry): {status_resp.get('msg')}")
150
+ continue
151
+
152
+ task = status_resp.get("data") or {}
153
+ status = task.get("status", "unknown")
154
+
155
+ if status == "succeeded":
156
+ return task.get("result") or {}
157
+ if status == "failed":
158
+ raise DocExtractError(f"extraction failed: {task.get('error', 'unknown error')}")
159
+
160
+ emit(f"[doc-extract] status={status} elapsed={elapsed}s")
161
+
162
+ raise DocExtractError(f"extraction timed out after {max_wait_sec}s (taskId={task_id})")
163
+
164
+
165
+ def slides_by_index(result: dict) -> dict[int, dict]:
166
+ """``slides[]`` 按 1-based ``slideIndex`` 建索引。
167
+
168
+ 不用列表位置当页号:``--slide N`` 单页抽取时列表只有一项,而它的 slideIndex 是 N。
169
+ 按位置取会把第 7 页当第 1 页用,而且画面看起来"有内容",没人会发现。
170
+ """
171
+ index: dict[int, dict] = {}
172
+ for position, slide in enumerate(result.get("slides") or [], start=1):
173
+ if not isinstance(slide, dict):
174
+ continue
175
+ raw = slide.get("slideIndex")
176
+ index[raw if isinstance(raw, int) and raw > 0 else position] = slide
177
+ return index
178
+
179
+
180
+ def summarize(result: dict, out_path: Optional[str] = None) -> str:
181
+ """人能读的一页纸。
182
+
183
+ **这是 doc-parse 存在的一半价值** —— 端点给的是全量结构,决定"喂多少进上下文"
184
+ 是入口层的责任(设计文档 §4.4)。每页一行:页码、档位、拍数、标题、讲稿来源。
185
+ 档位与拍数决定后面怎么写旁白,讲稿来源决定走严格改写还是扩写。
186
+ """
187
+ src = result.get("source", {})
188
+ lines = [
189
+ f"共 {src.get('slideCount', '?')} 页,本次抽到 {len(result.get('slides', []))} 页"
190
+ f"(fields={'+'.join(src.get('fields', []))})",
191
+ "",
192
+ ]
193
+
194
+ for s in result.get("slides", []):
195
+ idx = s.get("slideIndex", "?")
196
+ bits = [f"第 {idx} 页"]
197
+
198
+ if s.get("mode"):
199
+ beats = len(s.get("beats") or [])
200
+ bits.append(f"{s['mode']}/{beats} 拍")
201
+ if s.get("title"):
202
+ bits.append(f"「{s['title']}」")
203
+
204
+ notes = (s.get("rawNotes") or "").strip()
205
+ if notes:
206
+ head = notes.replace("\n", " ")[:40]
207
+ bits.append(f"备注 {len(notes)} 字:{head}…")
208
+ elif "rawNotes" in s:
209
+ bits.append("无备注(旁白需按正文扩写)")
210
+
211
+ lines.append(" " + " · ".join(bits))
212
+
213
+ if s.get("modeReason"):
214
+ lines.append(f" 判定:{s['modeReason']}")
215
+ flags = s.get("flags") or []
216
+ if flags:
217
+ # flag 是显式的降级记录,不是日志 —— 画面上少一块,这里不说就没人知道为什么。
218
+ lines.append(f" ⚠ 降级:{'; '.join(flags[:3])}")
219
+
220
+ if out_path:
221
+ lines += ["", f"完整结果已写入:{out_path}"]
222
+ return "\n".join(lines)
223
+
224
+
225
+ def stderr_logger(msg: str) -> None:
226
+ print(msg, file=sys.stderr, flush=True)