Compare commits
51 Commits
087c004839
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10679ea51 | ||
| 481b47b0b2 | |||
|
|
33c60aa08a | ||
| 15b2812507 | |||
| d46152ccb8 | |||
|
|
3e10890ed1 | ||
| c3cacbb5a9 | |||
| 063675e848 | |||
|
|
d78383e222 | ||
| c053bfc4f4 | |||
|
|
fe9f24b895 | ||
| cf4033eb1d | |||
|
|
1d3034b75e | ||
| 47b480b2a9 | |||
|
|
1f3126d251 | ||
| e1c603ff17 | |||
| b23feaefd6 | |||
| d7378a08bd | |||
| cb74d3eb53 | |||
| 1513e37458 | |||
| a0eb6fd3ae | |||
|
|
18670d2267 | ||
| 2239b6d718 | |||
|
|
c11b61bfbe | ||
| 6a4857c097 | |||
|
|
64b80f1f5f | ||
|
|
368fcfa577 | ||
| b71ec7ea0f | |||
| 2691af44f3 | |||
|
|
9b05fba722 | ||
| 1c37564d75 | |||
| ae704e31fd | |||
| 7e077465c8 | |||
| 02a6f08263 | |||
| 61b93f5366 | |||
| 47cc9132cd | |||
| 6801ca42bc | |||
| 96e1db49cf | |||
| 1eeca8f636 | |||
|
|
6a4384ab0c | ||
| 7035d0a5c5 | |||
| 7a1c957bd1 | |||
| 93823d926c | |||
| e31d06b6ac | |||
| 2e6e600129 | |||
| 52f0e2596b | |||
|
|
5faf84d4b0 | ||
| 1146216320 | |||
| 6d87046f0e | |||
| d6b1d71634 | |||
| 3c8c0e1d05 |
@@ -153,6 +153,363 @@
|
||||
"message": "auto-save 2026-04-18 12:09 (~1)",
|
||||
"hash": "3e6f1cf",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:13:23+08:00",
|
||||
"type": "commit",
|
||||
"message": "人物闭环 + 终章环状收尾 + 危机伏笔强化(+4200 字)",
|
||||
"hash": "087c004",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:14:46+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:14 (~1)",
|
||||
"hash": "3c8c0e1",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:20:50+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:20 (~1)",
|
||||
"hash": "d6b1d71",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:26:19+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:26 (~1)",
|
||||
"hash": "6d87046",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:31:56+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:31 (~2)",
|
||||
"hash": "1146216",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:34:45+08:00",
|
||||
"type": "commit",
|
||||
"message": "转向反英雄叙事:阴暗面 + 创伤应激 + 终章留悬念(+5500 字)",
|
||||
"hash": "5faf84d",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:37:24+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:37 (~1)",
|
||||
"hash": "52f0e25",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:42:51+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:42 (~1)",
|
||||
"hash": "2e6e600",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:48:19+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:48 (~1)",
|
||||
"hash": "e31d06b",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:53:58+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:53 (~1)",
|
||||
"hash": "93823d9",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T12:59:27+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 12:59 (+2, ~1)",
|
||||
"hash": "7a1c957",
|
||||
"files_changed": 8
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:05:15+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:05 (+6, ~3, -6)",
|
||||
"hash": "7035d0a",
|
||||
"files_changed": 15
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:06:08+08:00",
|
||||
"type": "commit",
|
||||
"message": "新增 6 张扩集插图(Gemini 3 Pro Image)+ 整合到展示站",
|
||||
"hash": "6a4384a",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:11:05+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:11 (~1)",
|
||||
"hash": "1eeca8f",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:16:54+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:16 (~1)",
|
||||
"hash": "96e1db4",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:22:43+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:22 (~1)",
|
||||
"hash": "6801ca4",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:28:33+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:28 (~1)",
|
||||
"hash": "47cc913",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:34:11+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:34 (~1)",
|
||||
"hash": "61b93f5",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:39:40+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:39 (~1)",
|
||||
"hash": "02a6f08",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:45:20+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:45 (~1)",
|
||||
"hash": "7e07746",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:50:48+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:50 (~1)",
|
||||
"hash": "ae704e3",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T13:58:31+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 13:56 (~1)",
|
||||
"hash": "1c37564",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:02:36+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第一章《另一个看见了的人》(+304 行 / ~3800 字)",
|
||||
"hash": "9b05fba",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:06:31+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:04 (~1)",
|
||||
"hash": "2691af4",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:13:18+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:13 (~1)",
|
||||
"hash": "b71ec7e",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:17:40+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第二章《他差一点把那些刺长回来》(+3800 字)",
|
||||
"hash": "368fcfa",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:18:26+08:00",
|
||||
"type": "commit",
|
||||
"message": "网页适配第二部多章:正则支持第N章(中文/阿拉伯数字)+ CHAPTERS 加第二部 Ch2 条目",
|
||||
"hash": "64b80f1",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:19:00+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:18 (~1)",
|
||||
"hash": "6a4857c",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:24:52+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第三章《她说她找不到一个值得攻击的地方》(+3800 字)",
|
||||
"hash": "c11b61b",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:25:11+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:25 (~1)",
|
||||
"hash": "2239b6d",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:31:03+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第四章《那一夜,他关掉了屏幕》(+3800 字)",
|
||||
"hash": "18670d2",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:31:20+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:31 (~1)",
|
||||
"hash": "a0eb6fd",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:37:45+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:37 (~1)",
|
||||
"hash": "1513e37",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:43:32+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:43 (~1)",
|
||||
"hash": "cb74d3e",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:49:20+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:49 (~1)",
|
||||
"hash": "d7378a0",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T14:55:41+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 14:54 (~1)",
|
||||
"hash": "b23feae",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:02:58+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:02 (~3)",
|
||||
"hash": "e1c603f",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:03:08+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第五章《让光回到所有看见的人身上》(+4500 字)",
|
||||
"hash": "1f3126d",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:09:00+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:08 (~1)",
|
||||
"hash": "47b480b",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:10:53+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第六章《那封邮件的作者》(+3800 字)",
|
||||
"hash": "1d3034b",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:14:50+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:14 (~2)",
|
||||
"hash": "cf4033e",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:17:20+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第七章《她记得的那碗面》(+3800 字)",
|
||||
"hash": "fe9f24b",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:21:04+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:20 (~1)",
|
||||
"hash": "c053bfc",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:23:25+08:00",
|
||||
"type": "commit",
|
||||
"message": "第二部 · 第八章《接下来这二十年,换我来写》(+3500 字) —— 全书·完",
|
||||
"hash": "d78383e",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:28:26+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:26 (~1)",
|
||||
"hash": "063675e",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:34:38+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:34 (~1)",
|
||||
"hash": "c3cacbb",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:40:38+08:00",
|
||||
"type": "commit",
|
||||
"message": "worklog.json auto-save tail",
|
||||
"hash": "3e10890",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:42:03+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:40 (~1)",
|
||||
"hash": "d46152c",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:48:56+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:48 (~2)",
|
||||
"hash": "15b2812",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:50:40+08:00",
|
||||
"type": "commit",
|
||||
"message": "修阅读'一直载入中':novel.md gzip + 章节索引 + loading 状态",
|
||||
"hash": "33c60aa",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-18T15:55:26+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-18 15:54 (~1)",
|
||||
"hash": "481b47b",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4387
novel/《古法代码之王》.md
4387
novel/《古法代码之王》.md
File diff suppressed because it is too large
Load Diff
183
scripts/gen-images.py
Normal file
183
scripts/gen-images.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Gen 6 new illustrations via OpenRouter + google/gemini-3-pro-image-preview.
|
||||
Protagonist reference image + shared style anchor baked into every prompt.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import urllib.request
|
||||
|
||||
API_KEY = "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"
|
||||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
MODEL = "google/gemini-3-pro-image-preview"
|
||||
PROXY = "http://127.0.0.1:10808"
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REF_IMG = ROOT / "web" / "images" / "protagonist_reference.jpg"
|
||||
OUT_DIR = ROOT / "web" / "images" / "extra"
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Shared style anchor — extracted from docs/illustration_style_guide.md
|
||||
STYLE_ANCHOR = """
|
||||
Cinematic near-future urban realism. Muted, cold blue-grey palette. 35mm film grain. Subtle cold screen glow where screens appear. Photographic, NOT cartoon, NOT anime. Realistic human proportions. Emotion conveyed through posture, gaze and environment, NOT through exaggerated facial expression. 16:9 cinematic framing.
|
||||
""".strip()
|
||||
|
||||
# Character anchor — must be repeated every time to keep consistency
|
||||
CHAR_ANCHOR = """
|
||||
The man in the attached reference image is GU CHENZHOU. Keep his identity consistent: black short hair (slightly messy in earlier life, neater later), deep-set brows, quiet reserved demeanor, slim build.
|
||||
""".strip()
|
||||
|
||||
SCENES = [
|
||||
{
|
||||
"id": "ch06_panic_night",
|
||||
"age_hint": "around 25 years old, haggard, unshaven, clothes wrinkled",
|
||||
"scene": "A dim cramped rental room at night. GU CHENZHOU is curled up against the wall in a corner, knees to chest, hands covering his ears. A single thin beam of moonlight crosses the ceiling. On the small bedside table there is only a glass of cold water. The room is otherwise empty and in half-darkness. No other characters. His face is not crying — his eyes are open and fixed on nothing. The whole composition conveys a panic attack that no one sees.",
|
||||
"mood": "Cold blue-grey. Shadow occupies 70% of the frame. Silent. Oppressive ceiling. PTSD origin point."
|
||||
},
|
||||
{
|
||||
"id": "ch10_cafe_read_alone",
|
||||
"age_hint": "around 40 years old, clean-shaven but visibly exhausted, a thin shaven old scar on the suit cuff edge",
|
||||
"scene": "A small 24-hour cafe, very late afternoon cold grey light through the window. GU CHENZHOU sits alone at a two-person table by the window, wearing a plain dark grey old suit that has clearly been ironed carefully but whose cuffs have been slightly frayed. A plain black coffee cup sits in front of him. On the table, 30 pages of handwritten typed speech manuscript are spread open. His right index finger rests on the first line of the first page. His head is slightly tilted down. He is reading the manuscript to himself, alone. The background is out of focus — empty cafe, a couple of empty chairs. The mood is not sad, not angry, only quietly ceremonial.",
|
||||
"mood": "Cool muted light. Solitary. Ceremonial quietness. Middle-aged composure."
|
||||
},
|
||||
{
|
||||
"id": "ch14_linwanqiao_window",
|
||||
"age_hint": "NOT the protagonist — focus on a different character, a composed woman in her mid-30s named LIN WANQIAO",
|
||||
"scene": "Floor-to-ceiling window of the 31st floor of a city office tower, late morning under heavy overcast sky. A composed elegant Chinese woman in her mid-30s, wavy shoulder-length hair, beige trench coat, pale gold earrings, stands with her back turned to the viewer, facing the window. In her hand she holds a cup of coffee, faint steam rising. On a wall-mounted flat TV to her right, the screen shows ONLY a silent footage: a dim gray narrow city alley, an unmarked black van parked at the alley entrance, a thin-shouldered Chinese man in a plain worn dark coat walking past the van carrying a plain black canvas shoulder bag. The TV screen shows NO NEWS BANNER, NO ENGLISH TEXT, NO HEADLINE, NO SUBTITLE — the entire screen is just the alley footage with cinematic film grain. The open-plan office behind her is chaotic — out-of-focus colleagues crowded around a projector in a distant glass-walled meeting room. She herself stands still, not moving. Camera framed from slightly behind her shoulder. Her face cannot be seen clearly.",
|
||||
"mood": "Cold blue-white. The city in the background is in disarray, stillness in the foreground. A look across time, regret without tears. ABSOLUTELY NO TEXT ANYWHERE ON THE TV OR IN THE SCENE."
|
||||
},
|
||||
{
|
||||
"id": "ch16_victory_night_alone",
|
||||
"age_hint": "around 50 years old, eyes bloodshot, face exhausted",
|
||||
"scene": "A temporary small emergency centre rest room at night, door locked. GU CHENZHOU sits on the floor with his back against the door. Next to him on the floor is a premium glass liquor bottle with its neck snapped off — glass shards around it. His palm has a thin red cut but he does not tend to it. A small framed mirror on the opposite wall shows his own reflection — bloodshot eyes, dry eyelids, stubble. He is not crying. His hand rests on his forehead. There is no one else in the room. Cold fluorescent ceiling light. Outside the door (implied faintly through sound lines, not visible) people are celebrating — but he is silent. Ultra quiet composition.",
|
||||
"mood": "The emptiness of victory. Cold overhead light. More unsettling than any defeat scene."
|
||||
},
|
||||
{
|
||||
"id": "ring_classroom_10yrs_later",
|
||||
"age_hint": "NOT the protagonist — a new young professor in his early 40s, different face, carrying GU CHENZHOU's legacy",
|
||||
"scene": "A university lecture hall for freshmen, first class of the semester, bright practical fluorescent light, about 100 first-year students visible at the back of frame, heads slightly turned up. At the front a young professor in his early 40s, thin, intense, wearing a simple dark sweater. He has set an old worn canvas bag on the lecturer's desk — the side of the bag reads the faded handwriting 'GUIXU · 2038'. On the large blackboard behind him he has just finished writing three Chinese characters in chalk: '写 代 码' (very prominent, centered, large). He stands still, chalk in hand, turning to face the students. An old worn hand-bound notebook lies open on the desk in front of him — the first page visible. Mood: warm respect, a new generation inheriting.",
|
||||
"mood": "Warm but restrained light. A ring closes — echoing back to Ch 1 graduation recruiting banner but inverted meaning."
|
||||
},
|
||||
{
|
||||
"id": "s2_blank_second_page",
|
||||
"age_hint": "around 64 years old — NOT older, NOT 70+. Hair is salt-and-pepper with roughly equal black and grey strands, still thick and neatly combed, NOT bald, NOT thinning on top. Face remains the same person as in the reference image — just aged by about 20 years. Slim but upright posture. Still composed and sharp.",
|
||||
"scene": "A clean simple home study by a wide river window at dusk. Warm golden horizontal sunset light cutting in across a worn wooden desk. GU CHENZHOU, same face as the reference image but about 64 years old, hair still thick but now half black half grey, sits alone at the desk. On the desk, in the foreground: an old worn canvas shoulder bag with the faded handwritten text 'GUIXU · 2038' on its side — the bag lies open. A very old small hand-bound notebook lies open on the desk — the first page shows one line of small hand-written Chinese characters; the second page is completely blank. His hand with slightly prominent veins rests lightly on the blank second page. His eyes are fixed on the empty page, a silent expression of doubt. To his right, a slim modern laptop sits — its screen glowing faintly — on the laptop screen is a nearly-empty minimal text editor, and at the top of the editor three large Chinese characters are clearly readable: '我 怕 的——' followed by a blinking cursor. The laptop screen is positioned and framed so the viewer can clearly read those three Chinese characters. No other person in the room. Absolute stillness.",
|
||||
"mood": "Quiet, ceremonial, doubt. An old hero facing a second choice. Open ending. Warm golden light against the cold blue river outside."
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_prompt(scene: dict) -> str:
|
||||
return "\n\n".join([
|
||||
"GENERATE ONE cinematic illustration.",
|
||||
f"STYLE: {STYLE_ANCHOR}",
|
||||
f"CHARACTER: {CHAR_ANCHOR}",
|
||||
f"AGE/APPEARANCE: {scene['age_hint']}",
|
||||
f"SCENE: {scene['scene']}",
|
||||
f"MOOD: {scene['mood']}",
|
||||
"IMPORTANT: no visible text overlays beyond what is described in SCENE. No watermarks. Output a single full 16:9 image only."
|
||||
])
|
||||
|
||||
|
||||
def encode_ref_image() -> str:
|
||||
with open(REF_IMG, "rb") as f:
|
||||
return "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()
|
||||
|
||||
|
||||
def call_api(scene: dict, ref_data_url: str) -> bytes:
|
||||
body = {
|
||||
"model": MODEL,
|
||||
"modalities": ["image", "text"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": ref_data_url}},
|
||||
{"type": "text", "text": build_prompt(scene)}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
ENDPOINT,
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://gufa-code.kang-kang.com",
|
||||
"X-Title": "Gufa Code King Illustrations",
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
proxy_handler = urllib.request.ProxyHandler({"http": PROXY, "https": PROXY})
|
||||
opener = urllib.request.build_opener(proxy_handler)
|
||||
with opener.open(req, timeout=180) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if "error" in data:
|
||||
raise RuntimeError(f"{scene['id']}: {data['error']}")
|
||||
msg = data["choices"][0]["message"]
|
||||
imgs = msg.get("images", [])
|
||||
if not imgs:
|
||||
raise RuntimeError(f"{scene['id']}: no images in response — msg keys {list(msg.keys())}")
|
||||
url = imgs[0]["image_url"]["url"]
|
||||
if not url.startswith("data:image"):
|
||||
raise RuntimeError(f"{scene['id']}: unexpected url format")
|
||||
b64 = url.split(",", 1)[1]
|
||||
cost = data.get("usage", {}).get("cost", 0)
|
||||
print(f" ✓ {scene['id']} cost=${cost:.4f} size={len(b64) // 1024} KB b64", flush=True)
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
def generate_one(scene: dict, ref_data_url: str) -> tuple[str, Path | None, str | None]:
|
||||
t0 = time.time()
|
||||
try:
|
||||
png = call_api(scene, ref_data_url)
|
||||
out = OUT_DIR / f"{scene['id']}.png"
|
||||
out.write_bytes(png)
|
||||
dt = time.time() - t0
|
||||
print(f" saved {out.name} ({dt:.1f}s, {len(png) // 1024}KB)", flush=True)
|
||||
return scene["id"], out, None
|
||||
except Exception as e:
|
||||
dt = time.time() - t0
|
||||
print(f" ✗ {scene['id']} FAILED after {dt:.1f}s: {e}", flush=True)
|
||||
return scene["id"], None, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
only = sys.argv[1:] if len(sys.argv) > 1 else None
|
||||
scenes = [s for s in SCENES if not only or s["id"] in only]
|
||||
print(f"=== gen {len(scenes)} images ===")
|
||||
if only:
|
||||
print(f"filter: {only}")
|
||||
print(f"ref image: {REF_IMG} ({REF_IMG.stat().st_size // 1024}KB)")
|
||||
ref_url = encode_ref_image()
|
||||
print(f"ref base64 len: {len(ref_url) // 1024}KB")
|
||||
print(f"out dir: {OUT_DIR}")
|
||||
print()
|
||||
print("scenes:")
|
||||
for s in scenes:
|
||||
print(f" - {s['id']}")
|
||||
print()
|
||||
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures = [pool.submit(generate_one, s, ref_url) for s in scenes]
|
||||
for f in as_completed(futures):
|
||||
results.append(f.result())
|
||||
|
||||
print()
|
||||
print("=== summary ===")
|
||||
ok = [r for r in results if r[1]]
|
||||
fail = [r for r in results if not r[1]]
|
||||
print(f" ok: {len(ok)} fail: {len(fail)}")
|
||||
if fail:
|
||||
for sid, _, err in fail:
|
||||
print(f" fail {sid}: {err}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,7 +16,13 @@ RUN printf 'server {\n\
|
||||
access_log off;\n\
|
||||
}\n\
|
||||
\n\
|
||||
# html / md: always revalidate\n\
|
||||
# novel.md specifically — force text/markdown so gzip triggers\n\
|
||||
location = /novel.md {\n\
|
||||
default_type "text/markdown; charset=utf-8";\n\
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";\n\
|
||||
}\n\
|
||||
\n\
|
||||
# html / md catch-all: always revalidate\n\
|
||||
location ~* \\.(html|md)$ {\n\
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";\n\
|
||||
}\n\
|
||||
@@ -26,8 +32,10 @@ RUN printf 'server {\n\
|
||||
}\n\
|
||||
\n\
|
||||
gzip on;\n\
|
||||
gzip_types text/plain text/markdown text/css application/javascript application/json image/svg+xml;\n\
|
||||
gzip_min_length 1024;\n\
|
||||
gzip_comp_level 6;\n\
|
||||
gzip_types text/plain text/markdown text/css application/javascript application/json image/svg+xml text/html;\n\
|
||||
gzip_min_length 512;\n\
|
||||
gzip_vary on;\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
BIN
web/images/extra/ch06_panic_night.jpg
Normal file
BIN
web/images/extra/ch06_panic_night.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
BIN
web/images/extra/ch10_cafe_read_alone.jpg
Normal file
BIN
web/images/extra/ch10_cafe_read_alone.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
BIN
web/images/extra/ch14_linwanqiao_window.jpg
Normal file
BIN
web/images/extra/ch14_linwanqiao_window.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
BIN
web/images/extra/ch16_victory_night_alone.jpg
Normal file
BIN
web/images/extra/ch16_victory_night_alone.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 546 KiB |
BIN
web/images/extra/ring_classroom_10yrs_later.jpg
Normal file
BIN
web/images/extra/ring_classroom_10yrs_later.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
BIN
web/images/extra/s2_blank_second_page.jpg
Normal file
BIN
web/images/extra/s2_blank_second_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
231
web/index.html
231
web/index.html
@@ -449,6 +449,106 @@ section {
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
/* ---------- Part II separator ---------- */
|
||||
.reader-chapter-part2 {
|
||||
margin-top: 200px !important;
|
||||
padding-top: 120px;
|
||||
border-top: 1px solid var(--gold-soft);
|
||||
position: relative;
|
||||
}
|
||||
.reader-chapter-part2::before {
|
||||
content: 'PART · II';
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--gold);
|
||||
font-family: 'Songti SC', serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6em;
|
||||
padding: 6px 24px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--gold-soft);
|
||||
}
|
||||
.reader-chapter-part2 .reader-chapter-num {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* ---------- Reader extras (章末延伸图) ---------- */
|
||||
.reader-extras {
|
||||
margin: 64px 0 40px;
|
||||
padding: 32px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.reader-extras-label {
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.reader-extras-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
.reader-extra {
|
||||
margin: 0;
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.reader-extra:hover { transform: translateY(-2px); }
|
||||
.reader-extra img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
object-fit: cover;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.reader-extra figcaption {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
.reader-extra-cap {
|
||||
font-family: 'Songti SC', serif;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.reader-extra-sub {
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- Gallery extras divider ---------- */
|
||||
.gallery-extras-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 80px 0 32px;
|
||||
color: var(--gold);
|
||||
font-family: 'Songti SC', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.gallery-extras-heading::before, .gallery-extras-heading::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--gold-soft), transparent);
|
||||
}
|
||||
.gallery-extras-sub {
|
||||
text-align: center;
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3em;
|
||||
margin: -16px 0 40px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer {
|
||||
padding: 64px 24px 40px;
|
||||
@@ -675,6 +775,10 @@ footer p { margin: 8px 0; }
|
||||
<h2 class="section-title">命运的二十二帧。</h2>
|
||||
<p style="color: var(--fg-soft); max-width: 620px; margin: -24px 0 40px;">从毕业即过时,到代码之王——画面随男主心境推进:冷灰蓝 → 工业寒夜 → 红色告警 → 深黑金巅峰。点击任一幅放大查看。</p>
|
||||
<div class="gallery-grid" id="gallery-grid"></div>
|
||||
|
||||
<div class="gallery-extras-heading">扩集画面 · EXTRA</div>
|
||||
<div class="gallery-extras-sub">扩写之后新生的关键时刻 · 6 幅</div>
|
||||
<div class="gallery-grid" id="gallery-extras"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -696,22 +800,46 @@ const CHAPTERS = [
|
||||
{ n: 3, title: '她说你这样没有未来', img: 'ch03_you_have_no_future.jpg', volume: '第一卷 · 被时代埋掉的人', hint: '贫穷与理想,裂痕第一道' },
|
||||
{ n: 4, title: '全世界都在笑他', img: 'ch04_the_world_laughed_at_him.jpg', volume: '第一卷 · 被时代埋掉的人', hint: '韩锐风光无限,他在出租屋里修服务器' },
|
||||
{ n: 5, title: '被裁员的人没有资格谈梦想', img: 'ch05_laid_off_no_dreams.jpg', volume: '第一卷 · 被时代埋掉的人', hint: '第一份工作失去,跌入谷底' },
|
||||
{ n: 6, title: '旧电脑与冷泡面', img: 'ch06_old_computer_cold_noodles.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '低端外包,熬夜,凄惨地活着' },
|
||||
{ n: 6, title: '旧电脑与冷泡面', img: 'ch06_old_computer_cold_noodles.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '低端外包,熬夜,凄惨地活着',
|
||||
extras: [
|
||||
{ file: 'extra/ch06_panic_night.jpg', cap: '第三天夜里 · 被自己的恐惧按在地上', sub: 'PTSD 原点' }
|
||||
] },
|
||||
{ n: 7, title: '只有她递来一把伞', img: 'ch07_she_brought_an_umbrella.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '苏青禾登场,一点温柔的光' },
|
||||
{ n: 8, title: '爱也会输给房租', img: 'ch08_love_lost_to_rent.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '林晚乔正式离开,情感谷底' },
|
||||
{ n: 9, title: '没人相信的底层能力', img: 'ch09_no_one_believed_him.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '想推销离线开发,被当笑话' },
|
||||
{ n: 10, title: '十年一梦,满身风雪', img: 'ch10_ten_years_in_snow.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '在行业边缘漂泊多年' },
|
||||
{ n: 10, title: '十年一梦,满身风雪', img: 'ch10_ten_years_in_snow.jpg', volume: '第二卷 · 寒冬里独自敲键盘', hint: '在行业边缘漂泊多年',
|
||||
extras: [
|
||||
{ file: 'extra/ch10_cafe_read_alone.jpg', cap: '他给自己读完一场没有听众的演讲', sub: '40 岁 · 平静庄严' }
|
||||
] },
|
||||
{ n: 11, title: '聪明人都不会手写代码了', img: 'ch11_no_one_writes_code_anymore.jpg', volume: '第三卷 · 黑箱时代的裂缝', hint: '传统工程师彻底绝迹' },
|
||||
{ n: 12, title: '第一次异常', img: 'ch12_first_anomaly.jpg', volume: '第三卷 · 黑箱时代的裂缝', hint: '核心系统零星故障,被当波动' },
|
||||
{ n: 13, title: '无人能读懂的补丁', img: 'ch13_unreadable_patch.jpg', volume: '第三卷 · 黑箱时代的裂缝', hint: '大平台自修复越修越乱' },
|
||||
{ n: 14, title: '世界停电的那一天', img: 'ch14_the_day_the_world_went_dark.jpg',volume: '第三卷 · 黑箱时代的裂缝', hint: '金融交通医疗能源连锁崩塌' },
|
||||
{ n: 14, title: '世界停电的那一天', img: 'ch14_the_day_the_world_went_dark.jpg',volume: '第三卷 · 黑箱时代的裂缝', hint: '金融交通医疗能源连锁崩塌',
|
||||
extras: [
|
||||
{ file: 'extra/ch14_linwanqiao_window.jpg', cap: '她认出了那只黑色帆布包', sub: '旧情视角 · 一面落地窗' }
|
||||
] },
|
||||
{ n: 15, title: '求他出山的人排到了楼下', img: 'ch15_people_queued_to_beg_him.jpg', volume: '第三卷 · 黑箱时代的裂缝', hint: '昔日嘲笑他的人开始低头' },
|
||||
{ n: 16, title: '一人重启一座城', img: 'ch16_one_man_restart_a_city.jpg', volume: '第四卷 · 旧时代火种', hint: '凭古法工程修复核心调度' },
|
||||
{ n: 16, title: '一人重启一座城', img: 'ch16_one_man_restart_a_city.jpg', volume: '第四卷 · 旧时代火种', hint: '凭古法工程修复核心调度',
|
||||
extras: [
|
||||
{ file: 'extra/ch16_victory_night_alone.jpg', cap: '庆功夜 · 锁门独饮', sub: '成功的空旷 · 那接下来呢' }
|
||||
] },
|
||||
{ n: 17, title: '财团、公权与资本都在抢他', img: 'ch17_everyone_is_fighting_for_him.jpg',volume: '第四卷 · 旧时代火种', hint: '沈知意、国家机构、巨头同时伸手' },
|
||||
{ n: 18, title: '她们都在等他一句话', img: 'ch18_they_wait_for_his_answer.jpg', volume: '第四卷 · 旧时代火种', hint: '情感线全面升温' },
|
||||
{ n: 19, title: '代码之王', img: 'ch19_king_of_code.jpg', volume: '第四卷 · 旧时代火种', hint: '组建离线工程联盟,重塑秩序' },
|
||||
{ n: 20, title: '坐拥繁花,归来仍是少年', img: 'ch20_among_flowers_still_young.jpg', volume: '第四卷 · 旧时代火种', hint: '站上巅峰,事业情感双圆满' },
|
||||
{ n: 0, title: '终章 · 写代码的人,重新定义世界', img: 'epilogue_redefine_the_world.jpg', volume: '终章', hint: 'AI 重新成为工具而非拐杖' },
|
||||
{ n: 0, title: '终章 · 写代码的人,重新定义世界', img: 'epilogue_redefine_the_world.jpg', volume: '终章', hint: 'AI 重新成为工具而非拐杖',
|
||||
extras: [
|
||||
{ file: 'extra/ring_classroom_10yrs_later.jpg', cap: '十年后 · 新一代走进教室', sub: '环状收尾 · 黑板三字:写 代 码' },
|
||||
{ file: 'extra/s2_blank_second_page.jpg', cap: '我 怕 的——', sub: '第二部 · 序章 · 留白' }
|
||||
] },
|
||||
{ n: -1, title: '另一个看见了的人', img: 'extra/s2_blank_second_page.jpg', volume: '第二部 · 起笔', hint: '沈陌登门:顾先生,我写到这里,就等您告诉我,下面该不该写下去' },
|
||||
{ n: -2, title: '他差一点把那些刺长回来', img: null, volume: '第二部 · 起笔', hint: '三天读尽白皮书,差一点对一个系统动了信任测试的念头;出门去见了韩锐' },
|
||||
{ n: -3, title: '她说她找不到一个值得攻击的地方', img: null, volume: '第二部 · 起笔', hint: '许幼宁 86 次攻防 0 成功。她揭穿他三年前埋的第三层签名偏差——"我想看你什么时候自己能看到它不再需要"' },
|
||||
{ n: -4, title: '那一夜,他关掉了屏幕', img: null, volume: '第二部 · 起笔', hint: '审查会前夜:读完沈陌整本笔记 + 想起二十三年前老总监那封被遗忘的邮件"替我看住这套东西" + 关掉屏幕不写答案直接去睡 + 苏青禾那句"我信你"' },
|
||||
{ n: -5, title: '让光回到所有看见的人身上', img: null, volume: '第二部 · 起笔', hint: '审查会现场:帆布包里三本东西摆上评议桌。有条件通过。请许幼宁+沈陌上台站到他两侧。请联盟找回那位说"让他自己醒醒"的安全侧负责人旁听。走出大厅下起春雨,他发给苏青禾三个字"我回家"' },
|
||||
{ n: -6, title: '那封邮件的作者', img: null, volume: '第二部 · 起笔', hint: '老总监周以谦被找到:潮屿海边阳台,"那封邮件我后来很多年都后悔发过。那不是上级该对下级说的话。那是一个认输的人在找替身"。拒绝上台:不是所有被按下去的人都需要被带回舞台。你那根刺这次我看见你真的开始把它收起来了' },
|
||||
{ n: -7, title: '她记得的那碗面', img: null, volume: '第二部 · 起笔', hint: '北方县城养老院:母亲轻度阿尔茨海默"你是修理工吗?我家那台老电视机最近总是雪花"。他不纠正她。她用讲给陌生人的语气讲他小时候。临别她清了一瞬"阿舟,你最近辛苦了吧"。他去县城街角小餐馆吃葱花挂面——这辈子第一次为自己流眼泪' },
|
||||
{ n: -8, title: '接下来这二十年,换我来写', img: null, volume: '第二部 · 收束', hint: '七年流速切片:v0.7 全球落地 / 周以谦病了看够了 / 沈陌女儿入学挑《操作系统基础 I》/ 母亲走了他在老屋自己煮一碗葱花面 / 城中村被拆成空地他没进去 / 沈陌接替他坐上评议席 / 71 岁出书《那三个夜晚和之后的事》扉页三个名字 / 沈陌新笔记第一页"接下来这二十年,换我来写" / 阳台摇椅"反正我也在" —— 全书·完' },
|
||||
];
|
||||
|
||||
function renderChapterList() {
|
||||
@@ -722,7 +850,10 @@ function renderChapterList() {
|
||||
html += `<div class="volume-heading">${ch.volume}</div>`;
|
||||
lastVolume = ch.volume;
|
||||
}
|
||||
const num = ch.n === 0 ? '终' : ch.n.toString().padStart(2, '0');
|
||||
let num;
|
||||
if (ch.n === 0) num = '终';
|
||||
else if (ch.n < 0) num = 'II·' + String(-ch.n).padStart(2, '0');
|
||||
else num = ch.n.toString().padStart(2, '0');
|
||||
const title = ch.n === 0 ? ch.title.replace('终章 · ', '') : ch.title;
|
||||
html += `
|
||||
<div class="chapter-row" onclick="jumpToChapter(${idx})">
|
||||
@@ -751,6 +882,27 @@ function renderGallery() {
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Extras gallery — new scenes born from expansion
|
||||
const extrasWrap = document.getElementById('gallery-extras');
|
||||
if (extrasWrap) {
|
||||
const extraItems = [];
|
||||
CHAPTERS.forEach(ch => {
|
||||
if (!ch.extras) return;
|
||||
const num = ch.n === 0 ? '终章' : `第 ${ch.n} 章`;
|
||||
ch.extras.forEach(ex => {
|
||||
extraItems.push({ ...ex, num, chTitle: ch.title });
|
||||
});
|
||||
});
|
||||
extrasWrap.innerHTML = extraItems.map(ex => `
|
||||
<div class="gallery-item" onclick="openLightbox('./images/${ex.file}')">
|
||||
<img src="./images/${ex.file}" alt="${ex.cap}" loading="lazy">
|
||||
<div class="gallery-meta">
|
||||
<div class="gallery-cap">${ex.cap}</div>
|
||||
<div class="gallery-sub">${ex.num} · ${ex.sub}</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToChapter(idx) {
|
||||
@@ -770,13 +922,21 @@ function closeLightbox() {
|
||||
}
|
||||
|
||||
async function loadNovel() {
|
||||
const container = document.getElementById('reader-content');
|
||||
try {
|
||||
const resp = await fetch('./novel.md');
|
||||
container.innerHTML = '<div class="loading">正在下载全本 …</div>';
|
||||
const resp = await fetch('./novel.md', { cache: 'no-cache' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
container.innerHTML = '<div class="loading">正在渲染章节 …</div>';
|
||||
const text = await resp.text();
|
||||
// Yield to the browser so the loading message paints before heavy work
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
renderNovel(text);
|
||||
} catch (e) {
|
||||
document.getElementById('reader-content').innerHTML =
|
||||
'<div class="loading" style="color: var(--red)">小说载入失败:' + e.message + '</div>';
|
||||
console.error('loadNovel failed:', e);
|
||||
container.innerHTML =
|
||||
'<div class="loading" style="color: var(--red)">小说载入失败:' + e.message +
|
||||
'。<a href="./novel.md" style="color: var(--gold); text-decoration: underline;">直接打开 novel.md</a></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,14 +948,32 @@ function renderNovel(md) {
|
||||
const lines = sec.split('\n');
|
||||
const heading = lines[0].trim();
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
// match "第1章 标题" or "终章 标题"
|
||||
const m = heading.match(/^(第(\d+)章|终章)\s+(.+)$/);
|
||||
if (!m) return;
|
||||
const isEpilogue = !m[2];
|
||||
const num = isEpilogue ? '终' : m[2].padStart(2, '0');
|
||||
const title = m[3];
|
||||
const ch = CHAPTERS[idx];
|
||||
const imgPath = ch ? `./images/${ch.img}` : '';
|
||||
// match "第1章 标题" / "终章 标题" / "第二部 · 第一章 · 标题"
|
||||
let m = heading.match(/^(第(\d+)章|终章)\s+(.+)$/);
|
||||
let isPart2 = false;
|
||||
let num, title;
|
||||
const cnMap = { '一': '01', '二': '02', '三': '03', '四': '04', '五': '05', '六': '06', '七': '07', '八': '08', '九': '09', '十': '10' };
|
||||
const cnToInt = { '一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10 };
|
||||
let kicker, chN;
|
||||
if (m) {
|
||||
const isEpilogue = !m[2];
|
||||
num = isEpilogue ? '终' : m[2].padStart(2, '0');
|
||||
title = m[3];
|
||||
kicker = isEpilogue ? 'EPILOGUE' : 'CHAPTER ' + num;
|
||||
chN = isEpilogue ? 0 : parseInt(m[2]);
|
||||
} else {
|
||||
const m2 = heading.match(/^第二部\s*·\s*第(.+?)章\s*·\s*(.+)$/);
|
||||
if (!m2) return;
|
||||
isPart2 = true;
|
||||
const arabic = cnMap[m2[1]] || m2[1];
|
||||
num = 'II · ' + arabic;
|
||||
title = m2[2];
|
||||
kicker = 'PART II · CHAPTER ' + arabic;
|
||||
chN = -(cnToInt[m2[1]] || parseInt(m2[1])); // -1 = Part II Ch1, -2 = Part II Ch2, ...
|
||||
}
|
||||
// Match chapter by semantic n (immune to section index drift caused by non-chapter ## headings)
|
||||
const ch = CHAPTERS.find(c => c.n === chN);
|
||||
const imgPath = (ch && ch.img) ? `./images/${ch.img}` : '';
|
||||
const paragraphs = body.split(/\n\s*\n/).map(p => {
|
||||
const line = p.trim();
|
||||
if (!line) return '';
|
||||
@@ -804,12 +982,27 @@ function renderNovel(md) {
|
||||
}
|
||||
return '<p>' + escapeHtml(line) + '</p>';
|
||||
}).join('\n');
|
||||
const extrasHtml = (ch && ch.extras) ? `
|
||||
<div class="reader-extras">
|
||||
<div class="reader-extras-label">章末延伸画面 · Extra</div>
|
||||
<div class="reader-extras-grid">
|
||||
${ch.extras.map(ex => `
|
||||
<figure class="reader-extra" onclick="openLightbox('./images/${ex.file}')">
|
||||
<img src="./images/${ex.file}" alt="${ex.cap}" loading="lazy">
|
||||
<figcaption>
|
||||
<div class="reader-extra-cap">${ex.cap}</div>
|
||||
<div class="reader-extra-sub">${ex.sub}</div>
|
||||
</figcaption>
|
||||
</figure>`).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
out.push(`
|
||||
<article class="reader-chapter" id="chapter-${idx}">
|
||||
<div class="reader-chapter-num">${isEpilogue ? 'EPILOGUE' : 'CHAPTER ' + num}</div>
|
||||
<article class="reader-chapter ${isPart2 ? 'reader-chapter-part2' : ''}" id="chapter-${idx}">
|
||||
<div class="reader-chapter-num">${kicker}</div>
|
||||
<h2 class="reader-chapter-title">${title}</h2>
|
||||
${imgPath ? `<img class="reader-chapter-img" src="${imgPath}" alt="${title}" loading="lazy" onclick="openLightbox('${imgPath}')" style="cursor: zoom-in;">` : ''}
|
||||
<div class="reader-chapter-body">${paragraphs}</div>
|
||||
${extrasHtml}
|
||||
${idx < sections.length - 1 ? '<div class="chapter-divider">· · ·</div>' : ''}
|
||||
</article>
|
||||
`);
|
||||
|
||||
4387
web/novel.md
4387
web/novel.md
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user