@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.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/CHANGELOG.md +42 -0
- package/README.md +10 -6
- package/README.tr.md +143 -0
- package/docs/architecture.md +23 -8
- package/docs/ecosystem.md +237 -0
- package/install/_plugin-skills.mjs +16 -2
- package/install/codex.mjs +9 -4
- package/install/templates/copilot-instructions.md +12 -9
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/extract-conventions.sh +44 -15
- package/pipeline/lib/fetch-figma-annotations.sh +8 -1
- package/pipeline/lib/fetch-fortify.sh +23 -8
- package/pipeline/lib/figma-screenshot.sh +11 -1
- package/pipeline/lib/issue-fetcher.sh +76 -9
- package/pipeline/lib/md2confluence-v3.py +16 -2
- package/pipeline/lib/plan-todos.sh +5 -2
- package/pipeline/lib/post-pr-review.sh +8 -6
- package/pipeline/lib/shadow-git.sh +50 -9
- package/pipeline/lib/submodule-detector.sh +8 -1
- package/pipeline/multi-agent-refs/_input-parser.md +1 -1
- package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
- package/pipeline/multi-agent-refs/channels/jira.md +13 -2
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +20 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +2 -2
- package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
- package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
- package/pipeline/multi-agent-refs/phases/modes.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
- package/pipeline/multi-agent-refs/phases.md +6 -0
- package/pipeline/multi-agent-refs/rules.md +2 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/audit-log-rotate.sh +10 -0
- package/pipeline/scripts/build-stack-plugins.mjs +8 -1
- package/pipeline/scripts/check-derived-drift.mjs +13 -1
- package/pipeline/scripts/diff-explain.mjs +41 -3
- package/pipeline/scripts/diff-risk-score.mjs +72 -8
- package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
- package/pipeline/scripts/learning-curve.mjs +8 -2
- package/pipeline/scripts/output-quality-check.sh +15 -4
- package/pipeline/scripts/phase-tracker.sh +21 -8
- package/pipeline/scripts/pre-commit-check.sh +69 -22
- package/pipeline/scripts/render-agent-log-cost.sh +8 -3
- package/pipeline/scripts/render-cost-summary.sh +42 -22
- package/pipeline/scripts/render-work-summary.sh +47 -13
- package/pipeline/scripts/review-scope.mjs +1 -1
- package/pipeline/scripts/run-aggregator.mjs +38 -14
- package/pipeline/scripts/smoke-schema-validation.sh +5 -1
- package/pipeline/scripts/test-gap-scan.mjs +45 -6
- package/pipeline/scripts/uninstall.mjs +39 -4
- package/pipeline/scripts/update-issue-progress.sh +12 -16
- package/pipeline/scripts/worktree-finalize.sh +23 -2
- package/pipeline/skills/.skills-index.json +11 -2
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
- package/pipeline/skills/skills-index.md +3 -2
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a PER-KEY "screenpiece" for every mapping row: a cropped band of the screen
|
|
3
|
+
with a RED BOX around exactly that element, so the table's "Ekran Görüntüsü" cell shows
|
|
4
|
+
the content team where each key lives on screen (ported from the web skill's
|
|
5
|
+
render-key-shots.mjs). No AI image generation - pure geometry + HTML + headless Chrome.
|
|
6
|
+
|
|
7
|
+
Input modes (same split as render-overlay.py):
|
|
8
|
+
* REST (default) - Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
|
|
9
|
+
geometry GET /v1/files/<key>/nodes?ids=<frame> → per-text absoluteBoundingBox,
|
|
10
|
+
with ANCESTOR VISIBILITY (`visible!=false`) + cumulative opacity tracked,
|
|
11
|
+
so hidden component variants with stale bboxes are never boxed;
|
|
12
|
+
image GET /v1/images/<key>?ids=<frame>&format=png&scale=<n> → one render,
|
|
13
|
+
cropped per key.
|
|
14
|
+
* --spec <file> (repeatable; MCP / offline) - the SAME spec shape render-overlay.py
|
|
15
|
+
takes: { fileKey?, pages:[{nodeId, image, frame:{w,h}, nodes:[{id, characters,
|
|
16
|
+
x,y,w,h}]}] }. `image` may be a data URL **or a file path** (resolved relative to
|
|
17
|
+
the spec file) - a committed example spec can reference screenshot.png instead of
|
|
18
|
+
inlining base64. Several --spec files = several screen states; each row is boxed
|
|
19
|
+
on the FIRST spec/page where its node matches.
|
|
20
|
+
|
|
21
|
+
Rows are matched to nodes exactly like the overlay: by `cmsNodeId` first, else by
|
|
22
|
+
normalized value (new/cms/legacy tr+en, element label). Every matched row gets a shot;
|
|
23
|
+
unmatched rows are listed in the manifest's `missing` (blank cell in the table - never
|
|
24
|
+
a guessed crop).
|
|
25
|
+
|
|
26
|
+
Output, into --out:
|
|
27
|
+
keyshots/keyshot__<NN>__<key>.png one per matched row; NN = the row's 1-based
|
|
28
|
+
position in mapping.rows - the SAME number the
|
|
29
|
+
table and the overlay cards use.
|
|
30
|
+
<slug>.keyshots.manifest.json {shots:[{row,key,element,file,...}], missing:[...]}
|
|
31
|
+
build-artifact.py reads this (mapping top-level
|
|
32
|
+
`keyshots`) to fill the table cells;
|
|
33
|
+
publish-confluence.py --keyshots attaches the PNGs.
|
|
34
|
+
No headless Chrome → per-shot `.html` files are written instead (open/print manually);
|
|
35
|
+
the manifest marks them `"fallback": "html"`. Read-only against every source.
|
|
36
|
+
"""
|
|
37
|
+
import argparse
|
|
38
|
+
import base64
|
|
39
|
+
import importlib.util
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
|
|
46
|
+
sys.dont_write_bytecode = True
|
|
47
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
sys.path.insert(0, _HERE)
|
|
49
|
+
|
|
50
|
+
# Shared with the overlay renderer on purpose: SAME matching = the shot, the
|
|
51
|
+
# overlay card and the table row can never disagree about which node a key is.
|
|
52
|
+
# The filename is not a valid identifier, so it loads by path rather than by
|
|
53
|
+
# `import`: spec_from_file_location yields a real module object, and no source
|
|
54
|
+
# text is evaluated at runtime.
|
|
55
|
+
_RO_PATH = os.path.join(_HERE, "render-overlay.py")
|
|
56
|
+
_spec = importlib.util.spec_from_file_location("render_overlay", _RO_PATH)
|
|
57
|
+
if _spec is None or _spec.loader is None:
|
|
58
|
+
sys.exit(f"render-key-shots: cannot load {_RO_PATH}")
|
|
59
|
+
_ro = importlib.util.module_from_spec(_spec)
|
|
60
|
+
sys.modules["render_overlay"] = _ro
|
|
61
|
+
_spec.loader.exec_module(_ro)
|
|
62
|
+
|
|
63
|
+
canon_id = _ro.canon_id
|
|
64
|
+
slug = _ro.slug
|
|
65
|
+
build_index = _ro.build_index
|
|
66
|
+
match_row = _ro.match_row
|
|
67
|
+
figma_token = _ro.figma_token
|
|
68
|
+
http_get_json = _ro.http_get_json
|
|
69
|
+
http_get_bytes = _ro.http_get_bytes
|
|
70
|
+
find_chrome = _ro.find_chrome
|
|
71
|
+
FIGMA_API = _ro.FIGMA_API
|
|
72
|
+
|
|
73
|
+
RED = "#E81932"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def key_file(num, key):
|
|
77
|
+
# Drop a trailing "(owned by ...)" marker first: it is provenance, not identity, so
|
|
78
|
+
# letting it into the filename churns every PNG (and orphans the old ones) whenever
|
|
79
|
+
# a component is renamed or the tag is added. Matches build-spreadsheet's clean_key.
|
|
80
|
+
bare = re.sub(r"\s*\(.*\)\s*$", "", key or "").strip()
|
|
81
|
+
safe = re.sub(r'[/\\:*?"<>|\s]+', "-", bare or "key").strip("-")
|
|
82
|
+
return f"keyshot__{num:02d}__{safe}.png"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_shot_html(img_src, fw, fh, box, pad):
|
|
86
|
+
"""Full-frame-width band: `pad` px of context above/below the element, red box on it."""
|
|
87
|
+
cy0 = max(0, box["y"] - pad)
|
|
88
|
+
cy1 = min(fh, box["y"] + box["h"] + pad)
|
|
89
|
+
crop_w, crop_h = fw, cy1 - cy0
|
|
90
|
+
bx = max(0, box["x"] - 6)
|
|
91
|
+
by = box["y"] - cy0 - 6
|
|
92
|
+
bw, bh = box["w"] + 12, box["h"] + 12
|
|
93
|
+
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
|
94
|
+
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
95
|
+
body{{width:{crop_w}px;height:{crop_h}px;overflow:hidden;position:relative;background:#fff}}
|
|
96
|
+
img{{position:absolute;left:0;top:{-cy0}px;width:{fw}px;height:{fh}px}}
|
|
97
|
+
.box{{position:absolute;left:{bx}px;top:{by}px;width:{bw}px;height:{bh}px;
|
|
98
|
+
border:3px solid {RED};border-radius:6px;box-shadow:0 0 0 2px rgba(232,25,50,.15)}}
|
|
99
|
+
</style></head><body><img src="{img_src.replace('"', '"')}"/><div class="box"></div></body></html>"""
|
|
100
|
+
return html, crop_w, crop_h
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_shot_png(chrome, html, w, h, out_path, scale):
|
|
104
|
+
"""One headless-Chrome screenshot. Returns None on success, else an error string."""
|
|
105
|
+
import subprocess
|
|
106
|
+
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
|
|
107
|
+
tf.write(html)
|
|
108
|
+
tmp = tf.name
|
|
109
|
+
try:
|
|
110
|
+
r = subprocess.run(
|
|
111
|
+
[chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
|
|
112
|
+
f"--force-device-scale-factor={scale}",
|
|
113
|
+
f"--window-size={w},{int(h)}",
|
|
114
|
+
f"--screenshot={out_path}", tmp],
|
|
115
|
+
capture_output=True, text=True, timeout=120)
|
|
116
|
+
if not os.path.isfile(out_path):
|
|
117
|
+
return (r.stderr or "no output")[:200]
|
|
118
|
+
return None
|
|
119
|
+
except Exception as exc: # timeout / spawn failure - fall back per shot, not per run
|
|
120
|
+
return str(exc)[:200]
|
|
121
|
+
finally:
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(tmp)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def spec_image_src(page, spec_path):
|
|
129
|
+
"""`image` as a browser src: pass a data URL through; inline a file path (relative
|
|
130
|
+
to the spec file) as base64 so the crop HTML stays self-contained for Chrome."""
|
|
131
|
+
img = page.get("image") or ""
|
|
132
|
+
if img.startswith("data:"):
|
|
133
|
+
return img
|
|
134
|
+
cand = img if os.path.isabs(img) else os.path.join(os.path.dirname(os.path.abspath(spec_path)), img)
|
|
135
|
+
if not os.path.isfile(cand):
|
|
136
|
+
sys.exit(f"spec image not found: {cand}")
|
|
137
|
+
with open(cand, "rb") as fh:
|
|
138
|
+
return "data:image/png;base64," + base64.b64encode(fh.read()).decode("ascii")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def collect_rest_pages(file_key, node_ids, token, ca_file, scale):
|
|
142
|
+
"""Fetch each frame's VISIBLE text nodes (+ image render) - visibility is ancestor
|
|
143
|
+
`visible` AND cumulative opacity, exactly the web port's rule, because a node tree
|
|
144
|
+
carries text that is not rendered in this state and its stale bbox would land the
|
|
145
|
+
red box on the wrong content."""
|
|
146
|
+
headers = {"X-Figma-Token": token}
|
|
147
|
+
import urllib.parse
|
|
148
|
+
pages = []
|
|
149
|
+
for nid in node_ids:
|
|
150
|
+
data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(nid, safe='')}",
|
|
151
|
+
headers, ca_file)
|
|
152
|
+
doc = ((data.get("nodes") or {}).get(nid) or {}).get("document")
|
|
153
|
+
if not doc:
|
|
154
|
+
sys.stderr.write(f"# warning: node {nid} not found - skipped\n")
|
|
155
|
+
continue
|
|
156
|
+
fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
|
|
157
|
+
nodes = []
|
|
158
|
+
|
|
159
|
+
def walk(n, anc_visible, opacity):
|
|
160
|
+
vis = anc_visible and n.get("visible") is not False
|
|
161
|
+
op = opacity * (n.get("opacity") if isinstance(n.get("opacity"), (int, float)) else 1)
|
|
162
|
+
b = n.get("absoluteBoundingBox")
|
|
163
|
+
is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
|
|
164
|
+
if is_text and b and vis and op > 0:
|
|
165
|
+
nodes.append({"id": n.get("id"), "characters": n["characters"],
|
|
166
|
+
"x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
|
|
167
|
+
"w": round(b["width"]), "h": round(b["height"])})
|
|
168
|
+
for c in n.get("children") or []:
|
|
169
|
+
walk(c, vis, op)
|
|
170
|
+
|
|
171
|
+
walk(doc, True, 1)
|
|
172
|
+
img = http_get_json(
|
|
173
|
+
f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(nid, safe='')}&format=png&scale={scale}",
|
|
174
|
+
headers, ca_file)
|
|
175
|
+
url = (img.get("images") or {}).get(nid)
|
|
176
|
+
if not url:
|
|
177
|
+
sys.stderr.write(f"# warning: no image render for {nid} - skipped\n")
|
|
178
|
+
continue
|
|
179
|
+
raw = http_get_bytes(url, ca_file)
|
|
180
|
+
pages.append({"nodeId": nid,
|
|
181
|
+
"image": "data:image/png;base64," + base64.b64encode(raw).decode("ascii"),
|
|
182
|
+
"frame": {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))},
|
|
183
|
+
"nodes": nodes, "_src": "rest"})
|
|
184
|
+
return pages
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main():
|
|
188
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
189
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
190
|
+
ap.add_argument("--mapping", required=True, help="mapping JSON (rows + figmaFileKey/figmaNodes + screen)")
|
|
191
|
+
ap.add_argument("--spec", action="append", default=[],
|
|
192
|
+
help="spec JSON (repeatable - one per screen state; overlay-compatible shape; "
|
|
193
|
+
"image may be a data URL or a file path relative to the spec)")
|
|
194
|
+
ap.add_argument("--file", help="Figma fileKey (REST; else mapping figmaFileKey)")
|
|
195
|
+
ap.add_argument("--nodes", help="comma-separated frame node-ids (REST; else mapping figmaNodes)")
|
|
196
|
+
ap.add_argument("--pad", type=int, default=150, help="context band above/below the element (px)")
|
|
197
|
+
ap.add_argument("--scale", type=int, default=2)
|
|
198
|
+
ap.add_argument("--token")
|
|
199
|
+
ap.add_argument("--ca-file")
|
|
200
|
+
ap.add_argument("--out", default=".", help="output dir (shots go under <out>/keyshots/)")
|
|
201
|
+
ap.add_argument("--slug", help="override slug (default from screen name)")
|
|
202
|
+
args = ap.parse_args()
|
|
203
|
+
|
|
204
|
+
with open(args.mapping, encoding="utf-8") as fh:
|
|
205
|
+
m = json.load(fh)
|
|
206
|
+
rows = m.get("rows") or []
|
|
207
|
+
screen = m.get("screen") or "screen"
|
|
208
|
+
sl = args.slug or slug(screen)
|
|
209
|
+
shots_dir = os.path.join(args.out, "keyshots")
|
|
210
|
+
os.makedirs(shots_dir, exist_ok=True)
|
|
211
|
+
|
|
212
|
+
# ---- geometry pages, in precedence order (first match wins per row) ----
|
|
213
|
+
if args.spec:
|
|
214
|
+
pages = []
|
|
215
|
+
for sp in args.spec:
|
|
216
|
+
with open(sp, encoding="utf-8") as fh:
|
|
217
|
+
spec = json.load(fh)
|
|
218
|
+
for page in spec.get("pages") or []:
|
|
219
|
+
page["_img_src"] = spec_image_src(page, sp)
|
|
220
|
+
pages.append(page)
|
|
221
|
+
if not pages:
|
|
222
|
+
sys.exit("--spec files carry no pages")
|
|
223
|
+
else:
|
|
224
|
+
file_key = args.file or m.get("figmaFileKey")
|
|
225
|
+
node_ids = [canon_id(n) for n in (args.nodes or "").split(",") if n.strip()] or \
|
|
226
|
+
[canon_id(str(n)) for n in (m.get("figmaNodes") or [])]
|
|
227
|
+
if not (file_key and node_ids):
|
|
228
|
+
ap.error("REST needs a fileKey + node-ids (via --file/--nodes or the mapping). "
|
|
229
|
+
"Or pass --spec for the offline path.")
|
|
230
|
+
token = figma_token(args)
|
|
231
|
+
if not token:
|
|
232
|
+
sys.exit("No Figma token - pass --token, set FIGMA_ACCESS_TOKEN, or add the "
|
|
233
|
+
"keychain item FIGMA_ACCESS_TOKEN (or use --spec).")
|
|
234
|
+
pages = collect_rest_pages(file_key, node_ids, token, args.ca_file, args.scale)
|
|
235
|
+
for page in pages:
|
|
236
|
+
page["_img_src"] = page["image"]
|
|
237
|
+
if not pages:
|
|
238
|
+
sys.exit("no renderable frames")
|
|
239
|
+
|
|
240
|
+
# ---- choose one node per row: first page (in order) whose nodes match it ----
|
|
241
|
+
by = [build_index([r]) for r in rows] # per-row index → exact same matcher as overlay
|
|
242
|
+
chosen = {} # row idx (0-based) → {page, node}
|
|
243
|
+
for page in pages:
|
|
244
|
+
for n in sorted(page["nodes"], key=lambda x: x.get("y", 0)):
|
|
245
|
+
for i, r in enumerate(rows):
|
|
246
|
+
if i in chosen or not r.get("newKey"):
|
|
247
|
+
continue
|
|
248
|
+
if match_row(n, *by[i]) is r:
|
|
249
|
+
chosen[i] = {"page": page, "node": n}
|
|
250
|
+
missing = [{"row": i + 1, "key": r.get("newKey"),
|
|
251
|
+
"reason": "no visible node matched (cmsNodeId/value)"}
|
|
252
|
+
for i, r in enumerate(rows) if r.get("newKey") and i not in chosen]
|
|
253
|
+
|
|
254
|
+
chrome = find_chrome()
|
|
255
|
+
if not chrome:
|
|
256
|
+
sys.stderr.write("# no headless Chrome - writing per-shot .html fallbacks\n")
|
|
257
|
+
|
|
258
|
+
manifest = {"screen": screen, "slug": sl, "pad": args.pad, "scale": args.scale,
|
|
259
|
+
"dir": "keyshots", "shots": [], "missing": missing}
|
|
260
|
+
ok = fail = 0
|
|
261
|
+
for i in sorted(chosen):
|
|
262
|
+
r, c = rows[i], chosen[i]
|
|
263
|
+
page, n = c["page"], c["node"]
|
|
264
|
+
num = i + 1
|
|
265
|
+
html, w, h = build_shot_html(page["_img_src"], page["frame"]["w"], page["frame"]["h"],
|
|
266
|
+
{"x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"]}, args.pad)
|
|
267
|
+
fname = key_file(num, r["newKey"])
|
|
268
|
+
out_png = os.path.join(shots_dir, fname)
|
|
269
|
+
entry = {"row": num, "key": r["newKey"], "element": r.get("element"),
|
|
270
|
+
"file": f"keyshots/{fname}", "pageNode": page.get("nodeId"),
|
|
271
|
+
"cropW": w, "cropH": h}
|
|
272
|
+
if chrome:
|
|
273
|
+
err = render_shot_png(chrome, html, w, h, out_png, args.scale)
|
|
274
|
+
if err:
|
|
275
|
+
fail += 1
|
|
276
|
+
sys.stderr.write(f"# ✗ row {num} {r['newKey']}: {err}\n")
|
|
277
|
+
continue
|
|
278
|
+
else:
|
|
279
|
+
alt = out_png[:-4] + ".html"
|
|
280
|
+
with open(alt, "w", encoding="utf-8") as fh:
|
|
281
|
+
fh.write(html)
|
|
282
|
+
entry["file"] = f"keyshots/{os.path.basename(alt)}"
|
|
283
|
+
entry["fallback"] = "html"
|
|
284
|
+
ok += 1
|
|
285
|
+
manifest["shots"].append(entry)
|
|
286
|
+
|
|
287
|
+
mpath = os.path.join(args.out, sl + ".keyshots.manifest.json")
|
|
288
|
+
with open(mpath, "w", encoding="utf-8") as fh:
|
|
289
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
290
|
+
fh.write("\n")
|
|
291
|
+
sys.stderr.write(f"# keyshots: {ok} written, {len(missing)} unmatched, {fail} failed → "
|
|
292
|
+
f"{shots_dir}/ · manifest: {mpath}\n")
|
|
293
|
+
sys.stderr.write(f'# set the mapping\'s top-level "keyshots": "{os.path.basename(mpath)}" '
|
|
294
|
+
"so build-artifact.py fills the table cells\n")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
main()
|