Skip to content

Commit 017a7a7

Browse files
Kut Akdoganclaude
andcommitted
Add per-shot overwrite flag to skip existing outputs
Shots default to overwrite=false, skipping re-capture when the output PNG already exists. Supports per-shot, defaults-level, and --overwrite CLI override. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3dfaddb commit 017a7a7

File tree

5 files changed

+55
-1
lines changed

5 files changed

+55
-1
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ shots run-config --config shots.yaml --use-llm --use-llm-crop --save-source
4949
| `--use-llm-crop` | off | LLM picks a marketing-friendly crop rectangle |
5050
| `--max-crop-retries` | `2` | Crop validation retry attempts |
5151
| `--save-source` | off | Save uncropped source images alongside output |
52+
| `--overwrite` | off | Force re-capture of all shots, ignoring per-shot `overwrite` settings |
5253
| `--timeout-ms` | `10000` | Page-load / navigation timeout |
5354
| `--action-timeout-ms` | `5000` | Timeout for clicks/typing (fail fast) |
5455
| `--headed` | off | Show the browser window (debug) |
@@ -132,6 +133,7 @@ groups:
132133
| `defaults.viewport_preset` | top | `desktop` \| `laptop` \| `tablet` \| `mobile` |
133134
| `defaults.full_page` | top | Capture full scrollable page |
134135
| `defaults.max_nav_steps` | top | Max LLM navigation steps per shot (default: `12`) |
136+
| `defaults.overwrite` | top | Default overwrite behavior for all shots (default: `false`) |
135137
| `shots` | top | Flat list of shots (cannot coexist with `groups`) |
136138
| `groups` | top | List of shot groups (cannot coexist with `shots`) |
137139
| `groups[].id` | group | (required) Group identifier |
@@ -146,6 +148,7 @@ groups:
146148
| `viewport` | shot | Custom `{width, height, scale}` |
147149
| `full_page` | shot | Override full-page capture for this shot |
148150
| `label` | shot | Per-shot label override |
151+
| `overwrite` | shot | If `false` (default), skip when output PNG already exists |
149152

150153
## Notes
151154

shots/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def _add_common_run_flags(p: argparse.ArgumentParser) -> None:
2020
p.add_argument("--use-llm-crop", action="store_true", help="Use LLM to choose a crop box.")
2121
p.add_argument("--max-crop-retries", type=int, default=2, help="Max crop validation retries (default: 2).")
2222
p.add_argument("--save-source", action="store_true", help="Save uncropped source images too.")
23+
p.add_argument("--overwrite", action="store_true", help="Force re-capture of all shots, ignoring per-shot overwrite settings.")
2324

2425

2526
def build_parser() -> argparse.ArgumentParser:
@@ -97,6 +98,7 @@ def cmd_run_config(args) -> None:
9798
use_llm_crop=args.use_llm_crop,
9899
max_crop_retries=args.max_crop_retries,
99100
save_source=args.save_source,
101+
overwrite_all=args.overwrite,
100102
cli_fallback_viewport=fallback,
101103
)
102104
print(f"Report: {report_path}")

shots/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ShotSpec:
2020
viewport: dict[str, int] | None = None # width/height/scale
2121
full_page: bool | None = None
2222
label: str | None = None # per-shot label override
23+
overwrite: bool | None = None # skip if output exists (default: False via defaults)
2324

2425

2526
@dataclass
@@ -66,6 +67,7 @@ def _parse_shot(s: dict[str, Any], ctx: str) -> ShotSpec:
6667
viewport={k: int(v) for k, v in viewport.items()} if viewport else None,
6768
full_page=bool(s["full_page"]) if "full_page" in s else None,
6869
label=str(s["label"]).strip().replace("\\n", "\n") if s.get("label") else None,
70+
overwrite=bool(s["overwrite"]) if "overwrite" in s else None,
6971
)
7072

7173

shots/runner.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ def run_config(
241241
use_llm_crop: bool,
242242
max_crop_retries: int = 2,
243243
save_source: bool,
244+
overwrite_all: bool = False,
244245
cli_fallback_viewport: Viewport,
245246
) -> pathlib.Path:
246247
"""
@@ -287,6 +288,20 @@ def run_config(
287288
for shot in group.shots:
288289
shot_counter += 1
289290
log.info("--- shot %d/%d: %s ---", shot_counter, total_shots, shot.id)
291+
292+
# Check if we can skip this shot
293+
shot_png_path = group_dir / f"{safe_filename(shot.id)}.png"
294+
effective_overwrite = shot.overwrite if shot.overwrite is not None else bool(cfg.defaults.get("overwrite", False))
295+
if overwrite_all:
296+
effective_overwrite = True
297+
298+
if not effective_overwrite and shot_png_path.exists():
299+
log.info("[SKIP] %s -> %s (already exists)", shot.id, shot_png_path)
300+
print(f"[SKIP] {shot.id} -> {shot_png_path} (already exists)")
301+
group_report["shots"].append({"id": shot.id, "status": "skipped", "output": str(shot_png_path)})
302+
group_pngs.append(shot_png_path.read_bytes())
303+
continue
304+
290305
vp = _resolve_viewport_for_shot(cfg.defaults, shot, cli_fallback_viewport)
291306

292307
context = browser.new_context(
@@ -477,7 +492,6 @@ def run_config(
477492
log.info("added label: %s", label_text.replace("\n", " | "))
478493

479494
# Save individual shot PNG
480-
shot_png_path = group_dir / f"{safe_filename(shot.id)}.png"
481495
shot_png_path.write_bytes(out_bytes)
482496
group_pngs.append(out_bytes)
483497

tests/unit/test_config.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def test_shot_spec_defaults(self):
189189
assert shot.viewport_preset is None
190190
assert shot.viewport is None
191191
assert shot.full_page is None
192+
assert shot.overwrite is None
192193

193194
def test_shot_spec_with_all_fields(self):
194195
shot = ShotSpec(
@@ -248,3 +249,35 @@ def test_base_url_trailing_slash_stripped(self, tmp_path):
248249
path.write_text(json.dumps(config))
249250
cfg = load_config(str(path))
250251
assert cfg.base_url == "https://example.com"
252+
253+
254+
class TestShotSpecOverwrite:
255+
def test_overwrite_true_parsed(self, tmp_path):
256+
config: dict[str, Any] = {
257+
"base_url": "https://example.com",
258+
"shots": [{"id": "test", "description": "test", "overwrite": True}],
259+
}
260+
path = tmp_path / "config.json"
261+
path.write_text(json.dumps(config))
262+
cfg = load_config(str(path))
263+
assert cfg.groups[0].shots[0].overwrite is True
264+
265+
def test_overwrite_false_parsed(self, tmp_path):
266+
config: dict[str, Any] = {
267+
"base_url": "https://example.com",
268+
"shots": [{"id": "test", "description": "test", "overwrite": False}],
269+
}
270+
path = tmp_path / "config.json"
271+
path.write_text(json.dumps(config))
272+
cfg = load_config(str(path))
273+
assert cfg.groups[0].shots[0].overwrite is False
274+
275+
def test_overwrite_absent_defaults_to_none(self, tmp_path):
276+
config: dict[str, Any] = {
277+
"base_url": "https://example.com",
278+
"shots": [{"id": "test", "description": "test"}],
279+
}
280+
path = tmp_path / "config.json"
281+
path.write_text(json.dumps(config))
282+
cfg = load_config(str(path))
283+
assert cfg.groups[0].shots[0].overwrite is None

0 commit comments

Comments
 (0)