From 2495af5f1e642c3d27d6ce26a9246121c0a08826 Mon Sep 17 00:00:00 2001 From: Alwan Juliawan Date: Thu, 30 Jul 2026 15:36:11 +0700 Subject: [PATCH 1/4] docs: design speaker-aware balloon tails --- ...7-30-speaker-aware-balloon-tails-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md diff --git a/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md b/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md new file mode 100644 index 0000000..b47fc2a --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md @@ -0,0 +1,227 @@ +# Speaker-Aware Balloon Tails Design + +**Date:** 2026-07-30 + +**Status:** Approved design, implementation pending + +**Target:** Comic Sol prerelease after `v2.0.0rc3` + +## Problem + +RC2 dogfood produced readable lettering but visually incorrect speech-balloon tails: + +- a device line pointed toward a human or empty space; +- human dialogue could terminate near a forehead or shoulder instead of the voice source; +- a short triangular wedge made the speaker ambiguous; +- page QA accepted approximate direction without proving the tail endpoint and speaker matched. + +The current storyboard contract stores only `tail_target: [x, y]`. That coordinate has no semantic relationship to `speaker`, no voice-source type, and no protected endpoint around a face. The renderer then clamps the target into a short triangular polygon. The data can therefore be numerically valid while the rendered result is narratively wrong. + +## Goals + +1. Make the intended voice source explicit and machine-validatable. +2. Render a smooth, tapered, print-comic tail that joins the balloon body without a notch or doubled outline. +3. Keep the tail short enough not to cross faces or focal action while still clearly indicating the speaker. +4. Prevent system status and non-spoken device output from receiving speech tails. +5. Bind rendered tail geometry to deterministic provenance and stale-output validation. +6. Make page QA fail closed when speaker attribution or tail attachment is ambiguous. +7. Preserve project readability and deterministic, local-only operation without adding a model, network call, or dependency. + +## Non-goals + +- Face or mouth detection. +- Automatic inference of a speaker from image pixels. +- Changes to image-generation providers or the exact 17-tool MCP surface. +- Rewriting authored dialogue or changing balloon placement automatically. +- Claiming that geometry tests alone prove visual quality. + +## Storyboard contract + +### Dialogue text item + +A newly authored dialogue item contains: + +```json +{ + "id": "p01-02-t01", + "kind": "dialogue", + "speaker": "raka", + "voice_source": "human", + "speaker_anchor": [0.31, 0.27], + "content": "Kalau begitu... aku ubah aturannya!", + "anchor": "top-right", + "priority": 1 +} +``` + +Rules: + +- `speaker` remains an entity ID present in both the character bible and the panel. A + speaking drone/device is represented as a character-bible entity; the renderer never + accepts an unbound free-form source name. +- `voice_source` is required and is one of `human` or `device`. +- `speaker_anchor` is required and contains two finite normalized coordinates in `[0, 1]`. +- For `human`, the anchor identifies the visible voice-source region near the mouth/face, not the character centroid, forehead, shoulder, hand, or arbitrary empty space. +- For spoken `device` dialogue, the anchor identifies the visible audio/source region of that device. Device status text that is not spoken uses a caption instead. +- Legacy `tail_target` remains readable for inspection and migration but is not accepted for new lettering or advancement. It is migrated explicitly to `speaker_anchor`; it is never silently reinterpreted. + +### Caption and SFX + +- `caption` and `sfx` keep `speaker: null`. +- They have neither `voice_source` nor `speaker_anchor`. +- System state such as `RC2: MODE AMAN.` is a caption and has no tail. +- SFX remains image-model-authored artwork and is not drawn by Pillow. + +### Migration behavior + +- Legacy storyboard data remains readable and produces a stable `balloon-tail-migration-required` validation issue at lettering and later stages. +- Planning/storyboard inspection can report the issue without corrupting or rewriting source data. +- A migration updates the semantic item, invalidates lettering and every downstream artifact, and preserves raw/clean panel images when dialogue content and generated SFX are unchanged. +- No migration guesses whether an old device line was spoken dialogue or a system caption; that decision must be explicit. + +## Validation + +Validation occurs before rendering and advancement. + +For every dialogue item: + +1. `speaker`, `voice_source`, and `speaker_anchor` are present and schema-valid. +2. The speaker exists in the character bible and appears in the panel. +3. The anchor lies inside the panel and outside the final fitted balloon ellipse plus an outline-safe margin. +4. The ray from balloon center to anchor intersects the ellipse exactly once in the outward direction. +5. The visible tail length after clamping is at least the minimum readable length and no more than the panel-relative maximum. +6. The tail tip remains inside the panel and stops before the authored anchor, leaving a protected gap around the voice source. +7. Caption/SFX items reject dialogue-only fields. + +The engine does not claim that an authored human anchor is anatomically correct from coordinates alone. That judgment remains a required visual-QA check. + +## Organic tail geometry + +The renderer replaces the triangular polygon with a closed cubic path represented by two tapered sides: + +- **Attachment:** the nearest point on the fitted ellipse along the ray toward `speaker_anchor`. +- **Base pair:** two nearby points sampled along the ellipse tangent, narrow enough to merge naturally into the body. +- **Tip:** a point on the attachment-to-anchor ray that stops before the anchor by a protected source gap. +- **Control points:** bend each side gradually from the ellipse tangent toward the tip; both sides converge without crossing. + +Deterministic constraints: + +- geometry uses only storyboard coordinates, fitted balloon bounds, and panel dimensions; +- tail length is clamped by balloon minor radius and panel size; +- base width scales with balloon outline and is narrower than the existing wedge; +- source gap scales with panel size and prevents the tail from touching a face/device source; +- control points are finite, remain within panel bounds, and produce a non-self-intersecting silhouette; +- the supersampled body and tail are rasterized into one mask before a single outline is derived, preventing seams and doubled edges; +- no random values, computer vision, or platform-specific drawing primitives are used. + +The public geometry helper returns a semantic record rather than an opaque tuple. The record includes: + +- `voice_source`; +- normalized `speaker_anchor`; +- pixel `attachment`, `base`, `control`, and `tip` points; +- source-gap, length, and width measurements; +- renderer policy version. + +## Provenance and invalidation + +Lettering geometry provenance records the new semantic fields and complete resolved geometry. Its canonical hash changes when any of these change: + +- speaker; +- voice source; +- speaker anchor; +- balloon placement or fitted bounds; +- panel dimensions; +- font/text geometry; +- renderer policy version. + +A mismatch makes lettering, composition, page QA, PDF verification, and report output stale. Raw and clean panel art remain reusable unless generated SFX or panel-generation inputs changed. + +## Page QA + +The page-QA evidence contract must make attribution explicit for each dialogue item. +The existing schema-2.0 `bubble-tail-direction` check retains its bounded evidence +shape; its `regions` array contains exactly one structured entry per rendered dialogue +item. Each entry records: + +- text item ID; +- declared speaker and voice source; +- observed balloon location; +- observed tail endpoint region; +- whether the endpoint clearly indicates the declared source; +- whether the tail avoids faces, text, and focal action; +- whether body-tail joining and outline thickness look continuous. + +A page cannot be marked reviewed when any dialogue tail: + +- points to the wrong speaker or empty space; +- ends at an implausible human region such as a forehead or shoulder; +- is detached, self-crossing, needle-like, wedge-like, or visibly notched; +- touches a face/source instead of stopping before it; +- is ambiguous between multiple possible speakers. + +The deterministic lettering geometry supplies the expected item IDs and endpoints, so +the reviewer cannot omit a dialogue item or invent an extra one. System captions are +checked for having no tail. Deterministic checks prove geometry mechanics; retained +visual evidence is still required for a visual-quality claim. + +## Error handling and transactions + +- Invalid semantics or geometry fail before publication with stable categories. +- Lettering remains transactional: no partial panel, geometry record, cache, or manifest update is retained on failure. +- A failed re-letter operation preserves the last valid panel and provenance. +- Resume reuses upstream artifacts only when their semantic hashes remain current. + +## Testing strategy + +### RED contracts + +Tests first reproduce the four RC2 failure classes: + +1. device/status text attributed toward a human or empty space; +2. human tail endpoint at a forehead/shoulder rather than the authored voice-source anchor; +3. ambiguous short wedge that cannot establish a speaker; +4. body-tail notch or doubled outline. + +### Deterministic tests + +- schema acceptance/rejection and legacy migration gate; +- ellipse intersection, source gap, clamping, finite control points, bounds, and no self-intersection; +- renderer produces a connected silhouette with one continuous outline; +- geometry hash changes for every semantic input; +- transactional rollback and stale downstream validation; +- caption/system text has no tail; +- cross-platform byte determinism where the existing rendering contract requires it. + +### Visual acceptance + +A retained local fixture renders representative human and device cases at production panel sizes. Acceptance requires side-by-side inspection showing: + +- correct speaker attribution; +- smooth body attachment; +- compact, tapered shape; +- consistent outline thickness; +- protected face/source gap; +- no text, face, or focal-action obstruction. + +The final report must distinguish deterministic mechanics evidence from visual review evidence. + +## Compatibility and release + +- Canonical engine remains under `scripts/*.py`; changed runtime files remain required wheel members. +- No new dependency, provider, network call, GUI, or MCP tool is introduced. +- Exact MCP surface remains 17 tools. +- Existing RC3 tags and artifacts remain immutable. +- Delivery uses a feature branch, focused and full regression, build/distribution proof, visual fixture evidence, PR, cross-platform CI, merge, and a new prerelease only after merged-commit verification. + +## Acceptance criteria + +The change is complete only when: + +1. legacy free-coordinate tails fail with the stable migration gate; +2. new dialogue cannot render without explicit speaker semantics; +3. system captions render without tails; +4. organic curve geometry passes deterministic invariants and provenance checks; +5. actual rendered fixtures pass documented visual inspection; +6. the four RC2 failure classes are covered by regression tests; +7. full tests, build, distribution validation, clean-install base/MCP, and exact 17-tool smoke pass; +8. cross-platform CI passes before merge and release. From e9c57a81b196d5ee6a1b855817f160e859f62321 Mon Sep 17 00:00:00 2001 From: Alwan Juliawan Date: Thu, 30 Jul 2026 15:37:44 +0700 Subject: [PATCH 2/4] docs: plan speaker-aware balloon tails --- ...aker-aware-balloon-tails-implementation.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-speaker-aware-balloon-tails-implementation.md diff --git a/docs/superpowers/plans/2026-07-30-speaker-aware-balloon-tails-implementation.md b/docs/superpowers/plans/2026-07-30-speaker-aware-balloon-tails-implementation.md new file mode 100644 index 0000000..5b12a68 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-speaker-aware-balloon-tails-implementation.md @@ -0,0 +1,174 @@ +# Speaker-Aware Balloon Tails Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers/subagent-driven-development (recommended) or superpowers/executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace ambiguous free-coordinate triangular balloon tails with validated speaker-aware semantics, deterministic organic geometry, hash-bound provenance, and fail-closed visual evidence. + +**Architecture:** Storyboard validation owns semantic correctness; `letter_panels.py` resolves one canonical `TailGeometry` record and rasterizes its cubic silhouette into the existing supersampled balloon mask; lettering provenance binds that record; page quality cross-checks reviewer regions against current geometry. Legacy `tail_target` remains parseable but blocks lettering and later stages with `balloon-tail-migration-required`. + +**Tech Stack:** Python 3.11+, Pillow, `unittest`, canonical JSON/SHA-256, existing transactional project I/O. + +## Global Constraints + +- Canonical engine remains `scripts/*.py`; runtime files changed here remain required wheel members. +- No new dependency, provider, network call, GUI, or MCP tool. +- Exact MCP surface remains 17 tools. +- New dialogue requires `speaker`, `voice_source`, and normalized `speaker_anchor`. +- `voice_source` is exactly `human` or `device`; device entities still resolve through the character bible and current panel. +- Captions and SFX have no dialogue-only fields or tail. +- Legacy `tail_target` is never silently reinterpreted and produces `balloon-tail-migration-required` at lettering and later stages. +- Deterministic mechanics evidence must not be presented as visual-quality proof. +- Existing `v2.0.0rc3` tag and artifacts remain immutable. + +--- + +### Task 1: Storyboard semantics and migration boundary + +**Files:** +- Modify: `scripts/validate_project.py:410-465` +- Modify: `scripts/letter_panels.py:675-805` +- Modify: `references/schemas.md:178-197` +- Modify: `references/workflow.md:77-83,136-142` +- Modify: `tests/test_validation.py` +- Modify: `tests/test_lettering.py` +- Modify: synthetic/committed storyboard fixtures that represent current valid projects + +**Interfaces:** +- Consumes: text item dictionaries from `plan/storyboard.json`. +- Produces: validated fields `voice_source: Literal["human", "device"]` and `speaker_anchor: list[number, number]`; stable migration category `balloon-tail-migration-required`. + +- [ ] **Step 1: Write RED schema tests** + +Add tests proving: current dialogue accepts exactly the new fields; missing/invalid fields fail; legacy `tail_target` yields the migration category; caption/SFX reject all dialogue-only fields; a device speaker must resolve in the character bible and current panel. + +- [ ] **Step 2: Verify RED** + +Run: `/home/acer/.venvs/comic-sol-mcp/bin/python -m unittest tests.test_validation tests.test_lettering -v` + +Expected: failures because the validator only allows `tail_target` and lettering does not require source semantics. + +- [ ] **Step 3: Implement the minimal semantic validator and lettering preflight** + +Use one exact field set per kind. Do not infer or copy `tail_target` into `speaker_anchor`. Raise `ValueError("balloon-tail-migration-required: ...")` from direct lettering when legacy input reaches the renderer. + +- [ ] **Step 4: Migrate valid repository fixtures explicitly** + +For human fixture dialogue use `voice_source: "human"` and preserve the fixture’s intentional normalized endpoint as `speaker_anchor`; remove `tail_target`. Captions/SFX omit all dialogue-only fields. Keep dedicated legacy fixtures unchanged. + +- [ ] **Step 5: Verify GREEN and commit** + +Run the same command; expected all selected tests pass. Commit only schema, fixtures, tests, and docs for this slice. + +### Task 2: Deterministic organic-tail geometry and rasterization + +**Files:** +- Modify: `scripts/letter_panels.py:540-645,675-891` +- Modify: `tests/test_lettering.py` +- Create: `tests/fixtures/balloon-tails/README.md` + +**Interfaces:** +- Produces: `_organic_tail_geometry(rect, speaker_anchor, image_width, image_height, voice_source) -> dict[str, object]`. +- Geometry keys: `policy_version`, `voice_source`, `speaker_anchor`, `attachment`, `base`, `control`, `tip`, `source_gap`, `length`, `width`. +- `_draw_antialiased_balloon(..., tail_geometry | None)` rasterizes a closed cubic silhouette and balloon ellipse into one mask. + +- [ ] **Step 1: Write RED geometry tests** + +Assert finite/bounded points, one outward ellipse intersection, protected source gap, compact length, narrow base, non-crossing sides, semantic record shape, stable repeated result, and rejection when the anchor lies inside the fitted ellipse. + +- [ ] **Step 2: Write RED raster tests** + +Render onto a contrasting panel and assert one connected white silhouette, no interior dark seam, continuous outline around the join, caption with no tail, and a visibly tapered tip rather than the old broad wedge ratio. + +- [ ] **Step 3: Verify RED** + +Run: `/home/acer/.venvs/comic-sol-mcp/bin/python -m unittest tests.test_lettering -v` + +Expected: imports/assertions fail because organic geometry is absent and old wedge behavior remains. + +- [ ] **Step 4: Implement minimal cubic geometry** + +Resolve the ellipse attachment analytically. Clamp tip distance, reserve a panel-relative source gap, derive tangent-aligned base points, and derive two cubic control-point pairs. Reject invalid or self-crossing geometry before drawing. + +- [ ] **Step 5: Rasterize one merged supersampled silhouette** + +Sample each cubic side deterministically into the local mask, close the path through the base, union it with the ellipse, derive one outline from the merged mask, then downsample with LANCZOS. + +- [ ] **Step 6: Verify GREEN and commit** + +Run lettering plus typography tests; expected all pass and deterministic fixture hashes remain stable where semantics are unchanged. + +### Task 3: Provenance, staleness, and transactional behavior + +**Files:** +- Modify: `scripts/letter_panels.py:859-1005` +- Modify: `scripts/validate_project.py:950-1060,1627-1665` +- Modify: `scripts/typography.py:52-67` only if canonical hashing needs a versioned record assertion +- Modify: `tests/test_typography.py` +- Modify: `tests/test_finalization.py` +- Modify: `tests/test_resume.py` or current resume-contract test module + +**Interfaces:** +- Lettering geometry item `tail` stores the complete semantic geometry record from Task 2. +- Lettering geometry schema advances to `2.0`; schema `1.0` remains readable but blocks downstream advancement with `balloon-tail-migration-required`. + +- [ ] **Step 1: Write RED provenance/staleness tests** + +Prove the geometry hash changes for speaker, voice source, anchor, fitted box, dimensions, and policy version; old geometry blocks finalization; changing an anchor invalidates lettering and downstream cache/page/PDF while preserving raw/clean panels. + +- [ ] **Step 2: Write RED rollback/resume tests** + +Force invalid geometry during re-lettering and prove the prior lettered PNG, geometry, cache, and manifest bytes remain unchanged. Prove a no-change resume reuses current geometry. + +- [ ] **Step 3: Verify RED, implement, and verify GREEN** + +Run targeted typography/finalization/resume modules. Update geometry validation fail-closed and preserve existing `ProjectTransaction` boundaries. Commit this slice. + +### Task 4: Bounded page-QA attribution evidence + +**Files:** +- Modify: `scripts/page_quality.py:122-259` +- Modify: `scripts/quality_records.py` only if stricter region validation belongs in the shared record validator +- Modify: `tests/test_page_quality.py` +- Modify: `references/schemas.md:257 onward` +- Modify: `references/workflow.md` page-review instructions + +**Interfaces:** +- `bubble-tail-direction.regions` contains exactly one entry per dialogue geometry item. +- Required region keys: `panel_id`, `item_id`, `speaker`, `voice_source`, `balloon_region`, `observed_endpoint_region`, `points_to_declared_source`, `avoids_face_text_action`, `continuous_join`. + +- [ ] **Step 1: Write RED bounded-evidence tests** + +Reject missing/extra/duplicate item regions, wrong speaker/source, generic evidence, false booleans paired with `pass`, and any caption reported as a tailed dialogue. Accept exact item-complete reviewer evidence bound to current lettering hashes. + +- [ ] **Step 2: Verify RED, implement cross-checking, and verify GREEN** + +Derive expected IDs and semantics from current lettering geometry. Preserve subjective reviewer ownership; do not infer visual pass from deterministic points. Run page-quality/finalization tests and commit. + +### Task 5: Visual proof, docs, packaging, and delivery + +**Files:** +- Create: deterministic local render fixture/output under a temporary test directory; commit only compact source/README unless an approved golden artifact is required +- Modify: `SKILL.md` +- Modify: `references/workflow.md` +- Modify: `references/schemas.md` +- Modify: relevant release/package member contracts only when runtime membership changes + +**Interfaces:** +- A local-only fixture command renders human-left/right and device-source cases at production panel sizes. +- Visual evidence is retained separately from deterministic mechanics disclosure. + +- [ ] **Step 1: Render actual before/after fixture evidence** + +Render the old wedge using the RC3 tag into a temporary baseline and the branch result from identical panel/text inputs. Inspect at full resolution for attribution, smooth join, taper, outline consistency, source gap, and obstruction. + +- [ ] **Step 2: Iterate only through new RED visual-mechanics regressions** + +If inspection exposes a deterministic defect, first encode the mechanical failure as a test, confirm RED, then adjust one geometry variable and rerender. Subjective preference alone is documented rather than fabricated as a deterministic assertion. + +- [ ] **Step 3: Run focused and full verification** + +Run lettering, validation, typography, page quality, finalization, resume, MCP, and clean-install suites; then full `unittest discover`, `compileall`, build wheel/sdist, `python -m comic_sol_product.release dist/*`, clean-install base/MCP, exact 17-tool smoke, and `git diff --check`. + +- [ ] **Step 4: Deliver through review gates** + +Commit specific files, push feature branch, open PR to `ai/post-event-development`, require all cross-platform checks, squash merge, rerun merged-commit acceptance, and publish a new prerelease only after all release identity/contracts and public asset verification pass. From 251852c3dd94d126f6e67a48431da150a7325125 Mon Sep 17 00:00:00 2001 From: Alwan Juliawan Date: Thu, 30 Jul 2026 15:47:10 +0700 Subject: [PATCH 3/4] feat: require speaker-aware balloon semantics --- references/schemas.md | 7 ++- references/workflow.md | 13 ++-- scripts/letter_panels.py | 37 +++++++++--- scripts/validate_project.py | 52 ++++++++++++---- .../interrupted-two-page/plan/storyboard.json | 6 +- .../valid-one-page/plan/storyboard.json | 11 ++-- tests/test_lettering.py | 48 ++++++++++----- tests/test_validation.py | 60 ++++++++++++++++++- 8 files changed, 180 insertions(+), 54 deletions(-) diff --git a/references/schemas.md b/references/schemas.md index f2fb2ea..d21b14b 100644 --- a/references/schemas.md +++ b/references/schemas.md @@ -181,13 +181,14 @@ Each `continuity` entry is `:`, where `owner-id` is a character |---|---|---| | `id` | ID | Unique project-wide text ID | | `kind` | enum | `dialogue`, `caption`, or `sfx` | -| `speaker` | ID or null | Required for dialogue; null for caption/SFX | +| `speaker` | ID or null | Required for dialogue and resolves to an entity present in the character bible and panel; null for caption/SFX | +| `voice_source` | enum or omitted | Dialogue only: exactly `human` or `device` | +| `speaker_anchor` | array[number] or omitted | Dialogue only: finite normalized `[x,y]` coordinates for the visible voice-source region | | `content` | string | NFC-normalized; dialogue ≤32 words, caption ≤45, SFX ≤3 | | `anchor` | enum | One of eight anchors below | -| `tail_target` | array[number] or null | Dialogue `[x,y]`, each normalized 0–1; otherwise null | | `priority` | integer | Positive placement order; ties break by item ID | -Anchors are `top-left`, `top-center`, `top-right`, `middle-left`, `middle-right`, `bottom-left`, `bottom-center`, and `bottom-right`. `anchor` places every text item, captions included. Control characters other than newline are invalid. Explicit newlines are optional wrapping hints. Authored punctuation and words are not rewritten by deterministic scripts. +Anchors are `top-left`, `top-center`, `top-right`, `middle-left`, `middle-right`, `bottom-left`, `bottom-center`, and `bottom-right`. `anchor` places every text item, captions included. Human `speaker_anchor` identifies the visible mouth/face voice-source region; spoken devices use `voice_source: device` and anchor their visible audio source. Non-spoken system status is a caption and has no tail. Captions and SFX omit `voice_source` and `speaker_anchor`. Legacy `tail_target` remains readable but produces `balloon-tail-migration-required` at lettering and later stages; it is never silently reinterpreted. Control characters other than newline are invalid. Explicit newlines are optional wrapping hints. Authored punctuation and words are not rewritten by deterministic scripts. The word limits are a ceiling, not a guarantee of fit. Dialogue is inscribed in an oval, which holds roughly half the text of the rectangle bounding it, and an anchor area is about 42% of panel width by 30% of panel height. A 32-word line needs a panel of roughly 1000x1200 px or larger; a 720x1064 panel holds about 14 words. Lettering fails with `text item {id} does not fit inside the panel` rather than printing over the artwork, so size dialogue to the panel rectangle the storyboard assigns it. diff --git a/references/workflow.md b/references/workflow.md index fa11d0d..10788d0 100644 --- a/references/workflow.md +++ b/references/workflow.md @@ -77,10 +77,15 @@ semantic content, then `comic_sol.py transition PROJECT_DIR PLANNED`. ### 3. Script and storyboard Write dialogue, captions, exact SFX, pacing, camera, light, continuity, fixed layouts, -and absolute rectangles to `plan/storyboard.json`. SFX is authored artwork content; -dialogue and captions are deterministic lettering content. Transition through -`SCRIPTED`, validate with `validate_project.py PROJECT_DIR --stage storyboard`, then -transition to `STORYBOARDED`. +and absolute rectangles to `plan/storyboard.json`. Every dialogue identifies its +character-bible `speaker`, sets `voice_source` to `human` or `device`, and places a +normalized `speaker_anchor` on the visible mouth/face or device audio-source region. +Non-spoken system status is a caption with no tail; captions and SFX omit dialogue-only +fields. Legacy `tail_target` is readable but blocks lettering with +`balloon-tail-migration-required` and must be migrated explicitly. SFX is authored +artwork content; dialogue and captions are deterministic lettering content. Transition +through `SCRIPTED`, validate with `validate_project.py PROJECT_DIR --stage storyboard`, +then transition to `STORYBOARDED`. ### 4. Detect image capability diff --git a/scripts/letter_panels.py b/scripts/letter_panels.py index 7b55209..37fc8ba 100644 --- a/scripts/letter_panels.py +++ b/scripts/letter_panels.py @@ -713,7 +713,7 @@ def render_text_item( text_y = y0 + max(0, (bounded["height"] - text_height) / 2) if kind == "dialogue": - tail = item.get("tail_target") + tail = item.get("speaker_anchor") if isinstance(tail, list) and len(tail) == 2 and all(isinstance(value, (int, float)) for value in tail): tail_polygon = _ellipse_tail_polygon( bounded | {"x": x0, "y": y0}, tail, image_width, image_height @@ -782,6 +782,32 @@ def letter_panel( for item in ordered: if item.get("kind") == "dialogue" and not _known_character(character_bible, item.get("speaker")): raise ValueError(f"unknown dialogue character: {item.get('speaker')}") + if item.get("kind") == "dialogue": + if "tail_target" in item: + raise ValueError( + "balloon-tail-migration-required: replace tail_target with " + "explicit voice_source and speaker_anchor" + ) + if item.get("voice_source") not in {"human", "device"}: + raise ValueError( + f"text item {item.get('id', 'unknown')} voice_source must be human or device" + ) + speaker_anchor = item.get("speaker_anchor") + if ( + not isinstance(speaker_anchor, list) + or len(speaker_anchor) != 2 + or any( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or not 0 <= value <= 1 + for value in speaker_anchor + ) + ): + raise ValueError( + f"text item {item.get('id', 'unknown')} speaker_anchor must be " + "finite normalized coordinates" + ) content = normalize_content(item.get("content", "")) limit = {"dialogue": 32, "caption": 45, "sfx": 3}.get(item.get("kind")) if limit is None: @@ -791,13 +817,6 @@ def letter_panel( anchor = item.get("anchor", "top-left") if anchor not in ANCHORS: raise ValueError(f"text item {item.get('id', 'unknown')} has unknown anchor") - tail = item.get("tail_target") - if isinstance(tail, list) and any( - isinstance(value, float) and not math.isfinite(value) for value in tail - ): - raise ValueError( - f"text item {item.get('id', 'unknown')} has a non-finite tail target" - ) item["content"] = content rendered_text_count = sum(item.get("kind") != "sfx" for item in ordered) @@ -857,7 +876,7 @@ def letter_panel( for run_text, run_font in _styled_font_runs(display, font) ] tail_geometry = None - tail = item.get("tail_target") + tail = item.get("speaker_anchor") if ( item.get("kind") == "dialogue" and isinstance(tail, list) diff --git a/scripts/validate_project.py b/scripts/validate_project.py index bd1a5fe..0c8eb25 100644 --- a/scripts/validate_project.py +++ b/scripts/validate_project.py @@ -418,8 +418,9 @@ def _validate_text_item( path: str, prefix: str, ) -> int: - fields = {"id", "kind", "speaker", "content", "anchor", "tail_target", "priority"} - item = _object(value, fields, fields, issues, path, prefix) + base_fields = {"id", "kind", "speaker", "content", "anchor", "priority"} + dialogue_fields = {"voice_source", "speaker_anchor", "tail_target"} + item = _object(value, base_fields | dialogue_fields, base_fields, issues, path, prefix) if item is None: return 0 _identifier(item.get("id"), issues, path, f"{prefix}.id") @@ -448,16 +449,45 @@ def _validate_text_item( _add(issues, path, f"{prefix}.speaker", "must be null for caption and sfx") if item.get("anchor") not in ANCHORS: _add(issues, path, f"{prefix}.anchor", "unknown text anchor") - tail = item.get("tail_target") + has_legacy_tail = "tail_target" in item + has_voice_source = "voice_source" in item + has_speaker_anchor = "speaker_anchor" in item if kind == "dialogue": - if ( - not isinstance(tail, list) - or len(tail) != 2 - or any(isinstance(number, bool) or not isinstance(number, (int, float)) or not 0 <= number <= 1 for number in tail) - ): - _add(issues, path, f"{prefix}.tail_target", "must be normalized [x, y] coordinates") - elif tail is not None: - _add(issues, path, f"{prefix}.tail_target", "must be null for caption and sfx") + if has_legacy_tail: + _add( + issues, + path, + f"{prefix}.tail_target", + "balloon-tail-migration-required: replace tail_target with explicit voice_source and speaker_anchor", + ) + else: + if item.get("voice_source") not in {"human", "device"}: + _add(issues, path, f"{prefix}.voice_source", "must be human or device") + speaker_anchor = item.get("speaker_anchor") + if ( + not isinstance(speaker_anchor, list) + or len(speaker_anchor) != 2 + or any( + isinstance(number, bool) + or not isinstance(number, (int, float)) + or not math.isfinite(float(number)) + or not 0 <= number <= 1 + for number in speaker_anchor + ) + ): + _add( + issues, + path, + f"{prefix}.speaker_anchor", + "must be finite normalized [x, y] coordinates", + ) + else: + if has_legacy_tail: + _add(issues, path, f"{prefix}.tail_target", "must be omitted for caption and sfx") + if has_voice_source: + _add(issues, path, f"{prefix}.voice_source", "must be omitted for caption and sfx") + if has_speaker_anchor: + _add(issues, path, f"{prefix}.speaker_anchor", "must be omitted for caption and sfx") _integer(item.get("priority"), 1, 1_000_000, issues, path, f"{prefix}.priority") return word_count diff --git a/tests/fixtures/interrupted-two-page/plan/storyboard.json b/tests/fixtures/interrupted-two-page/plan/storyboard.json index 00c8a9e..159bbed 100644 --- a/tests/fixtures/interrupted-two-page/plan/storyboard.json +++ b/tests/fixtures/interrupted-two-page/plan/storyboard.json @@ -38,8 +38,7 @@ "id": "p01-01-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] } @@ -83,8 +82,7 @@ "id": "p01-02-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] } diff --git a/tests/fixtures/valid-one-page/plan/storyboard.json b/tests/fixtures/valid-one-page/plan/storyboard.json index 26bbf4f..fa4c2ad 100644 --- a/tests/fixtures/valid-one-page/plan/storyboard.json +++ b/tests/fixtures/valid-one-page/plan/storyboard.json @@ -38,8 +38,7 @@ "id": "p01-01-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] }, @@ -78,10 +77,11 @@ "kind": "dialogue", "priority": 1, "speaker": "mira", - "tail_target": [ + "speaker_anchor": [ 0.7, 0.55 - ] + ], + "voice_source": "human" } ] }, @@ -119,8 +119,7 @@ "id": "p01-03-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] } diff --git a/tests/test_lettering.py b/tests/test_lettering.py index b168e42..db62f5e 100644 --- a/tests/test_lettering.py +++ b/tests/test_lettering.py @@ -36,7 +36,8 @@ def dialogue(content="Keep moving.", priority=1, anchor="top-left"): return { "id": f"dialogue-{priority}", "kind": "dialogue", "speaker": "mira", - "content": content, "anchor": anchor, "tail_target": [0.75, 0.7], + "content": content, "anchor": anchor, "voice_source": "human", + "speaker_anchor": [0.75, 0.7], "priority": priority, } @@ -44,7 +45,7 @@ def dialogue(content="Keep moving.", priority=1, anchor="top-left"): def caption(content="Below the city, daylight became a delivery.", priority=1, anchor="bottom-right"): return { "id": f"caption-{priority}", "kind": "caption", "speaker": None, - "content": content, "anchor": anchor, "tail_target": None, + "content": content, "anchor": anchor, "priority": priority, } @@ -52,7 +53,7 @@ def caption(content="Below the city, daylight became a delivery.", priority=1, a def sfx(content="KRAK!", priority=1, anchor="middle-right"): return { "id": f"sfx-{priority}", "kind": "sfx", "speaker": None, - "content": content, "anchor": anchor, "tail_target": None, + "content": content, "anchor": anchor, "priority": priority, } @@ -73,6 +74,25 @@ def test_normalize_content_and_word_count(self): self.assertEqual(3, normalized_word_count(" one\t two\nthree ")) self.assertEqual("Wait... — now!", normalize_content("Wait... — now!")) + def test_direct_lettering_rejects_legacy_free_coordinate_tail(self): + item = dialogue() + item.pop("voice_source") + item.pop("speaker_anchor") + item["tail_target"] = [0.75, 0.7] + with self.assertRaisesRegex(ValueError, "balloon-tail-migration-required"): + letter_panel(str(self.panel), 800, 1000, [item], self.characters) + + def test_direct_lettering_requires_explicit_speaker_semantics(self): + item = dialogue() + item.pop("voice_source") + item.pop("speaker_anchor") + with self.assertRaisesRegex(ValueError, "voice_source"): + letter_panel(str(self.panel), 800, 1000, [item], self.characters) + + item["voice_source"] = "human" + with self.assertRaisesRegex(ValueError, "speaker_anchor"): + letter_panel(str(self.panel), 800, 1000, [item], self.characters) + def test_emphasis_parsing(self): from letter_panels import _parse_emphasis @@ -150,7 +170,7 @@ def rendered_ink(content): image = Image.new("RGB", (480, 220), (28, 32, 40)) draw = ImageDraw.Draw(image) item = dialogue(content) - item["tail_target"] = None + item["speaker_anchor"] = None render_text_item( draw, item, @@ -280,7 +300,7 @@ def test_styled_layout_wraps_visible_runs_and_centers_each_line(self): centered_image = Image.new("RGB", (500, 260), (28, 32, 40)) centered_draw = ImageDraw.Draw(centered_image) centered_item = dialogue("I\n**WIDE**") - centered_item["tail_target"] = None + centered_item["speaker_anchor"] = None rect = {"x": 20, "y": 20, "width": 460, "height": 220} centered_layout = _layout_styled_text( centered_draw, @@ -374,8 +394,8 @@ def test_dialogue_tail_attachment_has_no_internal_seam(self): draw = ImageDraw.Draw(image) rect = {"x": 40, "y": 30, "width": 120, "height": 70} item = dialogue("Hi") - item["tail_target"] = [0.8, 0.8] - base_one, base_two, _ = _ellipse_tail_polygon(rect, item["tail_target"], 240, 240) + item["speaker_anchor"] = [0.8, 0.8] + base_one, base_two, _ = _ellipse_tail_polygon(rect, item["speaker_anchor"], 240, 240) attachment = tuple(round((first + second) / 2) for first, second in zip(base_one, base_two)) render_text_item( @@ -543,7 +563,7 @@ def test_balloon_circumscribes_a_maximum_length_dialogue_block(self): ) item = dialogue(MAXIMUM_DIALOGUE) - item["tail_target"] = None + item["speaker_anchor"] = None self.assertEqual(32, normalized_word_count(item["content"])) image = Image.new("RGB", (1200, 1600), (28, 32, 40)) draw = ImageDraw.Draw(image, "RGBA") @@ -607,14 +627,14 @@ def test_caption_honors_its_authored_anchor(self): self.assertLessEqual(box[3], 962) self.assertAlmostEqual(400, (box[0] + box[2]) / 2, delta=2) - def test_non_finite_tail_target_is_rejected_as_value_error(self): + def test_non_finite_speaker_anchor_is_rejected_as_value_error(self): from letter_panels import _ellipse_tail_polygon before = self.panel.read_bytes() for value in (float("inf"), float("-inf"), float("nan")): item = dialogue() - item["tail_target"] = [value, 0.5] - with self.assertRaisesRegex(ValueError, "non-finite tail target"): + item["speaker_anchor"] = [value, 0.5] + with self.assertRaisesRegex(ValueError, "speaker_anchor"): letter_panel(str(self.panel), 800, 1000, [item], self.characters) self.assertEqual(before, self.panel.read_bytes()) with self.assertRaisesRegex(ValueError, "finite"): @@ -900,7 +920,7 @@ def test_cli_rejects_missing_font_override_without_traceback(self): self.assertIn("font", errors.getvalue().lower()) self.assertNotIn("Traceback", errors.getvalue()) - def test_cli_rejects_non_finite_tail_target_without_traceback(self): + def test_cli_rejects_non_finite_speaker_anchor_without_traceback(self): import contextlib import io @@ -911,7 +931,7 @@ def test_cli_rejects_non_finite_tail_target_without_traceback(self): shutil.copytree(FIXTURES / "valid-one-page", project) storyboard_path = project / "plan/storyboard.json" storyboard = json.loads(storyboard_path.read_text("utf-8")) - storyboard["pages"][0]["panels"][1]["text"][0]["tail_target"] = [ + storyboard["pages"][0]["panels"][1]["text"][0]["speaker_anchor"] = [ float("inf"), 0.55, ] storyboard_path.write_text(json.dumps(storyboard), "utf-8") @@ -920,7 +940,7 @@ def test_cli_rejects_non_finite_tail_target_without_traceback(self): with contextlib.redirect_stderr(errors): self.assertEqual(1, letter_main([str(project)])) self.assertTrue(errors.getvalue().startswith("ERROR ValueError:")) - self.assertIn("tail target", errors.getvalue()) + self.assertIn("speaker_anchor", errors.getvalue()) self.assertNotIn("Traceback", errors.getvalue()) def test_cli_rejects_oversized_panel_image_without_traceback(self): diff --git a/tests/test_validation.py b/tests/test_validation.py index e907eeb..b9f9d3d 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -109,7 +109,8 @@ def valid_storyboard(): "text": [{ "id": "p01-01-t01", "kind": "dialogue", "speaker": "mira", "content": "I have one delivery left.", "anchor": "top-left", - "tail_target": [0.7, 0.5], "priority": 1, + "voice_source": "human", "speaker_anchor": [0.7, 0.5], + "priority": 1, }], }], }], @@ -341,13 +342,64 @@ def test_storyboard_rejects_references_text_and_geometry(self): data = valid_storyboard(); data["pages"][0]["panels"][0]["text"][0]["speaker"] = "ghost"; cases.append((data, "speaker")) data = valid_storyboard(); data["pages"][0]["panels"][0]["text"][0]["content"] = "word " * 33; cases.append((data, "content")) data = valid_storyboard(); data["pages"][0]["panels"][0]["text"][0]["anchor"] = "center"; cases.append((data, "anchor")) - data = valid_storyboard(); data["pages"][0]["panels"][0]["text"][0]["tail_target"] = [1.2, 0.5]; cases.append((data, "tail_target")) + data = valid_storyboard(); data["pages"][0]["panels"][0]["text"][0]["speaker_anchor"] = [1.2, 0.5]; cases.append((data, "speaker_anchor")) data = valid_storyboard(); data["pages"][0]["panels"][0]["rect"]["x"] = 0; cases.append((data, "rect")) for data, field in cases: with self.subTest(field=field): self.assert_issue(validate_storyboard(data, story, characters), field) self.assertEqual([], validate_storyboard(valid_storyboard(), story, characters)) + def test_storyboard_requires_speaker_aware_dialogue_semantics(self): + story, characters = valid_story(), valid_characters() + current = valid_storyboard() + text = current["pages"][0]["panels"][0]["text"][0] + text.update({"voice_source": "human", "speaker_anchor": [0.7, 0.5]}) + self.assertEqual([], validate_storyboard(current, story, characters)) + + cases = [] + for field in ("voice_source", "speaker_anchor"): + data = deepcopy(current) + data["pages"][0]["panels"][0]["text"][0].pop(field) + cases.append((data, field)) + data = deepcopy(current) + data["pages"][0]["panels"][0]["text"][0]["voice_source"] = "system" + cases.append((data, "voice_source")) + data = deepcopy(current) + data["pages"][0]["panels"][0]["text"][0]["speaker_anchor"] = [0.7, True] + cases.append((data, "speaker_anchor")) + for data, field in cases: + with self.subTest(field=field): + self.assert_issue(validate_storyboard(data, story, characters), field) + + def test_storyboard_reports_legacy_tail_migration_and_tail_free_captions(self): + story, characters = valid_story(), valid_characters() + legacy = valid_storyboard() + legacy_item = legacy["pages"][0]["panels"][0]["text"][0] + legacy_item.pop("voice_source") + legacy_item.pop("speaker_anchor") + legacy_item["tail_target"] = [0.7, 0.5] + legacy_issues = validate_storyboard(legacy, story, characters) + self.assertTrue(any( + "balloon-tail-migration-required" in issue.message + for issue in legacy_issues + )) + + for kind in ("caption", "sfx"): + data = valid_storyboard() + item = data["pages"][0]["panels"][0]["text"][0] + item.update({ + "kind": kind, + "speaker": None, + "content": "System status." if kind == "caption" else "KRAK!", + }) + item.pop("voice_source") + item.pop("speaker_anchor") + self.assertEqual([], validate_storyboard(data, story, characters)) + item.update({"voice_source": "human", "speaker_anchor": [0.7, 0.5]}) + issues = validate_storyboard(data, story, characters) + self.assert_issue(issues, "voice_source") + self.assert_issue(issues, "speaker_anchor") + def test_storyboard_rejects_page_panel_and_text_limits(self): story, characters = valid_story(), valid_characters() data = valid_storyboard() @@ -381,7 +433,9 @@ def test_storyboard_rejects_page_panel_and_text_limits(self): for kind, limit in (("caption", 45), ("sfx", 3)): data = valid_storyboard(); text = data["pages"][0]["panels"][0]["text"][0] - text.update({"kind": kind, "speaker": None, "tail_target": None, "content": "word " * (limit + 1)}) + text.update({"kind": kind, "speaker": None, "content": "word " * (limit + 1)}) + text.pop("voice_source") + text.pop("speaker_anchor") with self.subTest(kind=kind): self.assert_issue(validate_storyboard(data, story, characters), "content") From f6731a144d68f8c2fa6994395f6ed980235fd7fe Mon Sep 17 00:00:00 2001 From: Alwan Juliawan Date: Thu, 30 Jul 2026 16:35:32 +0700 Subject: [PATCH 4/4] feat: add speaker-aware organic balloon tails --- .github/workflows/release.yml | 16 +- CHANGELOG.md | 20 ++ README.md | 4 +- comic_sol_product/__init__.py | 2 +- comic_sol_product/distribution.py | 2 +- compose.yaml | 2 +- docs/install.md | 22 +- docs/releases/v2.0.0rc4.md | 38 +++ ...7-30-speaker-aware-balloon-tails-design.md | 24 +- pyproject.toml | 2 +- references/schemas.md | 2 + references/visual-qa.md | 9 +- samples/sunlight-courier/plan/storyboard.json | 14 +- samples/sunlight-courier/project.json | 2 +- scripts/assemble_release.py | 2 +- scripts/letter_panels.py | 226 +++++++++++++----- scripts/page_quality.py | 90 +++++++ scripts/validate_project.py | 63 ++++- tests/support.py | 28 +++ tests/test_distribution.py | 24 +- tests/test_finalization.py | 9 +- tests/test_lettering.py | 113 +++++---- tests/test_mcp_server.py | 8 +- tests/test_page_quality.py | 62 ++++- tests/test_product_cli.py | 2 +- tests/test_quality_matrix.py | 10 +- tests/test_release_docs.py | 6 +- tests/test_typography.py | 26 +- 28 files changed, 619 insertions(+), 209 deletions(-) create mode 100644 docs/releases/v2.0.0rc4.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f274e6a..55b3699 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Native Release on: workflow_dispatch: push: - tags: [ 'v2.0.0rc3' ] + tags: [ 'v2.0.0rc4' ] permissions: contents: read @@ -54,9 +54,9 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build OCI image - run: docker build -t comic-sol:2.0.0rc3 . + run: docker build -t comic-sol:2.0.0rc4 . - name: Smoke OCI image - run: docker run --rm --entrypoint comic-sol comic-sol:2.0.0rc3 doctor --output-root /data/doctor + run: docker run --rm --entrypoint comic-sol comic-sol:2.0.0rc4 doctor --output-root /data/doctor source: runs-on: ubuntu-latest @@ -82,7 +82,7 @@ jobs: publish: needs: [native, container, source] runs-on: ubuntu-latest - if: github.ref == 'refs/tags/v2.0.0rc3' + if: github.ref == 'refs/tags/v2.0.0rc4' permissions: contents: write env: @@ -108,7 +108,7 @@ jobs: records = [] for platform in ('linux', 'macos', 'windows'): bundle = root / f'comic-sol-{platform}-x86_64' - verify_release_directory(bundle, ReleaseIdentity('2.0.0rc3', platform, 'x86_64')) + verify_release_directory(bundle, ReleaseIdentity('2.0.0rc4', platform, 'x86_64')) for path in sorted(bundle.iterdir()): if path.name == 'SHA256SUMS': continue @@ -121,6 +121,6 @@ jobs: - name: Publish immutable prerelease assets shell: bash run: | - gh release create v2.0.0rc3 --repo "${GITHUB_REPOSITORY}" --verify-tag --prerelease --title 'Comic Sol v2.0.0rc3' --notes-file docs/releases/v2.0.0rc3.md - find bundles -type f ! -name SHA256SUMS -print0 | xargs -0 gh release upload v2.0.0rc3 --repo "${GITHUB_REPOSITORY}" --clobber - gh release upload v2.0.0rc3 bundles/SHA256SUMS --repo "${GITHUB_REPOSITORY}" --clobber + gh release create v2.0.0rc4 --repo "${GITHUB_REPOSITORY}" --verify-tag --prerelease --title 'Comic Sol v2.0.0rc4' --notes-file docs/releases/v2.0.0rc4.md + find bundles -type f ! -name SHA256SUMS -print0 | xargs -0 gh release upload v2.0.0rc4 --repo "${GITHUB_REPOSITORY}" --clobber + gh release upload v2.0.0rc4 bundles/SHA256SUMS --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 540d7a2..59d674b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 2.0.0rc4 — 2026-07-30 + +Speaker-aware balloon-tail prerelease. + +### Added + +- Validated `speaker`, `voice_source`, and `speaker_anchor` semantics for every dialogue tail. +- Deterministic `organic-cubic-v1` geometry provenance and artifact-bound per-dialogue page-QA regions. + +### Fixed + +- Replaced short triangular wedge tails with merged-body, non-crossing cubic silhouettes that preserve a durable white core and point toward the declared voice source. +- Captions, system status, and SFX now fail closed if dialogue-only tail fields are present. +- Legacy free-coordinate `tail_target` inputs require explicit migration instead of silent reinterpretation. + +### Distribution and limitations + +- Linux, macOS, and Windows x86_64 bundles, CycloneDX SBOMs, and SHA-256 manifests remain available. +- Native archives and installers remain unsigned; verify them against `SHA256SUMS`. + ## 2.0.0rc3 — 2026-07-30 Focused PDF fidelity prerelease. diff --git a/README.md b/README.md index ae97150..0062a18 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ The CLI currently exposes `doctor`, `init`, `status`, `validate`, `resume`, `finalize`, and the optional `mcp` launcher. Machine-readable responses use one stable envelope containing `ok`, `command`, `data`, and `error`. -## Native Distribution (`v2.0.0rc3`) +## Native Distribution (`v2.0.0rc4`) -The `v2.0.0rc3` prerelease provides bundled portable archives for Linux, macOS, +The `v2.0.0rc4` prerelease provides bundled portable archives for Linux, macOS, and Windows x86_64, plus transactional user-local installers and a non-root OCI image. Native archives include Python, Pillow, MCP, fonts, templates, the Skill, and references, so installed execution does not require a system Python. diff --git a/comic_sol_product/__init__.py b/comic_sol_product/__init__.py index 6766f87..2896ce2 100644 --- a/comic_sol_product/__init__.py +++ b/comic_sol_product/__init__.py @@ -1,3 +1,3 @@ """Installed product surface for Comic Sol.""" -__version__ = "2.0.0rc3" +__version__ = "2.0.0rc4" diff --git a/comic_sol_product/distribution.py b/comic_sol_product/distribution.py index 3d2515e..380df72 100644 --- a/comic_sol_product/distribution.py +++ b/comic_sol_product/distribution.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Iterable -_VERSION = "2.0.0rc3" +_VERSION = "2.0.0rc4" _PLATFORMS = {"linux", "macos", "windows"} _ARCHITECTURES = {"x86_64", "arm64"} _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") diff --git a/compose.yaml b/compose.yaml index d41c338..2a84f98 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ services: comic-sol: build: . - image: comic-sol:2.0.0rc3 + image: comic-sol:2.0.0rc4 read_only: true command: ["mcp", "--root", "/data"] volumes: diff --git a/docs/install.md b/docs/install.md index fef773a..397a32a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ -# Install Comic Sol v2.0.0rc3 +# Install Comic Sol v2.0.0rc4 -Comic Sol `v2.0.0rc3` is a prerelease distributed as native portable archives for Linux, macOS, and Windows, plus a Python wheel/source archive and an OCI image definition. Native archives bundle Python 3.11, Pillow, MCP, fonts, templates, the Skill, and references; no system Python is required after extraction. +Comic Sol `v2.0.0rc4` is a prerelease distributed as native portable archives for Linux, macOS, and Windows, plus a Python wheel/source archive and an OCI image definition. Native archives bundle Python 3.11, Pillow, MCP, fonts, templates, the Skill, and references; no system Python is required after extraction. ## Security status @@ -11,29 +11,29 @@ This release is **unsigned**. It is not Authenticode-signed, notarized, or GPG-s Download the matching ZIP and copy `installers/install.sh` from the same release or repository checkout. Read it before execution, then verify and install: ```bash -sha256sum comic-sol-2.0.0rc3-linux-x86_64.zip +sha256sum comic-sol-2.0.0rc4-linux-x86_64.zip # Compare the digest with SHA256SUMS. sh installers/install.sh \ - --archive ./comic-sol-2.0.0rc3-linux-x86_64.zip \ + --archive ./comic-sol-2.0.0rc4-linux-x86_64.zip \ --sha256 $HOME/.local/share/comic-sol/bin/comic-sol --version $HOME/.local/share/comic-sol/bin/comic-sol doctor ``` -For macOS, use `comic-sol-2.0.0rc3-macos-x86_64.zip`. The default installation root is `$HOME/.local/share/comic-sol`. Override it with `--install-root PATH` or `COMIC_SOL_INSTALL_ROOT`. +For macOS, use `comic-sol-2.0.0rc4-macos-x86_64.zip`. The default installation root is `$HOME/.local/share/comic-sol`. Override it with `--install-root PATH` or `COMIC_SOL_INSTALL_ROOT`. The POSIX installer requires `sha256sum`, `unzip`, and standard POSIX utilities. Native binaries are unsigned, so macOS Gatekeeper may require an explicit local approval for this prerelease. ## Windows PowerShell -Download `comic-sol-2.0.0rc3-windows-x86_64.zip` and copy `installers/install.ps1` from the same release or repository checkout, then run: +Download `comic-sol-2.0.0rc4-windows-x86_64.zip` and copy `installers/install.ps1` from the same release or repository checkout, then run: ```powershell -(Get-FileHash .\comic-sol-2.0.0rc3-windows-x86_64.zip -Algorithm SHA256).Hash +(Get-FileHash .\comic-sol-2.0.0rc4-windows-x86_64.zip -Algorithm SHA256).Hash # Compare the digest with SHA256SUMS. .\installers\install.ps1 ` - -Archive .\comic-sol-2.0.0rc3-windows-x86_64.zip ` + -Archive .\comic-sol-2.0.0rc4-windows-x86_64.zip ` -SHA256 & "$HOME\AppData\Local\ComicSol\bin\comic-sol.exe" --version @@ -80,8 +80,8 @@ Uninstall removes only the user-local runtime installation. Comic projects are p Build and run the non-root image from a checkout: ```bash -docker build -t comic-sol:2.0.0rc3 . -docker run --rm --entrypoint comic-sol comic-sol:2.0.0rc3 doctor --output-root /tmp/comic-sol-doctor +docker build -t comic-sol:2.0.0rc4 . +docker run --rm --entrypoint comic-sol comic-sol:2.0.0rc4 doctor --output-root /tmp/comic-sol-doctor docker compose up ``` @@ -91,7 +91,7 @@ The image runs as `comic-sol`, uses `/data` for persistent projects, and exposes Each platform bundle contains: -- `comic-sol-2.0.0rc3--x86_64.zip` +- `comic-sol-2.0.0rc4--x86_64.zip` - platform metadata declaring the unsigned state - a CycloneDX SBOM - `SHA256SUMS` diff --git a/docs/releases/v2.0.0rc4.md b/docs/releases/v2.0.0rc4.md new file mode 100644 index 0000000..d40220a --- /dev/null +++ b/docs/releases/v2.0.0rc4.md @@ -0,0 +1,38 @@ +# Comic Sol v2.0.0rc4 + +`v2.0.0rc4` is a speaker-aware balloon-tail **prerelease** that replaces ambiguous free-coordinate wedges with validated voice-source semantics and production-quality organic cubic tails. + +## Fixed + +- Every dialogue now binds `speaker`, `voice_source`, and normalized `speaker_anchor` semantics before lettering. +- Human and spoken-device tails resolve toward the declared visible voice source; captions, system status, and SFX remain tail-free. +- Legacy `tail_target` projects remain readable for inspection but fail closed with `balloon-tail-migration-required` until explicitly migrated. +- Balloon tails use a deterministic `organic-cubic-v1` geometry record with body overlap, non-crossing controls, protected source gap, durable white core, and a single merged body/tail outline. +- Lettering provenance binds the complete tail geometry and rejects stale or legacy wedge records downstream. +- `bubble-tail-direction` page QA requires exactly one artifact-bound region per dialogue; generic `all-bubbles` evidence is rejected. + +## Visual evidence + +A local deterministic fixture rendered the RC3 wedge and RC4 cubic tail from identical panel, text, and speaker-anchor inputs. The final V4 geometry passed strict visual inspection for body integration, smooth taper, white-core durability, outline consistency, clean antialiasing and tip, and alignment to the mouth anchor. Deterministic mechanics tests remain separate from this retained visual review. + +## Quality and compatibility + +RC4 retains deterministic normalization, typography preflight, the immutable `four-grid` layout, artifact-bound page QA, full-content PDF verification, and the deterministic quality matrix. Matrix evidence proves mechanics only and is not a substitute for visual review. + +- The exact 17-tool MCP surface is unchanged. +- Full-page PDF thresholds remain `3.1 / 0.021 / 16.0`. +- RC3 PDF quality 95 fidelity behavior is retained. +- Raw and clean panel art remains reusable when only dialogue-tail semantics change. + +## Distribution + +The prerelease includes: + +- Linux, macOS, and Windows x86_64 portable archives +- Python wheel and source archive +- transactional POSIX and PowerShell installers +- SHA-256 checksums +- CycloneDX SBOMs +- non-root OCI build definition + +Native archives and installers remain **unsigned**. Verify every downloaded artifact against `SHA256SUMS` before use. diff --git a/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md b/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md index b47fc2a..ddf3f45 100644 --- a/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md +++ b/docs/superpowers/specs/2026-07-30-speaker-aware-balloon-tails-design.md @@ -2,9 +2,9 @@ **Date:** 2026-07-30 -**Status:** Approved design, implementation pending +**Status:** Implemented and visually verified -**Target:** Comic Sol prerelease after `v2.0.0rc3` +**Target:** Comic Sol `v2.0.0rc4` prerelease ## Problem @@ -99,10 +99,10 @@ The engine does not claim that an authored human anchor is anatomically correct The renderer replaces the triangular polygon with a closed cubic path represented by two tapered sides: -- **Attachment:** the nearest point on the fitted ellipse along the ray toward `speaker_anchor`. -- **Base pair:** two nearby points sampled along the ellipse tangent, narrow enough to merge naturally into the body. +- **Attachment:** the nearest point on the fitted ellipse along the ray toward `speaker_anchor`; this remains the semantic ray origin in provenance. +- **Base pair:** two points begin slightly inside the balloon body on opposite sides of the ray, so the merged mask—not a forced outline endpoint—forms the visible body-tail junction without a notch or doubled edge. - **Tip:** a point on the attachment-to-anchor ray that stops before the anchor by a protected source gap. -- **Control points:** bend each side gradually from the ellipse tangent toward the tip; both sides converge without crossing. +- **Control points:** preserve each side of the ray, retain a durable white core, and converge without crossing or producing a needle-like section. Deterministic constraints: @@ -143,13 +143,13 @@ The existing schema-2.0 `bubble-tail-direction` check retains its bounded eviden shape; its `regions` array contains exactly one structured entry per rendered dialogue item. Each entry records: -- text item ID; -- declared speaker and voice source; -- observed balloon location; -- observed tail endpoint region; -- whether the endpoint clearly indicates the declared source; -- whether the tail avoids faces, text, and focal action; -- whether body-tail joining and outline thickness look continuous. +- `panel_id` and `text_id`; +- declared `speaker` and `voice_source`; +- current normalized `speaker_anchor`; +- current resolved pixel `tip` from lettering geometry; +- reviewer `result`, which must agree with the enclosing check result. + +The reviewer evidence text remains responsible for confirming that the endpoint clearly indicates the declared source, avoids faces/text/focal action, and has a continuous body-tail join and outline. Deterministic fields bind that visual judgment to the current rendered dialogue; they do not infer a visual pass. A page cannot be marked reviewed when any dialogue tail: diff --git a/pyproject.toml b/pyproject.toml index f95fa1f..a2d61bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "comic-sol" -version = "2.0.0rc3" +version = "2.0.0rc4" description = "Local-first deterministic comic production pipeline" readme = "README.md" requires-python = ">=3.11" diff --git a/references/schemas.md b/references/schemas.md index d21b14b..451056e 100644 --- a/references/schemas.md +++ b/references/schemas.md @@ -192,6 +192,8 @@ Anchors are `top-left`, `top-center`, `top-right`, `middle-left`, `middle-right` The word limits are a ceiling, not a guarantee of fit. Dialogue is inscribed in an oval, which holds roughly half the text of the rectangle bounding it, and an anchor area is about 42% of panel width by 30% of panel height. A 32-word line needs a panel of roughly 1000x1200 px or larger; a 720x1064 panel holds about 14 words. Lettering fails with `text item {id} does not fit inside the panel` rather than printing over the artwork, so size dialogue to the panel rectangle the storyboard assigns it. +Dialogue tails are stored in lettering geometry as `organic-cubic-v1` records containing `attachment`, `base`, `control`, `tip`, `speaker_anchor`, `voice_source`, `source_gap`, `length`, `width`, and `policy_version`. The body and cubic tail are supersampled into one mask before one outline is derived. Page-QA check `bubble-tail-direction` requires exactly one current `regions` entry per dialogue with `panel_id`, `text_id`, `speaker`, `voice_source`, `speaker_anchor`, `tip`, and `result`; generic or stale regions fail closed. + Dialogue and captions are deterministic lettering inputs. SFX is authored storyboard content for generation prompts and visual QA, but Pillow neither draws SFX nor allocates a placement rectangle or overlap reservation. Lettering summaries retain `text_count` for the total authored item count and additionally report `rendered_text_count` for diff --git a/references/visual-qa.md b/references/visual-qa.md index c4a4a2c..bcaa22e 100644 --- a/references/visual-qa.md +++ b/references/visual-qa.md @@ -50,5 +50,10 @@ override an unreadable/corrupt image, safety refusal, or non-visual failure. After composition, inspect numeric order, page continuity, borders/gutters, clipped or overlapping text, bubble/caption readability, tail direction, covered faces/actions, and -consistent reading flow. Any error-level panel or page keeps export blocked. Deterministic -hash/dimension/PDF checks complement visual inspection; they do not replace it. +consistent reading flow. For `bubble-tail-direction`, record exactly one bounded region per +dialogue with its `panel_id`, `text_id`, speaker, voice source, speaker anchor, resolved tip, +and result; generic `all-bubbles` evidence is rejected. Verify the tail points to the declared +voice source, stops before it, and has a continuous join, durable white core, consistent +outline, and clean tip. Any error-level panel or page keeps export blocked. Deterministic +hash/dimension/PDF checks bind evidence to current artifacts; they do not replace visual +inspection. diff --git a/samples/sunlight-courier/plan/storyboard.json b/samples/sunlight-courier/plan/storyboard.json index bdefbc6..2b8693e 100644 --- a/samples/sunlight-courier/plan/storyboard.json +++ b/samples/sunlight-courier/plan/storyboard.json @@ -39,8 +39,7 @@ "id": "p01-01-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] }, @@ -80,10 +79,11 @@ "kind": "dialogue", "priority": 1, "speaker": "mira", - "tail_target": [ + "speaker_anchor": [ 0.3, 0.2 - ] + ], + "voice_source": "human" } ] } @@ -128,8 +128,7 @@ "id": "p02-01-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] }, @@ -168,8 +167,7 @@ "id": "p02-02-t01", "kind": "caption", "priority": 1, - "speaker": null, - "tail_target": null + "speaker": null } ] } diff --git a/samples/sunlight-courier/project.json b/samples/sunlight-courier/project.json index 3ded9b2..f78a5c7 100644 --- a/samples/sunlight-courier/project.json +++ b/samples/sunlight-courier/project.json @@ -18,7 +18,7 @@ }, "storyboard": { "path": "plan/storyboard.json", - "sha256": "ef5bba8521b06cb7614c477f5d11f21f0da4d92a4312524620804c7dbaa15fa3" + "sha256": "e6facb3c4414533eda08774f5c9129291fb7ee2adedaf22ee3c0b2e5cdc8737d" } }, "capability": { diff --git a/scripts/assemble_release.py b/scripts/assemble_release.py index 5eb1e0f..36ddec4 100644 --- a/scripts/assemble_release.py +++ b/scripts/assemble_release.py @@ -29,7 +29,7 @@ def main() -> int: parser.add_argument("--architecture", default="x86_64", choices=("x86_64", "arm64")) args = parser.parse_args() - identity = ReleaseIdentity("2.0.0rc3", args.platform, args.architecture) + identity = ReleaseIdentity("2.0.0rc4", args.platform, args.architecture) output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) archive = output / artifact_name(identity, "zip") diff --git a/scripts/letter_panels.py b/scripts/letter_panels.py index 37fc8ba..05fe11e 100644 --- a/scripts/letter_panels.py +++ b/scripts/letter_panels.py @@ -50,7 +50,7 @@ # Panels are page-sized at most (1600x2400); sixteen page areas leaves room for # oversampled source art while rejecting decompression bombs. MAX_DECODED_PIXELS = 1600 * 2400 * 16 -BALLOON_SUPERSAMPLE = 4 +BALLOON_SUPERSAMPLE = 6 def normalize_content(text: str) -> str: @@ -537,74 +537,128 @@ def _fitted_item_rect( return {"x": x, "y": y, "width": fitted_width, "height": fitted_height} -def _ellipse_tail_polygon( +def _organic_tail_geometry( rect: dict[str, int], - tail_target: list[float], + speaker_anchor: list[float], image_width: int, image_height: int, -) -> tuple[tuple[float, float], tuple[float, float], tuple[int, int]]: - """Return a triangular tail based at the nearest ellipse point toward target.""" - if not all(math.isfinite(float(value)) for value in tail_target): - raise ValueError("dialogue tail target coordinates must be finite numbers") - target_x = min(image_width - 1, max(0, round(float(tail_target[0]) * image_width))) - target_y = min(image_height - 1, max(0, round(float(tail_target[1]) * image_height))) + voice_source: str, +) -> dict[str, object]: + """Resolve one compact, tapered cubic tail toward an explicit voice source.""" + if voice_source not in {"human", "device"}: + raise ValueError("dialogue voice source must be human or device") + if ( + not isinstance(speaker_anchor, list) + or len(speaker_anchor) != 2 + or any( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or not 0 <= value <= 1 + for value in speaker_anchor + ) + ): + raise ValueError("speaker anchor must contain finite normalized coordinates") + target_x = round(float(speaker_anchor[0]) * image_width) + target_y = round(float(speaker_anchor[1]) * image_height) center_x = rect["x"] + rect["width"] / 2 center_y = rect["y"] + rect["height"] / 2 radius_x = max(0.5, rect["width"] / 2) radius_y = max(0.5, rect["height"] / 2) delta_x = target_x - center_x delta_y = target_y - center_y - if delta_x == 0 and delta_y == 0: - delta_y = 1.0 - scale = 1 / math.sqrt((delta_x / radius_x) ** 2 + (delta_y / radius_y) ** 2) + normalized_distance = math.sqrt( + (delta_x / radius_x) ** 2 + (delta_y / radius_y) ** 2 + ) + if normalized_distance <= 1.08: + raise ValueError("speaker anchor must remain outside the balloon outline") + scale = 1 / normalized_distance attachment_x = center_x + delta_x * scale attachment_y = center_y + delta_y * scale - # A tail is a short pointer, not a leash across faces or focal action. - minor_radius = min(radius_x, radius_y) - max_tail = max( - 1.0, - min( - minor_radius * 0.75, - rect["height"] * 0.35, - min(image_width, image_height) * 0.055, - ), + source_distance = math.hypot( + target_x - attachment_x, target_y - attachment_y + ) + minimum_source_gap = max( + 8.0, min(24.0, min(image_width, image_height) * 0.025) ) - length = math.hypot(delta_x, delta_y) - unit_x, unit_y = delta_x / length, delta_y / length - # The authored target can land inside a fitted balloon after wrapping. In - # that case target→attachment points inward and the tail disappears under - # the white fill. Always extend outward along the speaker ray and preserve - # a visible minimum while retaining the strict upper bounds above. - target_projection = ( - (target_x - attachment_x) * unit_x - + (target_y - attachment_y) * unit_y + available_length = source_distance - minimum_source_gap + maximum_length = min( + min(radius_x, radius_y) * 0.9, + min(image_width, image_height) * 0.12, ) - desired_tail = ( - target_projection - if target_projection > 0 - else minor_radius * 0.35 + if available_length < 12.0: + raise ValueError("speaker anchor is too close to the balloon for a readable tail") + tail_length = min(available_length, maximum_length) + source_gap = source_distance - tail_length + unit_x = (target_x - attachment_x) / source_distance + unit_y = (target_y - attachment_y) / source_distance + half_base = max(5.0, min(14.0, tail_length * 0.18)) + + # Begin both cubic sides inside the balloon body. The merged mask then + # computes the visible ellipse/tail intersection naturally, avoiding the + # cusp and shoulder produced by forcing roots onto the ellipse outline. + normal_x, normal_y = -unit_y, unit_x + root_depth = min(10.0, half_base * 0.7) + base_one = ( + attachment_x - unit_x * root_depth + normal_x * half_base, + attachment_y - unit_y * root_depth + normal_y * half_base, + ) + base_two = ( + attachment_x - unit_x * root_depth - normal_x * half_base, + attachment_y - unit_y * root_depth - normal_y * half_base, + ) + tip = ( + attachment_x + unit_x * tail_length, + attachment_y + unit_y * tail_length, + ) + shoulder_run = min(22.0, tail_length * 0.30) + near_tip_run = min(24.0, tail_length * 0.30) + near_tip_half_width = max(2.6, half_base * 0.30) + first_controls = ( + ( + attachment_x + unit_x * shoulder_run + normal_x * half_base * 0.92, + attachment_y + unit_y * shoulder_run + normal_y * half_base * 0.92, + ), + ( + tip[0] - unit_x * near_tip_run + normal_x * near_tip_half_width, + tip[1] - unit_y * near_tip_run + normal_y * near_tip_half_width, + ), ) - tail_length = min(max_tail, max(12.0, desired_tail)) - clamped_x = round(attachment_x + unit_x * tail_length) - clamped_y = round(attachment_y + unit_y * tail_length) - tangent_x, tangent_y = -delta_y / length, delta_x / length - # Use a broad comic-print wedge rather than a needle-like spike. Keep the - # base proportional to the balloon while the independently capped length - # prevents it from crossing faces or focal action. - half_base = max( - 7.0, - min(radius_x, radius_y) * 0.18, - tail_length * 0.34, + second_controls = ( + ( + attachment_x + unit_x * shoulder_run - normal_x * half_base * 0.92, + attachment_y + unit_y * shoulder_run - normal_y * half_base * 0.92, + ), + ( + tip[0] - unit_x * near_tip_run - normal_x * near_tip_half_width, + tip[1] - unit_y * near_tip_run - normal_y * near_tip_half_width, + ), ) - base_one = (attachment_x + tangent_x * half_base, attachment_y + tangent_y * half_base) - base_two = (attachment_x - tangent_x * half_base, attachment_y - tangent_y * half_base) - return base_one, base_two, (clamped_x, clamped_y) + + def rounded(point: tuple[float, float]) -> list[float]: + return [round(point[0], 4), round(point[1], 4)] + + return { + "attachment": rounded((attachment_x, attachment_y)), + "base": [rounded(base_one), rounded(base_two)], + "control": [ + [rounded(point) for point in first_controls], + [rounded(point) for point in second_controls], + ], + "length": round(tail_length, 4), + "policy_version": "organic-cubic-v1", + "source_gap": round(source_gap, 4), + "speaker_anchor": [float(value) for value in speaker_anchor], + "tip": rounded(tip), + "voice_source": voice_source, + "width": round(half_base * 2, 4), + } def _draw_antialiased_balloon( draw: ImageDraw.ImageDraw, bounds: tuple[int, int, int, int], - tail: tuple[tuple[float, float], tuple[float, float], tuple[int, int]] | None, + tail: dict[str, object] | None, ) -> None: """Draw one seamless supersampled balloon and composite it onto the panel.""" image = draw._image @@ -613,7 +667,14 @@ def _draw_antialiased_balloon( x0, y0, x1, y1 = bounds points = [(float(x0), float(y0)), (float(x1), float(y1))] if tail is not None: - points.extend((float(x), float(y)) for x, y in tail) + base = tail.get("base") + control = tail.get("control") + tip_value = tail.get("tip") + if not isinstance(base, list) or not isinstance(control, list) or not isinstance(tip_value, list): + raise ValueError("tail geometry has an invalid point record") + points.extend(tuple(point) for point in base) + points.extend(tuple(point) for side in control for point in side) + points.append(tuple(tip_value)) margin = 5 left = max(0, math.floor(min(x for x, _ in points)) - margin) top = max(0, math.floor(min(y for _, y in points)) - margin) @@ -626,7 +687,39 @@ def scaled_point(point: tuple[float, float]) -> tuple[int, int]: return round((point[0] - left) * scale), round((point[1] - top) * scale) if tail is not None: - mask_draw.polygon(tuple(scaled_point(point) for point in tail), fill=255) + base = tail.get("base") + control = tail.get("control") + tip_value = tail.get("tip") + if not isinstance(base, list) or not isinstance(control, list) or not isinstance(tip_value, list): + raise ValueError("tail geometry has an invalid point record") + base_one, base_two = (tuple(point) for point in base) + first_control, second_control = ( + tuple(tuple(point) for point in side) for side in control + ) + tip = tuple(tip_value) + + def cubic_points(start, controls, end): + first, second = controls + result = [] + for index in range(25): + t = index / 24 + inverse = 1 - t + result.append(( + inverse ** 3 * start[0] + + 3 * inverse ** 2 * t * first[0] + + 3 * inverse * t ** 2 * second[0] + + t ** 3 * end[0], + inverse ** 3 * start[1] + + 3 * inverse ** 2 * t * first[1] + + 3 * inverse * t ** 2 * second[1] + + t ** 3 * end[1], + )) + return result + + first_side = cubic_points(base_one, first_control, tip) + second_side = cubic_points(base_two, second_control, tip) + silhouette = first_side + list(reversed(second_side)) + mask_draw.polygon(tuple(scaled_point(point) for point in silhouette), fill=255) mask_draw.ellipse( ((x0 - left) * scale, (y0 - top) * scale, (x1 - left) * scale, (y1 - top) * scale), @@ -714,11 +807,18 @@ def render_text_item( if kind == "dialogue": tail = item.get("speaker_anchor") + voice_source = item.get("voice_source") if isinstance(tail, list) and len(tail) == 2 and all(isinstance(value, (int, float)) for value in tail): - tail_polygon = _ellipse_tail_polygon( - bounded | {"x": x0, "y": y0}, tail, image_width, image_height + if not isinstance(voice_source, str): + raise ValueError("dialogue voice source must be human or device") + tail_geometry = _organic_tail_geometry( + bounded | {"x": x0, "y": y0}, + tail, + image_width, + image_height, + voice_source, ) - _draw_antialiased_balloon(draw, (x0, y0, x1, y1), tail_polygon) + _draw_antialiased_balloon(draw, (x0, y0, x1, y1), tail_geometry) else: _draw_antialiased_balloon(draw, (x0, y0, x1, y1), None) assert layout is not None @@ -877,22 +977,22 @@ def letter_panel( ] tail_geometry = None tail = item.get("speaker_anchor") + voice_source = item.get("voice_source") if ( item.get("kind") == "dialogue" and isinstance(tail, list) and len(tail) == 2 and all(isinstance(value, (int, float)) for value in tail) ): - base_one, base_two, target = _ellipse_tail_polygon( - rect, tail, panel_width, panel_height + if not isinstance(voice_source, str): + raise ValueError("dialogue voice source must be human or device") + tail_geometry = _organic_tail_geometry( + rect, + tail, + panel_width, + panel_height, + voice_source, ) - tail_geometry = { - "origin": [ - round((base_one[0] + base_two[0]) / 2), - round((base_one[1] + base_two[1]) / 2), - ], - "target": [target[0], target[1]], - } summary["placements"].append({ "anchor": selected_anchor, "box": {key: int(rect[key]) for key in ("x", "y", "width", "height")}, diff --git a/scripts/page_quality.py b/scripts/page_quality.py index b81911f..a72d10c 100644 --- a/scripts/page_quality.py +++ b/scripts/page_quality.py @@ -212,6 +212,95 @@ def _reviewer_checks(values: Sequence[Mapping[str, object]]) -> list[dict[str, o return checks +def _validate_tail_evidence( + context: Mapping[str, object], checks: Sequence[Mapping[str, object]] +) -> None: + """Require one current bounded-review region for every authored dialogue.""" + tail_check = next( + (check for check in checks if check.get("id") == "bubble-tail-direction"), + None, + ) + if tail_check is None: + raise ValueError("bubble-tail-evidence-mismatch: check is missing") + regions = tail_check.get("regions") + if not isinstance(regions, list): + raise ValueError("bubble-tail-evidence-mismatch: regions must be an array") + + panels = context.get("panels") + lettering = context.get("lettering") + if not isinstance(panels, list) or not isinstance(lettering, list): + raise ValueError("bubble-tail-evidence-mismatch: page context is invalid") + geometry_by_panel = { + panel_id: geometry + for panel_id, _, geometry in lettering + if isinstance(panel_id, str) and isinstance(geometry, dict) + } + expected: dict[tuple[str, str], dict[str, object]] = {} + for panel in panels: + if not isinstance(panel, dict) or not isinstance(panel.get("id"), str): + raise ValueError("bubble-tail-evidence-mismatch: panel is invalid") + panel_id = panel["id"] + geometry = geometry_by_panel.get(panel_id) + items = geometry.get("items") if isinstance(geometry, dict) else None + if not isinstance(items, list): + raise ValueError("bubble-tail-evidence-mismatch: lettering geometry is missing") + placed = { + item.get("id"): item + for item in items + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + text_items = panel.get("text") + if not isinstance(text_items, list): + raise ValueError("bubble-tail-evidence-mismatch: panel text is invalid") + for item in text_items: + if not isinstance(item, dict) or item.get("kind") != "dialogue": + continue + text_id = item.get("id") + geometry_item = placed.get(text_id) + tail = geometry_item.get("tail") if isinstance(geometry_item, dict) else None + if not isinstance(text_id, str) or not isinstance(tail, dict): + raise ValueError("bubble-tail-evidence-mismatch: dialogue tail is missing") + expected[(panel_id, text_id)] = { + "panel_id": panel_id, + "text_id": text_id, + "speaker": item.get("speaker"), + "voice_source": item.get("voice_source"), + "speaker_anchor": item.get("speaker_anchor"), + "tip": tail.get("tip"), + } + + observed: dict[tuple[str, str], Mapping[str, object]] = {} + required_fields = { + "panel_id", "text_id", "speaker", "voice_source", + "speaker_anchor", "tip", "result", + } + for region in regions: + if not isinstance(region, dict) or set(region) != required_fields: + raise ValueError("bubble-tail-evidence-mismatch: region fields are invalid") + panel_id = region.get("panel_id") + text_id = region.get("text_id") + if not isinstance(panel_id, str) or not isinstance(text_id, str): + raise ValueError("bubble-tail-evidence-mismatch: region identity is invalid") + key = (panel_id, text_id) + if key in observed or region.get("result") not in {"pass", "fail"}: + raise ValueError("bubble-tail-evidence-mismatch: region identity is invalid") + observed[key] = region + + if set(observed) != set(expected): + raise ValueError("bubble-tail-evidence-mismatch: dialogue coverage is incomplete") + for key, expected_region in expected.items(): + region = observed[key] + if any(region.get(field) != value for field, value in expected_region.items()): + raise ValueError("bubble-tail-evidence-mismatch: region is stale") + + results = [region.get("result") for region in observed.values()] + check_result = tail_check.get("result") + if (check_result == "pass" and any(result != "pass" for result in results)) or ( + check_result == "fail" and results and all(result == "pass" for result in results) + ): + raise ValueError("bubble-tail-evidence-mismatch: check result is inconsistent") + + def build_page_quality_record( project_dir: Path, page_number: int, @@ -222,6 +311,7 @@ def build_page_quality_record( context = _page_context(project_dir, page_number) deterministic = _deterministic_checks(context) subjective = _reviewer_checks(visual_checks) + _validate_tail_evidence(context, subjective) checks_by_id = {check["id"]: check for check in deterministic + subjective} checks = [checks_by_id[check_id] for check_id in PAGE_CHECK_IDS] failures = [check for check in checks if check.get("result") == "fail"] diff --git a/scripts/validate_project.py b/scripts/validate_project.py index 0c8eb25..38e5356 100644 --- a/scripts/validate_project.py +++ b/scripts/validate_project.py @@ -1138,22 +1138,61 @@ def stale(field: str, detail: str) -> None: stale("items.box", "item box is missing or non-positive") tail = entry.get("tail") if tail is not None: - valid_tail = isinstance(tail, dict) - for key in ("origin", "target"): - point = tail.get(key) if isinstance(tail, dict) else None - valid_tail = ( - valid_tail - and isinstance(point, list) - and len(point) == 2 + expected_tail_fields = { + "attachment", "base", "control", "length", "policy_version", + "source_gap", "speaker_anchor", "tip", "voice_source", "width", + } + + def finite_point(value: object) -> bool: + return ( + isinstance(value, list) + and len(value) == 2 and all( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and math.isfinite(value) - for value in (point or []) + isinstance(coordinate, (int, float)) + and not isinstance(coordinate, bool) + and math.isfinite(coordinate) + for coordinate in value ) ) + + valid_tail = ( + isinstance(tail, dict) + and set(tail) == expected_tail_fields + and tail.get("policy_version") == "organic-cubic-v1" + and tail.get("voice_source") in {"human", "device"} + and finite_point(tail.get("speaker_anchor")) + and all(0 <= coordinate <= 1 for coordinate in tail.get("speaker_anchor", [])) + and finite_point(tail.get("attachment")) + and finite_point(tail.get("tip")) + ) + base = tail.get("base") if isinstance(tail, dict) else None + control = tail.get("control") if isinstance(tail, dict) else None + valid_tail = ( + valid_tail + and isinstance(base, list) + and len(base) == 2 + and all(finite_point(point) for point in base) + and isinstance(control, list) + and len(control) == 2 + and all( + isinstance(side, list) + and len(side) == 2 + and all(finite_point(point) for point in side) + for side in control + ) + and all( + isinstance(tail.get(field), (int, float)) + and not isinstance(tail.get(field), bool) + and math.isfinite(tail[field]) + and tail[field] > 0 + for field in ("length", "source_gap", "width") + ) + ) if not valid_tail: - stale("items.tail", "tail coordinates must be finite points") + stale( + "items.tail", + "tail must be a finite organic-cubic-v1 semantic geometry record", + ) if len(orders) != len(set(orders)): stale("items.reading_order", "reading order values must be unique") diff --git a/tests/support.py b/tests/support.py index e50d50c..ce03052 100644 --- a/tests/support.py +++ b/tests/support.py @@ -65,6 +65,34 @@ def _scenario(name: str, *dimensions: str) -> dict[str, object]: } +def bounded_tail_regions(project: Path, page_number: int) -> list[dict[str, object]]: + """Build exact test-only tail regions from current storyboard and geometry.""" + storyboard = json.loads((Path(project) / "plan/storyboard.json").read_text("utf-8")) + page = next( + page for page in storyboard["pages"] if page.get("number") == page_number + ) + regions: list[dict[str, object]] = [] + for panel in page["panels"]: + geometry = json.loads( + (Path(project) / f"panels/{panel['id']}/lettering.json").read_text("utf-8") + ) + placed = {item["id"]: item for item in geometry["items"]} + for item in panel["text"]: + if item.get("kind") != "dialogue": + continue + tail = placed[item["id"]]["tail"] + regions.append({ + "panel_id": panel["id"], + "text_id": item["id"], + "speaker": item["speaker"], + "voice_source": item["voice_source"], + "speaker_anchor": item["speaker_anchor"], + "tip": tail["tip"], + "result": "pass", + }) + return regions + + def _fixture_image(size: tuple[int, int], color: tuple[int, int, int]) -> Image.Image: image = Image.new("RGB", size, color) draw = ImageDraw.Draw(image) diff --git a/tests/test_distribution.py b/tests/test_distribution.py index f981996..b3ffa7a 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -24,25 +24,25 @@ class NativeDistributionContractTests(unittest.TestCase): def setUp(self): self.identity = ReleaseIdentity( - version="2.0.0rc3", platform="linux", architecture="x86_64" + version="2.0.0rc4", platform="linux", architecture="x86_64" ) def test_identity_and_artifact_names_are_canonical(self): - self.assertEqual("v2.0.0rc3", self.identity.tag) + self.assertEqual("v2.0.0rc4", self.identity.tag) self.assertEqual( - "comic-sol-2.0.0rc3-linux-x86_64.tar.gz", + "comic-sol-2.0.0rc4-linux-x86_64.tar.gz", artifact_name(self.identity, "tar.gz"), ) with self.assertRaises(ValueError): ReleaseIdentity("2.0.0-rc2", "linux", "x86_64") with self.assertRaises(ValueError): - ReleaseIdentity("2.0.0rc3", "Linux", "amd64") + ReleaseIdentity("2.0.0rc4", "Linux", "amd64") def test_metadata_checksum_and_sbom_are_deterministic(self): with tempfile.TemporaryDirectory() as temporary_directory: release = Path(temporary_directory) first = release / artifact_name(self.identity, "tar.gz") - second = release / "comic-sol-2.0.0rc3-linux-x86_64.sbom.json" + second = release / "comic-sol-2.0.0rc4-linux-x86_64.sbom.json" first.write_bytes(b"portable-runtime") second.write_text("{}\n", encoding="utf-8") @@ -64,7 +64,7 @@ def test_metadata_checksum_and_sbom_are_deterministic(self): self.assertEqual("CycloneDX", sbom_record["bomFormat"]) self.assertEqual("1.6", sbom_record["specVersion"]) self.assertEqual("comic-sol", sbom_record["metadata"]["component"]["name"]) - self.assertEqual("2.0.0rc3", sbom_record["metadata"]["component"]["version"]) + self.assertEqual("2.0.0rc4", sbom_record["metadata"]["component"]["version"]) def test_verifier_rejects_missing_or_tampered_artifact(self): with tempfile.TemporaryDirectory() as temporary_directory: @@ -75,7 +75,7 @@ def test_verifier_rejects_missing_or_tampered_artifact(self): write_sbom(release, self.identity) write_checksums( release, - [artifact, release / "comic-sol-2.0.0rc3-linux-x86_64.sbom.json"], + [artifact, release / "comic-sol-2.0.0rc4-linux-x86_64.sbom.json"], ) verify_release_directory(release, self.identity) @@ -155,7 +155,7 @@ def test_container_and_release_workflow_are_hardened(self): self.assertIn("/data", compose) self.assertIn("workflow_dispatch:", workflow) - self.assertIn("tags: [ 'v2.0.0rc3' ]", workflow) + self.assertIn("tags: [ 'v2.0.0rc4' ]", workflow) for runner in ("ubuntu-latest", "macos-latest", "windows-latest"): self.assertIn(runner, workflow) self.assertIn("scripts/build_portable.py", workflow) @@ -169,9 +169,9 @@ def test_container_and_release_workflow_are_hardened(self): self.assertIn("installers/install.sh", workflow) self.assertIn("installers/install.ps1", workflow) self.assertIn("prerelease: true", workflow) - self.assertIn("if: github.ref == 'refs/tags/v2.0.0rc3'", workflow) - self.assertIn("comic-sol:2.0.0rc3", workflow) - self.assertIn("gh release create v2.0.0rc3", workflow) + self.assertIn("if: github.ref == 'refs/tags/v2.0.0rc4'", workflow) + self.assertIn("comic-sol:2.0.0rc4", workflow) + self.assertIn("gh release create v2.0.0rc4", workflow) self.assertNotIn("refs/tags/v2.0.0rc1", workflow) for line in workflow.splitlines(): if "uses:" in line: @@ -187,7 +187,7 @@ def test_rc2_version_sources_and_quality_runtime_are_consistent(self): compose = (root / "compose.yaml").read_text(encoding="utf-8") release_contract = (root / "comic_sol_product/release.py").read_text(encoding="utf-8") for source in (pyproject, package, distribution, assembler, compose): - self.assertIn("2.0.0rc3", source) + self.assertIn("2.0.0rc4", source) for module in ( "normalize_panels.py", "typography.py", "layouts.py", "page_quality.py", "pdf_quality.py", "quality_sample.py", diff --git a/tests/test_finalization.py b/tests/test_finalization.py index b03afed..f1dd039 100644 --- a/tests/test_finalization.py +++ b/tests/test_finalization.py @@ -38,9 +38,10 @@ valid_story, valid_storyboard, ) +from tests.support import bounded_tail_regions # noqa: E402 -def valid_page_reviewer_checks(): +def valid_page_reviewer_checks(project: Path, page_number: int): return [ { "id": "face-action-obstruction", @@ -58,7 +59,7 @@ def valid_page_reviewer_checks(): "evidence": "Reviewer inspected every bubble tail against its intended speaker.", "method": "bounded-visual-review", "reviewer": "fixture-reviewer", - "regions": [{"scope": "all-bubbles"}], + "regions": bounded_tail_regions(project, page_number), }, { "id": "accidental-text-watermark", @@ -129,7 +130,7 @@ def _add_lettered_page_qas(self): self.project, 1, build_page_quality_record( - self.project, 1, valid_page_reviewer_checks() + self.project, 1, valid_page_reviewer_checks(self.project, 1) ), ) @@ -291,7 +292,7 @@ def _add_lettered_page_qas(self): self.project, 1, build_page_quality_record( - self.project, 1, valid_page_reviewer_checks() + self.project, 1, valid_page_reviewer_checks(self.project, 1) ), ) diff --git a/tests/test_lettering.py b/tests/test_lettering.py index db62f5e..e0b35bb 100644 --- a/tests/test_lettering.py +++ b/tests/test_lettering.py @@ -388,21 +388,31 @@ def test_dialogue_has_white_oval_dark_stroke_and_tail(self): self.assertTrue(any(max(image.getpixel((x, 40))) < 80 for x in range(32, 370))) def test_dialogue_tail_attachment_has_no_internal_seam(self): - from letter_panels import _ellipse_tail_polygon + from letter_panels import _organic_tail_geometry image = Image.new("RGB", (240, 240), (96, 96, 96)) draw = ImageDraw.Draw(image) rect = {"x": 40, "y": 30, "width": 120, "height": 70} item = dialogue("Hi") item["speaker_anchor"] = [0.8, 0.8] - base_one, base_two, _ = _ellipse_tail_polygon(rect, item["speaker_anchor"], 240, 240) - attachment = tuple(round((first + second) / 2) for first, second in zip(base_one, base_two)) + geometry = _organic_tail_geometry( + rect, item["speaker_anchor"], 240, 240, item["voice_source"] + ) + attachment = tuple(geometry["attachment"]) render_text_item( draw, item, rect, ImageFont.truetype(str(FONT), 24), self.characters ) - self.assertEqual((255, 255, 255), image.getpixel(attachment)) + root_pixel = image.getpixel(attachment) + self.assertGreaterEqual(min(root_pixel), 245) + root_x, root_y = (round(value) for value in attachment) + root_patch = [ + image.getpixel((x, y)) + for x in range(max(0, root_x - 1), min(image.width, root_x + 2)) + for y in range(max(0, root_y - 1), min(image.height, root_y + 2)) + ] + self.assertGreaterEqual(sum(min(pixel) >= 220 for pixel in root_patch), 6) def test_dialogue_uses_uppercase_comic_lettering_without_mutating_authored_text(self): item = dialogue("Wait, did you call me Peter?") @@ -429,43 +439,51 @@ def capture_runs(_draw, runs, _position, _fill): "".join(text for text, _ in captured_runs).replace(" ", ""), ) - def test_dialogue_tail_is_a_short_pointer_not_a_cross_panel_leash(self): - from letter_panels import _ellipse_tail_polygon + def test_organic_tail_geometry_is_semantic_tapered_and_deterministic(self): + from letter_panels import _organic_tail_geometry rect = {"x": 40, "y": 30, "width": 240, "height": 160} - base_one, base_two, tip = _ellipse_tail_polygon(rect, [1.0, 1.0], 800, 1000) - attachment = ( - (base_one[0] + base_two[0]) / 2, - (base_one[1] + base_two[1]) / 2, + first = _organic_tail_geometry(rect, [0.36, 0.24], 800, 1000, "human") + second = _organic_tail_geometry(rect, [0.36, 0.24], 800, 1000, "human") + self.assertEqual(first, second) + self.assertEqual( + { + "policy_version", "voice_source", "speaker_anchor", "attachment", + "base", "control", "tip", "source_gap", "length", "width", + }, + set(first), ) - tail_length = math.hypot(tip[0] - attachment[0], tip[1] - attachment[1]) - tail_base = math.hypot( - base_two[0] - base_one[0], - base_two[1] - base_one[1], + self.assertEqual("organic-cubic-v1", first["policy_version"]) + self.assertEqual("human", first["voice_source"]) + self.assertEqual([0.36, 0.24], first["speaker_anchor"]) + self.assertGreaterEqual(first["length"], 12) + self.assertLessEqual(first["length"], min(800, 1000) * 0.12 + 1) + self.assertLess(first["width"] / first["length"], 0.5) + self.assertGreater(first["source_gap"], 0) + anchor = (round(0.36 * 800), round(0.24 * 1000)) + self.assertAlmostEqual( + first["source_gap"], + math.dist(first["tip"], anchor), + delta=1.5, ) + for point in ( + first["attachment"], first["tip"], *first["base"], + *first["control"][0], *first["control"][1], + ): + self.assertTrue(all(math.isfinite(value) for value in point)) + self.assertGreaterEqual(point[0], 0) + self.assertLess(point[0], 800) + self.assertGreaterEqual(point[1], 0) + self.assertLess(point[1], 1000) - # Reference-style tails stay near the balloon instead of crossing the - # central face/action zone. - self.assertGreaterEqual(tail_length, 12) - self.assertLessEqual(tail_length, rect["height"] * 0.35 + 1) - self.assertLessEqual(tail_length, min(800, 1000) * 0.055 + 1) - self.assertGreaterEqual(tail_base / tail_length, 0.65) - - def test_tail_target_inside_balloon_still_produces_visible_outward_pointer(self): - from letter_panels import _ellipse_tail_polygon + def test_organic_tail_rejects_anchor_inside_balloon_and_unknown_source(self): + from letter_panels import _organic_tail_geometry rect = {"x": 100, "y": 100, "width": 240, "height": 160} - base_one, base_two, tip = _ellipse_tail_polygon(rect, [0.28, 0.22], 800, 1000) - attachment = ( - (base_one[0] + base_two[0]) / 2, - (base_one[1] + base_two[1]) / 2, - ) - center = (rect["x"] + rect["width"] / 2, rect["y"] + rect["height"] / 2) - outward = (attachment[0] - center[0], attachment[1] - center[1]) - pointer = (tip[0] - attachment[0], tip[1] - attachment[1]) - - self.assertGreater(outward[0] * pointer[0] + outward[1] * pointer[1], 0) - self.assertGreaterEqual(math.hypot(*pointer), 12) + with self.assertRaisesRegex(ValueError, "outside the balloon"): + _organic_tail_geometry(rect, [0.28, 0.18], 800, 1000, "human") + with self.assertRaisesRegex(ValueError, "voice source"): + _organic_tail_geometry(rect, [0.6, 0.6], 800, 1000, "system") def test_single_line_dialogue_balloon_is_compact_but_contains_text(self): from letter_panels import _balloon_box, _layout_styled_text @@ -487,10 +505,10 @@ def test_single_line_dialogue_balloon_is_compact_but_contains_text(self): def test_dialogue_uses_adaptive_oval_and_boundary_tail_geometry(self): from letter_panels import ( - _ellipse_tail_polygon, _fitted_item_rect, _item_font, _layout_styled_text, + _organic_tail_geometry, ) image = Image.new("RGB", (800, 1000), (28, 32, 40)) @@ -514,17 +532,15 @@ def test_dialogue_uses_adaptive_oval_and_boundary_tail_geometry(self): self.assertGreaterEqual(short_rect["x"], maximum["x"]) self.assertGreaterEqual(short_rect["y"], maximum["y"]) - base_one, base_two, target = _ellipse_tail_polygon( - short_rect, [-0.25, 1.25], 800, 1000 + geometry = _organic_tail_geometry( + short_rect, [0.40, 0.28], 800, 1000, "human" ) + attachment = tuple(geometry["attachment"]) + target = tuple(geometry["tip"]) self.assertLess(target[0], 800) self.assertLess(target[1], 1000) self.assertGreaterEqual(target[0], 0) self.assertGreaterEqual(target[1], 0) - attachment = ( - (base_one[0] + base_two[0]) / 2, - (base_one[1] + base_two[1]) / 2, - ) center = ( short_rect["x"] + short_rect["width"] / 2, short_rect["y"] + short_rect["height"] / 2, @@ -540,7 +556,7 @@ def test_dialogue_uses_adaptive_oval_and_boundary_tail_geometry(self): expected_attachment = (center[0] + delta_x * scale, center[1] + delta_y * scale) self.assertAlmostEqual(expected_attachment[0], attachment[0], delta=1.0) self.assertAlmostEqual(expected_attachment[1], attachment[1], delta=1.0) - self.assertNotIn(center, (base_one, base_two, target)) + self.assertNotIn(center, (*map(tuple, geometry["base"]), target)) with mock.patch( "letter_panels._draw_antialiased_balloon", @@ -628,7 +644,7 @@ def test_caption_honors_its_authored_anchor(self): self.assertAlmostEqual(400, (box[0] + box[2]) / 2, delta=2) def test_non_finite_speaker_anchor_is_rejected_as_value_error(self): - from letter_panels import _ellipse_tail_polygon + from letter_panels import _organic_tail_geometry before = self.panel.read_bytes() for value in (float("inf"), float("-inf"), float("nan")): @@ -638,8 +654,12 @@ def test_non_finite_speaker_anchor_is_rejected_as_value_error(self): letter_panel(str(self.panel), 800, 1000, [item], self.characters) self.assertEqual(before, self.panel.read_bytes()) with self.assertRaisesRegex(ValueError, "finite"): - _ellipse_tail_polygon( - {"x": 40, "y": 30, "width": 120, "height": 70}, [value, 0.5], 240, 240 + _organic_tail_geometry( + {"x": 40, "y": 30, "width": 120, "height": 70}, + [value, 0.5], + 240, + 240, + "human", ) def test_oversized_panel_image_is_rejected_as_value_error(self): @@ -779,12 +799,13 @@ def test_all_anchor_drawing_stays_inside_panel_boundary(self): image = Image.new("RGB", (512, 512), "black") draw = ImageDraw.Draw(image) font = ImageFont.truetype(str(FONT), 24) - rect = {"x": 4, "y": 4, "width": 504, "height": 504} + rect = {"x": 96, "y": 96, "width": 250, "height": 180} for anchor in ( "top-left", "top-center", "top-right", "middle-left", "middle-right", "bottom-left", "bottom-center", "bottom-right", ): item = dialogue(anchor=anchor) + item["speaker_anchor"] = [0.9, 0.85] render_text_item(draw, item, rect, font, self.characters) self.assertEqual((512, 512), image.size) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 92785ff..f4146d4 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -from tests.support import make_symlink # noqa: E402 +from tests.support import bounded_tail_regions, make_symlink # noqa: E402 from page_quality import build_page_quality_record, write_page_quality_record # noqa: E402 MCP_AVAILABLE = importlib.util.find_spec("mcp") is not None @@ -45,7 +45,7 @@ } -def valid_page_reviewer_checks(): +def valid_page_reviewer_checks(project: Path, page_number: int): return [ { "id": "face-action-obstruction", @@ -63,7 +63,7 @@ def valid_page_reviewer_checks(): "evidence": "Reviewer inspected every bubble tail against its intended speaker.", "method": "bounded-visual-review", "reviewer": "fixture-reviewer", - "regions": [{"scope": "all-bubbles"}], + "regions": bounded_tail_regions(project, page_number), }, { "id": "accidental-text-watermark", @@ -83,7 +83,7 @@ def write_current_page_qa_records(project: Path, page_numbers): project, page_number, build_page_quality_record( - project, page_number, valid_page_reviewer_checks() + project, page_number, valid_page_reviewer_checks(project, page_number) ), ) diff --git a/tests/test_page_quality.py b/tests/test_page_quality.py index 44a3b11..ea5d58c 100644 --- a/tests/test_page_quality.py +++ b/tests/test_page_quality.py @@ -29,8 +29,8 @@ } -def reviewer_checks(): - return [ +def reviewer_checks(project=None): + checks = [ { "id": check_id, "result": "pass", @@ -42,6 +42,33 @@ def reviewer_checks(): } for check_id in sorted(SUBJECTIVE) ] + if project is None: + return checks + + storyboard = json.loads((project / "plan/storyboard.json").read_text("utf-8")) + regions = [] + for panel in storyboard["pages"][0]["panels"]: + geometry = json.loads( + (project / f"panels/{panel['id']}/lettering.json").read_text("utf-8") + ) + geometry_by_id = {item["id"]: item for item in geometry["items"]} + for item in panel["text"]: + if item["kind"] != "dialogue": + continue + tail = geometry_by_id[item["id"]]["tail"] + regions.append({ + "panel_id": panel["id"], + "text_id": item["id"], + "speaker": item["speaker"], + "voice_source": item["voice_source"], + "speaker_anchor": item["speaker_anchor"], + "tip": tail["tip"], + "result": "pass", + }) + next(check for check in checks if check["id"] == "bubble-tail-direction")[ + "regions" + ] = regions + return checks class PageQualityTests(unittest.TestCase): @@ -69,7 +96,7 @@ def test_exact_check_ids_and_method_boundaries(self): DETERMINISTIC_PAGE_CHECK_IDS, ) - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) checks = {check["id"]: check for check in record["checks"]} self.assertEqual(set(PAGE_CHECK_IDS), set(checks)) for check_id in DETERMINISTIC_PAGE_CHECK_IDS: @@ -83,13 +110,30 @@ def test_exact_check_ids_and_method_boundaries(self): def test_missing_or_generic_subjective_evidence_is_rejected(self): with self.assertRaisesRegex(ValueError, "subjective page checks"): build_page_quality_record(self.project, 1, []) - generic = reviewer_checks() + generic = reviewer_checks(self.project) generic[0]["evidence"] = "ok" with self.assertRaisesRegex(ValueError, "quality-evidence-generic"): build_page_quality_record(self.project, 1, generic) + def test_tail_direction_requires_one_current_region_per_dialogue(self): + missing = reviewer_checks(self.project) + tail_check = next( + check for check in missing if check["id"] == "bubble-tail-direction" + ) + tail_check["regions"] = [] + with self.assertRaisesRegex(ValueError, "bubble-tail-evidence-mismatch"): + build_page_quality_record(self.project, 1, missing) + + stale = reviewer_checks(self.project) + tail_check = next( + check for check in stale if check["id"] == "bubble-tail-direction" + ) + tail_check["regions"][0]["tip"][0] += 1 + with self.assertRaisesRegex(ValueError, "bubble-tail-evidence-mismatch"): + build_page_quality_record(self.project, 1, stale) + def test_record_binds_page_cache_layout_storyboard_and_ordered_lettering(self): - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) bindings = record["bindings"] self.assertEqual("2.0", record["schema_version"]) self.assertEqual("page-qa", record["kind"]) @@ -106,7 +150,7 @@ def test_record_binds_page_cache_layout_storyboard_and_ordered_lettering(self): for value in bindings["lettering_sha256s"])) def test_write_is_canonical_and_current_record_validates(self): - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) path = write_page_quality_record(self.project, 1, record) self.assertEqual((self.project / "qa/pages/page-001.json").resolve(), path) loaded = json.loads(path.read_text("utf-8")) @@ -118,7 +162,7 @@ def test_write_is_canonical_and_current_record_validates(self): self.assertEqual((), validate_page_quality(self.project, 1)) def test_page_cache_storyboard_layout_and_lettering_drift_are_stale(self): - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) write_page_quality_record(self.project, 1, record) cases = ( ("pages/page-001.png", "bindings.page_sha256", "append"), @@ -161,7 +205,7 @@ def test_geometry_detects_clipping_overlap_and_bad_reading_order(self): json.dumps(geometry, ensure_ascii=False, indent=2, sort_keys=True) + "\n", "utf-8", ) - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) checks = {check["id"]: check for check in record["checks"]} self.assertEqual("fail", checks["clipped-text"]["result"]) self.assertEqual("fail", checks["text-overlap"]["result"]) @@ -169,7 +213,7 @@ def test_geometry_detects_clipping_overlap_and_bad_reading_order(self): self.assertEqual("regenerate", record["decision"]) def test_export_ready_gate_requires_current_schema_two_page_quality(self): - record = build_page_quality_record(self.project, 1, reviewer_checks()) + record = build_page_quality_record(self.project, 1, reviewer_checks(self.project)) write_page_quality_record(self.project, 1, record) current = validate_project(self.project, "export-ready") self.assertFalse(any( diff --git a/tests/test_product_cli.py b/tests/test_product_cli.py index 4505eee..48e9e68 100644 --- a/tests/test_product_cli.py +++ b/tests/test_product_cli.py @@ -25,7 +25,7 @@ def test_version_flag_reports_release_identity(self): with self.assertRaises(SystemExit) as exit_context, redirect_stdout(stdout): cli.build_parser().parse_args(["--version"]) self.assertEqual(0, exit_context.exception.code) - self.assertEqual("comic-sol 2.0.0rc3\n", stdout.getvalue()) + self.assertEqual("comic-sol 2.0.0rc4\n", stdout.getvalue()) def test_default_output_roots_are_platform_native(self): home = Path("/users/example") diff --git a/tests/test_quality_matrix.py b/tests/test_quality_matrix.py index 670561e..ee7acca 100644 --- a/tests/test_quality_matrix.py +++ b/tests/test_quality_matrix.py @@ -21,7 +21,7 @@ from normalize_panels import normalize_panel from page_quality import build_page_quality_record, write_page_quality_record from quality_sample import EvidenceModeError, build_evidence_record, main -from tests.support import QUALITY_SCENARIOS, build_quality_fixture +from tests.support import QUALITY_SCENARIOS, bounded_tail_regions, build_quality_fixture from tests.test_validation import ( valid_characters, valid_manifest, @@ -74,7 +74,7 @@ def tree_digest(root: Path) -> str: return digest.hexdigest() -def page_reviewer_checks(): +def page_reviewer_checks(project: Path, page_number: int): return [ { "id": "face-action-obstruction", @@ -92,7 +92,7 @@ def page_reviewer_checks(): "evidence": "Reviewer inspected every bubble tail against its intended speaker.", "method": "bounded-visual-review", "reviewer": "matrix-reviewer", - "regions": [{"scope": "all-bubbles"}], + "regions": bounded_tail_regions(project, page_number), }, { "id": "accidental-text-watermark", @@ -272,7 +272,7 @@ def test_engine_lifecycle_gates_review_then_completes_without_regenerating_panel write_page_quality_record( project, 1, - build_page_quality_record(project, 1, page_reviewer_checks()), + build_page_quality_record(project, 1, page_reviewer_checks(project, 1)), ) result = finalize_project(project) @@ -295,7 +295,7 @@ def test_interrupted_export_resume_preserves_upstream_panel_hash(self): write_page_quality_record( project, 1, - build_page_quality_record(project, 1, page_reviewer_checks()), + build_page_quality_record(project, 1, page_reviewer_checks(project, 1)), ) first_result = finalize_project(project) pdf = project / first_result["pdf"] diff --git a/tests/test_release_docs.py b/tests/test_release_docs.py index 6706340..e449c22 100644 --- a/tests/test_release_docs.py +++ b/tests/test_release_docs.py @@ -9,11 +9,11 @@ def setUpClass(cls): cls.readme = (cls.root / "README.md").read_text(encoding="utf-8") cls.install = (cls.root / "docs/install.md").read_text(encoding="utf-8") cls.changelog = (cls.root / "CHANGELOG.md").read_text(encoding="utf-8") - cls.notes = (cls.root / "docs/releases/v2.0.0rc3.md").read_text(encoding="utf-8") + cls.notes = (cls.root / "docs/releases/v2.0.0rc4.md").read_text(encoding="utf-8") def test_readme_links_native_install_and_release_security(self): self.assertIn("docs/install.md", self.readme) - self.assertIn("v2.0.0rc3", self.readme) + self.assertIn("v2.0.0rc4", self.readme) self.assertIn("SHA256SUMS", self.readme) self.assertIn("unsigned", self.readme.lower()) self.assertIn("docker compose", self.install) @@ -42,7 +42,7 @@ def test_install_guide_covers_every_supported_lifecycle(self): def test_release_notes_and_changelog_identify_rc_limitations(self): for document in (self.changelog, self.notes): - self.assertIn("2.0.0rc3", document) + self.assertIn("2.0.0rc4", document) self.assertIn("unsigned", document.lower()) self.assertIn("Linux", document) self.assertIn("macOS", document) diff --git a/tests/test_typography.py b/tests/test_typography.py index 2d8b055..bb2f5d5 100644 --- a/tests/test_typography.py +++ b/tests/test_typography.py @@ -233,6 +233,19 @@ def test_valid_batch_publishes_preflight_image_and_complete_geometry(self): )) self.assertTrue(placed["font_runs"]) self.assertTrue(all("/" not in run["font_id"] for run in placed["font_runs"])) + if placed["kind"] == "dialogue": + tail = placed["tail"] + self.assertEqual("organic-cubic-v1", tail["policy_version"]) + self.assertEqual("human", tail["voice_source"]) + self.assertEqual( + { + "attachment", "base", "control", "length", "policy_version", + "source_gap", "speaker_anchor", "tip", "voice_source", "width", + }, + set(tail), + ) + else: + self.assertIsNone(placed["tail"]) self.assertEqual( json.dumps(geometry, ensure_ascii=False, indent=2, sort_keys=True) + "\n", geometry_path.read_text("utf-8"), @@ -324,7 +337,18 @@ def test_geometry_hash_font_policy_notdef_order_box_and_tail_are_rejected(self): ("bindings.font_policy_sha256", lambda g, t: g["bindings"].update(font_policy_sha256="0" * 64)), ("items.reading_order", lambda g, t: g["items"].append(dict(g["items"][0]))), ("items.box", lambda g, t: g["items"][0].update(box={"x": 1, "y": 1, "width": 0, "height": 2})), - ("items.tail", lambda g, t: g["items"][0].update(tail={"origin": [1, 2], "target": [float("inf"), 3]})), + ( + "items.tail", + lambda g, t: g["items"][0]["tail"]["control"][0][0].__setitem__( + 0, float("inf") + ), + ), + ( + "items.tail", + lambda g, t: g["items"][0].update( + tail={"origin": [1, 2], "target": [3, 4]} + ), + ), ("glyphs.font_id", lambda g, t: t["glyphs"][0].update(font_id=".notdef")), ) for field, mutate in cases: