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.
- package/AGENTS.md +93 -153
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +143 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1404 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +1095 -285
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/update.mjs +265 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- 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
|
-
|
|
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
|
-
|
|
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 = {
|
|
2192
|
+
futures = {key: pool.submit(_synthesize_edge_take, text, voice, work, key)
|
|
2177
2193
|
for index, text, voice, key in pending}
|
|
2178
|
-
for index,
|
|
2194
|
+
for index, text, voice, key in pending: # 按提交(镜)序收结果
|
|
2179
2195
|
try:
|
|
2180
|
-
|
|
2196
|
+
futures[key].result()
|
|
2181
2197
|
except Exception as exc:
|
|
2182
|
-
|
|
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
|
-
#
|
|
2279
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
2351
|
-
|
|
2352
|
-
if
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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":
|
|
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")
|