` / `` (stripped on export), or a credits appendix slide. Executor renders inline credits per slide under [`executor-web-image.md`](./executor-web-image.md) and [`image-searcher.md`](./image-searcher.md) §7.
+License and attribution data live only in `project/images/image_sources.json` — never in `notes/*.md` (TTS would speak them), `total.md`, or SVG `` / `` (stripped on export). A closing sources page may summarize them, but it never replaces the per-slide credit: Executor renders inline credits per slide under [`executor-web-image.md`](./executor-web-image.md) and [`image-searcher.md`](./image-searcher.md) §7.
SVG authoring consumes `project/images/*.{jpg,png,webp}` and `image_sources.json`. Default Executor never invokes `image_gen.py` / `image_search.py` / `slice_images.py` / `image_treat.py` — missing material returns to Strategist-owned preparation; Quick finishes acquisition and derivation before authoring and neither acquires, derives, nor reselects while drawing. Completion: every row, file, manifest, and provenance record verified; Default proceeds to Executor, Quick exports only with validated evidence and usable statuses; report only blocking recovery.
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-data-interface.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-data-interface.md
index 4a8bda5f..22c5f6a4 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-data-interface.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-data-interface.md
@@ -66,7 +66,7 @@ The hash is a synchronization receipt, not proof of semantic equivalence; never
### Table schema — `ppt-master.semantic-table.v2`
-Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` / `runs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign`, `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
+Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` / `runs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
**Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry).
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md
index 6a4406be..7c67b0a3 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md
@@ -187,7 +187,7 @@ These forms are needed only when the stated PPT behavior matters:
### 4.3 Element Grouping (Mandatory)
-**Hard rule — root groups protect body-text layout**: every visible direct root `` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group — one group is the image plus its overlay — so it takes no separate zone and creates no root-group overlap; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are the only exemptions; thresholds and estimator detail: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
+**Hard rule — root groups protect body-text layout**: every visible direct root `` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group, so it takes no separate zone and creates no root-group overlap, and a clipped picture's zone is its visible region; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are the only exemptions; thresholds and estimator detail: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
Wrap each logical Slide-local body unit in one descriptive top-level ``; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. Titles, direct atomic Master/Layout elements, and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `` solely to silence an ungrouped-element advisory.
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md
index e8de5714..e59cf404 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md
@@ -34,7 +34,7 @@ For specialized or regulated paper-figure subjects keep the prompt depth of [`im
## 3. Image Resource List
-**Prepared derivatives**: keep the canonical row and add a deterministic child with a distinct `.png`, `Reference: Derived from ; treatment=;`, inheriting acquisition (§4.4 follows `user` / `ai` above); lock placed children; [`image-base.md`](./image-base.md) §1–2 owns preparation.
+**Prepared derivatives**: keep the canonical row and add a deterministic child with a distinct `.png`, `Reference: Derived from ; treatment=;`, inheriting acquisition (§4.4 follows `user` / `ai` above); lock placed children; when the canonical original itself never appears on a page, give its row `Type: Source` — like an Illustration Sheet it stays out of the lock and off every page, and the checker requires at least one row derived from it; [`image-base.md`](./image-base.md) §1–2 owns preparation.
**References describe visual intent**: AI rows carry subject + intent + composition without repeating rendering or HEX; web rows carry the exact subject, view/mood, focal/quiet region, crop safety, and positive quality cues, from which Image_Searcher derives a separate short provider query (complete entity names or disambiguation may use more words). When page use depends on stable composition, put subject/quiet zones, boundary or direction, intended overlap/seam, and approximate share in `Reference` or the §IX block, not only in `Image pattern`.
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md
index 3a06d710..5c0afd44 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md
@@ -180,7 +180,8 @@ duotone, Gaussian blur, and `--fit WxH` (downscale to fit inside a pixel
box, aspect ratio and alpha preserved; never upscales). They compose in a
fixed order: brightness → contrast → tone treatment → blur → fit. Desaturation, grayscale, and duotone are
mutually exclusive. At least one option must produce a real change; animated
-or multi-frame sources are rejected rather than reduced to one frame.
+sources are rejected rather than reduced to one frame, while a camera
+multi-picture JPEG (MPO) contributes its primary frame.
Both input and output are bare filenames directly under `images/`; output must
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
@@ -279,11 +280,11 @@ python3 scripts/image_search.py "abstract gradient" \
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
-- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/.jpg` (the placed asset stays full-resolution).
+- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/.jpg` (the placed asset stays full-resolution). A downloaded camera multi-picture JPEG (MPO, common among Commons originals) is rewritten as its primary frame before validation so every later consumer sees a single-frame JPEG.
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
- When the current Generate agent can inspect images, use `--save-candidates`. The tool saves only the first ranked page of review-eligible provider previews (**8 by default**), writes `candidates//review_sheet.jpg`, and leaves the target image and `image_sources.json` untouched. Standalone CLI use remains best-only unless this flag is explicit.
- Compare the thumbnail set against the active Reference/Crop Policy. Only after one passes, run `--promote candidate_03.jpg --filename .jpg`; this downloads and validates exactly that original. In batch mode, pass the same `--batch images/image_queries.json` so `Needs-Selection` becomes `Sourced`.
-- If no thumbnail passes and `has_more_candidates` is true, fetch `--candidate-page 2` (or set the batch row's `candidate_page` to `next_candidate_page` and reset it to `Pending`). Candidate numbering continues at 9; no original is downloaded. Only after the pool is exhausted should you materially change the identity wording, viewpoint, translation, alias, or disambiguator and generate a fresh pool.
+- If no thumbnail passes and `has_more_candidates` is true, fetch `--candidate-page 2` with the same query and filename — the pool's saved `required_terms`, `query_variants`, orientation, and minimum dimensions are inherited unless given again, and a changed query is refused as a page continuation (start a new page-1 pool instead). In batch mode pass `--candidate-page 2` to rerun every `Needs-Selection` row at that page, or set one row's `candidate_page` to `next_candidate_page` and reset only it to `Pending`. Candidate numbering continues at 9, earlier pages stay in `candidates.json` (`saved_pages`) so any saved thumbnail remains promotable, and no original is downloaded. Only after the pool is exhausted should you materially change the identity wording, viewpoint, translation, alias, or disambiguator and generate a fresh pool.
- Without multimodal inspection, omit `--save-candidates`. Best-only mode rejects visual-verification-required near matches, accepts only a strict metadata candidate, downloads one original, and records `selection_method: metadata-ranked`; if metadata cannot prove the entity or the active visual requirement, use `Needs-Manual` rather than claiming visual confirmation.
- `--from-url --filename .jpg` downloads a user-chosen image URL and replaces the target (recorded `license_tier: manual`) — the model-agnostic manual path; works even without a multimodal model.
@@ -291,7 +292,7 @@ Full review / escalation flow: [`image-searcher.md`](../../references/image-sear
Output:
-- `--save-candidates`: thumbnail-only `candidates//candidates.json`, at most 8 provider previews by default, and `review_sheet.jpg`; no target image or provenance entry. `--candidate-page N` advances through the ranked pool; `--max-candidates 0` explicitly dumps all candidates for exceptional debugging
+- `--save-candidates`: thumbnail-only `candidates//candidates.json`, at most 8 provider previews by default, and `review_sheet.jpg`; no target image or provenance entry. `--candidate-page N` advances through the ranked pool while keeping earlier pages' entries in `candidates.json`; `--max-candidates 0` explicitly dumps all candidates for exceptional debugging
- Best-only / `--promote`: one original saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
- Best-only / `--promote`: `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text)
- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement
@@ -315,14 +316,14 @@ The full role-level reference (intent → query translation, on-slide attributio
| `--require-terms` | — | Repeatable identity gate; comma separates groups, `A|B` aliases |
| `--save-candidates` | off | Thumbnail mode: one ranked page of previews plus `review_sheet.jpg`, no original |
| `--max-candidates` | `8` | Page size; `0` = complete pool, debugging only |
-| `--candidate-page` | `1` | Ranked page; page 2 starts at rank 9 |
+| `--candidate-page` | `1` | Ranked page; page 2 starts at rank 9. Single-query continuation inherits the saved pool request; batch reruns every `Needs-Selection` row at that page |
| `--promote ` | — | Download exactly one selected original, enforce gates, write provenance |
| `--from-url ` | — | Manual replacement recorded as `license_tier: manual`; works without vision |
| `--manifest ` | `images/image_queries.json` | Override the manifest path |
### Batch runner and ranking
-Required per item: `filename`, `query`, `status`; optional: `query_variants`, `candidate_page`, `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`. The runner revalidates every `Sourced` row against its file, dimensions, and manifest entry (drift → `Failed`), then searches all `Pending` / `Failed` rows concurrently (default concurrency 3, `--concurrency N` or `IMAGE_SEARCH_CONCURRENCY`; `1` for strict pacing on rate-sensitive free providers). Thumbnail mode writes `Needs-Selection` with `candidate_page`, `candidate_count`, `candidate_total`, `has_more_candidates`, `next_candidate_page`, and the `review_sheet` path, creating no image or provenance; to see the next page for one row set its `candidate_page` to `next_candidate_page`, reset only that row to `Pending`, and rerun. Promoting with the same `--batch` manifest moves the row to `Sourced`. Provider failures stay retryable `Failed`; clean exhaustion becomes `Needs-Manual`; status is saved after each completion.
+Required per item: `filename`, `query`, `status`; optional: `query_variants`, `candidate_page`, `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`. The runner revalidates every `Sourced` row against its file, dimensions, and manifest entry (drift → `Failed`), then searches all `Pending` / `Failed` rows concurrently (default concurrency 3, `--concurrency N` or `IMAGE_SEARCH_CONCURRENCY`; `1` for strict pacing on rate-sensitive free providers). Thumbnail mode writes `Needs-Selection` with `candidate_page`, `candidate_count`, `candidate_total`, `has_more_candidates`, `next_candidate_page`, and the `review_sheet` path, creating no image or provenance; to see the next page for one row set its `candidate_page` to `next_candidate_page`, reset only that row to `Pending`, and rerun; `--candidate-page N` on the batch run advances every `Needs-Selection` row at once. A positional query is rejected in batch mode rather than ignored. Promoting with the same `--batch` manifest moves the row to `Sourced`. Provider failures stay retryable `Failed`; clean exhaustion becomes `Needs-Manual`; status is saved after each completion.
**Ranking** orders provider metadata, never pixels, and must not be tuned into a taste engine: hard-reject invalid licenses and zero relevance; in best-only mode reject any candidate missing a `required_terms` group; in thumbnail mode keep strict matches first and admit a near match only when exactly one group is missing and the finding query still has strong relevance (marked `identity_evidence: visual-verification-required`, never auto-promoted); then metadata-verified identity in the title outranks a URL-only match; concrete query tokens match whole ASCII tokens (`office` ≠ `officer`) and dominate generic words; orientation is a small penalty, no-attribution a small bonus, pixel count capped so a huge weak match cannot beat a smaller accurate one.
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
index fc076299..93ea0833 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
@@ -507,7 +507,9 @@ implementation before publishing their authoring SVG. Standard workflows do
not rewrite completed SVG: they pass `--canonical-authoring` to
`svg_quality_checker.py`, which reports any remaining deterministic change as an
advisory warning (run `compact_svg_styles.py --inplace` on
-authored project pages and rerun the final gate to normalize, or keep the
+authored project pages, re-run `stamp_native_fallbacks.py --write` on pages
+that carry Chart/Table fallbacks because the rewrite changes their
+fingerprinted subtree, then rerun the final gate to normalize, or keep the
explicit form). Structured template rosters keep their explicit form: per-slide
compaction would make shared Master/Layout atoms diverge and shift native
fallback hashes, so the normalizer is not applied to them; mirror
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py
index fcce0ec5..51d0ecf2 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py
@@ -434,6 +434,59 @@ def _validate_downloaded_quality(
return False
+def _normalize_multi_frame_jpeg(path: Path) -> bool:
+ """Rewrite a camera MPO (multi-picture JPEG) as its primary frame in place.
+
+ Photo hosts serve stereo/multi-frame camera originals under a ``.jpg``
+ extension. ``image_treat.py``, the quality checker, and the PPTX exporter
+ all expect a single-frame JPEG, so the primary frame is re-encoded at high
+ quality with its EXIF and ICC data and the sibling frames are dropped.
+ Returns ``True`` when the file was rewritten.
+ """
+ if path.suffix.lower() not in {".jpg", ".jpeg"}:
+ return False
+ try:
+ from PIL import Image # type: ignore
+ except ImportError:
+ return False
+ try:
+ with Image.open(path) as source:
+ multi_frame = (
+ (source.format or "").upper() == "MPO"
+ or int(getattr(source, "n_frames", 1)) != 1
+ )
+ if not multi_frame:
+ return False
+ source.seek(0)
+ frame = source.convert("RGB")
+ save_kwargs: dict[str, object] = {"quality": 95}
+ for key in ("exif", "icc_profile"):
+ value = source.info.get(key)
+ if value:
+ save_kwargs[key] = value
+ except (OSError, ValueError, SyntaxError):
+ return False
+ fd, temp_name = tempfile.mkstemp(
+ prefix=f".{path.stem}.frame0.",
+ suffix=path.suffix,
+ dir=str(path.parent),
+ )
+ os.close(fd)
+ temp_path = Path(temp_name)
+ try:
+ frame.save(temp_path, "JPEG", **save_kwargs)
+ os.replace(temp_path, path)
+ except (OSError, ValueError):
+ try:
+ temp_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ return False
+ finally:
+ frame.close()
+ return True
+
+
def _stage_and_validate_image(
output_path: Path,
materialize: Callable[[Path], object],
@@ -455,6 +508,11 @@ def _stage_and_validate_image(
keep_temp = False
try:
materialize(temp_path)
+ if _normalize_multi_frame_jpeg(temp_path):
+ print(
+ " normalized a multi-frame camera JPEG (MPO) to its primary frame",
+ file=sys.stderr,
+ )
if not _validate_downloaded_quality(
temp_path,
min_width=min_width,
@@ -710,6 +768,56 @@ def _dedupe_ranked_candidates(
return deduped
+def _retained_pool_candidates(
+ meta_path: Path,
+ review_dir: Path,
+ request: ImageSearchRequest,
+ *,
+ current_ranks: set[int],
+) -> tuple[list[dict], list[int]]:
+ """Keep earlier pages of the same pool so any saved thumbnail stays promotable.
+
+ A pool belongs together while the target filename and query are unchanged;
+ a new query replaces it. Entries whose review file disappeared are dropped.
+ """
+ if not meta_path.is_file():
+ return [], []
+ try:
+ existing = json.loads(meta_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return [], []
+ if not isinstance(existing, dict):
+ return [], []
+ prior_request = existing.get("request")
+ if (
+ existing.get("target_filename") != request.filename
+ or not isinstance(prior_request, dict)
+ or str(prior_request.get("query") or "") != request.query
+ ):
+ return [], []
+ retained: list[dict] = []
+ for entry in existing.get("candidates") or []:
+ if not isinstance(entry, dict):
+ continue
+ rank = entry.get("rank")
+ review = entry.get("review")
+ if (
+ not isinstance(rank, int)
+ or isinstance(rank, bool)
+ or rank in current_ranks
+ or not isinstance(review, str)
+ or not (review_dir / Path(review).name).is_file()
+ ):
+ continue
+ retained.append(entry)
+ saved_pages = [
+ page
+ for page in existing.get("saved_pages") or [existing.get("candidate_page")]
+ if isinstance(page, int) and not isinstance(page, bool) and page > 0
+ ]
+ return retained, saved_pages
+
+
def _save_candidate_thumbnails(
ranked: list[tuple[float, str, AssetCandidate]],
output_dir: Path,
@@ -830,6 +938,14 @@ def _save_candidate_thumbnails(
except OSError:
pass
+ meta_path = cand_dir / "candidates.json"
+ retained, saved_pages = _retained_pool_candidates(
+ meta_path,
+ review_dir,
+ request,
+ current_ranks={entry["rank"] for entry in pool},
+ )
+ saved_pages = sorted(set(saved_pages) | {candidate_page})
meta = {
"schema_version": 3,
"candidate_storage": "thumbnail-only",
@@ -838,6 +954,7 @@ def _save_candidate_thumbnails(
"searched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"license_stage": license_stage,
"candidate_page": candidate_page,
+ "saved_pages": saved_pages,
"page_size": max_candidates,
"candidate_total": candidate_total,
"has_more_candidates": has_more_candidates,
@@ -851,9 +968,8 @@ def _save_candidate_thumbnails(
"min_width": request.min_width,
"min_height": request.min_height,
},
- "candidates": pool,
+ "candidates": sorted(retained + pool, key=lambda entry: entry["rank"]),
}
- meta_path = cand_dir / "candidates.json"
_write_json_atomic(meta_path, meta)
review_sheet: Optional[Path] = None
@@ -1314,6 +1430,27 @@ def write_sources_manifest(path: Path, item: dict) -> Path:
# ---------------------------------------------------------------------------
+def _load_saved_candidate_request(
+ output_dir: Path,
+ target_filename: str,
+) -> Optional[dict]:
+ """Return the ``request`` block of an existing candidate pool, if any."""
+ stem = Path(target_filename).stem
+ meta_path = output_dir / "candidates" / stem / "candidates.json"
+ if not meta_path.is_file():
+ return None
+ try:
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(meta, dict):
+ return None
+ if meta.get("target_filename") != target_filename:
+ return None
+ request = meta.get("request")
+ return request if isinstance(request, dict) else None
+
+
def promote_candidate(
output_dir: Path,
target_filename: str,
@@ -2023,6 +2160,25 @@ def run_search_manifest(
if repaired_sourced:
save_search_manifest(manifest_path, manifest)
+ if candidate_page > 1:
+ # An explicit page on the command line continues every shortlisted
+ # row; Needs-Selection is otherwise terminal, so without this the
+ # flag would silently do nothing.
+ advanced = 0
+ for item in items:
+ if item["status"] != SEARCH_STATUS_NEEDS_SELECTION:
+ continue
+ item["candidate_page"] = candidate_page
+ item["status"] = SEARCH_STATUS_PENDING
+ advanced += 1
+ if advanced:
+ print(
+ f"[Batch] --candidate-page {candidate_page}: {advanced} "
+ "Needs-Selection row(s) rerun at that page; set one row's "
+ "candidate_page in the manifest instead to advance only it."
+ )
+ save_search_manifest(manifest_path, manifest)
+
pending_idx = [
i for i, it in enumerate(items)
if it["status"] in SEARCH_RETRYABLE_STATUSES
@@ -2415,6 +2571,11 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.error("--candidate-page must be a positive integer")
if args.max_candidates == 0 and args.candidate_page != 1:
parser.error("--candidate-page must be 1 when --max-candidates is 0")
+ if args.batch and args.query and not args.promote and not args.from_url:
+ parser.error(
+ "a positional query is not used in --batch mode; put the query in "
+ "the manifest row, or drop --batch for a single-query search"
+ )
output_dir = Path(args.output)
@@ -2501,16 +2662,68 @@ def main(argv: Optional[list[str]] = None) -> int:
if not args.filename:
parser.error("--filename is required in single-query mode")
+ saved_request = _load_saved_candidate_request(output_dir, args.filename)
+ if args.candidate_page > 1 and saved_request is None:
+ parser.error(
+ f"--candidate-page {args.candidate_page} continues an existing "
+ f"candidate pool, but none was saved for {args.filename!r}; run "
+ "page 1 with --save-candidates first"
+ )
+ if saved_request is not None and args.candidate_page > 1:
+ saved_query = str(saved_request.get("query") or "")
+ if saved_query and saved_query != args.query:
+ parser.error(
+ f"--candidate-page {args.candidate_page} must keep the pool's "
+ f"query {saved_query!r}; a changed query starts a new page-1 "
+ "pool instead"
+ )
+ required_terms = _parse_required_terms(args.require_terms)
+ query_variants = _parse_query_variants(args.query_variant)
+ orientation = "" if args.orientation == "any" else args.orientation
+ min_width = args.min_width
+ min_height = args.min_height
+ if saved_request is not None and args.candidate_page > 1:
+ inherited: list[str] = []
+ if not required_terms and saved_request.get("required_terms"):
+ required_terms = _parse_required_terms(saved_request["required_terms"])
+ inherited.append(f"required_terms={list(required_terms)}")
+ if not query_variants and saved_request.get("query_variants"):
+ query_variants = _parse_query_variants(saved_request["query_variants"])
+ inherited.append(f"query_variants={list(query_variants)}")
+ saved_orientation = str(saved_request.get("orientation") or "any")
+ if not orientation and saved_orientation != "any":
+ orientation = saved_orientation
+ inherited.append(f"orientation={orientation}")
+ for field in ("min_width", "min_height"):
+ saved_value = saved_request.get(field)
+ if (
+ isinstance(saved_value, int)
+ and not isinstance(saved_value, bool)
+ and saved_value > 0
+ and getattr(args, field) == parser.get_default(field)
+ and saved_value != getattr(args, field)
+ ):
+ if field == "min_width":
+ min_width = saved_value
+ else:
+ min_height = saved_value
+ inherited.append(f"{field}={saved_value}")
+ if inherited:
+ print(
+ " continuing the saved candidate pool; inherited "
+ + ", ".join(inherited),
+ file=sys.stderr,
+ )
request = ImageSearchRequest(
query=args.query,
purpose=args.purpose,
- orientation="" if args.orientation == "any" else args.orientation,
+ orientation=orientation,
filename=args.filename,
slide=args.slide,
- min_width=args.min_width,
- min_height=args.min_height,
- required_terms=_parse_required_terms(args.require_terms),
- query_variants=_parse_query_variants(args.query_variant),
+ min_width=min_width,
+ min_height=min_height,
+ required_terms=required_terms,
+ query_variants=query_variants,
)
_warn_weak_required_terms(request.required_terms)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py
index a1f2b612..21eeae11 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py
@@ -332,9 +332,14 @@ def _apply_treatments(
try:
with Image.open(source_path) as source:
if int(getattr(source, "n_frames", 1)) != 1:
- raise RuntimeError(
- f"animated or multi-frame images are unsupported: {source_path}"
- )
+ if (source.format or "").upper() == "MPO":
+ # A camera multi-picture JPEG: the primary frame is the
+ # photograph; the sibling frames are stereo/preview data.
+ source.seek(0)
+ else:
+ raise RuntimeError(
+ f"animated images are unsupported: {source_path}"
+ )
oriented = ImageOps.exif_transpose(source)
try:
oriented.load()
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
index 0b8f3de3..d334a5d1 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
@@ -1133,6 +1133,7 @@ class SVGQualityChecker:
canonical_authoring: bool = False,
):
self.template_mode = template_mode
+ self._image_pixel_sizes: Dict[Path, Tuple[int, int]] = {}
self.scan_banner = True
self.quick_generate = quick_generate
self.canonical_authoring = canonical_authoring
@@ -2191,8 +2192,9 @@ class SVGQualityChecker:
result['warnings'].extend(
f"Noncanonical compact authoring: {error} "
"(advisory; normalize with "
- "`python3 scripts/compact_svg_styles.py --inplace` "
- "and rerun the final gate, or leave the explicit form)"
+ "`python3 scripts/compact_svg_styles.py --inplace`, "
+ "re-stamp pages that carry Chart/Table fallbacks, and rerun the "
+ "final gate, or leave the explicit form)"
for error in errors
)
@@ -7433,6 +7435,47 @@ class SVGQualityChecker:
f"images/{filename} does not exist.",
))
+ # ``Type: Source`` marks a web/user/ai original that only feeds
+ # prepared derivatives; like a sheet it never enters the lock or a page.
+ derived_parents = set()
+ for row in rows:
+ match = re.search(
+ r'derived\s+from\s+`?([^;|`]+?)`?\s*(?:;|$)',
+ row.get('Reference', ''),
+ re.IGNORECASE,
+ )
+ if match:
+ derived_parents.add(Path(match.group(1).strip()).name)
+ for row in rows:
+ if self._row_type(row).lower() != 'source':
+ continue
+ filename = self._row_filename(row)
+ if not filename:
+ continue
+ if filename in lock_images:
+ self._illustration_issues.append((
+ 'error',
+ 'source_in_lock',
+ f"{filename} is a Source row (unplaced derivation parent) "
+ "but is listed in spec_lock.md images; lock only its "
+ "placed derivatives.",
+ ))
+ if filename in all_svg_references:
+ self._illustration_issues.append((
+ 'error',
+ 'source_referenced',
+ f"{filename} is a Source row but is referenced by an SVG; "
+ "give the row a placed Type or place a derivative instead.",
+ ))
+ if filename not in derived_parents:
+ self._illustration_issues.append((
+ 'error',
+ 'source_without_derivative',
+ f"{filename} is a Source row but no row is `Derived from "
+ f"{filename}`; a Source row exists only to feed prepared "
+ "derivatives.",
+ ))
+
if current_contract:
self._check_planned_image_closure(
rows,
@@ -7648,7 +7691,12 @@ class SVGQualityChecker:
inline_counts: Dict[Path, int] = {}
placements: Dict[
str,
- List[Tuple[Path, str, Tuple[str, ...]]],
+ List[Tuple[
+ Path,
+ str,
+ Tuple[str, ...],
+ Tuple[float, float, float, float] | None,
+ ]],
] = defaultdict(list)
for svg_path in discover_slide_svgs(svg_dir):
try:
@@ -7679,6 +7727,11 @@ class SVGQualityChecker:
working_root,
parent_by_id,
),
+ cls._image_frame_geometry(
+ element,
+ working_root,
+ parent_by_id,
+ ),
))
if _resolve_external_image_reference is not None:
resolved = _resolve_external_image_reference(
@@ -7692,6 +7745,88 @@ class SVGQualityChecker:
inline_counts[svg_path] = inline_count
return out, inline_counts, dict(placements)
+ @staticmethod
+ def _image_frame_geometry(
+ image: ET.Element,
+ root: ET.Element,
+ parent_by_id: Dict[int, ET.Element],
+ ) -> Tuple[float, float, float, float] | None:
+ """Return (frame width, frame height, source fraction w, h) of one instance.
+
+ Inside the nested-``