reshoot 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {reshoot-0.3.0 → reshoot-0.3.2}/.claude-plugin/plugin.json +1 -1
  2. reshoot-0.3.2/CHANGELOG.md +93 -0
  3. {reshoot-0.3.0 → reshoot-0.3.2}/PKG-INFO +4 -3
  4. {reshoot-0.3.0 → reshoot-0.3.2}/README.md +3 -2
  5. {reshoot-0.3.0 → reshoot-0.3.2}/pyproject.toml +1 -1
  6. {reshoot-0.3.0 → reshoot-0.3.2}/skills/check-privacy/SKILL.md +4 -0
  7. {reshoot-0.3.0 → reshoot-0.3.2}/skills/keep-current/SKILL.md +1 -1
  8. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-demo/SKILL.md +3 -3
  9. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-refresh/SKILL.md +2 -2
  10. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-review/SKILL.md +1 -1
  11. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/browser.py +33 -9
  12. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/check.py +2 -1
  13. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/cli.py +41 -9
  14. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/project.py +27 -4
  15. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/rec.js +6 -1
  16. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/rec.py +8 -1
  17. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/review.py +194 -1
  18. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/run.py +30 -2
  19. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/story.py +41 -10
  20. {reshoot-0.3.0 → reshoot-0.3.2}/tests/boards/selectors.html +1 -0
  21. {reshoot-0.3.0 → reshoot-0.3.2}/tests/person.py +10 -0
  22. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_project.py +25 -0
  23. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_reshoot.py +2 -0
  24. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_review.py +142 -0
  25. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_story.py +97 -0
  26. reshoot-0.3.0/CHANGELOG.md +0 -35
  27. {reshoot-0.3.0 → reshoot-0.3.2}/.claude-plugin/marketplace.json +0 -0
  28. {reshoot-0.3.0 → reshoot-0.3.2}/.gitignore +0 -0
  29. {reshoot-0.3.0 → reshoot-0.3.2}/LICENSE +0 -0
  30. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/about.html +0 -0
  31. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/edit.py +0 -0
  32. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/index.html +0 -0
  33. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/phone.py +0 -0
  34. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/release-v1.toml +0 -0
  35. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/release-v2/about.html +0 -0
  36. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/release-v2/index.html +0 -0
  37. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/release-v2.toml +0 -0
  38. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/releases.md +0 -0
  39. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/reshoot.toml +0 -0
  40. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/storyboard.py +0 -0
  41. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/storyboard.toml +0 -0
  42. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/terminal.tape +0 -0
  43. {reshoot-0.3.0 → reshoot-0.3.2}/examples/notes/voiced.py +0 -0
  44. {reshoot-0.3.0 → reshoot-0.3.2}/skills/README.md +0 -0
  45. {reshoot-0.3.0 → reshoot-0.3.2}/skills/cut-video/SKILL.md +0 -0
  46. {reshoot-0.3.0 → reshoot-0.3.2}/skills/plan-demo/SKILL.md +0 -0
  47. {reshoot-0.3.0 → reshoot-0.3.2}/skills/record-terminal/SKILL.md +0 -0
  48. {reshoot-0.3.0 → reshoot-0.3.2}/skills/record-terminal/template.tape +0 -0
  49. {reshoot-0.3.0 → reshoot-0.3.2}/skills/record-web/SKILL.md +0 -0
  50. {reshoot-0.3.0 → reshoot-0.3.2}/skills/record-window/SKILL.md +0 -0
  51. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-demo/agents/openai.yaml +0 -0
  52. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-refresh/agents/openai.yaml +0 -0
  53. {reshoot-0.3.0 → reshoot-0.3.2}/skills/reshoot-review/agents/openai.yaml +0 -0
  54. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/__init__.py +0 -0
  55. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/artifacts.py +0 -0
  56. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/config.py +0 -0
  57. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/cut.py +0 -0
  58. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/overlay.js +0 -0
  59. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/safety.py +0 -0
  60. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/scan.py +0 -0
  61. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/skills.py +0 -0
  62. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/BRIEF.md +0 -0
  63. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/about.html +0 -0
  64. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/edit.py +0 -0
  65. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/edit.toml +0 -0
  66. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/index.html +0 -0
  67. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/reshoot.toml +0 -0
  68. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/storyboard.py +0 -0
  69. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/templates/storyboard.toml +0 -0
  70. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/timeline.py +0 -0
  71. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/voice.py +0 -0
  72. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/web.py +0 -0
  73. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/window.py +0 -0
  74. {reshoot-0.3.0 → reshoot-0.3.2}/src/reshoot/zoom.py +0 -0
  75. {reshoot-0.3.0 → reshoot-0.3.2}/tests/boards/blur.py +0 -0
  76. {reshoot-0.3.0 → reshoot-0.3.2}/tests/boards/pages.py +0 -0
  77. {reshoot-0.3.0 → reshoot-0.3.2}/tests/boards/voice.py +0 -0
  78. {reshoot-0.3.0 → reshoot-0.3.2}/tests/conftest.py +0 -0
  79. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_distribution.py +0 -0
  80. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_edit.py +0 -0
  81. {reshoot-0.3.0 → reshoot-0.3.2}/tests/test_failures.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "reshoot",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "Agent skills to create, refresh and review software demo videos. Use the reshoot CLI to record the app, export an MP4 and README GIF, and keep the demo source beside the code.",
6
6
  "author": { "name": "Mumit Khan", "url": "https://github.com/mumit" },
7
7
  "homepage": "https://github.com/mumit/reshoot",
@@ -0,0 +1,93 @@
1
+ # Changelog
2
+
3
+ Each release names what changed for people making demos. Versions follow
4
+ [semantic versioning](https://semver.org/); before 1.0, a minor version may
5
+ change project files or commands.
6
+
7
+ ## 0.3.2 (2026-10-23)
8
+
9
+ ### Added
10
+
11
+ - A `select` action chooses a dropdown option by its value or visible label,
12
+ and `reshoot rec` writes one for each dropdown choice.
13
+
14
+ ### Fixed
15
+
16
+ - A result could appear a whole hold late in the video while every check passed: the evidence screenshot after
17
+ each beat could take the frame showing it. The evidence frame is now the last frame the video shows, saved at
18
+ the end of the hold. Frame times also no longer depend on when the first frame arrives.
19
+ - Storyboard errors name the beat and the action or check, such as
20
+ `beat 'fill', action 2 (key): target needs exactly one of css, test_id, or role`.
21
+ - The demo skill says to narrow the viewport or zoom when an app's content is
22
+ too small to read in the README GIF.
23
+ - A port Chrome refuses, such as 5060, is explained with the flag that allows
24
+ it, and a failure while opening the page no longer reports `beat None`.
25
+ - The demo skill and guides say how to write checks for apps that keep data
26
+ between takes and have no reset.
27
+
28
+ ## 0.3.1 (2026-10-23)
29
+
30
+ ### Added
31
+
32
+ - `reshoot accept RUN` records the owner's verdict in one step: the exports,
33
+ every beat, every scan item and the scan checklist, bound to the run's exact
34
+ files and scan policy. Each flagged item must be named with `--flagged`, and
35
+ `--reject` marks an item that must not be shared. It asks for confirmation,
36
+ and without a terminal it refuses unless given `--yes`.
37
+ - `reshoot run` and `reshoot preview` write the review page and print the
38
+ `reshoot accept` command for the run.
39
+ - `reshoot runs PROJECT` lists a project's runs, newest first, with their
40
+ result and review status. Run paths accept `latest` (the newest successful
41
+ run) and `accepted` (the newest accepted run), such as `demo/accepted`.
42
+ Run reports record `finished_at`.
43
+ - A refresh pull request in CI. `examples/github/refresh.yml` records the demo
44
+ when the app changes, compares it with the accepted run and opens a pull
45
+ request with the new MP4, GIF and a beat table. Merging it accepts the
46
+ recording.
47
+ - `reshoot accept` writes `demo/accepted/`, a small committed record of the run
48
+ (report, beat results and evidence frames), so CI and fresh clones can
49
+ compare against it. `reshoot export --record DIR` writes one for any run.
50
+ - `reshoot export --replace` updates media already in the destination, and
51
+ `reshoot compare --markdown FILE` writes a summary for a pull request.
52
+ - In GitHub Actions, each run writes a job summary with its checks, scan and
53
+ errors.
54
+
55
+ ### Fixed
56
+
57
+ - `reshoot init` moves the notes example off port 8765 when something else
58
+ is using it.
59
+ - A busy port names its loopback address instead of a redacted hash.
60
+ - `reshoot web --help` describes TOML storyboards, and `review`, `compare`
61
+ and `export` explain their arguments.
62
+ - A target that matches no element, or several, names the target and the count.
63
+ - A run's report includes the failed beat's error, not only the failed step.
64
+
65
+ ## 0.3.0 (2026-10-23)
66
+
67
+ The first release on PyPI. Install it with `uv tool install 'reshoot[browser]'`.
68
+
69
+ ### Added
70
+
71
+ - Published to PyPI, with a release workflow that builds, checks and publishes
72
+ each tagged version.
73
+ - An agent-first entry point: a first-demo prompt in the README, the agent
74
+ quickstart and a skill catalog.
75
+ - Guides for each stage after the first recording: features, customizing,
76
+ fuller demos and tutorials, publishing, CI and faster shoots.
77
+
78
+ ### Fixed
79
+
80
+ - Review pages show baseline GIFs as images; MP4s keep their video players.
81
+ - Joined title cards no longer vanish or freeze in GIF exports.
82
+ - Clip joins keep an exact frame rate.
83
+ - Captions stay visible across same-origin page loads.
84
+ - Previews that mix web and VHS takes keep matching dimensions.
85
+ - Runs leave incomplete expectations unverified instead of passing them.
86
+ - `doctor` rejects a `RESHOOT_CHROME` that does not name a usable browser.
87
+
88
+ ## 0.2.0
89
+
90
+ Repeatable demo projects: `reshoot.toml`, TOML storyboards with checked
91
+ expectations, `init`, `doctor`, `preview`, isolated versioned runs, `compare`,
92
+ `review` with bound decisions, `export`, and shared Claude Code and Codex skills
93
+ installed by `reshoot skills`. Installed from Git only.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reshoot
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Agent skills and recording tools for repeatable software demo videos
5
5
  Project-URL: Homepage, https://github.com/mumit/reshoot
6
6
  Project-URL: Documentation, https://github.com/mumit/reshoot/tree/main/guide
@@ -182,8 +182,9 @@ takes outside it. Select an explicit baseline when comparing updates.
182
182
 
183
183
  Native demos keep their capture instructions and edit script separately; see
184
184
  the [macOS window workflow](https://github.com/mumit/reshoot/blob/main/guide/macos-window.md).
185
- Use the [CI workflow](https://github.com/mumit/reshoot/blob/main/guide/ci.md) to record browser and terminal candidates after
186
- app changes. Review the outputs before updating published demo media.
185
+ The [CI workflow](https://github.com/mumit/reshoot/blob/main/guide/ci.md) records the demo again when the app changes and
186
+ opens a pull request with the new video, GIF and a beat-by-beat comparison with
187
+ the accepted run. Merging it accepts the new recording.
187
188
 
188
189
  ## Choose the recording for the task
189
190
 
@@ -153,8 +153,9 @@ takes outside it. Select an explicit baseline when comparing updates.
153
153
 
154
154
  Native demos keep their capture instructions and edit script separately; see
155
155
  the [macOS window workflow](guide/macos-window.md).
156
- Use the [CI workflow](guide/ci.md) to record browser and terminal candidates after
157
- app changes. Review the outputs before updating published demo media.
156
+ The [CI workflow](guide/ci.md) records the demo again when the app changes and
157
+ opens a pull request with the new video, GIF and a beat-by-beat comparison with
158
+ the accepted run. Merging it accepts the new recording.
158
159
 
159
160
  ## Choose the recording for the task
160
161
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reshoot"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Agent skills and recording tools for repeatable software demo videos"
5
5
  dynamic = ["readme"]
6
6
  license = "MIT"
@@ -34,6 +34,10 @@ keeps a face, a name or a house number out of a public video.
34
34
  bytes, so the next round shows only what's new.
35
35
  6. Repeat until `reshoot check` says everything has been checked.
36
36
 
37
+ For a versioned run, `reshoot accept RUN` replaces steps 4 and 5 when this CLI provides it: it records the scan
38
+ checklist and the recording verdict together, and needs each flagged label named with `--flagged` or `--reject`.
39
+ The person runs it, or tells you explicitly to.
40
+
37
41
  ## What fails an image
38
42
 
39
43
  - A person: faces, heads at the edge of the frame, tiny figures far away, reflections in glass, people behind
@@ -38,4 +38,4 @@ description: Set up a reshoot.toml so one command (`reshoot run`) records a proj
38
38
 
39
39
  The run report and Action outputs distinguish `scan_status` and `review_status`. `ok=true` means execution completed, not approval to publish. A required check error stops the run, and the app remains available until its URL-based checks finish.
40
40
 
41
- Versioned runs keep separate candidate files. Compare two explicit runs with `reshoot compare OLD NEW --out FILE`, then generate `reshoot review NEW --previous OLD`. Matching beat IDs do not imply matching results. No new run promotes itself to the baseline.
41
+ For a versioned project (`schema_version = 1`), use `../../examples/github/refresh.yml` instead of the legacy workflow above. Accept a run locally first and commit the `demo/accepted/` record that `reshoot accept` writes; the workflow compares each new run with it, copies the new media to `docs/demo/` and opens a pull request whose merge accepts the recording. Changing repository settings, pushing, and merging are the person's call. Versioned runs keep separate candidate files. Compare two explicit runs with `reshoot compare OLD NEW --out FILE`, then generate `reshoot review NEW --previous OLD`. Matching beat IDs do not imply matching results. No new run promotes itself to the baseline.
@@ -15,13 +15,13 @@ For a new browser project, use `reshoot init DIR --url DEMO_URL`; omit `--url` t
15
15
 
16
16
  For a native macOS app, save a brief, capture recipe and edit script in a demo folder. Use `reshoot window --list` to select the exact demo window, then `reshoot window APP NEW_TAKE.mov --id ID -s SECONDS`. Check Screen Recording permission for the command's host. Drive the app through app-specific automation or coordinate the owner's interaction during capture; the window command does not operate controls. Look up the ID each take and choose unused output paths. Versioned projects support web and VHS, not native window takes. Do not invent a native run manifest, browser overlay or result assertions. Export with `reshoot edit EDIT.toml NEW_TAKE.mov`, using numeric cuts, and a GIF export profile, or a Python edit. The primary skill can follow this route without supporting plugin skills.
17
17
 
18
- Use made-up data in a separate account, folder, or database. Document and run its reset before each take. Record only a headless browser page, VHS terminal, or one macOS window by ID. Never capture the whole screen or reuse a person's browser profile.
18
+ Use made-up data in a separate account, folder, or database. Document and run its reset before each take. When a running app keeps data and offers no reset, ask for one before the first take, or write checks that pass however many times the task has run: check the item the take created, not totals that earlier takes change. Record only a headless browser page, VHS terminal, or one macOS window by ID. Never capture the whole screen or reuse a person's browser profile.
19
19
 
20
- For browser/VHS projects, run `reshoot preview CONFIG`, inspect frames and expected results, then `reshoot run CONFIG`. Read the printed run report. For native apps, inspect a standalone test take, then capture and edit a fresh final in a new folder. Generate check sheets for source and final media with `reshoot check SOURCES --full --review NEW_REVIEW_DIR`. Inspect actual outputs on either route. Execution success does not mean a scan passed or a person reviewed the video. When the owner wants a quicker refresh of checked data, follow the trade-offs in the guide's `speed.md` and say which checks were reduced; agent-written terminal text always needs reading. Fix failed inputs and actions rather than suppressing checks. Give the owner the final files and pending review items.
20
+ For browser/VHS projects, run `reshoot preview CONFIG`, inspect frames and expected results, then `reshoot run CONFIG`. Read the printed run report. For native apps, inspect a standalone test take, then capture and edit a fresh final in a new folder. Generate check sheets for source and final media with `reshoot check SOURCES --full --review NEW_REVIEW_DIR`. Inspect actual outputs on either route. Execution success does not mean a scan passed or a person reviewed the video. When the owner wants a quicker refresh of checked data, follow the trade-offs in the guide's `speed.md` and say which checks were reduced; agent-written terminal text always needs reading. Fix failed inputs and actions rather than suppressing checks. Give the owner the final files and pending review items. For a versioned run, if this CLI provides `reshoot accept`, the run prints the command that records the owner's verdict; pass it on with `--flagged LABEL` for each flagged item. Run it, or pass `--yes`, only when the owner explicitly asks you to accept.
21
21
 
22
22
  For combined web and VHS edits, configure matching take dimensions. Mixed previews retain the browser's configured dimensions because VHS keeps its tape dimensions.
23
23
 
24
- Keep captions short and explain the result, not the click. Leave time to read. Identify mocked responses or shortened waits. Do not change the product claim merely to make a recording finish.
24
+ Keep captions short and explain the result, not the click. Leave time to read. Judge text size in the README GIF, which is usually 800 px wide: when the app's content is a narrow column in the 1280x720 frame, set a narrower `size`, such as `"960x540"`, or `zoom = true`, so the result stays readable. A narrower viewport enlarges the text; a higher pixel ratio does not. For a dropdown, use a `select` action with the option's visible label, if this CLI supports it, rather than key presses. Identify mocked responses or shortened waits. Do not change the product claim merely to make a recording finish.
25
25
 
26
26
  Inspect captions after page navigation. The overlay restores the current caption on same-origin page loads; zoomed or styled captions are drawn after recording.
27
27
 
@@ -7,7 +7,7 @@ description: Update an existing reshoot demo after an app or release changes. Us
7
7
 
8
8
  For combined web and VHS edits, keep take dimensions matched. Mixed previews retain the configured browser dimensions to match the unchanged VHS output size.
9
9
 
10
- Requires reshoot CLI 0.2 or newer. Run `reshoot --version`. For a versioned browser/VHS project, read its brief, config, storyboard and previous run before changing anything, then run `reshoot doctor --config CONFIG`.
10
+ Requires reshoot CLI 0.2 or newer. Run `reshoot --version`. For a versioned browser/VHS project, read its brief, config, storyboard and previous run before changing anything, then run `reshoot doctor --config CONFIG`. If this CLI provides `reshoot runs`, use it to list runs; a run path ending in `accepted` or `latest` resolves to the newest accepted or successful run. Record the exact ID of the baseline you compare against, since aliases move as new runs finish.
11
11
 
12
12
  For a standalone native recording, read the brief, capture/edit scripts and exact previous media instead of expecting a versioned config or run report. Look up the current demo window ID, reset synthetic data, and use `reshoot window APP NEW_TAKE.mov --id ID -s SECONDS` with app-specific automation or coordinated manual interaction. Keep test and fresh final takes in new folders; edit and scan final exports. Compare the old/new MP4s and animated GIFs manually. Disclose that browser assertions, versioned `preview`/`run`, and automated beat comparison do not apply. Follow the versioned flow below only for browser/VHS projects.
13
13
 
@@ -19,4 +19,4 @@ A failed or missing browser take leaves the overall expectations unverified unle
19
19
 
20
20
  If this CLI version provides `reshoot compare`, compare the previous and new runs by beat and inspect the actual frames. Otherwise, show the old and new recordings and disclose that comparison is manual. A successful run must not silently replace the chosen comparison baseline or inherit approval from it.
21
21
 
22
- Use only made-up data and page, terminal, or single-window capture. Call out changes to the story's claim, fixture meaning, speed, or narration. Present the new files and unresolved items. Publishing and repository changes follow the owner's existing authorization.
22
+ Use only made-up data and page, terminal, or single-window capture. Call out changes to the story's claim, fixture meaning, speed, or narration. Present the new files and unresolved items. The new run needs its own verdict: give the owner its `reshoot accept RUN` command when this CLI provides it, and do not run it without their explicit instruction. Publishing and repository changes follow the owner's existing authorization.
@@ -19,7 +19,7 @@ For a versioned run, if this CLI provides `reshoot review`, use its local page t
19
19
  Previous and current GIFs display as images in the comparison page; inspect their
20
20
  animation and loop as well as the MP4 playback.
21
21
 
22
- The owner decides whether a recording is accepted. Do not run `check --ok`, import approval, or promote a baseline merely because your inspection found nothing. Carry out those actions when the owner explicitly authorizes the decision. Give the owner the files, evidence, and unresolved findings; do not publish without authorization.
22
+ The owner decides whether a recording is accepted. For a versioned run, if this CLI provides `reshoot accept`, give the owner the exact command: `reshoot accept RUN`, plus `--flagged LABEL` for each flagged item they must inspect, or `--reject LABEL` for one that fails. Do not run `accept`, `check --ok` or an import, pass `--yes`, or promote a baseline merely because your inspection found nothing. Carry out those actions only when the owner explicitly authorizes the decision. Give the owner the files, evidence, and unresolved findings; do not publish without authorization.
23
23
 
24
24
  `check --text-file FINAL.srt` scans exact caption text. Runs also check generated
25
25
  subtitle and narration sidecars. Inspect text findings at their recorded cue times.
@@ -37,8 +37,9 @@ async def action(page, entry, events, root):
37
37
  if kind == 'wait':
38
38
  await item.wait_for(state='visible', timeout=timeout)
39
39
  return
40
- if await item.count() != 1:
41
- raise ValueError('action target must match exactly one element')
40
+ if (found := await item.count()) != 1:
41
+ target = ', '.join(f'{k} = {v!r}' for k, v in entry['target'].items())
42
+ raise ValueError(f'{kind} target {{{target}}} matched {found} elements; it must match exactly one')
42
43
  await item.scroll_into_view_if_needed(timeout=timeout)
43
44
  box = await item.bounding_box()
44
45
  rect = [box[k] for k in ('x', 'y', 'width', 'height')] if box else None
@@ -52,6 +53,9 @@ async def action(page, entry, events, root):
52
53
  if entry.get('clear', True):
53
54
  await item.fill('', timeout=timeout)
54
55
  await item.press_sequentially(entry['text'], delay=65, timeout=timeout)
56
+ elif kind == 'select':
57
+ await page.wait_for_timeout(250) # let the cursor rest on the control before the choice changes
58
+ await item.select_option(entry['option'], timeout=timeout)
55
59
  elif kind == 'key':
56
60
  await item.press(entry['key'], timeout=timeout)
57
61
  elif kind == 'upload':
@@ -59,6 +63,19 @@ async def action(page, entry, events, root):
59
63
  events.append((time.monotonic(), {'kind': 'act', 'phase': 'end', 'rect': rect}))
60
64
 
61
65
 
66
+ async def evidence(page, frames, shot):
67
+ """Save the beat's evidence: the last frame the video shows, at the end of its hold. A screenshot taken during
68
+ capture can take the frame that shows a result, and a still page sends no other, so the video would show the old
69
+ page through the whole hold."""
70
+ last = frames[-1][1] if len(frames) else None
71
+ if last is not None:
72
+ import io
73
+ from PIL import Image
74
+ Image.open(io.BytesIO(last)).save(shot)
75
+ else:
76
+ await page.screenshot(path=str(shot))
77
+
78
+
62
79
  async def assertion(page, entry):
63
80
  from playwright.async_api import expect
64
81
  kind = entry['type']
@@ -150,11 +167,12 @@ async def capture(board, a, frames, out, metadata, spoken):
150
167
  'mobile': bool(a.mobile or board.get('mobile')), 'screenWidth': w, 'screenHeight': h,
151
168
  'dontSetVisibleSize': False})
152
169
  async def frame(event):
153
- nonlocal clock
154
- timestamp = event['metadata']['timestamp']
155
- if clock is None:
156
- clock = time.monotonic() - timestamp
157
- frames.append((timestamp + clock, base64.b64decode(event['data'])))
170
+ # Chrome stamps each frame with the wall-clock time it was painted. Map that onto the beats' clock with
171
+ # an offset measured directly, not from when a frame arrives: large frames arrive late, and an offset
172
+ # taken from a late first frame would shift the whole video behind the actions.
173
+ timestamp = event['metadata'].get('timestamp')
174
+ painted = timestamp + clock if timestamp else time.monotonic()
175
+ frames.append((painted, base64.b64decode(event['data'])))
158
176
  await cdp.send('Page.screencastFrameAck', {'sessionId': event['sessionId']})
159
177
  frame_tasks = []
160
178
  frame_handler = lambda event: frame_tasks.append(asyncio.create_task(frame(event)))
@@ -172,6 +190,7 @@ async def capture(board, a, frames, out, metadata, spoken):
172
190
  await assertion(page, entry)
173
191
  metadata['replayed_before'] = ids[:split]
174
192
  events.clear()
193
+ clock = time.monotonic() - time.time()
175
194
  await cdp.send('Page.startScreencast', {'format': 'jpeg', 'quality': 92, 'maxWidth': int(w*cap_dpr), 'maxHeight': int(h*cap_dpr)})
176
195
  for beat in board['beat'][split:]:
177
196
  current = beat['id']
@@ -195,10 +214,10 @@ async def capture(board, a, frames, out, metadata, spoken):
195
214
  result.update(verified_at=time.monotonic(), trim_wait=bool(beat.get('trim_wait')), min_reading=max(beat.get('min_reading', 0), board.get('caption', {}).get('min_seconds', 0)))
196
215
  shot = out.parent / (out.stem + '.evidence') / (current + '.png')
197
216
  shot.parent.mkdir(exist_ok=True)
198
- await page.screenshot(path=str(shot))
199
217
  result.update(status='passed' if beat.get('expect') else 'unverified', screenshot=str(shot.relative_to(out.parent)), start=start)
200
218
  await asyncio.sleep(max(beat.get('hold', 1), beat.get('min_reading', 0), board.get('caption', {}).get('min_seconds', 0) if beat.get('caption') else 0, spoken.get(current, ('', 0))[1] + .3 - (time.monotonic() - start)))
201
219
  result['end'] = time.monotonic()
220
+ await evidence(page, frames, shot)
202
221
  stop = time.monotonic()
203
222
  await cdp.send('Page.stopScreencast')
204
223
  cdp.remove_listener('Page.screencastFrame', frame_handler)
@@ -220,7 +239,12 @@ async def capture(board, a, frames, out, metadata, spoken):
220
239
  metadata['failure_screenshot'] = str(failure.relative_to(out.parent))
221
240
  except Exception:
222
241
  pass
223
- raise RuntimeError(f'beat {current!r} failed: {redact(str(e))}') from None
242
+ where = f'beat {current!r}' if current else 'opening the storyboard url'
243
+ hint = ''
244
+ if 'ERR_UNSAFE_PORT' in str(e):
245
+ hint = ('. Chrome refuses some ports, such as 5060 and 6000: run the app on another port, or set '
246
+ 'RESHOOT_CHROME_FLAGS=--explicitly-allowed-ports=PORT')
247
+ raise RuntimeError(f'{where} failed: {redact(str(e))}{hint}') from None
224
248
  finally:
225
249
  cdp.remove_listener('Page.screencastFrame', frame_handler)
226
250
  page.remove_listener('response', resource_handler)
@@ -252,7 +252,8 @@ def perform(a, report):
252
252
  for label, flags in flagged.items():
253
253
  print(f" {label} {key[label][0]}: " + "; ".join(f"{why} {part}" for why, part in flags))
254
254
  print(f"{len(key)} frames, images, or text findings not checked yet, on {(len(todo) + 3) // 4} sheets in {review}/. Look at each one "
255
- "at full size, then run `reshoot check --fail LABEL...` for any that fail and `reshoot check --ok`.")
255
+ "at full size. For a run, record the verdict with `reshoot accept RUN`; for these sheets alone, run "
256
+ "`reshoot check --fail LABEL...` for any that fail and `reshoot check --ok`.")
256
257
  return 1
257
258
 
258
259
 
@@ -8,9 +8,10 @@ def main(argv=None):
8
8
  ap.add_argument("--version", action="version", version=version("reshoot"))
9
9
  sub = ap.add_subparsers(dest="cmd", required=True)
10
10
 
11
- from reshoot import web, window, check
11
+ from reshoot import web, window, check, story
12
12
  p = sub.add_parser("web", help="record a web page acting out a storyboard, in headless Chrome",
13
- description=web.__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
13
+ description=story.__doc__ + "\nLegacy Python storyboards\n\n" + web.__doc__,
14
+ formatter_class=argparse.RawDescriptionHelpFormatter)
14
15
  p.add_argument("storyboard"); p.add_argument("out", help="the video to write, such as takes/web.mp4")
15
16
  p.add_argument("--url", help="the page to open (default: the storyboard's URL)")
16
17
  p.add_argument("--size", help="CSS width x height @ device pixel ratio, such as 390x844@3 (default 1280x720@1.5)")
@@ -95,15 +96,46 @@ def main(argv=None):
95
96
  p.add_argument('--directory', default='.', help='project root receiving the skill bundle')
96
97
  p.set_defaults(run=skills.install)
97
98
  from reshoot import review
98
- p = sub.add_parser('compare', help='compare matching beat IDs between two explicit runs')
99
- p.add_argument('previous'); p.add_argument('current'); p.add_argument('--out')
99
+ runs = ('A run is its folder, such as demo/takes/runs/ID. End the path with `latest` for the newest successful '
100
+ 'run or `accepted` for the newest accepted one, such as demo/latest.')
101
+ p = sub.add_parser('runs', help='list a project\'s runs, newest first, with their result and review status',
102
+ description='List the runs of a versioned project, newest first. ' + runs)
103
+ p.add_argument('project', nargs='?', default='.', help='the project folder or its reshoot.toml (default: here)')
104
+ p.set_defaults(run=review.listing)
105
+ p = sub.add_parser('compare', help='compare matching beat IDs between two explicit runs',
106
+ description='Compare two runs beat by beat: which beat IDs match, and whether each beat\'s '
107
+ 'definition, duration, evidence frame and results changed. A matched ID does not mean equal '
108
+ 'pixels. ' + runs)
109
+ p.add_argument('previous', help='the earlier run, usually the accepted one')
110
+ p.add_argument('current', help='the run to compare against it')
111
+ p.add_argument('--out', help='write the comparison JSON here instead of printing it')
112
+ p.add_argument('--markdown', metavar='FILE', help='also write a short Markdown summary, such as for a pull request')
100
113
  p.set_defaults(run=lambda a: (review.compare(a), 0)[1])
101
- p = sub.add_parser('review', help='create a local review page or import owner decisions')
102
- p.add_argument('run_path'); p.add_argument('--previous'); p.add_argument('--import-decisions')
114
+ p = sub.add_parser('review', help='create a local review page or import owner decisions',
115
+ description='Write RUN/review.html: the exports, each beat\'s result and evidence, and the scan '
116
+ 'items, with a comparison when --previous is given. Runs write this page themselves; use this to '
117
+ 'add a comparison or rebuild it. ' + runs)
118
+ p.add_argument('run_path', help='the run to review')
119
+ p.add_argument('--previous', help='an earlier run to compare against, usually the accepted one')
120
+ p.add_argument('--import-decisions', metavar='FILE',
121
+ help='record per-beat decisions downloaded from the page; `reshoot accept` is the one-step route')
103
122
  p.set_defaults(run=review.main)
104
- p = sub.add_parser('export', help='copy explicitly chosen run media to a local directory')
105
- p.add_argument('run_path'); p.add_argument('--directory', required=True)
106
- p.add_argument('--file', action='append')
123
+ p = sub.add_parser('accept', help='accept a run after watching it: exports, beats and scan items in one step',
124
+ description='Accept a run after watching its exports and looking at its scan sheets. Unflagged '
125
+ 'items are accepted together; each flagged item must be named with --flagged or --reject. The '
126
+ 'verdict is bound to the run\'s exact files and scan policy.')
127
+ p.add_argument('run_path', help='the run folder, such as demo/takes/runs/ID or demo/latest')
128
+ p.add_argument('--flagged', nargs='+', metavar='LABEL', help='flagged items you inspected and accept')
129
+ p.add_argument('--reject', nargs='+', metavar='LABEL', help='items that must not be shared; marks the run failed')
130
+ p.add_argument('--yes', action='store_true', help='skip the confirmation; only for the owner\'s own scripts')
131
+ p.set_defaults(run=review.accept)
132
+ p = sub.add_parser('export', help='copy explicitly chosen run media to a local directory', description=runs)
133
+ p.add_argument('run_path', help='the run to copy from, such as demo/latest')
134
+ p.add_argument('--directory', help='where to copy the chosen --file media')
135
+ p.add_argument('--file', action='append', help='a file listed in run.json, such as takes/demo-final.mp4')
136
+ p.add_argument('--replace', action='store_true', help='overwrite media already in --directory, such as the repo\'s demo')
137
+ p.add_argument('--record', metavar='DIR', help='also write the run\'s small record (report, beat results, evidence '
138
+ 'frames) to DIR, such as demo/accepted, for later comparisons')
107
139
  p.set_defaults(run=project.export)
108
140
  a = ap.parse_args(argv)
109
141
  try:
@@ -56,8 +56,11 @@ def init(a):
56
56
  if conflicts:
57
57
  raise ValueError('existing files would be overwritten: ' + ', '.join(conflicts))
58
58
  root.mkdir(parents=True, exist_ok=True)
59
+ port = None if a.url else free_port()
59
60
  for name in names:
60
61
  text = template.joinpath(name).read_text()
62
+ if port:
63
+ text = text.replace('8765', str(port))
61
64
  if a.url:
62
65
  if name == 'storyboard.toml':
63
66
  text = 'schema_version = 1\nurl = ' + json.dumps(a.url) + '\n[[beat]]\nid = "intro"\ncaption = "Replace this with one useful task"\nhold = 2\n'
@@ -69,19 +72,39 @@ def init(a):
69
72
  return 0
70
73
 
71
74
 
75
+ def free_port():
76
+ """8765 if nothing is using it, otherwise any free local port, so the notes example doesn't collide with another app."""
77
+ import socket
78
+ for port in (8765, 0):
79
+ with socket.socket() as s:
80
+ try:
81
+ s.bind(('127.0.0.1', port))
82
+ except OSError:
83
+ continue
84
+ return s.getsockname()[1]
85
+
86
+
72
87
  def export(a):
73
- from reshoot.review import read
88
+ from reshoot.review import read, record
74
89
  root, report = read(a.run_path)
75
90
  selected = a.file or []
91
+ if not selected and not getattr(a, 'record', None):
92
+ raise ValueError('choose at least one --file from run.json files, or --record')
93
+ if selected and not a.directory:
94
+ raise ValueError('--file needs --directory')
95
+ if getattr(a, 'record', None):
96
+ print(record(root, report, a.record))
76
97
  if not selected:
77
- raise ValueError('choose at least one --file from run.json files')
98
+ return 0
78
99
  known = {item['path'] for item in report.get('files', [])}
79
100
  if not set(selected) <= known:
80
101
  raise ValueError('export names an artifact not recorded in this run')
81
102
  destination = Path(a.directory).resolve()
82
103
  targets = [destination / Path(name).name for name in selected]
83
- if len(set(targets)) != len(targets) or any(p.exists() for p in targets):
84
- raise ValueError('export would overwrite a file or collide with another selected name')
104
+ if len(set(targets)) != len(targets):
105
+ raise ValueError('export would collide with another selected name')
106
+ if not getattr(a, 'replace', False) and any(p.exists() for p in targets):
107
+ raise ValueError('export would overwrite a file; pass --replace to update media already there')
85
108
  if any(p.suffix not in ('.mp4', '.gif', '.srt', '.mov', '.png') for p in targets):
86
109
  raise ValueError('export accepts final media and subtitle files')
87
110
  destination.mkdir(parents=True, exist_ok=True)
@@ -74,7 +74,12 @@
74
74
  send({ kind: "key", key: ev.key, where: locate(ev.target) });
75
75
  }, true);
76
76
  addEventListener("change", ev => {
77
- if (ev.isTrusted && ev.target.matches('select,input[type=checkbox],input[type=radio]'))
77
+ if (!ev.isTrusted) return;
78
+ const e = ev.target;
79
+ // A dropdown choice replays as a select action, found by the option's visible label.
80
+ if (e.matches("select:not([multiple])") && e.selectedOptions.length)
81
+ send({ kind: "select", where: locate(e), option: e.selectedOptions[0].label });
82
+ else if (e.matches("select,input[type=checkbox],input[type=radio]"))
78
83
  send({kind: "unsupported-change"});
79
84
  }, true);
80
85
  addEventListener("drop", ev => { if (ev.isTrusted) send({kind: "unsupported-drop"}); }, true);
@@ -51,6 +51,11 @@ def steps_from(calls, include_tab=False):
51
51
  secret=ev.get("secret")))
52
52
  elif kind == "click":
53
53
  steps.append(dict(start=t, verb="click", where=ev["where"]))
54
+ elif kind == "select":
55
+ if last and last["verb"] == "click" and last["where"] == ev["where"]:
56
+ steps.pop() # the click that opened the dropdown; select replays the whole choice
57
+ t = last["start"]
58
+ steps.append(dict(start=t, verb="select", where=ev["where"], option=ev["option"]))
54
59
  elif kind == "key" and ev["key"] in (("Enter", "Escape", "Tab") if include_tab else ("Enter", "Escape")):
55
60
  steps.append(dict(start=t, verb="key", where=ev["where"], key=ev["key"]))
56
61
  elif kind == "scroll":
@@ -153,7 +158,7 @@ def main(a):
153
158
 
154
159
 
155
160
  def write_toml(path, url, size, calls):
156
- unsupported = [ev['kind'] for _, ev in calls if ev['kind'] not in ('click', 'type', 'key', 'scroll')]
161
+ unsupported = [ev['kind'] for _, ev in calls if ev['kind'] not in ('click', 'type', 'select', 'key', 'scroll')]
157
162
  if unsupported:
158
163
  raise ValueError('recording includes unsupported interactions: ' + ', '.join(sorted(set(unsupported))))
159
164
  lines = ['schema_version = 1', 'url = ' + json.dumps(url), 'size = ' + json.dumps(size)]
@@ -173,6 +178,8 @@ def write_toml(path, url, size, calls):
173
178
  if step['verb'] == 'type':
174
179
  value = 'demo-password' if step.get('secret') else step['value']
175
180
  lines += ['text = ' + json.dumps(value), 'clear = true']
181
+ elif step['verb'] == 'select':
182
+ lines += ['option = ' + json.dumps(step['option'])]
176
183
  elif step['verb'] == 'key':
177
184
  lines += ['key = ' + json.dumps(step['key'])]
178
185
  elif step['verb'] == 'scroll':