1	from __future__ import annotations
2
3	import json
4	import os
5	import re
6	import subprocess
7	from dataclasses import asdict, dataclass
8	from datetime import datetime, timezone
9	from pathlib import Path
10	from typing import Any
11
12	from PIL import Image, ImageStat
13
14	from core.media.ffmpeg_utils import probe_media, probe_peak_volume_db
15
16	REVIEW_VERSION = "2026-07-11.v1"
17
18
19	@dataclass(slots=True)
20	class FrameMetrics:
21	    frame: str
22	    top_mean: float
23	    mid_mean: float
24	    bottom_mean: float
25	    mid_stddev: float
26	    edge_dark_ratio: float
27	    outro_candidate: bool
28
29
30	@dataclass(slots=True)
31	class CliCheckResult:
32	    command: str
33	    ok: bool
34	    skipped: bool
35	    exit_code: int
36	    stdout: str
37	    stderr: str
38
39
40	@dataclass(slots=True)
41	class HyperframesReviewReport:
42	    phase: str
43	    version: str
44	    generated_at: str
45	    project: str
46	    reference_project: str | None
47	    overall_score: int
48	    recommendation: str
49	    category_scores: dict[str, int]
50	    hard_failures: list[dict[str, Any]]
51	    top_issues: list[dict[str, Any]]
52	    deterministic_checks: dict[str, Any]
53	    comparison: dict[str, Any]
54	    cli_checks: list[dict[str, Any]]
55	    frame_metrics: list[dict[str, Any]]
56	    summary: str
57
58	    def to_dict(self) -> dict[str, Any]:
59	        return asdict(self)
60
61
62	def review_hyperframes_project(
63	    project_dir: Path,
64	    *,
65	    reference_dir: Path | None = None,
66	    run_cli_checks: bool = True,
67	    extract_frames: bool = True,
68	    frame_count: int = 6,
69	) -> HyperframesReviewReport:
70	    project_dir = project_dir.resolve()
71	    index_html = project_dir / "index.html"
72	    final_video = project_dir / "final_listing_reel.mp4"
73	    review_dir = project_dir / "qa" / "hyperframes_review"
74	    frames_dir = review_dir / "frames"
75	    review_dir.mkdir(parents=True, exist_ok=True)
76
77	    html = index_html.read_text(encoding="utf-8")
78	    scene_count = len(re.findall(r'class="scene clip"', html))
79	    photo_count = len(re.findall(r'src="assets/photos/', html))
80	    declared_duration = _extract_float_attr(html, "data-duration")
81	    declared_width = _extract_int_attr(html, "data-width")
82	    declared_height = _extract_int_attr(html, "data-height")
83	    declared_portrait = declared_height > declared_width
84	    has_contact_outro = "contact-main" in html or "LET'S" in html
85	    has_profile_photo = bool(re.search(r"(profile|headshot|avatar)", html, re.IGNORECASE))
86	    photo_cover_rule = all(
87	        token in html
88	        for token in ("object-fit: cover", ".photo-frame { position: absolute; inset: 0;", 'data-layout-allow-overflow')
89	    )
90
91	    cli_checks = run_cli_review_checks(project_dir) if run_cli_checks else []
92	    probe = probe_media(final_video)
93	    audio_peak_db = probe_peak_volume_db(project_dir / "narration.wav") if (project_dir / "narration.wav").exists() else (
94	        probe_peak_volume_db(final_video) if probe.has_audio else float("-inf")
95	    )
96
97	    if extract_frames:
98	        frame_paths = extract_review_frames(final_video, frames_dir, frame_count=frame_count)
99	    else:
100	        frame_paths = sorted((project_dir / "snapshots").glob("frame-*.png"))
101	    frames = [analyze_frame(path) for path in frame_paths]
102	    non_outro_frames = [frame for frame in frames if not frame.outro_candidate] or frames
103	    avg_mid_mean = _avg(frame.mid_mean for frame in non_outro_frames)
104	    avg_mid_stddev = _avg(frame.mid_stddev for frame in non_outro_frames)
105	    first_frame = non_outro_frames[0] if non_outro_frames else None
106
107	    reference_comparison: dict[str, Any] = {}
108	    if reference_dir is not None:
109	        reference_video = reference_dir / "final_listing_reel.mp4"
110	        reference_frames_dir = review_dir / "reference_frames"
111	        reference_frame_paths = extract_review_frames(reference_video, reference_frames_dir, frame_count=frame_count)
112	        reference_frames = [analyze_frame(path) for path in reference_frame_paths]
113	        reference_non_outro = [frame for frame in reference_frames if not frame.outro_candidate] or reference_frames
114	        reference_comparison = {
115	            "reference_project": str(reference_dir),
116	            "avg_mid_mean_delta": round(avg_mid_mean - _avg(frame.mid_mean for frame in reference_non_outro), 1),
117	            "avg_mid_stddev_delta": round(avg_mid_stddev - _avg(frame.mid_stddev for frame in reference_non_outro), 1),
118	            "first_frame_mid_stddev_delta": round(
119	                (first_frame.mid_stddev if first_frame else 0.0) - (reference_non_outro[0].mid_stddev if reference_non_outro else 0.0),
120	                1,
121	            ),
122	        }
123
124	    category_scores = {
125	        "composition_script": _score_script(cli_checks, scene_count, photo_count, declared_portrait, photo_cover_rule),
126	        "visual_density": _score_visual_density(avg_mid_mean, avg_mid_stddev),
127	        "opening_framing": _score_opening(first_frame, reference_comparison),
128	        "motion_delivery": _score_delivery(probe, declared_duration, scene_count),
129	        "narration_audio": _score_audio(probe.has_audio, audio_peak_db),
130	        "outro_branding": _score_outro(has_contact_outro, has_profile_photo),
131	    }
132	    overall = round(sum(category_scores.values()) / len(category_scores))
133	    issues = build_issues(
134	        category_scores=category_scores,
135	        cli_checks=cli_checks,
136	        has_profile_photo=has_profile_photo,
137	        has_contact_outro=has_contact_outro,
138	        has_audio=probe.has_audio,
139	        audio_peak_db=audio_peak_db,
140	        reference_comparison=reference_comparison,
141	        avg_mid_mean=avg_mid_mean,
142	        avg_mid_stddev=avg_mid_stddev,
143	        first_frame=first_frame,
144	    )
145	    hard_failures = [item for item in issues if item["severity"] == "error"]
146	    recommendation = (
147	        "reject"
148	        if hard_failures
149	        else "publishable" if overall >= 85 and not issues else "revise" if overall >= 65 else "reject"
150	    )


Let me look at how the score recommendation translates to failure and continue reading the rest of the review:


150	    )
151	    summary = build_summary(project_dir.name, overall, issues, reference_comparison)
152	    return HyperframesReviewReport(
153	        phase="video_review",
154	        version=REVIEW_VERSION,
155	        generated_at=datetime.now(timezone.utc).isoformat(),
156	        project=str(project_dir),
157	        reference_project=str(reference_dir) if reference_dir else None,
158	        overall_score=overall,
159	        recommendation=recommendation,
160	        category_scores=category_scores,
161	        hard_failures=hard_failures,
162	        top_issues=issues[:5],
163	        deterministic_checks={
164	            "scene_count": scene_count,
165	            "photo_count": photo_count,
166	            "declared_duration_seconds": declared_duration,
167	            "declared_width": declared_width,
168	            "declared_height": declared_height,
169	            "video_duration_seconds": round(probe.duration_seconds, 3),
170	            "video_width": probe.width,
171	            "video_height": probe.height,
172	            "video_frame_rate": probe.frame_rate,
173	            "video_has_audio": probe.has_audio,
174	            "audio_peak_db": audio_peak_db,
175	            "declared_portrait": declared_portrait,
176	            "has_contact_outro": has_contact_outro,
177	            "has_profile_photo": has_profile_photo,
178	            "avg_mid_mean": round(avg_mid_mean, 1),
179	            "avg_mid_stddev": round(avg_mid_stddev, 1),
180	        },
181	        comparison=reference_comparison,
182	        cli_checks=[asdict(item) for item in cli_checks],
183	        frame_metrics=[asdict(frame) for frame in frames],
184	        summary=summary,
185	    )
186
187
188	CLI_CHECK_TIMEOUT_SECONDS = 90
189	SNAPSHOT_CHECK_TIMEOUT_SECONDS = 60
190
191
192	def _review_check_env(project_dir: Path) -> dict[str, str]:
193	    env = dict(os.environ)
194	    env["NPM_CONFIG_CACHE"] = str((project_dir / ".npm-cache").resolve())
195	    # These checks only feed deterministic scoring, not the network-dependent
196	    # update nag or Gemini vision descriptions, both of which have caused the
197	    # subprocess to sit for minutes with no output.
198	    env["HYPERFRAMES_NO_UPDATE_CHECK"] = "1"
199	    return env
200
201
202	def _run_cli_check(command: list[str], *, project_dir: Path, env: dict[str, str], timeout: int) -> CliCheckResult:
203	    try:
204	        completed = subprocess.run(
205	            command, cwd=project_dir, capture_output=True, text=True, env=env, timeout=timeout
206	        )
207	    except subprocess.TimeoutExpired:
208	        return CliCheckResult(
209	            command=" ".join(command),
210	            ok=False,
211	            skipped=False,
212	            exit_code=-1,
213	            stdout="",
214	            stderr=f"timed out after {timeout}s",
215	        )
216	    return CliCheckResult(
217	        command=" ".join(command),
218	        ok=completed.returncode == 0 or _is_browser_runtime_failure(completed.stdout, completed.stderr),
219	        skipped=_is_browser_runtime_failure(completed.stdout, completed.stderr),
220	        exit_code=completed.returncode,
221	        stdout=completed.stdout.strip(),
222	        stderr=completed.stderr.strip(),
223	    )
224
225
226	def run_cli_review_checks(project_dir: Path) -> list[CliCheckResult]:
227	    runner = detect_hyperframes_runner(project_dir)
228	    env = _review_check_env(project_dir)
229	    checks: list[list[str]] = [
230	        [*runner, "lint", "--json"],
231	        [*runner, "validate", "--json"],
232	        [*runner, "inspect", "--json"],
233	    ]
234	    results = [
235	        _run_cli_check(command, project_dir=project_dir, env=env, timeout=CLI_CHECK_TIMEOUT_SECONDS)
236	        for command in checks
237	    ]
238	    # --describe false skips Gemini vision frame descriptions: we score frames
239	    # ourselves via analyze_frame(), and the vision call is otherwise unbounded
240	    # network I/O that has made this step look hung.
241	    snapshot_command = [*runner, "snapshot", "--frames", "6", "--describe", "false"]
242	    results.append(
243	        _run_cli_check(snapshot_command, project_dir=project_dir, env=env, timeout=SNAPSHOT_CHECK_TIMEOUT_SECONDS)
244	    )
245	    return results
246
247
248	def run_cli_script_checks(project_dir: Path) -> list[CliCheckResult]:
249	    runner = detect_hyperframes_runner(project_dir)


Now let me check if there's any existing retry logic:


Bash

grep -r "retry\|loop\|attempt" /workspace/extra/projects/stagehand/core/workflows --include="*.py" | head -20


/workspace/extra/projects/stagehand/core/workflows/modules.py:    # Strip Whisper hallucination tail: any repeating sequence of 2+ chars looping 10+ times
/workspace/extra/projects/stagehand/core/workflows/modules.py:    # words with any of the three preceding sentences, Whisper is looping — trim it.


Let me check the rest of the modules.py to see how rendering is integrated:


290
291
292	class InstagramPackageModule:
293	    """Assemble the manual-handoff package: video copy, caption, metadata, thumbnail."""
294
295	    def run(self, context: WorkflowContext, params: dict) -> dict:
296	        aspect_ratio = params.get("aspect_ratio", "9:16")
297	        publish_dir = context.workspace / "publish"
298	        publish_dir.mkdir(parents=True, exist_ok=True)
299
300	        caption = (
301	            context.data.get("approved_caption")
302	            or context.data.get("approved_script")
303	            or context.data.get("caption_draft")
304	            or context.data.get("generated_script")
305	            or "New video."
306	        )
307	        hashtags = context.data.get("instagram_hashtags")
308	        handle = str(context.data.get("instagram_handle") or "")
309	        tenant_id = getattr(context.job, "tenant_id", "")
310
311	        # Source video being published: the rendered output if present, else the input.
312	        source_video = context.data.get("output_video") or context.data.get("input_path")
313	        publish_video_path = None
314	        thumbnail_path = None
315	        cover_path = None
316	        listing_snapshot_package_path = None
317	        duration_seconds = None
318	        if source_video and Path(source_video).exists():
319	            src = Path(source_video)
320	            delivery_title = resolve_delivery_title(
321	                explicit=str(context.data.get("delivery_title") or ""),
322	                listing_snapshot_path=context.data.get("listing_snapshot_path"),
323	                listing_url=str(context.data.get("listing_url") or ""),
324	                listing_description=str(context.data.get("listing_description") or ""),
325	                source_video=src,
326	            )
327	            delivery_content_type = resolve_delivery_content_type(
328	                explicit=str(context.data.get("delivery_content_type") or ""),
329	                listing_snapshot_path=context.data.get("listing_snapshot_path"),
330	                listing_url=str(context.data.get("listing_url") or ""),
331	                title=delivery_title,
332	                description=str(context.data.get("listing_description") or ""),
333	                source_video=src,
334	                default_content_type="video",
335	            )
336	            publish_video = publish_dir / format_delivery_video_filename(
337	                source_video=src,
338	                delivery_title=delivery_title,
339	                delivery_content_type=delivery_content_type,
340	            )
341	            shutil.copyfile(src, publish_video)
342	            publish_video_path = publish_video
343	            duration_seconds = _probe_duration(src)
344	            designed_cover = context.data.get("cover_image_path")
345	            if designed_cover and Path(designed_cover).exists():
346	                cover_source = Path(designed_cover)
347	                suffix = cover_source.suffix.lower()
348	                if suffix not in {".jpg", ".jpeg", ".png"}:
349	                    suffix = ".jpg"
350	                cover_path = publish_dir / f"cover{suffix}"
351	                shutil.copyfile(cover_source, cover_path)
352	                thumbnail_path = cover_path
353	            else:
354	                thumbnail = publish_dir / "thumbnail.png"
355	                try:
356	                    at = min(1.0, (duration_seconds or 1.0) / 2)
357	                    create_thumbnail(src, thumbnail, at_seconds=at, aspect_ratio=aspect_ratio)
358	                    thumbnail_path = thumbnail
359	                except Exception:
360	                    thumbnail_path = None  # caption + video are enough for manual posting
361
362	        snapshot_source = context.data.get("listing_snapshot_path")
363	        if snapshot_source and Path(snapshot_source).exists():
364	            listing_snapshot_package_path = publish_dir / "listing_snapshot.json"
365	            shutil.copyfile(snapshot_source, listing_snapshot_package_path)
366
367	        caption_path = publish_dir / "caption.txt"
368	        caption_path.write_text(caption, encoding="utf-8")
369
370	        metadata = build_metadata(
371	            tenant_id=tenant_id,
372	            handle=handle,
373	            aspect_ratio=aspect_ratio,
374	            duration_seconds=duration_seconds,
375	            caption=caption,
376	            hashtags=hashtags,
377	            video_filename=publish_video_path.name if publish_video_path else "",
378	            cover_filename=cover_path.name if cover_path else "",
379	            listing_snapshot_filename=(
380	                listing_snapshot_package_path.name if listing_snapshot_package_path else ""
381	            ),
382	            review_filename=Path(str(context.data.get("review_page_path") or "")).name,
383	            review_url=str(context.data.get("review_page_url") or ""),
384	        )
385	        metadata_path = publish_dir / "metadata.json"
386	        metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
387
388	        return {
389	            "publish_dir": str(publish_dir),
390	            "caption_path": str(caption_path),
391	            "metadata_path": str(metadata_path),
392	            "publish_video_path": str(publish_video_path) if publish_video_path else None,
393	            "thumbnail_path": str(thumbnail_path) if thumbnail_path else None,
394	            "cover_path": str(cover_path) if cover_path else None,
395	            "listing_snapshot_package_path": (
396	                str(listing_snapshot_package_path) if listing_snapshot_package_path else None
397	            ),
398	            "review_page_path": str(context.data.get("review_page_path") or "") or None,
399	            "review_page_url": str(context.data.get("review_page_url") or "") or None,
400	            "publish_method": "manual_handoff",
401	        }
402
403
404	def default_modules(tts_client: Any = None) -> dict:
405	    from core.workflows.runner import ApprovalGate
406	    from core.workflows.listing_reel_modules import (
407	        ListingGraphicsModule,
408	        ListingIntakeModule,
409	        ListingPhotoReelRenderModule,
410	        ListingReviewArtifactsModule,
411	        ListingReviewApprovalModule,
412	        ListingReviewDraftModule,
413	        ListingScriptReviewModule,
414	        ListingVideoReviewModule,
415	    )
416
417	    return {
418	        "transcription": LocalTranscriptionModule(),
419	        "llm_script": LocalScriptModule(),
420	        "approval_gate": ApprovalGate(),
421	        "voice_synthesis": LocalVoiceModule(tts_client),
422	        "captions": LocalCaptionModule(),
423	        "video_format": LocalRenderModule(),
424	        "instagram_caption": InstagramCaptionModule(),
425	        "instagram_package": InstagramPackageModule(),
426	        "listing_intake": ListingIntakeModule(),
427	        "listing_review_draft": ListingReviewDraftModule(),
428	        "listing_review_approval": ListingReviewApprovalModule(),
429	        "listing_script_review": ListingScriptReviewModule(),
430	        "listing_pre_render_score": ListingScriptReviewModule(),
431	        "listing_graphics": ListingGraphicsModule(),
432	        "listing_photo_reel": ListingPhotoReelRenderModule(),
433	        "listing_video_review": ListingVideoReviewModule(),
434	        "listing_post_render_score": ListingVideoReviewModule(),
435	        "listing_review_artifacts": ListingReviewArtifactsModule(),
436	    }
437
438
439	# ── Audio extraction ──────────────────────────────────────────────────────────


Let me look for the store and job state management:


1	from __future__ import annotations
2
3	import json
4	from dataclasses import asdict
5	from pathlib import Path
6
7	from core.models import ApprovalStatus, Job, JobStatus, JobStep, StepStatus
8
9
10	class JsonJobStore:
11	    def __init__(self, root: str | Path) -> None:
12	        self.root = Path(root)
13	        self.root.mkdir(parents=True, exist_ok=True)
14
15	    def save(self, job: Job) -> None:
16	        path = self._path(job.id)
17	        path.write_text(json.dumps(_job_to_dict(job), indent=2), encoding="utf-8")
18
19	    def get(self, job_id: str) -> Job:
20	        path = self._path(job_id)
21	        if not path.exists():
22	            raise KeyError(f"Unknown job id: {job_id}")
23	        return _job_from_dict(json.loads(path.read_text(encoding="utf-8")))
24
25	    def _path(self, job_id: str) -> Path:
26	        return self.root / f"{job_id}.json"
27
28
29	def _job_to_dict(job: Job) -> dict:
30	    data = asdict(job)
31	    data["status"] = job.status.value
32	    data["approval_status"] = job.approval_status.value
33	    for index, step in enumerate(job.steps):
34	        data["steps"][index]["status"] = step.status.value
35	    return data
36
37
38	def _job_from_dict(data: dict) -> Job:
39	    steps = [
40	        JobStep(
41	            step_id=step["step_id"],
42	            status=StepStatus(step["status"]),
43	            input_data=step.get("input_data", {}),
44	            output_data=step.get("output_data", {}),
45	            approved_by=step.get("approved_by"),
46	            approved_at=step.get("approved_at"),
47	            duration_ms=step.get("duration_ms"),
48	        )
49	        for step in data.get("steps", [])
50	    ]
51	    return Job(
52	        id=data["id"],
53	        tenant_id=data["tenant_id"],
54	        workflow_name=data["workflow_name"],
55	        input_params=data["input_params"],
56	        status=JobStatus(data["status"]),
57	        output_params=data.get("output_params", {}),
58	        approval_status=ApprovalStatus(data.get("approval_status", "not_required")),
59	        approved_at=data.get("approved_at"),
60	        error_message=data.get("error_message"),
61	        started_at=data.get("started_at"),
62	        completed_at=data.get("completed_at"),
63	        steps=steps,
64	    )
65


Excellent! Now let me check the CLAUDE.md file to understand the project documentation:


1	# Stagehand Project Instructions
2	> Symlinked as `AGENTS.md`; edits affect both. Every agent (Claude Code, Codex) must read the same project instructions from here — do not reintroduce a divergent `AGENTS.md`.
3
4	This project is building a concierge-first local AI workflow service that can later become a platform.
5
6	## Listing Reel Engine (canonical)
7
8	The listing reel is rendered with **HyperFrames** (HTML → video). This is the one canonical engine for every agent — outputs live under `output/hyperframes/<project>/` as a HyperFrames project (`index.html`, `hyperframes.json`, `final_listing_reel.mp4` + `_delivery`). QA it with the repo-local skill `skills/hyperframes-reel-review/SKILL.md`.
9
10	- The older ffmpeg photo-slideshow path (`python -m cli.main run …`, writing `output/<uuid>/final_video.mp4` via `core/media/photo_reel.py`) is **legacy**. It stays for its tests and existing jobs, but it is not the default: do not build new listing reels through it. If a reel is requested, use HyperFrames.
11	- Rationale: the two paths previously produced qualitatively different videos (animated composition vs. Ken-Burns slideshow) in different folders depending on which agent ran — see the convergence decision. One engine, one output location.
12
13	## Execution Standard
14
15	- Use `docs/punch-list.md` as the project todo list and keep it current.
16	- Before starting a meaningful task, identify which punch-list items it advances.
17	- When work is completed and verified, update the checkbox in `docs/punch-list.md`.
18	- Do not mark discovery items complete until the required user/client input exists.
19	- Keep raw client/demo media, generated outputs, secrets, and private addresses out of git.
20	- For repeatable local HyperFrames reel QA, use the repo-local skill at `skills/hyperframes-reel-review/SKILL.md`.
21
22	## Testing Standard
23
24	- Testing is non-negotiable.
25	- Use TDD for deterministic business logic, config parsing, tenant scoping, approval gates, CLI behavior, and media command construction.
26	- Use BDD-style tests for multi-step workflow behavior such as "generated script pauses for approval" and "approved script resumes rendering."
27	- The default test suite must not require live paid API calls or network access.
28	- Live provider tests must be opt-in and guarded by explicit environment flags.
29	- For workflow or media-rendering changes, run a local end-to-end render through approval with a real fixture before calling the work implemented. Inspect or verify the produced video artifact enough to catch issues that unit tests miss, such as silent TTS fallback, static captions, source-audio leakage, or render duration problems.
30	- The full test suite runs automatically at the end of every turn via a `Stop` hook (`pytest -q`, configured in `.claude/settings.json`). Treat a failure it surfaces as a regression to fix before handing back; do not rely on it as a gate (it is non-blocking), and still run targeted tests while iterating.
31
32	## Current Scope
33
34	- Build a local CLI workflow first.
35	- Keep the system concierge-first: human review is part of the product.
36	- Do not add social posting, billing, authentication, or a web UI until the first workflow is proven.
37	- Vercel is acceptable later for thin UI/API surfaces, Neon for hosted Postgres, and Cloudflare for DNS/domains.
38	- Long-running video rendering should remain in a local/background worker, not in a Vercel request handler.
39


Now let me look at one specific workflow config to fully understand the complete picture:


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


Let me check if there are any existing retry implementations in the tests:


find /workspace/extra/projects/stagehand/tests -name "*.py" | xargs grep -l "retry\|resume\|loop" | head -10


/workspace/extra/projects/stagehand/tests/test_workflow_runner.py
/workspace/extra/projects/stagehand/tests/test_listing_reel_workflow.py
/workspace/extra/projects/stagehand/tests/test_cli.py


1	from __future__ import annotations
2
3	import tempfile
4	import unittest
5	from pathlib import Path
6	from unittest.mock import MagicMock, patch
7
8	from core.media.local_tts import LocalTtsError
9	from core.models import ApprovalStatus, JobStatus
10	from core.workflows.config import load_workflow_config
11	from core.workflows.modules import LocalTranscriptionModule, _apply_clip_speeds, _simple_srt, default_modules
12	from core.workflows.runner import WorkflowContext, WorkflowRunner
13	from core.workflows.store import JsonJobStore
14
15
16	class WorkflowRunnerScenarioTests(unittest.TestCase):
17	    def test_failed_post_approval_step_is_persisted_and_can_resume(self) -> None:
18	        class DraftModule:
19	            def run(self, context, params):
20	                return {"generated_script": "Review me."}
21
22	        class FailOnceModule:
23	            def __init__(self) -> None:
24	                self.should_fail = True
25
26	            def run(self, context, params):
27	                if self.should_fail:
28	                    raise RuntimeError("provider unavailable")
29	                return {"artifact": "complete"}
30
31	        from core.workflows.config import WorkflowConfig, WorkflowStep
32	        from core.workflows.runner import ApprovalGate
33
34	        with tempfile.TemporaryDirectory() as tmp:
35	            root = Path(tmp)
36	            store = JsonJobStore(root / "jobs")
37	            flaky = FailOnceModule()
38	            runner = WorkflowRunner(
39	                WorkflowConfig(
40	                    name="recoverable",
41	                    description="",
42	                    steps=[
43	                        WorkflowStep("draft", "draft", {}),
44	                        WorkflowStep("approve", "approval_gate", {}),
45	                        WorkflowStep("provider", "provider", {}),
46	                    ],
47	                ),
48	                {"draft": DraftModule(), "approval_gate": ApprovalGate(), "provider": flaky},
49	                store,
50	                output_root=root / "output",
51	            )
52	            waiting = runner.start("demo", {})
53	            approval_path = Path(waiting.output_params["approval_artifact"])
54
55	            with self.assertRaisesRegex(RuntimeError, "provider unavailable"):
56	                runner.approve(waiting.id, approval_path)
57
58	            failed = store.get(waiting.id)
59	            self.assertEqual(failed.status, JobStatus.FAILED)
60	            self.assertEqual(failed.error_message, "provider unavailable")
61	            self.assertEqual(failed.steps[-1].status.value, "failed")
62
63	            flaky.should_fail = False
64	            completed = runner.resume(waiting.id)
65
66	        self.assertEqual(completed.status, JobStatus.COMPLETED)
67	        self.assertIsNone(completed.error_message)
68	        self.assertEqual(completed.output_params["artifact"], "complete")
69
70	    def test_resume_rejects_jobs_that_are_not_approved_failures(self) -> None:
71	        with tempfile.TemporaryDirectory() as tmp:
72	            root = Path(tmp)
73	            runner = WorkflowRunner(
74	                load_workflow_config("workflows/video_narration/config.yaml"),
75	                default_modules(),
76	                JsonJobStore(root / "jobs"),
77	                output_root=root / "output",
78	            )
79	            waiting = runner.start("demo", {"input_path": "input/demo.mov"})
80
81	            with self.assertRaisesRegex(ValueError, "approved failed job"):
82	                runner.resume(waiting.id)
83
84	    def test_video_workflow_pauses_for_approval_then_resumes(self) -> None:
85	        with tempfile.TemporaryDirectory() as tmp:
86	            root = Path(tmp)
87	            store = JsonJobStore(root / "jobs")
88	            runner = WorkflowRunner(
89	                load_workflow_config("workflows/video_narration/config.yaml"),
90	                default_modules(),
91	                store,
92	                output_root=root / "output",
93	            )
94
95	            job = runner.start(
96	                tenant_id="demo",
97	                input_params={
98	                    "input_path": "input/IMG_2157.MOV",
99	                    "listing_description": "Demo walkthrough in Cedar Park.",
100	                    "transcript": "So this is the kitchen, um, then we walk into the living room and back patio.",
101	                },
102	            )
103
104	            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)
105	            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)
106	            approval_path = Path(job.output_params["approval_artifact"])
107	            self.assertTrue(approval_path.exists())
108	            approval_text = approval_path.read_text(encoding="utf-8")
109	            self.assertIn("This is the kitchen", approval_text)
110	            self.assertNotIn(" um,", approval_text)
111	            self.assertNotIn("output_video", job.output_params)
112
113	            approval_path.write_text("Approved narration for the demo video.", encoding="utf-8")
114	            completed = runner.approve(job.id, approval_path, approved_by="tester")
115
116	            self.assertEqual(completed.status, JobStatus.COMPLETED)
117	            self.assertEqual(completed.approval_status, ApprovalStatus.APPROVED)
118	            self.assertIn("output_video", completed.output_params)
119	            self.assertIn(
120	                completed.output_params["voice_provider"],
121	                {"local_say", "local_stub_silent_audio", "local_say_failed_silent_audio", "local_say_timed"},
122	            )
123	            self.assertTrue((root / "output" / job.id / "render_command.txt").exists())
124	            self.assertNotIn("caption_image_path", completed.output_params)
125	            self.assertGreaterEqual(len(completed.output_params["caption_overlays"]), 1)
126
127	    def test_local_say_tts_is_used_when_available(self) -> None:
128	        with tempfile.TemporaryDirectory() as tmp, patch("core.workflows.modules.say_is_available", return_value=True), patch(
129	            "core.workflows.modules.create_say_audio"
130	        ) as say_audio:
131	            root = Path(tmp)
132	            store = JsonJobStore(root / "jobs")
133	            runner = WorkflowRunner(
134	                load_workflow_config("workflows/video_narration/config.yaml"),
135	                default_modules(),
136	                store,
137	                output_root=root / "output",
138	            )
139	            job = runner.start("demo", {"input_path": "input/IMG_2157.MOV", "say_voice": "Samantha", "say_rate": 170})
140	            approval_path = Path(job.output_params["approval_artifact"])
141	            approval_path.write_text("Approved narration for the demo video.", encoding="utf-8")
142
143	            completed = runner.approve(job.id, approval_path)
144
145	        self.assertEqual(completed.output_params["voice_provider"], "local_say")
146	        say_audio.assert_called_once()
147
148	    def test_local_say_falls_back_when_audio_generation_fails(self) -> None:
149	        with tempfile.TemporaryDirectory() as tmp, patch("core.workflows.modules.say_is_available", return_value=True), patch(
150	            "core.workflows.modules.create_say_audio", side_effect=LocalTtsError("empty audio")
151	        ), patch("core.workflows.modules.create_silent_audio") as silent_audio:
152	            root = Path(tmp)
153	            store = JsonJobStore(root / "jobs")
154	            runner = WorkflowRunner(
155	                load_workflow_config("workflows/video_narration/config.yaml"),
156	                default_modules(),
157	                store,
158	                output_root=root / "output",
159	            )
160	            job = runner.start("demo", {"input_path": "input/IMG_2157.MOV", "say_voice": "Samantha", "say_rate": 170})
161	            approval_path = Path(job.output_params["approval_artifact"])
162	            approval_path.write_text("Approved narration for the demo video.", encoding="utf-8")
163
164	            completed = runner.approve(job.id, approval_path)
165
166	        self.assertEqual(completed.output_params["voice_provider"], "local_say_failed_silent_audio")
167	        silent_audio.assert_called_once()
168
169	    def test_continuous_elevenlabs_uses_approved_narration(self) -> None:
170	        mock_client = MagicMock()
171	        mock_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b"audio")
172	        module = default_modules(tts_client=mock_client)["voice_synthesis"]
173	        context = WorkflowContext(
174	            job=MagicMock(),
175	            workspace=Path(tempfile.mkdtemp()),
176	            data={
177	                "approved_script": "The JSON approval artifact should not be narrated.",
178	                "approved_narration": "Approved property narration.",
179	                "local_tts_provider": "elevenlabs",
180	                "require_audible_narration": True,
181	                "target_duration_seconds": 30,
182	            },
183	        )
184	        probe = MagicMock(duration_seconds=4.25)
185
186	        with patch("core.workflows.modules.probe_media", return_value=probe), patch(
187	            "core.workflows.modules.probe_peak_volume_db", return_value=-12.0
188	        ):
189	            result = module.run(context, {})
190
191	        mock_client.synthesize_to_file.assert_called_once()
192	        self.assertEqual(mock_client.synthesize_to_file.call_args.args[0], "Approved property narration.")
193	        self.assertEqual(result["voice_provider"], "elevenlabs")
194	        self.assertEqual(result["narration_duration_seconds"], 4.25)
195
196	    def test_strict_narration_rejects_missing_or_failed_provider_audio(self) -> None:
197	        module = default_modules()["voice_synthesis"]
198	        context = WorkflowContext(
199	            job=MagicMock(),
200	            workspace=Path(tempfile.mkdtemp()),
201	            data={
202	                "approved_narration": "Approved property narration.",
203	                "local_tts_provider": "elevenlabs",
204	                "require_audible_narration": True,
205	            },
206	        )
207	        with self.assertRaisesRegex(RuntimeError, "audible narration"):
208	            module.run(context, {})
209
210	        context.data["local_tts_provider"] = "silent"
211	        with self.assertRaisesRegex(RuntimeError, "audible narration"):
212	            module.run(context, {})
213
214	    def test_strict_narration_rejects_synthesized_silence(self) -> None:
215	        mock_client = MagicMock()
216	        mock_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b"audio")
217	        module = default_modules(tts_client=mock_client)["voice_synthesis"]
218	        context = WorkflowContext(
219	            job=MagicMock(),
220	            workspace=Path(tempfile.mkdtemp()),
221	            data={
222	                "approved_narration": "Approved property narration.",
223	                "local_tts_provider": "elevenlabs",
224	                "require_audible_narration": True,
225	            },
226	        )
227	        with patch("core.workflows.modules.probe_media", return_value=MagicMock(duration_seconds=4.0)), patch(
228	            "core.workflows.modules.probe_peak_volume_db", return_value=-91.0
229	        ):
230	            with self.assertRaisesRegex(RuntimeError, "effectively silent"):
231	                module.run(context, {})
232
233	    def test_rejected_script_does_not_render(self) -> None:
234	        with tempfile.TemporaryDirectory() as tmp:
235	            root = Path(tmp)
236	            store = JsonJobStore(root / "jobs")
237	            runner = WorkflowRunner(
238	                load_workflow_config("workflows/video_narration/config.yaml"),
239	                default_modules(),
240	                store,
241	                output_root=root / "output",
242	            )
243	            job = runner.start("demo", {"input_path": "input/IMG_2157.MOV"})
244
245	            rejected = runner.reject(job.id, "Needs factual correction")
246
247	            self.assertEqual(rejected.status, JobStatus.FAILED)
248	            self.assertEqual(rejected.approval_status, ApprovalStatus.REJECTED)
249	            self.assertNotIn("output_video", rejected.output_params)
250
251	    def test_manual_touch_time_is_tracked_per_job(self) -> None:
252	        with tempfile.TemporaryDirectory() as tmp:
253	            root = Path(tmp)
254	            store = JsonJobStore(root / "jobs")
255	            runner = WorkflowRunner(
256	                load_workflow_config("workflows/video_narration/config.yaml"),
257	                default_modules(),
258	                store,
259	                output_root=root / "output",
260	            )
261	            job = runner.start("demo", {"input_path": "input/IMG_2157.MOV"})
262
263	            updated = runner.log_touch_time(job.id, 4.5, "Reviewed script")
264	            updated = runner.log_touch_time(updated.id, 2.0, "Prepared delivery")
265
266	            self.assertEqual(updated.output_params["manual_touch_minutes"], 6.5)
267	            self.assertEqual(len(updated.output_params["manual_touch_log"]), 2)
268
269	            with self.assertRaises(ValueError):
270	                runner.log_touch_time(job.id, 0)
271
272	    def test_local_demo_can_render_final_video_after_approval(self) -> None:
273	        with tempfile.TemporaryDirectory() as tmp:
274	            root = Path(tmp)
275	            store = JsonJobStore(root / "jobs")
276	            runner = WorkflowRunner(
277	                load_workflow_config("workflows/video_narration/config.yaml"),
278	                default_modules(),
279	                store,
280	                output_root=root / "output",
281	            )
282	            job = runner.start(
283	                "demo",
284	                {
285	                    "input_path": "tests/fixtures/short_walkthrough.mp4",
286	                    "execute_render": True,
287	                    "local_tts_provider": "silent",
288	                    "transcript": "Short demo walkthrough.",
289	                    "target_duration_seconds": 2,
290	                },
291	            )
292	            approval_path = Path(job.output_params["approval_artifact"])
293	            approval_path.write_text("Approved short fixture narration.", encoding="utf-8")
294
295	            completed = runner.approve(job.id, approval_path)
296
297	            output_video = Path(completed.output_params["output_video"])
298	            self.assertTrue(output_video.exists())
299	            self.assertTrue(completed.output_params["render_executed"])
300	            self.assertEqual(completed.output_params["target_duration_seconds"], 2)
301	            self.assertEqual(completed.output_params["rendered_width"], 1080)
302	            self.assertEqual(completed.output_params["rendered_height"], 1920)
303
304	    def test_captions_are_segmented_across_narration_duration(self) -> None:
305	        text = (
306	            "Welcome inside this bright entry and open living room. "
307	            "The kitchen connects cleanly to the dining area. "
308	            "Down the hall, the bedrooms are quiet and comfortable."
309	        )
310
311	        srt = _simple_srt(text, duration_seconds=12)
312
313	        self.assertIn("1\n00:00:00,000 -->", srt)
314	        self.assertIn("\n2\n", srt)
315	        self.assertIn("\n3\n", srt)
316	        self.assertIn("00:00:12,000", srt)
317
318	    def test_transcript_cleanup_capitalizes_after_removed_filler(self) -> None:
319	        with tempfile.TemporaryDirectory() as tmp:
320	            root = Path(tmp)
321	            store = JsonJobStore(root / "jobs")
322	            runner = WorkflowRunner(
323	                load_workflow_config("workflows/video_narration/config.yaml"),
324	                default_modules(),
325	                store,
326	                output_root=root / "output",
327	            )
328
329	            job = runner.start(
330	                "demo",
331	                {
332	                    "input_path": "input/IMG_2157.MOV",
333	                    "transcript": "So welcome inside this bright entry. Um, the living room opens into the kitchen.",
334	                },
335	            )
336
337	            approval_path = Path(job.output_params["approval_artifact"])
338	            approval_text = approval_path.read_text(encoding="utf-8")
339
340	        self.assertIn("entry. The living room", approval_text)
341	        self.assertNotIn("Um", approval_text)
342
343	    def test_whisper_client_transcribe_with_segments_is_called_and_stored(self) -> None:
344	        mock_client = MagicMock()
345	        mock_client.transcribe_with_segments.return_value = (
346	            "This is the kitchen, then we walk into the living room.",
347	            [
348	                {"start": 0.0, "end": 3.5, "text": "This is the kitchen,"},
349	                {"start": 3.5, "end": 7.0, "text": "then we walk into the living room."},
350	            ],
351	        )
352	        module = LocalTranscriptionModule(mock_client)
353	        # Use a path that does not exist on disk so audio extraction is skipped;
354	        # this test exercises client wiring, not the extraction step.
355	        context = WorkflowContext(
356	            job=MagicMock(),
357	            workspace=Path("/tmp/stagehand_test_workspace"),
358	            data={"input_path": "input/nonexistent_walkthrough.mov"},
359	        )
360
361	        result = module.run(context, {})
362
363	        mock_client.transcribe_with_segments.assert_called_once_with("input/nonexistent_walkthrough.mov")
364	        self.assertEqual(result["transcript"], "This is the kitchen, then we walk into the living room.")
365	        self.assertEqual(len(result["transcript_segments"]), 2)
366	        self.assertAlmostEqual(result["transcript_segments"][0]["start"], 0.0)
367	        self.assertAlmostEqual(result["transcript_segments"][1]["end"], 7.0)
368
369	    def test_zero_duration_segments_are_filtered_before_storage(self) -> None:
370	        mock_client = MagicMock()
371	        mock_client.transcribe_with_segments.return_value = (
372	            "Kitchen then living room.",
373	            [
374	                {"start": 0.0, "end": 3.5, "text": "Kitchen"},
375	                {"start": 3.5, "end": 3.5, "text": ""},   # zero-duration hallucination
376	                {"start": 3.5, "end": 3.5, "text": ""},   # another zero-duration
377	                {"start": 3.5, "end": 7.0, "text": "then living room."},
378	            ],
379	        )
380	        module = LocalTranscriptionModule(mock_client)
381	        context = WorkflowContext(
382	            job=MagicMock(),
383	            workspace=Path("/tmp/stagehand_test_workspace"),
384	            data={"input_path": "input/nonexistent_walkthrough.mov"},
385	        )
386
387	        result = module.run(context, {})
388
389	        self.assertEqual(len(result["transcript_segments"]), 2)
390	        self.assertAlmostEqual(result["transcript_segments"][0]["end"], 3.5)
391	        self.assertAlmostEqual(result["transcript_segments"][1]["start"], 3.5)
392	        self.assertAlmostEqual(result["transcript_segments"][1]["end"], 7.0)
393
394	    def test_whisper_client_falls_back_to_transcribe_when_no_segments_method(self) -> None:
395	        mock_client = MagicMock(spec=["transcribe"])
396	        mock_client.transcribe.return_value = "A kitchen and living room."
397	        module = LocalTranscriptionModule(mock_client)
398	        context = WorkflowContext(
399	            job=MagicMock(),
400	            workspace=Path("/tmp/stagehand_test_workspace"),
401	            data={"input_path": "input/demo.mov"},
402	        )
403
404	        result = module.run(context, {})
405
406	        mock_client.transcribe.assert_called_once_with("input/demo.mov")
407	        self.assertEqual(result["transcript"], "A kitchen and living room.")
408	        self.assertNotIn("transcript_segments", result)
409
410	    def test_transcription_module_offline_placeholder_is_explicit(self) -> None:
411	        module = LocalTranscriptionModule()
412	        context = WorkflowContext(
413	            job=MagicMock(),
414	            workspace=Path("/tmp"),
415	            data={"input_path": "input/no_client.mov"},
416	        )
417
418	        result = module.run(context, {})
419
420	        self.assertIn("DEMO PLACEHOLDER", result["transcript"])
421	        self.assertIn("no_client.mov", result["transcript"])
422
423	    def test_captions_use_whisper_segment_timestamps_scaled_to_tts_duration(self) -> None:
424	        with tempfile.TemporaryDirectory() as tmp:
425	            root = Path(tmp)
426	            store = JsonJobStore(root / "jobs")
427	            runner = WorkflowRunner(
428	                load_workflow_config("workflows/video_narration/config.yaml"),
429	                default_modules(),
430	                store,
431	                output_root=root / "output",
432	            )
433	            # Whisper reported 10s of original speech; TTS will run at 8s
434	            job = runner.start(
435	                "demo",
436	                {
437	                    "input_path": "input/IMG_2157.MOV",
438	                    "transcript": "Welcome inside. The kitchen opens to the living room.",
439	                    "transcript_segments": [
440	                        {"start": 0.0, "end": 2.0, "text": "Welcome inside."},
441	                        {"start": 2.0, "end": 6.0, "text": "The kitchen"},
442	                        {"start": 6.0, "end": 10.0, "text": "opens to the living room."},
443	                    ],
444	                    "target_duration_seconds": 8,
445	                    # Use silent TTS so narration_duration_seconds stays at
446	                    # target_duration_seconds (8s) and caption timestamps are deterministic.
447	                    "local_tts_provider": "silent",
448	                },
449	            )
450	            approval_path = Path(job.output_params["approval_artifact"])
451	            approval_path.write_text("Welcome inside. The kitchen opens to the living room.", encoding="utf-8")
452	            completed = runner.approve(job.id, approval_path)
453
454	            overlays = completed.output_params["caption_overlays"]
455	            # Captions should start at 0 and end at the narration duration (8s), not raw 10s
456	            self.assertAlmostEqual(overlays[0]["start"], 0.0)
457	            self.assertAlmostEqual(overlays[-1]["end"], 8.0, places=1)
458	            # Whisper-based timing reflects original pacing scaled to narration duration
459	            srt = Path(completed.output_params["captions_path"]).read_text(encoding="utf-8")
460	            self.assertIn("00:00:00,000 -->", srt)
461	            self.assertIn("00:00:08,000", srt)
462
463	    def test_timed_tts_generates_one_clip_per_segment_group(self) -> None:
464	        # Use the real MOV so LocalRenderModule.probe_media succeeds.
465	        # The voice module probes it to get the actual video duration for timed positioning.
466	        with tempfile.TemporaryDirectory() as tmp, \
467	             patch("core.workflows.modules.say_is_available", return_value=True), \
468	             patch("core.workflows.modules.create_say_audio") as mock_say, \
469	             patch("core.workflows.modules.run_render_command"):
470	            root = Path(tmp)
471	            store = JsonJobStore(root / "jobs")
472	            runner = WorkflowRunner(
473	                load_workflow_config("workflows/video_narration/config.yaml"),
474	                default_modules(),
475	                store,
476	                output_root=root / "output",
477	            )
478	            job = runner.start(
479	                "demo",
480	                {
481	                    "input_path": "input/IMG_2157.MOV",
482	                    "transcript": "Welcome inside. The kitchen is bright.",
483	                    "transcript_segments": [
484	                        {"start": 0.0, "end": 2.0, "text": "Welcome inside."},
485	                        {"start": 5.0, "end": 8.0, "text": "The kitchen is bright."},
486	                    ],
487	                },
488	            )
489	            approval_path = Path(job.output_params["approval_artifact"])
490	            approval_path.write_text("Welcome inside. The kitchen is bright.", encoding="utf-8")
491	            completed = runner.approve(job.id, approval_path)
492
493	        self.assertEqual(completed.output_params["voice_provider"], "local_say_timed")
494	        # One say call per segment group (two distinct segments → two clips)
495	        self.assertEqual(mock_say.call_count, 2)
496	        # Narration duration should equal the probed video duration (> 8s fallback estimate)
497	        self.assertGreater(completed.output_params["narration_duration_seconds"], 8.0)
498
499	    def test_elevenlabs_tts_is_used_when_provider_is_elevenlabs(self) -> None:
500	        mock_el_client = MagicMock()
501	        mock_el_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b"")
502
503	        with tempfile.TemporaryDirectory() as tmp, \
504	             patch("core.workflows.modules.run_render_command"), \
505	             patch("core.workflows.modules._apply_clip_speeds"):
506	            root = Path(tmp)
507	            store = JsonJobStore(root / "jobs")
508	            runner = WorkflowRunner(
509	                load_workflow_config("workflows/video_narration/config.yaml"),
510	                default_modules(tts_client=mock_el_client),
511	                store,
512	                output_root=root / "output",
513	            )
514	            job = runner.start(
515	                "demo",
516	                {
517	                    "input_path": "input/IMG_2157.MOV",
518	                    "transcript": "Welcome inside. The kitchen is bright.",
519	                    "transcript_segments": [
520	                        {"start": 0.0, "end": 2.0, "text": "Welcome inside."},
521	                        {"start": 5.0, "end": 8.0, "text": "The kitchen is bright."},
522	                    ],
523	                    "local_tts_provider": "elevenlabs",
524	                },
525	            )
526	            approval_path = Path(job.output_params["approval_artifact"])
527	            approval_path.write_text("Welcome inside. The kitchen is bright.", encoding="utf-8")
528	            completed = runner.approve(job.id, approval_path)
529
530	        self.assertEqual(completed.output_params["voice_provider"], "elevenlabs_timed")
531	        self.assertEqual(mock_el_client.synthesize_to_file.call_count, 2)
532	        # Confirm say was never invoked
533	        first_call_text = mock_el_client.synthesize_to_file.call_args_list[0][0][0]
534	        self.assertIn("Welcome inside", first_call_text)
535
536	    def test_apply_clip_speeds_sets_speed_when_clip_overruns_next_start(self) -> None:
537	        clips = [
538	            {"path": "tmp/seg_000.aiff", "start_seconds": 0.0},
539	            {"path": "tmp/seg_001.aiff", "start_seconds": 3.0},
540	            {"path": "tmp/seg_002.aiff", "start_seconds": 8.0},
541	        ]
542	        # First clip is 5s long but only has a 3s window → raw ratio 1.67, capped at 1.3
543	        # Second clip is 2s long and has a 5s window → no speed adjustment needed
544	        # Third clip is 1s long, last clip, window is total_duration(10) - 8 = 2s → fits
545	        def fake_probe(path):
546	            durations = {"tmp/seg_000.aiff": 5.0, "tmp/seg_001.aiff": 2.0, "tmp/seg_002.aiff": 1.0}
547	            m = MagicMock()
548	            m.duration_seconds = durations[str(path)]
549	            return m
550
551	        with patch("core.workflows.modules.probe_media", side_effect=fake_probe):
552	            _apply_clip_speeds(clips, total_duration=10.0)
553
554	        self.assertAlmostEqual(clips[0]["speed"], 1.3, places=4)
555	        self.assertNotIn("speed", clips[1])
556	        self.assertNotIn("speed", clips[2])
557
558	    def test_apply_clip_speeds_caps_speed_at_1_3x(self) -> None:
559	        clips = [
560	            {"path": "tmp/seg_000.aiff", "start_seconds": 0.0},
561	            {"path": "tmp/seg_001.aiff", "start_seconds": 1.0},
562	        ]
563	        # Clip is 10s long but only a 1s window → raw ratio is 10x, should cap at 1.3
564	        def fake_probe(path):
565	            m = MagicMock()
566	            m.duration_seconds = 10.0 if "seg_000" in str(path) else 0.5
567	            return m
568
569	        with patch("core.workflows.modules.probe_media", side_effect=fake_probe):
570	            _apply_clip_speeds(clips, total_duration=5.0)
571
572	        self.assertAlmostEqual(clips[0]["speed"], 1.3)
573
574	    def test_tts_grouping_never_splits_a_sentence_across_clips(self) -> None:
575	        from core.workflows.modules import _group_text_for_timed_tts
576
577	        # Long sentence spans multiple proportional buckets under chunk-based mapping.
578	        # Sentence-level grouping must keep it whole in a single TTS clip.
579	        segments = [
580	            {"start": 0.0, "end": 3.0, "text": "Intro."},
581	            {"start": 4.0, "end": 11.0, "text": "Middle segment."},
582	            {"start": 12.0, "end": 15.0, "text": "Outro."},
583	        ]
584	        text = (
585	            "Short intro. "
586	            "It is a really wonderful home with many great features for a growing family. "
587	            "Short outro."
588	        )
589	        groups = _group_text_for_timed_tts(text, segments)
590
591	        # The long middle sentence must appear entirely within a single group
592	        all_texts = [g["text"] for g in groups]
593	        long_sentence = "It is a really wonderful home with many great features for a growing family."
594	        self.assertTrue(
595	            any(long_sentence in t for t in all_texts),
596	            f"Long sentence was split across clips. Groups: {all_texts}",
597	        )
598
599	    def test_caption_segments_do_not_overlap_when_chunks_exceed_segment_count(self) -> None:
600	        from core.workflows.modules import _caption_segments_from_whisper
601
602	        # 2 whisper segments, many caption chunks — historically caused duplicate timestamps
603	        segments = [
604	            {"start": 0.0, "end": 8.0, "text": "First long sentence with many words here."},
605	            {"start": 9.0, "end": 12.0, "text": "Second sentence."},
606	        ]
607	        text = (
608	            "First long sentence with many words here. "
609	            "Second sentence that also has quite a few words in it."
610	        )
611	        result = _caption_segments_from_whisper(text, segments, narration_duration=12.0)
612
613	        starts = [seg["start"] for seg in result]
614	        ends = [seg["end"] for seg in result]
615	        # Every start must be strictly less than the corresponding end
616	        for i, (s, e) in enumerate(zip(starts, ends)):
617	            self.assertLess(s, e, f"Segment {i} has zero or negative duration: {s}–{e}")
618	        # No two adjacent segments should have the same start time
619	        for i in range(len(starts) - 1):
620	            self.assertLess(starts[i], starts[i + 1], f"Segments {i} and {i+1} share start time {starts[i]}")
621	        # Last segment must end at narration_duration
622	        self.assertAlmostEqual(ends[-1], 12.0)
623
624	    def test_transcript_cleanup_removes_duplicate_words_and_i_mean(self) -> None:
625	        with tempfile.TemporaryDirectory() as tmp:
626	            root = Path(tmp)
627	            store = JsonJobStore(root / "jobs")
628	            runner = WorkflowRunner(
629	                load_workflow_config("workflows/video_narration/config.yaml"),
630	                default_modules(),
631	                store,
632	                output_root=root / "output",
633	            )
634	            job = runner.start(
635	                "demo",
636	                {
637	                    "input_path": "input/IMG_2157.MOV",
638	                    "transcript": "I mean, the the kitchen is nice. And the living room.",
639	                },
640	            )
641	            approval_path = Path(job.output_params["approval_artifact"])
642	            approval_text = approval_path.read_text(encoding="utf-8")
643
644	        self.assertNotIn("I mean", approval_text)
645	        self.assertNotIn("the the", approval_text)
646	        self.assertIn("The kitchen", approval_text)
647
648
649	if __name__ == "__main__":
650	    unittest.main()
651


Perfect! Now I have enough information. Let me create a comprehensive summary document:

## Summary: StageHand (CreatorFlow) Pipeline Structure Analysis

Based on my thorough exploration of the codebase, here's the complete architecture for implementing an outer QA→fail→rerender retry loop:

### Key File Paths and Roles

**Pipeline Entry Points & Configuration:**
- `/workspace/extra/projects/stagehand/cli/main.py` - Main CLI entry point with all commands
- `/workspace/extra/projects/stagehand/core/workflows/runner.py` - Core workflow orchestration engine
- `/workspace/extra/projects/stagehand/core/workflows/config.py` - Workflow config loader (YAML/JSON)
- `/workspace/extra/projects/stagehand/core/workflows/store.py` - Job persistence (JSON-based)

**Workflow Definitions:**
- `/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml` - Listing reel workflow (JSON format, not YAML)
- `/workspace/extra/projects/stagehand/workflows/video_narration/config.yaml` - Video narration workflow
- `/workspace/extra/projects/stagehand/workflows/publish_instagram/config.yaml` - Publishing workflow

**QA/Review Modules:**
- `/workspace/extra/projects/stagehand/core/listings/scorecard.py` - Scoring logic with `score_pre_render()` and `score_post_render()` functions
- `/workspace/extra/projects/stagehand/core/hyperframes_review.py` - HyperFrames video review (generates scores, recommendations)
- `/workspace/extra/projects/stagehand/core/listings/review.py` - Listing review bundle building
- `/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py` - Listing-specific workflow modules including `ListingVideoReviewModule`, `ListingScriptReviewModule`

**Rendering Modules:**
- `/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py` - `ListingPhotoReelRenderModule` (line 182) - renders listing reels
- `/workspace/extra/projects/stagehand/core/media/photo_reel.py` - Low-level photo reel rendering
- `/workspace/extra/projects/stagehand/core/workflows/modules.py` - `LocalRenderModule` (legacy video narration rendering)

**State Management:**
- `/workspace/extra/projects/stagehand/core/models.py` - `Job`, `JobStatus`, `ApprovalStatus`, `JobStep`, `StepStatus` enums

---

### Current Pipeline Flow

**Listing Reel Workflow (canonical engine per CLAUDE.md):**

```
1. intake                      → ListingIntakeModule
2. draft_review               → ListingReviewDraftModule
3. approve_listing_reel       → ApprovalGate (WAITING_FOR_APPROVAL)
4. validate_approval          → ListingReviewApprovalModule
5. script_review             → ListingScriptReviewModule (pre-render QA scoring)
6. synthesize_voice          → LocalVoiceModule
7. build_captions            → LocalCaptionModule
8. build_graphics            → ListingGraphicsModule
9. render_reel               → ListingPhotoReelRenderModule (RENDERS VIDEO)
10. video_review             → ListingVideoReviewModule (post-render QA scoring) ← KEY STEP
11. build_review_artifacts   → ListingReviewArtifactsModule
12. package_instagram        → InstagramPackageModule
```

The workflow is defined as a linear list of steps in `/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml` (JSON format, despite the .yaml extension).

---

### How QA Failure is Communicated

**Score/QA Results:**

- `ListingVideoReviewModule` (line 235 in listing_reel_modules.py) runs after rendering
- Calls `score_post_render()` from scorecard.py (line 146), which generates a `ScoreReport` dataclass:
  - `overall_score: int` (0-100)
  - `recommendation: str` ("publishable", "revise", "reject")
  - `hard_gate_warnings: list[dict]` - list of failures with severity "error"
  - `top_issues: list[dict]` - list of warnings/issues

- **Key**: The module returns a dict with `video_review`, `post_render_score`, and `post_render_score_path`
- **No automatic failure mechanism** - the scoring happens, data is stored in `context.data`, but there's no built-in logic to fail the job or retry based on score

**HyperFrames Review (alternative review path for HyperFrames projects):**
- `review_hyperframes_project()` in hyperframes_review.py generates:
  - `overall_score: int`
  - `recommendation: str` ("publishable", "revise", "reject")
  - `hard_failures: list` - items with severity "error"

---

### How Rendering is Invoked

**Method 1: Automatic during workflow (if `execute_render: true`):**
```python
# In ListingPhotoReelRenderModule.run() (line 182 of listing_reel_modules.py)
if bool(context.data.get("execute_render", False)):
    run_photo_reel(command)  # Actually runs ffmpeg
    probe = probe_media(output_path)
    result.update({
        "render_executed": True,
        "rendered_duration_seconds": probe.duration_seconds,
        # ... other metrics
    })
```

**Method 2: CLI-invoked rendering:**
```bash
python -m cli.main listing-reel \
  --url "https://compass.com/property/..." \
  --manifest path/to/manifest.json \
  # ... other args (execute_render is True by default for listing-reel)
```

**Method 3: Post-approval rendering via `resume`:**
```bash
python -m cli.main resume <job_id>  # Resumes from failed step
```

**Rendering command construction:**
- `ListingPhotoReelRenderModule` calls `build_photo_reel_command()` (from core/media/photo_reel.py)
- Command is stored in `context.data["render_command"]` (list of strings)
- Actual execution: `run_photo_reel(command)` (subprocess wrapper)

---

### Existing Retry Handling

**Current retry mechanism (limited):**

1. **Resume after failure** (`runner.resume(job_id)`):
   - Located in `/workspace/extra/projects/stagehand/core/workflows/runner.py` (line 117)
   - Requirements:
     - Job must have `status == JobStatus.FAILED`
     - Job must have `approval_status == ApprovalStatus.APPROVED`
   - Behavior:
     - Calls `self.run(job_id)` which re-executes the workflow from the first non-completed step
     - Does NOT re-execute already completed steps (tracks `completed_steps` at line 64)

2. **Test evidence** (test_workflow_runner.py):
   - Lines 17-68: `test_failed_post_approval_step_is_persisted_and_can_resume()`
   - Demonstrates: step fails → job saved with FAILED status → external code fixes issue (flaky.should_fail = False) → resume succeeds

3. **No automatic retry**:
   - No built-in loop on QA failure
   - No mechanism to automatically re-render if score is below threshold
   - No configurable retry count or backoff strategy

---

### Workflow Config Format

**Format:** JSON (despite .yaml extensions)

**Example (listing_reel/config.yaml):**
```json
{
  "name": "listing_reel",
  "description": "...",
  "steps": [
    {
      "id": "intake",
      "module": "listing_intake",
      "params": {}
    },
    {
      "id": "render_reel",
      "module": "listing_photo_reel",
      "params": {
        "width": 1080,
        "height": 1920,
        "fps": 60,
        "outro_duration_seconds": 4.5
      }
    },
    {
      "id": "video_review",
      "module": "listing_video_review",
      "params": {}
    }
  ]
}
```

**Structure:**
- `name`: workflow identifier
- `description`: human-readable description
- `steps`: array of `WorkflowStep` objects (id, module, params)

**Parsing:**
- `load_workflow_config()` in config.py (line 23):
  - Tries YAML first (via `yaml.safe_load()`)
  - Falls back to JSON if YAML import fails
  - Creates `WorkflowStep` namedtuples with frozen dataclass semantics

---

### Job State Management Details

**Job Status Enum** (core/models.py):
```python
PENDING, RUNNING, WAITING_FOR_APPROVAL, COMPLETED, FAILED
```

**Approval Status Enum:**
```python
NOT_REQUIRED, PENDING, APPROVED, REJECTED
```

**Step Status Enum:**
```python
PENDING, RUNNING, COMPLETED, WAITING_FOR_APPROVAL, FAILED, SKIPPED
```

**Persistence:**
- JSON files in `output/jobs/<job_id>.json`
- Each step tracked with:
  - `step_id`, `status`, `input_data`, `output_data`
  - `approved_by`, `approved_at`, `duration_ms`
- Context data merged across steps: `context.data = {**job.input_params, **job.output_params}`

**Resume logic** (runner.py line 64-65):
```python
completed_steps = {step.step_id for step in job.steps if step.status == StepStatus.COMPLETED}
for workflow_step in self.config.steps:
    if workflow_step.id in completed_steps:
        continue  # Skip already-completed steps
```

---

### Implementation Strategy for QA→Fail→Rerender Loop

**Proposed approach:**

1. **Create a new retry wrapper module** that sits between `video_review` and `build_review_artifacts`:
   - Check if `overall_score` or `hard_gate_warnings` indicate failure
   - If threshold not met: roll back to a previous step (e.g., `synthesize_voice` or `render_reel`)
   - Re-execute the rendering pipeline with modified params or flags
   - Re-run QA
   - Increment retry counter

2. **Modify workflow config** to include a conditional retry step:
   - Add retry metadata to workflow steps
   - Or create a separate "retry_reel" workflow variant

3. **Leverage existing resume mechanism**:
   - Don't try to retry within a single job execution
   - Instead, programmatically call `runner.resume()` if QA fails
   - Or update job state to re-run from a specific step

4. **Key decision point**: Whether to:
   - Modify the job in-place (skip completed steps, re-execute from retry point)
   - Create a new job with updated params
   - Use a separate retry loop external to the workflow runner

**Key files to modify:**
- Add a `ListingQARetryModule` in listing_reel_modules.py that checks scores and manages retry logic
- Optionally modify runner.py to support step re-execution (currently blocks re-running completed steps)
- Potentially update listing_reel/config.yaml to add retry step configuration

The existing `resume()` mechanism already handles the hard part (re-running from a specific step while skipping completed ones). You'll need to wire it up to the QA scoring step.