@remixmate/cli 0.9.46 → 0.9.47
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/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.47",
|
|
4
4
|
"description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"test:countdown": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-countdown-strategy.py",
|
|
41
41
|
"test:prepare-contracts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-prepare-contracts.py",
|
|
42
42
|
"test:image-sequence": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-sequence.py",
|
|
43
|
-
"test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
|
|
43
|
+
"test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py",
|
|
44
|
+
"test:image-prompt-compose": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-prompt-compose.py"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"claude-code",
|
|
@@ -60,10 +60,17 @@ except ImportError:
|
|
|
60
60
|
_HAS_COUNTDOWN = False
|
|
61
61
|
|
|
62
62
|
try:
|
|
63
|
-
from video_dsl.runtime.prompt_enhancer import
|
|
63
|
+
from video_dsl.runtime.prompt_enhancer import (
|
|
64
|
+
COMPOSE_SUBJECT_PLACEHOLDER,
|
|
65
|
+
SKELETON_PREFIXES,
|
|
66
|
+
build_enhanced_prompt,
|
|
67
|
+
is_compose_mode,
|
|
68
|
+
resolve_style_guide,
|
|
69
|
+
)
|
|
64
70
|
_HAS_ENHANCER = True
|
|
65
71
|
except ImportError:
|
|
66
72
|
_HAS_ENHANCER = False
|
|
73
|
+
SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
|
|
67
74
|
|
|
68
75
|
# 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
|
|
69
76
|
# 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
|
|
@@ -188,11 +195,10 @@ def _check_narration_language(narrations, expected):
|
|
|
188
195
|
"""
|
|
189
196
|
cjk_re = __import__("re").compile(r"[\u4e00-\u9fff]")
|
|
190
197
|
latin_re = __import__("re").compile(r"[A-Za-z]")
|
|
191
|
-
skeleton_prefixes = (NARRATION_SKELETON_MARKER, "[skeleton placeholder]")
|
|
192
198
|
for text in narrations:
|
|
193
199
|
if not isinstance(text, str) or not text.strip():
|
|
194
200
|
continue
|
|
195
|
-
if text.lstrip().startswith(
|
|
201
|
+
if text.lstrip().startswith(SKELETON_PREFIXES):
|
|
196
202
|
continue
|
|
197
203
|
has_cjk = bool(cjk_re.search(text))
|
|
198
204
|
has_latin = bool(latin_re.search(text))
|
|
@@ -377,7 +383,7 @@ def estimate_narration_text(label: str, purpose: str, duration: int, topic: str,
|
|
|
377
383
|
|
|
378
384
|
|
|
379
385
|
# 骨架标记:出现此前缀说明 narration 仍是 gen_script 原始骨架,未被 agent 填充真实内容
|
|
380
|
-
NARRATION_SKELETON_MARKER =
|
|
386
|
+
NARRATION_SKELETON_MARKER = SKELETON_PREFIXES[0]
|
|
381
387
|
|
|
382
388
|
|
|
383
389
|
def generate_image_prompt(
|
|
@@ -1456,6 +1462,13 @@ def build_dsl(
|
|
|
1456
1462
|
assets = []
|
|
1457
1463
|
scenes = []
|
|
1458
1464
|
|
|
1465
|
+
# 模板的生图指南。compose 模式(imageStyleGuide.mode == "compose")下骨架里的
|
|
1466
|
+
# payload.prompt 只是「画面主体」占位 —— 画风 / 配色 / 负向词由 render_video 在旁白
|
|
1467
|
+
# 定稿后统一拼上,这里若先拼一份完整 prompt,agent 会以为写好了,或在它上面叠写。
|
|
1468
|
+
image_style_guide = resolve_style_guide(template_config) if _HAS_ENHANCER else {}
|
|
1469
|
+
image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
|
|
1470
|
+
image_ratio = image_style_guide.get("ratio") or ratio
|
|
1471
|
+
|
|
1459
1472
|
for idx, plan in enumerate(scene_plans):
|
|
1460
1473
|
scene_id = f"scene-{idx + 1:02d}"
|
|
1461
1474
|
# 视觉素材的 assetId 命名遵循类型前缀:image → img-,video → video-
|
|
@@ -1495,7 +1508,13 @@ def build_dsl(
|
|
|
1495
1508
|
"url": stub_image_url,
|
|
1496
1509
|
})
|
|
1497
1510
|
else:
|
|
1498
|
-
|
|
1511
|
+
if image_compose:
|
|
1512
|
+
image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
|
|
1513
|
+
else:
|
|
1514
|
+
image_result = generate_image_prompt(
|
|
1515
|
+
plan["purpose"], topic, style,
|
|
1516
|
+
narration_text=narration_text, style_guide=image_style_guide,
|
|
1517
|
+
)
|
|
1499
1518
|
|
|
1500
1519
|
# 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
|
|
1501
1520
|
# guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
|
|
@@ -1507,7 +1526,7 @@ def build_dsl(
|
|
|
1507
1526
|
img_payload = {
|
|
1508
1527
|
"prompt": image_result["prompt"],
|
|
1509
1528
|
"model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
|
|
1510
|
-
"ratio":
|
|
1529
|
+
"ratio": image_ratio,
|
|
1511
1530
|
}
|
|
1512
1531
|
if "negativePrompt" in image_result:
|
|
1513
1532
|
img_payload["negativePrompt"] = image_result["negativePrompt"]
|
|
@@ -1702,6 +1721,8 @@ def build_dsl(
|
|
|
1702
1721
|
"outputLanguage": output_language,
|
|
1703
1722
|
"style": style or "",
|
|
1704
1723
|
"description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
|
|
1724
|
+
# compose 模式:留一个空位让 agent 写全书共用的角色外观设定,每张图都会逐字注入
|
|
1725
|
+
**({"characterSheet": ""} if image_compose and has_visual else {}),
|
|
1705
1726
|
},
|
|
1706
1727
|
"global": {
|
|
1707
1728
|
"aspectRatio": ratio,
|
|
@@ -553,6 +553,26 @@ def _hydrate_dsl_payload(
|
|
|
553
553
|
LogPrint(f" {line}", file=sys.stderr)
|
|
554
554
|
|
|
555
555
|
|
|
556
|
+
def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
|
|
557
|
+
"""按模板 imageStyleGuide 把画风套到待生成图片的 prompt 上(就地改 dsl)。
|
|
558
|
+
|
|
559
|
+
只对 ``imageStyleGuide.mode == "compose"`` 的模板生效:agent 在 payload.prompt 里
|
|
560
|
+
只写画面主体,模板的画风 / 配色 / 负向词 / 比例在这里确定地、只一次地拼上去。
|
|
561
|
+
放在这里(而不是 gen_script)是因为此刻旁白已是定稿,不是骨架占位。
|
|
562
|
+
其余模板是零成本 no-op;registry 不可达时同样跳过,与 _hydrate_dsl_payload 一致。
|
|
563
|
+
"""
|
|
564
|
+
try:
|
|
565
|
+
from registry_loader import get_template # noqa: E402
|
|
566
|
+
from video_dsl.runtime.prompt_enhancer import compose_dsl_image_prompts # noqa: E402
|
|
567
|
+
template = get_template(template_id) if template_id else None
|
|
568
|
+
except Exception: # noqa: BLE001 - 取不到模板就不 compose,prompt 按 agent 写的原样用
|
|
569
|
+
return
|
|
570
|
+
if not template:
|
|
571
|
+
return
|
|
572
|
+
for line in compose_dsl_image_prompts(dsl, template):
|
|
573
|
+
LogPrint(f" {line}", file=sys.stderr)
|
|
574
|
+
|
|
575
|
+
|
|
556
576
|
def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
|
|
557
577
|
"""动作条数与旁白句数不等时出声。
|
|
558
578
|
|
|
@@ -968,6 +988,13 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
968
988
|
cmd.extend(["--size", payload["ratio"]])
|
|
969
989
|
if payload.get("model"):
|
|
970
990
|
cmd.extend(["--model", payload["model"]])
|
|
991
|
+
# 模板声明的负向词 / 引导强度 / 种子以前在这里被丢掉,gen-image 从没收到过。
|
|
992
|
+
if payload.get("negativePrompt"):
|
|
993
|
+
cmd.extend(["--negative-prompt", str(payload["negativePrompt"])])
|
|
994
|
+
if payload.get("guidanceScale") is not None:
|
|
995
|
+
cmd.extend(["--guidance-scale", str(payload["guidanceScale"])])
|
|
996
|
+
if payload.get("seed") is not None:
|
|
997
|
+
cmd.extend(["--seed", str(int(payload["seed"]))])
|
|
971
998
|
if private_token:
|
|
972
999
|
cmd.extend(["--priv-token", private_token])
|
|
973
1000
|
|
|
@@ -2480,6 +2507,10 @@ Examples:
|
|
|
2480
2507
|
# conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
|
|
2481
2508
|
_hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
|
|
2482
2509
|
|
|
2510
|
+
# compose 模式模板:agent 只写了画面主体,这里拼上模板画风(旁白此刻已定稿)。
|
|
2511
|
+
# --job-id / --render-plan 不走这里:计划已落库,不再改 prompt。
|
|
2512
|
+
_compose_image_prompts(dsl, args.template_id)
|
|
2513
|
+
|
|
2483
2514
|
errors = validate_dsl(dsl)
|
|
2484
2515
|
if errors:
|
|
2485
2516
|
LogPrint("❌ DSL validation failed:", file=sys.stderr)
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
纯函数模块,无外部依赖。主要入口:
|
|
5
5
|
- build_enhanced_prompt() : 根据 purpose/topic/style/narration/styleGuide 构建增强 prompt
|
|
6
6
|
- enrich_asset_payload() : 一站式入口,自动判断并增强 asset payload
|
|
7
|
+
- compose_image_payload() : imageStyleGuide.mode == "compose" 的模板专用 —— agent 只写
|
|
8
|
+
画面主体,这里在旁白定稿后确定地、只一次地套上模板画风
|
|
9
|
+
(见 docs/picture-book-image-prompt-design.md)
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
import re
|
|
@@ -39,19 +42,54 @@ DEFAULT_PURPOSE_PATTERNS: dict[str, str] = {
|
|
|
39
42
|
# prompt 长度阈值:超过此字符数的 prompt 视为已精心编写
|
|
40
43
|
WELL_CRAFTED_THRESHOLD = 80
|
|
41
44
|
|
|
45
|
+
# gen_script 骨架占位的前缀。旁白与 compose 模式的图片主体共用这一组:以它开头说明
|
|
46
|
+
# agent 还没把内容填进来。gen_script 从这里导入,两边不各写一份。
|
|
47
|
+
SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
|
|
48
|
+
|
|
49
|
+
# compose 模式下 gen_script 写进 payload.prompt 的占位:告诉 agent 这里只填「画什么」。
|
|
50
|
+
COMPOSE_SUBJECT_PLACEHOLDER = (
|
|
51
|
+
f"{SKELETON_PREFIXES[0]} 本页画面主体:谁、在哪、在做什么、什么表情、什么光线"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# compose 模式下,模板没给本 purpose 写 promptTemplate 时的兜底
|
|
55
|
+
COMPOSE_DEFAULT_TEMPLATE = "{subject}。{character_sheet}"
|
|
56
|
+
|
|
57
|
+
_CJK_CHAR = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
|
|
58
|
+
|
|
42
59
|
# ---------------------------------------------------------------------------
|
|
43
60
|
# 工具函数
|
|
44
61
|
# ---------------------------------------------------------------------------
|
|
45
62
|
|
|
46
63
|
|
|
64
|
+
def _is_mostly_latin(text: str) -> bool:
|
|
65
|
+
"""CJK 字符占非空白字符不足 10% —— 按英文等拼音文字处理。"""
|
|
66
|
+
chars = [c for c in text if not c.isspace()]
|
|
67
|
+
if not chars:
|
|
68
|
+
return False
|
|
69
|
+
return sum(1 for c in chars if _CJK_CHAR.match(c)) / len(chars) < 0.1
|
|
70
|
+
|
|
71
|
+
|
|
47
72
|
def summarize_narration(text: str, max_chars: int = 60) -> str:
|
|
48
73
|
"""从旁白文本提取视觉关键信息,去除口语化填充词。
|
|
49
74
|
|
|
75
|
+
英文旁白不套中文填充词正则("了""的话"之类对英文无意义,反而可能误删),
|
|
76
|
+
按单词边界截断,上限放宽到 200 字符 —— 60 字符的英文常常只剩半句。
|
|
77
|
+
|
|
50
78
|
>>> summarize_narration("那么接下来我们来看一下AI如何帮你高效背单词")
|
|
51
79
|
'AI如何帮你高效背单词'
|
|
80
|
+
>>> summarize_narration("A tiny seed slept in the warm, dark soil.")
|
|
81
|
+
'A tiny seed slept in the warm, dark soil'
|
|
52
82
|
"""
|
|
53
83
|
if not text:
|
|
54
84
|
return ""
|
|
85
|
+
if _is_mostly_latin(text):
|
|
86
|
+
limit = max(max_chars, 200)
|
|
87
|
+
cleaned = _PUNCT.sub(" ", text).strip().rstrip(".!?;:,")
|
|
88
|
+
if len(cleaned) > limit:
|
|
89
|
+
cut = cleaned[:limit]
|
|
90
|
+
space = cut.rfind(" ")
|
|
91
|
+
cleaned = (cut[:space] if space > 0 else cut).rstrip(".!?;:,")
|
|
92
|
+
return cleaned or text[:limit]
|
|
55
93
|
# 去除填充词
|
|
56
94
|
cleaned = _FILLER_PATTERNS.sub("", text)
|
|
57
95
|
# 归一化标点 → 空格
|
|
@@ -69,7 +107,7 @@ def extract_scene_context(scene: dict) -> dict:
|
|
|
69
107
|
"""从 DSL scene 提取视觉相关上下文。
|
|
70
108
|
|
|
71
109
|
Returns:
|
|
72
|
-
dict with keys: narration_text, title, badge, purpose
|
|
110
|
+
dict with keys: narration_text, title, badge, headline, subheadline, purpose
|
|
73
111
|
"""
|
|
74
112
|
purpose = scene.get("purpose", "default")
|
|
75
113
|
narration_text = ""
|
|
@@ -78,19 +116,19 @@ def extract_scene_context(scene: dict) -> dict:
|
|
|
78
116
|
if isinstance(narration, dict):
|
|
79
117
|
narration_text = narration.get("text", "")
|
|
80
118
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
for layer in scene.get("textLayers", []):
|
|
119
|
+
layers_by_role: dict[str, str] = {}
|
|
120
|
+
for layer in scene.get("textLayers", []) or []:
|
|
84
121
|
role = layer.get("role", "")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
badge = layer.get("content", "")
|
|
122
|
+
content = layer.get("content", "")
|
|
123
|
+
if role and isinstance(content, str) and role not in layers_by_role:
|
|
124
|
+
layers_by_role[role] = content
|
|
89
125
|
|
|
90
126
|
return {
|
|
91
|
-
"narration_text": narration_text,
|
|
92
|
-
"title": title,
|
|
93
|
-
"badge": badge,
|
|
127
|
+
"narration_text": narration_text or "",
|
|
128
|
+
"title": layers_by_role.get("title", ""),
|
|
129
|
+
"badge": layers_by_role.get("badge", ""),
|
|
130
|
+
"headline": layers_by_role.get("headline", ""),
|
|
131
|
+
"subheadline": layers_by_role.get("subheadline", ""),
|
|
94
132
|
"purpose": purpose,
|
|
95
133
|
}
|
|
96
134
|
|
|
@@ -170,7 +208,7 @@ def build_enhanced_prompt(
|
|
|
170
208
|
)
|
|
171
209
|
|
|
172
210
|
# 格式化模板(安全替换)
|
|
173
|
-
prompt = _safe_format(prompt_template, vars_)
|
|
211
|
+
prompt = _tidy_punctuation(_safe_format(prompt_template, vars_))
|
|
174
212
|
|
|
175
213
|
# 追加 styleModifier
|
|
176
214
|
style_modifier = override.get("styleModifier", "")
|
|
@@ -213,6 +251,38 @@ def _safe_format(template: str, vars_: dict) -> str:
|
|
|
213
251
|
return result
|
|
214
252
|
|
|
215
253
|
|
|
254
|
+
_EMPTY_BRACKETS = re.compile(r"[((]\s*[))]")
|
|
255
|
+
_PUNCT_BEFORE_CLOSE = re.compile(r"[,。、;,;.\s]+(?=[))])")
|
|
256
|
+
# 两个及以上相邻的分隔标点(中间可夹空白):空变量替换后留下的「,,」「。,」「, 。」等
|
|
257
|
+
_PUNCT_RUN = re.compile(r"[,。、;,;.]\s*(?:[,。、;,;.]\s*)+")
|
|
258
|
+
_LEADING_PUNCT = re.compile(r"^[\s,。、;,;.]+")
|
|
259
|
+
_TRAILING_SOFT_PUNCT = re.compile(r"[\s,、;,;]+$")
|
|
260
|
+
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _collapse_punct_run(match: "re.Match[str]") -> str:
|
|
264
|
+
run = match.group(0)
|
|
265
|
+
return "。" if ("。" in run or "." in run) else ","
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _tidy_punctuation(text: str) -> str:
|
|
269
|
+
"""清理模板变量为空时留下的标点噪声。
|
|
270
|
+
|
|
271
|
+
``{style_suffix}`` / ``{character_sheet}`` 这类变量常为空,替换后会留下
|
|
272
|
+
「,,」「。。」「()」和行首孤立的逗号。连续分隔符合并成一个(含句号就留句号),
|
|
273
|
+
去掉空括号、行首标点、行尾逗号,多余空格压成一个。单词间的单个空格不动。
|
|
274
|
+
"""
|
|
275
|
+
if not text:
|
|
276
|
+
return ""
|
|
277
|
+
out = _PUNCT_BEFORE_CLOSE.sub("", text)
|
|
278
|
+
out = _EMPTY_BRACKETS.sub("", out)
|
|
279
|
+
out = _PUNCT_RUN.sub(_collapse_punct_run, out)
|
|
280
|
+
out = _MULTI_SPACE.sub(" ", out)
|
|
281
|
+
out = _LEADING_PUNCT.sub("", out)
|
|
282
|
+
out = _TRAILING_SOFT_PUNCT.sub("", out)
|
|
283
|
+
return out.strip()
|
|
284
|
+
|
|
285
|
+
|
|
216
286
|
# ---------------------------------------------------------------------------
|
|
217
287
|
# 一站式入口
|
|
218
288
|
# ---------------------------------------------------------------------------
|
|
@@ -272,3 +342,152 @@ def enrich_asset_payload(
|
|
|
272
342
|
payload["guidanceScale"] = enhanced["guidanceScale"]
|
|
273
343
|
|
|
274
344
|
return payload
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# compose 模式:agent 写「画什么」,模板管「怎么画」
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def is_compose_mode(style_guide: Optional[dict]) -> bool:
|
|
353
|
+
return bool(style_guide) and style_guide.get("mode") == "compose"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _style_guide_stamp(template: dict) -> str:
|
|
357
|
+
return f"{template.get('templateId', '')}@{template.get('version', '')}"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _is_placeholder_subject(text: str) -> bool:
|
|
361
|
+
return not text or text.startswith(SKELETON_PREFIXES)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def compose_image_payload(
|
|
365
|
+
payload: dict,
|
|
366
|
+
scene: Optional[dict],
|
|
367
|
+
template: Optional[dict],
|
|
368
|
+
dsl_meta: Optional[dict] = None,
|
|
369
|
+
variant_id: str = "",
|
|
370
|
+
) -> dict:
|
|
371
|
+
"""把模板的 imageStyleGuide 套到一个 gen-image 素材的 payload 上(compose 模式专用)。
|
|
372
|
+
|
|
373
|
+
``payload.prompt`` 在 compose 模式下的语义是**画面主体**(这一页画什么),不含画风。
|
|
374
|
+
这里拼出最终 prompt = promptTemplate(主体 / 角色设定 / 文字层…) + styleModifier +
|
|
375
|
+
baseStyle + colorDirective,并补上 negativePrompt / ratio / guidanceScale / seed。
|
|
376
|
+
|
|
377
|
+
- 非 compose 模板:原样返回(不改变 legacy 模板的行为)。
|
|
378
|
+
- 幂等:``styleGuideApplied`` 等于当前模板版本时原样返回;版本变了则从
|
|
379
|
+
``promptSubject``(上次 compose 前的主体)重新拼,画风不会叠两遍。
|
|
380
|
+
- 主体为空或仍是骨架占位时退回本场景的旁白全文 / 文字层。
|
|
381
|
+
|
|
382
|
+
返回新 dict,不修改入参。
|
|
383
|
+
"""
|
|
384
|
+
guide = resolve_style_guide(template, variant_id)
|
|
385
|
+
if not is_compose_mode(guide):
|
|
386
|
+
return payload
|
|
387
|
+
stamp = _style_guide_stamp(template or {})
|
|
388
|
+
if payload.get("styleGuideApplied") == stamp:
|
|
389
|
+
return payload
|
|
390
|
+
|
|
391
|
+
meta = dsl_meta or {}
|
|
392
|
+
ctx = extract_scene_context(scene) if scene else {}
|
|
393
|
+
purpose = ctx.get("purpose") or "point"
|
|
394
|
+
|
|
395
|
+
# 上次 compose 过(模板升版后重跑)→ 主体在 promptSubject 里,prompt 已是成品
|
|
396
|
+
subject = str(payload.get("promptSubject") or payload.get("prompt") or "").strip()
|
|
397
|
+
if _is_placeholder_subject(subject):
|
|
398
|
+
subject = (
|
|
399
|
+
ctx.get("narration_text", "").strip()
|
|
400
|
+
or ctx.get("subheadline", "").strip()
|
|
401
|
+
or ctx.get("headline", "").strip()
|
|
402
|
+
)
|
|
403
|
+
if _is_placeholder_subject(subject):
|
|
404
|
+
subject = str(meta.get("topic") or meta.get("title") or "").strip()
|
|
405
|
+
|
|
406
|
+
style = meta.get("style", "")
|
|
407
|
+
vars_ = {
|
|
408
|
+
"subject": subject,
|
|
409
|
+
"scene_text": ctx.get("narration_text", ""),
|
|
410
|
+
"scene_headline": ctx.get("headline", ""),
|
|
411
|
+
"scene_subheadline": ctx.get("subheadline", ""),
|
|
412
|
+
"scene_title": ctx.get("title", "") or ctx.get("headline", ""),
|
|
413
|
+
"character_sheet": str(meta.get("characterSheet") or "").strip(),
|
|
414
|
+
"topic": str(meta.get("topic") or meta.get("title") or ""),
|
|
415
|
+
"style_suffix": f"{style}风格" if style else "",
|
|
416
|
+
# 旧变量名兼容:legacy promptTemplate 里写的 {narration_summary} 在这里就是主体
|
|
417
|
+
"narration_summary": subject,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
overrides = guide.get("purposeOverrides", {}) or {}
|
|
421
|
+
override = overrides.get(purpose) or {}
|
|
422
|
+
parts = [
|
|
423
|
+
# 没有针对本 purpose 的模板(如 cta / ending)时角色设定照样注入 —— 否则恰好
|
|
424
|
+
# 这一页的主角会换个样子。
|
|
425
|
+
_safe_format(override.get("promptTemplate") or COMPOSE_DEFAULT_TEMPLATE, vars_),
|
|
426
|
+
override.get("styleModifier", ""),
|
|
427
|
+
guide.get("baseStyle", ""),
|
|
428
|
+
guide.get("colorDirective", ""),
|
|
429
|
+
]
|
|
430
|
+
prompt = _tidy_punctuation(",".join(p.strip() for p in parts if p and p.strip()))
|
|
431
|
+
|
|
432
|
+
out = dict(payload)
|
|
433
|
+
out["prompt"] = prompt
|
|
434
|
+
out["promptSubject"] = subject
|
|
435
|
+
if not out.get("negativePrompt"):
|
|
436
|
+
out["negativePrompt"] = override.get("negativePrompt") or guide.get("negativePrompt")
|
|
437
|
+
if guide.get("ratio"):
|
|
438
|
+
out["ratio"] = guide["ratio"]
|
|
439
|
+
if out.get("guidanceScale") is None and guide.get("guidanceScale") is not None:
|
|
440
|
+
out["guidanceScale"] = guide["guidanceScale"]
|
|
441
|
+
if out.get("seed") is None and guide.get("seed") is not None:
|
|
442
|
+
out["seed"] = guide["seed"]
|
|
443
|
+
out["styleGuideApplied"] = stamp
|
|
444
|
+
return {k: v for k, v in out.items() if v not in (None, "")}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _scene_refs(node, asset_id: str) -> bool:
|
|
448
|
+
if isinstance(node, dict):
|
|
449
|
+
return any(
|
|
450
|
+
(k == "assetRef" and v == asset_id) or _scene_refs(v, asset_id)
|
|
451
|
+
for k, v in node.items()
|
|
452
|
+
)
|
|
453
|
+
if isinstance(node, list):
|
|
454
|
+
return any(_scene_refs(v, asset_id) for v in node)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def compose_dsl_image_prompts(dsl: dict, template: Optional[dict]) -> list[str]:
|
|
459
|
+
"""对整份 DSL 里待生成的图片素材逐个 compose(就地改 dsl),返回日志行。
|
|
460
|
+
|
|
461
|
+
只处理 ``type=image, source=gen-image, status∈{planned, missing}``;已生成 / 自带的图
|
|
462
|
+
不动。非 compose 模板是零成本 no-op(返回空列表)。
|
|
463
|
+
"""
|
|
464
|
+
meta = dsl.get("meta") or {}
|
|
465
|
+
variant_id = str(meta.get("variantId") or "")
|
|
466
|
+
if not is_compose_mode(resolve_style_guide(template, variant_id)):
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
scenes = dsl.get("scenes") or []
|
|
470
|
+
logs: list[str] = []
|
|
471
|
+
if not str(meta.get("characterSheet") or "").strip():
|
|
472
|
+
logs.append(
|
|
473
|
+
"⚠️ meta.characterSheet is empty — every image is generated independently, "
|
|
474
|
+
"so the main character may look different on each page"
|
|
475
|
+
)
|
|
476
|
+
for asset in dsl.get("assets") or []:
|
|
477
|
+
if not isinstance(asset, dict):
|
|
478
|
+
continue
|
|
479
|
+
if asset.get("type") != "image" or asset.get("source") != "gen-image":
|
|
480
|
+
continue
|
|
481
|
+
if asset.get("status", "planned") not in ("planned", "missing"):
|
|
482
|
+
continue
|
|
483
|
+
asset_id = asset.get("assetId", "")
|
|
484
|
+
scene = next((s for s in scenes if _scene_refs(s, asset_id)), None)
|
|
485
|
+
before = asset.get("payload") or {}
|
|
486
|
+
after = compose_image_payload(before, scene, template, meta, variant_id)
|
|
487
|
+
if after is before:
|
|
488
|
+
continue
|
|
489
|
+
asset["payload"] = after
|
|
490
|
+
subject = after.get("promptSubject", "")
|
|
491
|
+
short = subject if len(subject) <= 60 else subject[:60] + "…"
|
|
492
|
+
logs.append(f"🎨 {asset_id} composed (subject: {short})")
|
|
493
|
+
return logs
|