feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

View File

@@ -3406,6 +3406,7 @@ def run_phase_z2_mvp1(
override_frames: Optional[dict[str, str]] = None,
override_zone_geometries: Optional[dict[str, dict]] = None,
override_section_assignments: Optional[dict[str, list[str]]] = None,
override_image_overrides: Optional[dict[str, dict]] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
@@ -3419,6 +3420,15 @@ def run_phase_z2_mvp1(
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis.
image_id = stable id stamped on user-content `<img>` tags by
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
coordinates (0100, slide-absolute). Forward-compat kwarg: the
render-time CSS injection that consumes this mapping lands in
u7; until u7 wires the consumer, accepting the kwarg keeps the
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
client u3 + stamper u4) end-to-end addressable from CLI without
diverging the function signature.
"""
mdx_path = Path(mdx_path)
if run_id is None:
@@ -5375,6 +5385,36 @@ def run_phase_z2_mvp1(
# 7. Render single slide
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
# role attrs, then inject persisted `image_overrides` CSS so the
# next render re-applies the user-edited geometry.
#
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
# stamper returns an empty `stamped_image_ids` list and the CSS
# builder short-circuits to "". The HTML is therefore byte-for-byte
# identical to the pre-IMP-51 output until Phase Z starts emitting
# user-content imgs (separate axis, out of scope for #79).
from src.image_id_stamper import (
build_image_overrides_style,
inject_image_overrides_style,
stamp_user_content_images,
)
_user_content_image_srcs = [
(entry.get("path") or entry.get("src") or "")
for entry in (stage0_normalized_assets.get("images") or [])
if isinstance(entry, dict)
]
html, _stamped_image_ids = stamp_user_content_images(
html, sources=_user_content_image_srcs,
)
if override_image_overrides:
_image_overrides_css = build_image_overrides_style(
override_image_overrides, _stamped_image_ids,
)
if _image_overrides_css:
html = inject_image_overrides_style(html, _image_overrides_css)
# 8. Write final.html
out_path = run_dir / "final.html"
out_path.write_text(html, encoding="utf-8")
@@ -5844,6 +5884,27 @@ if __name__ == "__main__":
"--override-section-assignment bottom=03-2,03-3"
),
)
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
# percent-of-slide coordinates (0100, slide-absolute), consistent with
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
parser.add_argument(
"--override-image",
dest="override_image_overrides",
action="append",
default=[],
metavar="IMAGE_ID=X,Y,W,H",
help=(
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0100, "
"slide-absolute) — typed client ImageOverride shape 와 일치. "
"multiple flags: --override-image img-abc=10,15,30,25 "
"--override-image img-def=50,15,40,40"
),
)
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
# bypasses the ``user_approved`` gate only (``visual_check_passed``
# is never bypassable). Source of truth is
@@ -5943,6 +6004,54 @@ if __name__ == "__main__":
_seen_sections_across_zones[sid] = zid
overrides_section_assignments[zid] = section_ids
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
# Mirrors --override-zone-geometry parsing pattern: each flag is
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
# client (u3 ImageOverride) both expect percent-of-slide values in
# 0100; the CLI accepts floats without range clamping here so the
# error remains the user's mistake to read rather than a silent shift.
overrides_images: dict[str, dict[str, float]] = {}
for ov in args.override_image_overrides:
if "=" not in ov:
print(
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
iid, vals = ov.split("=", 1)
iid = iid.strip()
if not iid:
print(
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
if iid in overrides_images:
print(
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
f"(first assignment kept). Provide each image only once.",
file=sys.stderr,
)
sys.exit(2)
parts = vals.split(",")
if len(parts) != 4:
print(
f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'",
file=sys.stderr,
)
sys.exit(2)
try:
x, y, w, h = (float(p) for p in parts)
except ValueError:
print(
f"[error] --override-image floats parse fail: '{vals}'",
file=sys.stderr,
)
sys.exit(2)
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
# After argparse fully parses CLI flags, fill ONLY the axes the user
# did NOT pass on the command line. CLI payload always wins over the
@@ -6013,6 +6122,30 @@ if __name__ == "__main__":
if _sids:
_accepted_sec[_zid] = _sids
overrides_section_assignments = _accepted_sec
# image_overrides — CLI empty → fill from file (dict[str, dict]).
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
if not overrides_images:
_file_images = _persisted.get("image_overrides")
if isinstance(_file_images, dict):
_accepted_img: dict[str, dict] = {}
for _iid, _g in _file_images.items():
if (
isinstance(_iid, str)
and _iid
and isinstance(_g, dict)
and all(k in _g for k in ("x", "y", "w", "h"))
):
try:
_accepted_img[_iid] = {
"x": float(_g["x"]),
"y": float(_g["y"]),
"w": float(_g["w"]),
"h": float(_g["h"]),
}
except (TypeError, ValueError):
continue
overrides_images = _accepted_img
run_phase_z2_mvp1(
args.mdx_path,
@@ -6021,4 +6154,5 @@ if __name__ == "__main__":
override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None,
override_section_assignments=overrides_section_assignments or None,
override_image_overrides=overrides_images or None,
)