From 406e58115695b697aeb48af6140a4fdc14871222 Mon Sep 17 00:00:00 2001 From: Xu Jingxin Date: Fri, 8 May 2026 13:28:30 +0800 Subject: [PATCH] Improve hatch-pet playback QA --- skills/.curated/hatch-pet/SKILL.md | 48 +++++++- .../hatch-pet/scripts/compose_atlas.py | 14 ++- .../derive_running_left_from_running_right.py | 13 +- .../hatch-pet/scripts/extract_strip_frames.py | 113 +++++++++++++++++- .../hatch-pet/scripts/finalize_pet_run.py | 57 ++++++++- 5 files changed, 231 insertions(+), 14 deletions(-) diff --git a/skills/.curated/hatch-pet/SKILL.md b/skills/.curated/hatch-pet/SKILL.md index fef66b5c..c08f430a 100644 --- a/skills/.curated/hatch-pet/SKILL.md +++ b/skills/.curated/hatch-pet/SKILL.md @@ -62,14 +62,22 @@ Avoid these by default because they usually break transparent-background cleanup - chroma-key-adjacent colors in the pet, prop, effects, highlights, or shadows - stray pixels, disconnected outline bits, speckle/noise, cropped body parts, overlapping poses, or any pose that crosses into a neighboring frame slot +### Frame Extraction And Playback Stability + +Generated row strips may intentionally keep the pet at a constant apparent scale while a pose moves within the slot. Do not accept a final atlas that re-crops each frame to make the character fill the 192x208 cell when the row strip already has stable scale and baseline. This causes visible size popping in preview videos, especially for sitting, crying, crouching, jumping, split poses, or any action that intentionally shifts lower in the slot. + +Prefer normal component extraction when it preserves scale and separates frames cleanly. If the contact sheet or preview videos show per-frame size changes caused by cropping, re-finalize with `--extract-method fixed-slots --allow-slot-extraction` so each frame uses shared row-level slot geometry. Fixed-slot extraction must still use mask/component cleanup and shared placement; reject crude slot crops that clip wide limbs, include neighboring-slot fragments, or leave colored residue in transparent areas. + +Wide poses must be QAed against the contact sheet at native cell size. If a split jump, raised arm, prop, or long accessory touches a cell edge or is cut off, repair the row or enlarge the row-level safe padding before accepting it. Hidden RGB in fully transparent pixels must be cleared by the deterministic atlas composer; reject WebP/PNG outputs that show colored blocks, stripes, or remnants in external viewers. + State-specific guidance: -- `idle`: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head/body bob, a very small material sway, or another quiet persona-preserving motion. Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props. +- `idle`: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head/body bob, a very small material sway, or another quiet persona-preserving motion. Idle frames may be subtle, but they must not be near-identical copies; plan visible micro-variation across the loop, such as open eyes, blink, softer smile, tiny head tilt, breathing up/down, and return-to-neutral. Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props. - `waving`: show the wave through paw pose only. Do not draw wave marks, motion arcs, lines, sparkles, or symbols around the paw. - `jumping`: show vertical motion through body position only. Do not draw shadows, dust, landing marks, impact bursts, bounce pads, or floor cues. - `failed`: tears, attached smoke puffs, or attached stars are allowed if they obey the allowed-effects rules; do not use red X marks, floating symbols, detached smoke, detached stars, or separate tear droplets. - `review`: show focus through lean, blink, eyes, head tilt, or paw position. Do not add magnifying glasses, papers, code, UI, punctuation, or symbols unless that prop already exists in the base pet identity. -- `running-right` and `running-left`: show directional locomotion through body, limb, and prop movement only. Do not draw speed lines, dust clouds, floor shadows, or motion trails. +- `running-right` and `running-left`: show directional locomotion through body, limb, and prop movement only. `running-right` must face and travel right; `running-left` must face and travel left. The leg cycle must alternate clearly across frames, not repeat one static stride with small offsets. When deriving a mirrored row, mirror each frame in place and preserve temporal frame order. Do not draw speed lines, dust clouds, floor shadows, or motion trails. - `running`: show an active working/in-progress loop, as if the pet is busy running a task. Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel. ## Pet Naming @@ -104,7 +112,7 @@ What each step means: - `Getting ready.` Choose or confirm the pet name, description, source images, and working folder. - `Imagining 's main look.` Generate the pet's main reference image. This is required for new pets, even when the user does not provide an image, because it becomes the visual source of truth. -- `Picturing 's poses.` Create the pose rows, starting with `idle` and `running-right` to confirm the pet still looks consistent. Only mirror `running-left` if `running-right` clearly works when flipped. +- `Picturing 's poses.` Create the pose rows, starting with `idle` and `running-right` to confirm the pet still looks consistent. Confirm idle frames have visible micro-variation, and confirm the directional running row faces right with clear alternating legs. Only mirror `running-left` if `running-right` clearly works when flipped. - `Hatching .` Turn the approved poses into the final pet files, review the contact sheet, previews, and validation results, fix any broken parts, save `pet.json` and `spritesheet.webp` into the pet folder, then tell the user where the pet and QA files were saved. Only mark a step complete when the real file, image, or decision exists. If this is just a repair run, start from the first relevant step instead of restarting the whole checklist. @@ -145,7 +153,7 @@ The base job must complete first. If user references exist, the base job uses th When generating row strips, keep the identity lock in the row prompt authoritative: do not redesign the pet, and preserve the same head shape, face, markings, palette, prop, outline weight, body proportions, and silhouette. A row that looks like a related but different pet is failed even if the deterministic geometry QA passes. -Generate and record `running-right` before deciding how to complete `running-left`. Inspect `running-right` against the base and references. If the pet is visually symmetric enough that a horizontal mirror preserves identity, prop placement, handedness, markings, lighting, text-free details, and direction semantics, derive `running-left` with: +Generate and record `running-right` before deciding how to complete `running-left`. Inspect `running-right` against the base and references. The row must actually face right and show a readable alternating gait; a row that faces left or repeats one static stride is a failed `running-right` even if geometry validation passes. If the pet is visually symmetric enough that a horizontal mirror preserves identity, prop placement, handedness, markings, lighting, text-free details, and direction semantics, derive `running-left` with: ```bash python "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \ @@ -156,6 +164,8 @@ python "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \ If there is any asymmetric side-specific marking, readable text, non-mirrored logo, handed prop, one-sided accessory, lighting cue, or direction-specific pose that would become wrong when flipped, do not mirror. Generate `running-left` with `$imagegen` using its row prompt and all listed grounding images, including `decoded/running-right.png` as a gait reference. +Mirroring must preserve animation timing. Use `derive_running_left_from_running_right.py`, which mirrors each frame slot in place; do not mirror the whole strip in a way that reverses frame order. + For the built-in path, record the selected source image from `$CODEX_HOME/generated_images/.../ig_*.png`. Do not record files from the run directory, `tmp/`, hand-made fixtures, deterministic row folders, or post-processed copies as visual job sources. 4. After selecting a generated output for a job, ingest it: @@ -176,6 +186,17 @@ python "$SKILL_DIR/scripts/finalize_pet_run.py" \ --run-dir /absolute/path/to/run ``` +Use the default extraction first for a normal clean row strip. If the generated row strip has stable character scale but the final atlas or preview videos show size popping because each frame was individually fit to the cell, re-finalize with fixed-slot extraction: + +```bash +python "$SKILL_DIR/scripts/finalize_pet_run.py" \ + --run-dir /absolute/path/to/run \ + --extract-method fixed-slots \ + --allow-slot-extraction +``` + +After using fixed-slot extraction, inspect for the opposite failure mode: wide poses, raised arms, props, hoops, ribbons, or split jumps clipped by the fixed frame. If that happens, repair only the affected row or regenerate it with more safe padding; do not accept a clipped atlas just because playback scale is stable. + Expected output: ```text @@ -206,6 +227,8 @@ Review `qa/contact-sheet.png`, `qa/review.json`, `final/validation.json`, and `q Deterministic validation is necessary but not sufficient. Before calling the pet done, visually inspect the contact sheet for identity consistency. Block acceptance if any row changes species/body type, face, markings, palette, prop design, prop side unexpectedly, or overall silhouette. +Preview videos are required for playback-specific QA. Block acceptance if videos show unintended character size popping, sudden vertical jumps caused by cropping, wrong facing direction, reversed or non-alternating gait, idle frames that are effectively identical, or transparent-color blocks that were hidden in the contact sheet. + ## Subagent Row Generation After the base job has been recorded and `references/canonical-base.png` exists, row-strip visual generation must use subagents unless the user explicitly says not to use subagents for this session. Before row generation, state that subagents are being used and which row jobs are being delegated. If subagents cannot be spawned because the current environment or tool policy blocks them, stop before row-strip generation, explain the blocker, and ask for explicit user direction before continuing sequentially. @@ -253,6 +276,8 @@ Before returning, visually check: - clean flat chroma-key background - complete, separated, unclipped poses - no forbidden detached effects or slot-crossing artifacts +- for `idle`, visible subtle expression or posture variation across the loop +- for `running-right` and `running-left`, correct facing direction and clear alternating legs Do not edit manifests, copy into decoded, record results, mirror rows, finalize, repair, or package. Return only: selected_source=/absolute/path/to/$CODEX_HOME/generated_images/.../ig_*.png @@ -274,6 +299,10 @@ Then repeat the `$imagegen` generation and `record_imagegen_result.py` ingest lo For identity repairs, use the canonical base image, original references, contact sheet, and exact row failure note as grounding context. Repair only the failed row while preserving the canonical pet identity. +For playback-size popping where the generated horizontal strip itself has correct relative scale, do not regenerate images first. Re-run finalization with `--extract-method fixed-slots --allow-slot-extraction`, then re-check `qa/contact-sheet.png` and `qa/videos/`. If fixed slots clip a wide pose, repair only that row with more safe padding or a clearer row prompt. + +For directional running repairs, regenerate `running-right` if it faces the wrong way or lacks alternating legs. If the repaired `running-right` is safe to mirror, derive `running-left` from it with `derive_running_left_from_running_right.py` so the left row is a per-frame mirror with the same timing order. + ## Secondary Image Generation Fallback `scripts/generate_pet_images.py` is a secondary fallback for this skill. @@ -300,14 +329,21 @@ The secondary fallback requires `OPENAI_API_KEY`. - Generate every normal visual job with `$imagegen`: base plus all row strips that are not explicitly approved `running-left` mirror derivations. - Treat only the base job as eligible for prompt-only generation; every row job must attach its listed grounding images. - Delegate `running-right` first, then mirror `running-left` only when visual inspection confirms a mirror preserves identity and semantics; otherwise delegate `running-left` as a normal grounded `$imagegen` row. +- Treat a `running-right` row that faces left as failed. Treat a `running-left` row that faces right as failed. +- Treat directional walking/running rows as failed if the legs do not visibly alternate across the cycle. +- When mirroring `running-left`, mirror each frame in place and preserve frame order; never reverse the temporal sequence by mirroring the whole strip incorrectly. - Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing `$imagegen` outputs. - Never manually mutate `imagegen-jobs.json` to claim a visual job completed. - Do not rely on generated images for exact atlas geometry; use this skill's deterministic scripts. +- Do not accept per-frame fit-to-cell extraction when it creates playback size popping. Use fixed-slot extraction for rows whose generated strips already preserve stable scale and placement. - Use the chroma key stored in `pet_request.json`; do not force a fixed green screen. - Keep the pet's silhouette, face, materials, palette, and props consistent across all rows. - Enforce the transparency and effects rules above in every base, row, and repair prompt. - Treat visual identity drift as a blocker even when `qa/review.json` and `final/validation.json` have no errors. +- Treat idle rows with near-identical frames as failed; the loop must have visible but calm micro-variation. - Treat a contact sheet that shows cropped references, repeated tiles, white cell backgrounds, or non-sprite fragments as failed. +- Treat clipped wide poses, raised limbs, long accessories, or prop/extremity cutoffs as failed, even if fixed-slot extraction solved scale stability. +- Treat hidden transparent-pixel color residue that appears as colored blocks, stripes, or smears in preview/export as failed. - Treat forbidden detached effects, chroma-key-adjacent artifacts, shadows, glows, smears, dust, landing marks, wave marks, speed lines, or motion trails as failed rows. - Treat `qa/review.json` errors as blockers. Warnings require visual review. @@ -319,4 +355,8 @@ The secondary fallback requires `OPENAI_API_KEY`. - Contact sheet and preview videos have been produced unless explicitly skipped. - `qa/review.json` has no errors. - Row-by-row review confirms the animation cycles are complete enough for the Codex app. +- Preview videos show stable intended character scale and placement, with no unintended size popping from per-frame cropping. +- Directional rows face the correct way and use readable alternating gait frames. +- Idle has visible calm expression or posture variation rather than repeated copies. +- Wide poses and props are not clipped, and transparent areas render cleanly without hidden color blocks. - `${CODEX_HOME:-$HOME/.codex}/pets//pet.json` and `${CODEX_HOME:-$HOME/.codex}/pets//spritesheet.webp` are staged together for custom pets. diff --git a/skills/.curated/hatch-pet/scripts/compose_atlas.py b/skills/.curated/hatch-pet/scripts/compose_atlas.py index 61d25a05..2c865e83 100644 --- a/skills/.curated/hatch-pet/scripts/compose_atlas.py +++ b/skills/.curated/hatch-pet/scripts/compose_atlas.py @@ -107,12 +107,24 @@ def compose_from_frames(root: Path) -> Image.Image: return atlas +def clear_transparent_rgb(image: Image.Image) -> Image.Image: + rgba = image.convert("RGBA") + data = bytearray(rgba.tobytes()) + for index in range(0, len(data), 4): + if data[index + 3] == 0: + data[index] = 0 + data[index + 1] = 0 + data[index + 2] = 0 + return Image.frombytes("RGBA", rgba.size, bytes(data)) + + def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None: + atlas = clear_transparent_rgb(atlas) output.parent.mkdir(parents=True, exist_ok=True) atlas.save(output) if webp_output is not None: webp_output.parent.mkdir(parents=True, exist_ok=True) - atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6) + atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6, exact=True) def main() -> None: diff --git a/skills/.curated/hatch-pet/scripts/derive_running_left_from_running_right.py b/skills/.curated/hatch-pet/scripts/derive_running_left_from_running_right.py index 8fe04bf4..a4962e3c 100644 --- a/skills/.curated/hatch-pet/scripts/derive_running_left_from_running_right.py +++ b/skills/.curated/hatch-pet/scripts/derive_running_left_from_running_right.py @@ -57,6 +57,17 @@ def manifest_relative(path: Path, run_dir: Path) -> str: return str(path.resolve().relative_to(run_dir.resolve())) +def mirror_strip_preserve_frame_order(source: Image.Image, frame_count: int = 8) -> Image.Image: + rgba = source.convert("RGBA") + output = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) + slot_width = rgba.width / frame_count + for index in range(frame_count): + left = round(index * slot_width) + right = round((index + 1) * slot_width) + output.alpha_composite(ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))), (left, 0)) + return output + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--run-dir", required=True) @@ -99,7 +110,7 @@ def main() -> None: output.parent.mkdir(parents=True, exist_ok=True) with Image.open(source) as image: - mirrored = ImageOps.mirror(image.convert("RGBA")) + mirrored = mirror_strip_preserve_frame_order(image) mirrored.save(output) left_job["status"] = "complete" diff --git a/skills/.curated/hatch-pet/scripts/extract_strip_frames.py b/skills/.curated/hatch-pet/scripts/extract_strip_frames.py index 591d403c..51ac6444 100644 --- a/skills/.curated/hatch-pet/scripts/extract_strip_frames.py +++ b/skills/.curated/hatch-pet/scripts/extract_strip_frames.py @@ -99,6 +99,26 @@ def fit_to_cell(image: Image.Image) -> Image.Image: return target +def fit_viewport_to_cell(image: Image.Image) -> Image.Image: + target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) + if image.getbbox() is None: + return target + + viewport = image.copy() + max_width = CELL_WIDTH - 10 + max_height = CELL_HEIGHT - 10 + scale = min(max_width / viewport.width, max_height / viewport.height, 1.0) + if scale != 1.0: + viewport = viewport.resize( + (max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))), + Image.Resampling.LANCZOS, + ) + left = (CELL_WIDTH - viewport.width) // 2 + top = (CELL_HEIGHT - viewport.height) // 2 + target.alpha_composite(viewport, (left, top)) + return target + + def connected_components(image: Image.Image) -> list[dict[str, object]]: alpha = image.getchannel("A") width, height = image.size @@ -183,7 +203,10 @@ def component_group_image( return output -def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None: +def component_frame_groups( + strip: Image.Image, + frame_count: int, +) -> list[list[dict[str, object]]] | None: components = connected_components(strip) if not components: return None @@ -215,9 +238,44 @@ def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image ) groups[nearest_index].append(component) + return groups + + +def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None: + groups = component_frame_groups(strip, frame_count) + if groups is None: + return None return [fit_to_cell(component_group_image(strip, group)) for group in groups] +def components_bbox(components: list[dict[str, object]]) -> tuple[int, int, int, int]: + return ( + min(component["bbox"][0] for component in components), + min(component["bbox"][1] for component in components), + max(component["bbox"][2] for component in components), + max(component["bbox"][3] for component in components), + ) + + +def crop_with_transparent_padding( + source: Image.Image, + box: tuple[int, int, int, int], +) -> Image.Image: + left, top, right, bottom = box + output = Image.new("RGBA", (right - left, bottom - top), (0, 0, 0, 0)) + source_box = ( + max(0, left), + max(0, top), + min(source.width, right), + min(source.height, bottom), + ) + if source_box[0] >= source_box[2] or source_box[1] >= source_box[3]: + return output + crop = source.crop(source_box) + output.alpha_composite(crop, (source_box[0] - left, source_box[1] - top)) + return output + + def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: slot_width = strip.width / frame_count frames = [] @@ -229,6 +287,47 @@ def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Imag return frames +def extract_fixed_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: + component_groups = component_frame_groups(strip, frame_count) + if component_groups is not None: + padding = 4 + bboxes = [components_bbox(group) for group in component_groups] + top = max(0, min(bbox[1] for bbox in bboxes) - padding) + bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding) + viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2 + viewport_height = bottom - top + + frames = [] + for group, bbox in zip(component_groups, bboxes): + group_image = component_group_image(strip, group, padding=padding) + group_image_top = max(0, bbox[1] - padding) + viewport = Image.new( + "RGBA", + (viewport_width, viewport_height), + (0, 0, 0, 0), + ) + left = (viewport_width - group_image.width) // 2 + viewport.alpha_composite(group_image, (left, group_image_top - top)) + frames.append(fit_viewport_to_cell(viewport)) + return frames + + bbox = strip.getbbox() + if bbox is None: + return [Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) for _ in range(frame_count)] + + padding = 4 + top = max(0, bbox[1] - padding) + bottom = min(strip.height, bbox[3] + padding) + slot_width = strip.width / frame_count + frames = [] + for index in range(frame_count): + left = round(index * slot_width) + right = round((index + 1) * slot_width) + crop = strip.crop((left, top, right, bottom)) + frames.append(fit_viewport_to_cell(crop)) + return frames + + def extract_state( strip_path: Path, state: str, @@ -254,8 +353,12 @@ def extract_state( used_method = "components" if frames is None: - frames = extract_slot_frames(strip, frame_count) - used_method = "slots" + if method == "fixed-slots": + frames = extract_fixed_slot_frames(strip, frame_count) + used_method = "fixed-slots" + else: + frames = extract_slot_frames(strip, frame_count) + used_method = "slots" outputs = [] for index, frame in enumerate(frames): @@ -274,9 +377,9 @@ def main() -> None: parser.add_argument("--key-threshold", type=float, default=96.0) parser.add_argument( "--method", - choices=("auto", "components", "slots"), + choices=("auto", "components", "slots", "fixed-slots"), default="auto", - help="Use connected sprite components when possible, or fixed equal slots.", + help="Use connected sprite components, per-frame slots, or fixed row-level slot viewports.", ) args = parser.parse_args() diff --git a/skills/.curated/hatch-pet/scripts/finalize_pet_run.py b/skills/.curated/hatch-pet/scripts/finalize_pet_run.py index 8350b729..dac1eaa8 100644 --- a/skills/.curated/hatch-pet/scripts/finalize_pet_run.py +++ b/skills/.curated/hatch-pet/scripts/finalize_pet_run.py @@ -7,6 +7,7 @@ import hashlib import json import os +import shutil import subprocess import sys from pathlib import Path @@ -112,11 +113,20 @@ def validate_mirror_hash(job: dict[str, object], *, source: Path, output: Path, "rerun derive_running_left_from_running_right.py" ) with Image.open(source) as source_image, Image.open(output) as output_image: - expected = ImageOps.mirror(source_image.convert("RGBA")) + source_rgba = source_image.convert("RGBA") + expected = Image.new("RGBA", source_rgba.size, (0, 0, 0, 0)) + slot_width = source_rgba.width / 8 + for index in range(8): + left = round(index * slot_width) + right = round((index + 1) * slot_width) + expected.alpha_composite( + ImageOps.mirror(source_rgba.crop((left, 0, right, source_rgba.height))), + (left, 0), + ) actual = output_image.convert("RGBA") if expected.size != actual.size or expected.tobytes() != actual.tobytes(): raise SystemExit( - "running-left mirrored output is not an exact horizontal mirror of running-right" + "running-left mirrored output is not an exact per-frame horizontal mirror of running-right" ) @@ -217,10 +227,49 @@ def review_failures(review: dict[str, object]) -> list[str]: return failures +def should_derive_running_left_frames(run_dir: Path) -> bool: + manifest = load_json(run_dir / "imagegen-jobs.json") + jobs = manifest.get("jobs") + if not isinstance(jobs, list): + return False + for job in jobs: + if ( + isinstance(job, dict) + and job.get("id") == "running-left" + and job.get("source_provenance") == "deterministic-mirror" + and job.get("derived_from") == "running-right" + ): + return True + return False + + +def derive_running_left_frames_from_right(run_dir: Path) -> None: + right_dir = run_dir / "frames" / "running-right" + left_dir = run_dir / "frames" / "running-left" + if not right_dir.is_dir(): + raise SystemExit(f"cannot derive running-left frames; missing {right_dir}") + files = sorted(right_dir.glob("*.png")) + if len(files) != 8: + raise SystemExit(f"cannot derive running-left frames; expected 8 right frames, found {len(files)}") + + if left_dir.exists(): + shutil.rmtree(left_dir) + left_dir.mkdir(parents=True, exist_ok=True) + for frame_path in files: + with Image.open(frame_path) as frame: + ImageOps.mirror(frame.convert("RGBA")).save(left_dir / frame_path.name) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--run-dir", required=True) parser.add_argument("--allow-slot-extraction", action="store_true") + parser.add_argument( + "--extract-method", + choices=("auto", "components", "slots", "fixed-slots"), + default="auto", + help="Frame extraction strategy for decoded row strips.", + ) parser.add_argument("--skip-videos", action="store_true") parser.add_argument("--skip-package", action="store_true") parser.add_argument( @@ -262,9 +311,11 @@ def main() -> None: "--states", "all", "--method", - "auto", + args.extract_method, ] ) + if should_derive_running_left_frames(run_dir): + derive_running_left_frames_from_right(run_dir) review_path = qa_dir / "review.json" inspect_command = [