dsh-deepmoyu 2.3.0 → 2.4.2

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.
Files changed (90) hide show
  1. package/AGENTS.md +93 -153
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +143 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1404 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +1095 -285
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/update.mjs +265 -0
  54. package/lib/uvfetch.mjs +165 -153
  55. package/lib/verify.mjs +111 -127
  56. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  57. package/package.json +23 -12
  58. package/presets/deepmoyu.patch.yml +1 -1
  59. package/prompts/duty.md +4 -3
  60. package/screenshots.json +5 -0
  61. package/selfcheck.mjs +21 -6
  62. package/vendor/bilibili-mcp/requirements.in +9 -0
  63. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  64. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  65. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  66. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  67. package/tests/automation-routes.test.mjs +0 -183
  68. package/tests/automation.test.mjs +0 -79
  69. package/tests/background-tools.test.mjs +0 -182
  70. package/tests/background-window.test.mjs +0 -75
  71. package/tests/dup-cross-channel.test.mjs +0 -158
  72. package/tests/manual-browsing.test.mjs +0 -671
  73. package/tests/mcp-restart.test.mjs +0 -131
  74. package/tests/memory-chain.test.mjs +0 -413
  75. package/tests/model-relay.test.mjs +0 -116
  76. package/tests/persona.test.mjs +0 -67
  77. package/tests/pet-run-routes.test.mjs +0 -127
  78. package/tests/pet-run-settings.test.mjs +0 -38
  79. package/tests/pet-session.test.mjs +0 -125
  80. package/tests/pv-stage1.test.mjs +0 -203
  81. package/tests/sticker-routes.test.mjs +0 -141
  82. package/tests/sticker-upload-routes.test.mjs +0 -97
  83. package/tests/sticker-upload.test.mjs +0 -122
  84. package/tests/sticker.test.mjs +0 -169
  85. package/tests/test_guard_cross_channel.py +0 -141
  86. package/tests/test_pv_cover.py +0 -95
  87. package/tests/test_pv_stage1.py +0 -250
  88. package/tests/test_reply_pictures.py +0 -255
  89. package/tests/token-ledger.test.mjs +0 -78
  90. package/tests/verify_pv_sample.py +0 -91
package/lib/pv.py CHANGED
@@ -1581,17 +1581,25 @@ def _draw_desk_front(camera, prop):
1581
1581
  for y in range(top, 984):
1582
1582
  d.line([(0, y), (DW, y)], fill=mix(WOOD_HI, WOOD_MID, (y - top) / max(1, 984 - top)) + (255,))
1583
1583
  d.line((0, top, DW, top), fill=(238, 208, 170, 255), width=5)
1584
+ # RGBA ImageDraw replaces destination alpha; blend translucent grain separately
1585
+ # so the desk still occludes the character and background underneath it.
1586
+ grain = Image.new("RGBA", layer.size, (0, 0, 0, 0))
1587
+ gd = ImageDraw.Draw(grain, "RGBA")
1584
1588
  for i in range(9):
1585
1589
  y = top + 14 + i * 7
1586
- d.line((0, y, DW, y - 2), fill=(150, 105, 68, 26), width=2)
1590
+ gd.line((0, y, DW, y - 2), fill=(150, 105, 68, 26), width=2)
1591
+ layer.alpha_composite(grain)
1587
1592
  pool = _soft_dot(980, (255, 226, 176), 120).resize((1180, 300))
1588
1593
  layer.alpha_composite(pool, (60, top - 90))
1589
1594
  for y in range(984, DH):
1590
1595
  d.line([(0, y), (DW, y)], fill=mix(WOOD_MID, WOOD_LO, (y - 984) / max(1, DH - 984)) + (255,))
1591
1596
  d.rectangle((0, 984, DW, 990), fill=(226, 190, 152, 255))
1597
+ grain = Image.new("RGBA", layer.size, (0, 0, 0, 0))
1598
+ gd = ImageDraw.Draw(grain, "RGBA")
1592
1599
  for i in range(14):
1593
1600
  x = int(i * DW / 14)
1594
- d.line((x, 990, x - 26, DH), fill=(90, 60, 38, 30), width=3)
1601
+ gd.line((x, 990, x - 26, DH), fill=(90, 60, 38, 30), width=3)
1602
+ layer.alpha_composite(grain)
1595
1603
  if camera == "insert":
1596
1604
  _draw_bowl(layer, 720, 940, 250)
1597
1605
  _draw_cup(layer, 1180, 880, 62)
@@ -2158,6 +2166,7 @@ def prepare_audio(shots, base_voice, work, fps):
2158
2166
  # ④ 失败语义照旧:任一镜合成失败(edge-tts 和 SAPI 兜底都失败)就让整次
2159
2167
  # 渲染失败,按镜序抛**最早**那镜的异常 —— 原串行版就是死在第一句失败上。
2160
2168
  pending = []
2169
+ pending_keys = {}
2161
2170
  for index, (shot, voice) in enumerate(zip(shots, effective)):
2162
2171
  if index in gsv_paths:
2163
2172
  continue # GSV 镜头走上面的批处理(串行),不进 TTS 池
@@ -2168,18 +2177,26 @@ def prepare_audio(shots, base_voice, work, fps):
2168
2177
  source = work / "tts" / f"{key}.mp3"
2169
2178
  if source.exists() and source.stat().st_size >= 600:
2170
2179
  continue # 缓存命中(判定与下方串行循环逐字一致)
2180
+ # Identical lines share one content-addressed file. Submitting both would
2181
+ # have two threads writing the same .partial.mp3 and replacing it, which
2182
+ # reproducibly raised FileNotFoundError on Windows. Synthesize once per key.
2183
+ if key in pending_keys:
2184
+ pending_keys[key].append(index)
2185
+ continue
2186
+ pending_keys[key] = [index]
2171
2187
  pending.append((index, text, voice, key))
2172
2188
  if pending:
2173
2189
  print(f"🎙 edge-tts 并发合成 {len(pending)} 句({min(4, len(pending))} 线程)…", flush=True)
2174
2190
  failures = {}
2175
2191
  with ThreadPoolExecutor(max_workers=min(4, len(pending))) as pool:
2176
- futures = {index: pool.submit(_synthesize_edge_take, text, voice, work, key)
2192
+ futures = {key: pool.submit(_synthesize_edge_take, text, voice, work, key)
2177
2193
  for index, text, voice, key in pending}
2178
- for index, future in futures.items(): # 按提交(镜)序收结果
2194
+ for index, text, voice, key in pending: # 按提交(镜)序收结果
2179
2195
  try:
2180
- future.result()
2196
+ futures[key].result()
2181
2197
  except Exception as exc:
2182
- failures[index] = exc
2198
+ for failed_index in pending_keys[key]:
2199
+ failures[failed_index] = exc
2183
2200
  if failures:
2184
2201
  raise failures[min(failures)]
2185
2202
 
@@ -2244,6 +2261,7 @@ def make_timeline(shots, audios, lead, gap, tail, fps):
2244
2261
  "audio_duration":audio["dur"],"engine":audio.get("engine","unknown"),
2245
2262
  "cache_key":audio.get("cache_key",""),"camera":shot.get("camera","legacy"),
2246
2263
  "expression":shot.get("expression","neutral"),
2264
+ "scene":shot.get("scene",""),"headline":shot.get("headline",""),
2247
2265
  "provenance":shot.get("provenance","user-plan"),"source_ids":shot.get("source_ids",[]),
2248
2266
  "mouth_frames":sum(value > .05 for value in audio["env"])})
2249
2267
  cursor = end + max(0, float(shot.get("pause_after",gap)))
@@ -2275,8 +2293,12 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2275
2293
  raise ValueError("fps must be 1..60; scale must be 0.1..2")
2276
2294
  t_start = time.time()
2277
2295
  shots = plan["shots"]
2278
- # 对话舞台按 DW x DH(1080p)原生绘制;旧卡片方案仍是 1280x720。
2279
- NAT_W, NAT_H = (DW, DH) if plan.get("schema") == "dafeiyu-dialogue-v1" else (W, H)
2296
+ # Editorial v2 and dialogue plans use the 1080p stage. Legacy cards remain 720p.
2297
+ editorial_plan = plan.get("schema") == "deepmoyu-editorial-v2"
2298
+ editorial_renderer = None
2299
+ if editorial_plan:
2300
+ import pv_editorial as editorial_renderer
2301
+ NAT_W, NAT_H = (DW, DH) if editorial_plan or plan.get("schema") == "dafeiyu-dialogue-v1" else (W, H)
2280
2302
  if (int(plan.get("width", NAT_W)),int(plan.get("height", NAT_H))) != (NAT_W, NAT_H):
2281
2303
  raise ValueError(f"the current stage supports {NAT_W}x{NAT_H}; resize with --scale")
2282
2304
  W2,H2 = max(2,round(NAT_W*scale/2)*2),max(2,round(NAT_H*scale/2)*2)
@@ -2290,9 +2312,11 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2290
2312
 
2291
2313
  ff = ffmpeg_exe()
2292
2314
  # 有真立绘就用真立绘,没有才退回程序化角色
2293
- portrait = Portrait(AV_SIZE)
2294
- fish = portrait if portrait.ok else Fish(AV_SIZE)
2295
- if portrait.ok:
2315
+ portrait = None if editorial_plan else Portrait(AV_SIZE)
2316
+ fish = None if editorial_plan else (portrait if portrait.ok else Fish(AV_SIZE))
2317
+ if editorial_plan:
2318
+ print(f"🎨 动态短片 · {plan.get('theme', 'paper')} · {len(shots)} 镜", flush=True)
2319
+ elif portrait.ok:
2296
2320
  print(f"🐟 形象:真·大肥鱼立绘 {portrait.name}({portrait.size}px)", flush=True)
2297
2321
  else:
2298
2322
  print("🐟 形象:没找到立绘素材,退回程序化角色", flush=True)
@@ -2305,7 +2329,8 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2305
2329
  starts,total,cues = make_timeline(shots,audios,lead,gap,tail,fps)
2306
2330
  timeline = {"schema":"dafeiyu-timeline-v1","duration":total,"fps":fps,
2307
2331
  "disclosure":plan.get("disclosure",""),"sources":plan.get("sources",[]),
2308
- "cues":cues,"timing":"measured PCM utterances; RMS mouth animation, not phoneme alignment"}
2332
+ "visual_schema":plan.get("schema", "legacy"), "theme":plan.get("theme", ""),
2333
+ "cues":cues,"timing":"measured PCM utterances; editorial phrase positions proportional within each utterance; RMS mouth animation, not phoneme alignment"}
2309
2334
  (work / "timeline.json").write_text(json.dumps(timeline,ensure_ascii=False,indent=2),encoding="utf-8")
2310
2335
  write_captions(cues,work/"captions.srt")
2311
2336
 
@@ -2313,13 +2338,14 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2313
2338
  st = {
2314
2339
  "fish": fish,
2315
2340
  "dialogue_portrait": DialoguePortrait(),
2341
+ "theme": plan.get("theme", "paper"), "mode": plan.get("mode", "custom"), "shot_count": len(shots),
2316
2342
  "disclosure":plan.get("disclosure","原创对白"),
2317
2343
  "source_map":{source["id"]:source for source in plan.get("sources",[]) if "id" in source},
2318
- "bg_title": bg_ocean(7, "#eaf6ff", "#d2eafb", "#b6dcf2", rice=True),
2319
- "bg_stats": bg_ocean(11, "#e6f3fd", "#cfe6f8", "#b2d5ee"),
2320
- "bg_video": bg_ocean(13, "#e8f4fe", "#cde5f7", "#aed2ec"),
2321
- "bg_quote": bg_ocean(17, "#f0f8ff", "#d8ecfa", "#bcdcef", rice=True),
2322
- "bg_social": bg_ocean(19, "#ecf7fd", "#d4ebf9", "#b8d9ee"),
2344
+ "bg_title": None if editorial_plan else bg_ocean(7, "#eaf6ff", "#d2eafb", "#b6dcf2", rice=True),
2345
+ "bg_stats": None if editorial_plan else bg_ocean(11, "#e6f3fd", "#cfe6f8", "#b2d5ee"),
2346
+ "bg_video": None if editorial_plan else bg_ocean(13, "#e8f4fe", "#cde5f7", "#aed2ec"),
2347
+ "bg_quote": None if editorial_plan else bg_ocean(17, "#f0f8ff", "#d8ecfa", "#bcdcef", rice=True),
2348
+ "bg_social": None if editorial_plan else bg_ocean(19, "#ecf7fd", "#d4ebf9", "#b8d9ee"),
2323
2349
  "_cover_cache": {},
2324
2350
  }
2325
2351
  dialogue_plan = plan.get("schema") == "dafeiyu-dialogue-v1"
@@ -2347,9 +2373,12 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2347
2373
  env = audios[index]["env"]
2348
2374
  active = 0 <= local < duration
2349
2375
  st["mouth"] = env[min(len(env)-1,int(local*fps))] if active and env else 0.0
2350
- fn = CARDS.get(shot.get("kind","title"),title_card)
2351
- frame = fn(shot,max(0,local),duration,st)
2352
- if shot.get("kind") == "dialogue":
2376
+ st["shot_index"] = index
2377
+ fn = editorial_renderer.render if editorial_plan else CARDS.get(shot.get("kind","title"),title_card)
2378
+ frame = fn(shot,local if editorial_plan else max(0,local),duration,st)
2379
+ if editorial_plan:
2380
+ pass
2381
+ elif shot.get("kind") == "dialogue":
2353
2382
  frame = dialogue_subtitle(frame,shot,active,max(0,local))
2354
2383
  else:
2355
2384
  frame = subtitle(frame,str(shot.get("text","")),max(0,local),duration,shot.get("words"))
@@ -2397,7 +2426,7 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2397
2426
  tmp_mp4 = work / "video_noaudio.mp4"
2398
2427
  # 调色的一半在构图里(暖高光/冷阴影),这里只做很轻的统一 + 时变颗粒。
2399
2428
  # 颗粒必须 allf=t+u(时变+均匀):静态噪声会变成脏点。
2400
- vf = ("eq=contrast=1.045:saturation=1.05:brightness=-0.004,"
2429
+ vf = "null" if editorial_plan else ("eq=contrast=1.045:saturation=1.05:brightness=-0.004,"
2401
2430
  "curves=master='0/0.012 0.25/0.242 0.75/0.772 1/0.99',"
2402
2431
  "unsharp=5:5:0.35:5:5:0,"
2403
2432
  "noise=alls=4:allf=t+u")
@@ -2432,6 +2461,24 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2432
2461
  tails[si-1] = shot_frame(si-1, max(0.0, audios[si-1]["dur"] - .02))
2433
2462
  prev = tails[si-1]
2434
2463
  frame = Image.blend(prev, frame, ease_out(clamp(local / XFADE, 0, 1)))
2464
+ # Editorial plans author their own transition intent. A short
2465
+ # overlap gives the viewer a visual breath between beats while the
2466
+ # default cut keeps punchy hooks crisp.
2467
+ if editorial_plan and si > 0:
2468
+ transition = shots[si].get("transition", "cut")
2469
+ blend_window = .24 if transition in {"slide", "match"} else 0.0
2470
+ if blend_window and local < blend_window:
2471
+ tails = st.setdefault("_tail", {})
2472
+ if si - 1 not in tails:
2473
+ tails[si-1] = shot_frame(si-1, max(0.0, audios[si-1]["dur"] - .02))
2474
+ prev = tails[si-1]
2475
+ amount = ease_out(clamp(local / blend_window, 0, 1))
2476
+ if transition == "slide":
2477
+ shifted = prev.copy()
2478
+ shifted.paste(frame, (int(frame.width * (1 - amount)), 0))
2479
+ frame = Image.blend(prev, shifted, amount)
2480
+ else:
2481
+ frame = Image.blend(prev, frame, amount)
2435
2482
  # 片头片尾各 600ms 淡入淡出(像一段真正剪过的短片,而不是突然开始)
2436
2483
  if dialogue_plan:
2437
2484
  head = clamp(tnow / .60, 0, 1)
@@ -2440,7 +2487,7 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2440
2487
  if alpha < 1:
2441
2488
  frame = Image.blend(Image.new("RGB", frame.size, (12, 9, 7)), frame, ease_out(alpha))
2442
2489
  # Skits cut cleanly; legacy report plans retain a short global fade.
2443
- if plan.get("schema") != "dafeiyu-dialogue-v1":
2490
+ if not editorial_plan and plan.get("schema") != "dafeiyu-dialogue-v1":
2444
2491
  alpha = clamp(min(tnow,total-tnow)/.15,0,1)
2445
2492
  frame = Image.blend(Image.new("RGB",frame.size),frame,alpha)
2446
2493
  if fi in {max(1,int(lead*fps)+1),n_frames//2,max(0,n_frames-2)}:
@@ -2453,13 +2500,28 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
2453
2500
  print(f" {fi}/{n_frames} {el:.0f}s ETA {eta:.0f}s", flush=True)
2454
2501
  proc.stdin.close()
2455
2502
  err = proc.stderr.read().decode("utf-8", "ignore")
2503
+ proc.stderr.close()
2456
2504
  if proc.wait() != 0:
2457
2505
  raise RuntimeError("ffmpeg 视频编码失败:" + err[:600])
2458
2506
  except BaseException:
2507
+ try:
2508
+ if proc.stdin and not proc.stdin.closed:
2509
+ proc.stdin.close()
2510
+ except Exception:
2511
+ pass
2459
2512
  try:
2460
2513
  proc.kill()
2461
2514
  except Exception:
2462
2515
  pass
2516
+ try:
2517
+ proc.wait(timeout=5)
2518
+ except Exception:
2519
+ pass
2520
+ try:
2521
+ if proc.stderr and not proc.stderr.closed:
2522
+ proc.stderr.close()
2523
+ except Exception:
2524
+ pass
2463
2525
  raise
2464
2526
 
2465
2527
  # —— 5. 合音轨 + BGM ——
package/lib/pv_cover.py CHANGED
@@ -228,25 +228,97 @@ def _wrap_measured(draw: ImageDraw.ImageDraw, text: str, font, max_width: int, l
228
228
  return list(result[1]) if result else None
229
229
 
230
230
 
231
+ HEADLINE_MAX_SIZE = 126 # 一行两个字那么大的时候的上限
232
+ HEADLINE_MIN_SIZE = 48 # 再小就不像标题了,宁可截短
233
+ HEADLINE_READABLE = 72 # 「一眼能读」的下限:到这个字号还排不下才允许更小
234
+
235
+
236
+ def _text_field(ratio: str):
237
+ """Return ``(max_width, max_height)`` the headline may occupy.
238
+
239
+ Kept in one place so the measuring pass and :func:`render_cover` can never
240
+ disagree about how much room the artwork actually gives the headline.
241
+ """
242
+ (width, height), key = _dimensions(ratio)
243
+ portrait_size = 720 if key == "16x9" else 650
244
+ portrait_x = width - portrait_size - (52 if key == "16x9" else 34)
245
+ text_left = 90
246
+ text_right = portrait_x - (58 if key == "16x9" else 46)
247
+ text_width = max(420, text_right - text_left)
248
+ text_top = 180
249
+ text_bottom = height - 170
250
+ return text_width - 42, text_bottom - text_top - 30
251
+
252
+
253
+ def _layout_at(draw: ImageDraw.ImageDraw, text: str, max_width: int, max_height: int):
254
+ """Largest font size that fits the whole headline, per line count.
255
+
256
+ Returns ``{lines_wanted: (font, lines, line_height, spacing, size)}``;
257
+ a line count is absent when even the smallest size cannot hold the text.
258
+ """
259
+ fits = {}
260
+ for count in (1, 2, 3):
261
+ for size in range(HEADLINE_MAX_SIZE, HEADLINE_MIN_SIZE - 1, -2):
262
+ font = NF(size, "Bold")
263
+ lines = _wrap_measured(draw, text, font, max_width, count)
264
+ if not lines:
265
+ continue
266
+ boxes = [draw.textbbox((0, 0), line, font=font, stroke_width=0) for line in lines]
267
+ line_height = max(box[3] - box[1] for box in boxes)
268
+ spacing = max(10, int(size * 0.18))
269
+ total_height = line_height * len(lines) + spacing * (len(lines) - 1)
270
+ if total_height <= max_height:
271
+ fits[count] = (font, lines, line_height, spacing, size)
272
+ break
273
+ return fits
274
+
275
+
231
276
  def _fit_headline(draw: ImageDraw.ImageDraw, title: str, max_width: int, max_height: int):
232
- # Prefer a balanced one/two-line layout whenever it remains comfortably
233
- # readable; only fall back to three lines for headlines that need them.
234
- probe = NF(90, "Bold")
235
- preferred = next((count for count in (1, 2, 3)
236
- if _wrap_measured(draw, title, probe, max_width, count)), None)
237
- if preferred is None:
238
- raise ValueError("标题无法在三行内以可读字号排入封面")
239
- for size in range(126, 51, -2):
240
- font = NF(size, "Bold")
241
- lines = _wrap_measured(draw, title, font, max_width, preferred)
242
- if not lines:
243
- continue
244
- boxes = [draw.textbbox((0, 0), line, font=font, stroke_width=0) for line in lines]
245
- line_height = max(box[3] - box[1] for box in boxes)
246
- spacing = max(10, int(size * 0.18))
247
- total_height = line_height * len(lines) + spacing * (len(lines) - 1)
248
- if total_height <= max_height:
249
- return font, lines, line_height, spacing
277
+ """Pick a layout: fewest lines first, then the largest readable size.
278
+
279
+ A balanced one/two-line layout is preferred whenever it stays comfortably
280
+ readable; three lines are only used when they are the readable option.
281
+ """
282
+ fits = _layout_at(draw, title, max_width, max_height)
283
+ if not fits:
284
+ raise ValueError("标题无法在封面内完整排版")
285
+ for count in (1, 2, 3):
286
+ if count in fits and fits[count][4] >= HEADLINE_READABLE:
287
+ return fits[count][:4]
288
+ # Nothing stays comfortably readable: take whatever is largest.
289
+ return max(fits.values(), key=lambda item: item[4])[:4]
290
+
291
+
292
+ _BREAK_MARKS = ",,、;;::—–-?!!?。…"
293
+
294
+
295
+ def _trim_variants(text: str):
296
+ """Progressively shorter headlines, cut at a clause break when possible."""
297
+ cuts = sorted({index + 1 for index, char in enumerate(text) if char in _BREAK_MARKS})
298
+ for cut in reversed(cuts):
299
+ head = text[:cut].rstrip(" ,,、;;::—–-")
300
+ if 8 <= len(head) < len(text):
301
+ yield head + "…"
302
+ for size in range(len(text) - 1, 7, -2):
303
+ yield text[:size].rstrip(" ,,、;;::—–-") + "…"
304
+
305
+
306
+ def fitted_title(title: str, ratio: str) -> str:
307
+ """Return the headline that actually fits this ratio.
308
+
309
+ A concrete but very long source title (a full B站 video title, say) used to
310
+ abort the whole build with 「标题无法在三行内以可读字号排入封面」. A cover
311
+ thumbnail is a headline, not the full title, so overlong input is trimmed at
312
+ a clause boundary instead of failing the day's video.
313
+ """
314
+ headline = validate_title(title)
315
+ max_width, max_height = _text_field(ratio)
316
+ draw = ImageDraw.Draw(Image.new("RGBA", (8, 8)))
317
+ if _layout_at(draw, headline, max_width, max_height):
318
+ return headline
319
+ for candidate in _trim_variants(headline):
320
+ if _layout_at(draw, candidate, max_width, max_height):
321
+ return candidate
250
322
  raise ValueError("标题无法在封面内完整排版")
251
323
 
252
324
 
@@ -261,7 +333,7 @@ def _paint_portrait(canvas: Image.Image, size: int, x: int, y: int):
261
333
 
262
334
  def render_cover(title: str, ratio: str) -> Image.Image:
263
335
  """Render one 16:9 or 4:3 RGBA cover at its exact delivery size."""
264
- title = validate_title(title)
336
+ title = fitted_title(title, ratio)
265
337
  (width, height), key = _dimensions(ratio)
266
338
  seed = sum((index + 1) * ord(char) for index, char in enumerate(title)) % 997
267
339
  base = bg_ocean(seed=seed, top="#eaf6ff", mid="#cfe7f8", bottom="#a8d2ec", rice=True)
@@ -276,6 +348,7 @@ def render_cover(title: str, ratio: str) -> Image.Image:
276
348
  text_width = max(420, text_right - text_left)
277
349
  text_top = 180
278
350
  text_bottom = height - 170
351
+ headline_width, headline_height = _text_field(ratio)
279
352
 
280
353
  # A quiet translucent panel gives the measured headline a stable contrast
281
354
  # field without introducing another textual label or a subtitle.
@@ -288,7 +361,7 @@ def render_cover(title: str, ratio: str) -> Image.Image:
288
361
  canvas.alpha_composite(panel, (text_left - 42, text_top - 35))
289
362
 
290
363
  draw = ImageDraw.Draw(canvas, "RGBA")
291
- font, lines, line_height, spacing = _fit_headline(draw, title, text_width - 42, text_bottom - text_top - 30)
364
+ font, lines, line_height, spacing = _fit_headline(draw, title, headline_width, headline_height)
292
365
  total = line_height * len(lines) + spacing * (len(lines) - 1)
293
366
  y = text_top + max(0, (text_bottom - text_top - total) // 2)
294
367
  for line in lines:
@@ -314,28 +387,32 @@ def _atomic_save(image: Image.Image, target: Path):
314
387
  pass
315
388
 
316
389
 
317
- def generate_covers(outdir: os.PathLike | str, title: str, only_missing: bool = False):
390
+ def generate_covers(outdir: os.PathLike | str, title: str, only_missing: bool = False, theme=None):
318
391
  """Generate both daily covers and return their paths.
319
392
 
320
393
  Both images are rendered before either file is replaced, so a rendering
321
394
  error cannot leave one newly-written cover paired with one old cover.
395
+ An overlong headline is trimmed per ratio rather than failing the build;
396
+ ``title`` in the result stays the full validated headline.
322
397
  """
323
398
  output = Path(outdir)
324
399
  headline = validate_title(title)
325
400
  rendered = {}
326
- generated = []
327
401
  for ratio, filename in COVER_NAMES.items():
328
402
  path = output / filename
329
403
  if only_missing and valid_cover(path, ratio):
330
404
  continue
331
- rendered[path] = render_cover(headline, ratio)
405
+ if theme:
406
+ from pv_editorial import cover as editorial_cover
407
+ rendered[path] = editorial_cover(headline, ratio, theme)
408
+ else:
409
+ rendered[path] = render_cover(headline, ratio)
332
410
  for path, image in rendered.items():
333
411
  _atomic_save(image, path)
334
- generated.append(path)
335
412
  paths = {ratio: output / filename for ratio, filename in COVER_NAMES.items()}
336
413
  if not all(valid_cover(paths[ratio], ratio) for ratio in RATIO_SIZES):
337
414
  raise RuntimeError("封面生成后校验失败")
338
- return {"title": headline, "paths": paths, "generated": generated}
415
+ return {"title": headline, "paths": paths, "generated": list(rendered)}
339
416
 
340
417
 
341
418
  def main() -> int:
@@ -349,10 +426,15 @@ def main() -> int:
349
426
  # Windows 编辑器写出的 JSON 常带 BOM(utf-8-sig),不能因此判失败。
350
427
  data = json.loads(plan_path.read_text(encoding="utf-8-sig"))
351
428
  title = choose_title(data)
352
- result = generate_covers(args.outdir, title, only_missing=args.only_missing)
429
+ result = generate_covers(outdir=args.outdir, title=title, only_missing=args.only_missing,
430
+ theme=data.get("theme") if data.get("schema") == "deepmoyu-editorial-v2" else None)
353
431
  for ratio, path in result["paths"].items():
354
432
  print(f"封面 → {path} {path.stat().st_size:,} B", flush=True)
355
433
  print(f"封面文字:{title}", flush=True)
434
+ for ratio in RATIO_SIZES:
435
+ drawn = fitted_title(title, ratio)
436
+ if drawn != title:
437
+ print(f" {ratio} 排不下全文,封面用:{drawn}", flush=True)
356
438
  return 0
357
439
  except (OSError, ValueError, TypeError, json.JSONDecodeError, RuntimeError) as exc:
358
440
  parser.exit(1, f"封面生成失败:{exc}\n")