I need to understand the StageHand (CreatorFlow) pipeline structure at `/workspace/extra/projects/stagehand/` so I can implement an outer QA→fail→rerender retry loop.

Please find and read:
1. The main pipeline runner / CLI entry point (likely in `core/` or `cli/`)
2. Where the QA review / scoring step runs (look for `hyperframes-video-review`, `qa`, `score`, `review` in file names or content)
3. Where rendering / reel generation happens (look for `render`, `listing_reel`, `build_reel`)
4. Any existing retry or loop logic
5. The workflow config format (if there is one — YAML, JSON, or Python dicts describing steps)

Return:
- The key file paths and their roles
- The current pipeline flow (what calls what, in order)
- How QA failure is currently communicated (return code? exception? score dict?)
- How rendering is invoked (CLI command? Python function call?)
- Any existing retry handling

Be thorough — I need to understand the actual structure before writing code. Search breadth: very thorough.


I'll conduct a thorough search of the StageHand pipeline structure to understand the architecture and how QA, rendering, and retry logic work.


Bash

find /workspace/extra/projects/stagehand -type f -name "*.py" | head -20


Let me continue exploring the structure and get more files:


find /workspace/extra/projects/stagehand -type f \( -name "*.py" -o -name "*.yaml" -o -name "*.yml" -o -name "*.json" \) | grep -E "(cli|main|runner|workflow|render|review|qa)" | sort


Let me be more specific and look at the core directory structure:


total 60
drwxr-xr-x 30 501 dialout   960 Jul 17 16:07 .
drwxr-xr-x 75 501 dialout  2400 Jul 20 14:53 ..
-rw-r--r--  1 501 dialout 14340 Jul 17 16:33 .DS_Store
drwxr-xr-x  6 501 dialout   192 Jul 12 18:45 .claude
drwxr-xr-x  3 501 dialout    96 Jul 10 18:01 .codex
-rw-r--r--  1 501 dialout   953 Jul 18 07:29 .env
-rw-r--r--  1 501 dialout  1191 Jul 11 09:34 .env.example
drwxr-xr-x 12 501 dialout   384 Jul 20 21:17 .git
-rw-r--r--  1 501 dialout   177 Jul 15 20:40 .gitignore
drwxr-xr-x  6 501 dialout   192 Jun  2 10:21 .pytest_cache
drwxr-xr-x  6 501 dialout   192 Jun 13 15:26 .venv
lrwxr-xr-x  1 501 dialout     9 Jul 12 13:34 AGENTS.md -> CLAUDE.md
-rw-r--r--  1 501 dialout  3392 Jul 12 18:45 CLAUDE.md
-rw-r--r--  1 501 dialout  4280 Jul 10 10:42 README.md
drwxr-xr-x  5 501 dialout   160 Jul 11 13:57 cli
drwxr-xr-x 16 501 dialout   512 Jul 13 10:31 core
-rw-r--r--  1 501 dialout   295 Jun  2 10:20 docker-compose.yml
drwxr-xr-x 15 501 dialout   480 Jul 20 09:36 docs
drwxr-xr-x  7 501 dialout   224 Jul 11 14:59 input
drwxr-xr-x  4 501 dialout   128 Jun  2 13:03 memory
drwxr-xr-x 40 501 dialout  1280 Jul 15 09:22 output
-rw-r--r--  1 501 dialout   361 Jun  2 10:20 pyproject.toml
-rw-r--r--  1 501 dialout    29 Jul 14 19:25 redirectTo.md
-rw-r--r--  1 501 dialout    27 Jun  2 10:20 requirements-dev.txt
-rw-r--r--  1 501 dialout   117 Jun  2 12:02 requirements.txt
drwxr-xr-x  6 501 dialout   192 Jul 20 09:53 scripts
drwxr-xr-x  3 501 dialout    96 Jul 11 10:53 skills
drwxr-xr-x 29 501 dialout   928 Jul 20 09:35 tests
drwxr-xr-x  2 501 dialout    64 Jun  2 10:06 tmp
drwxr-xr-x  5 501 dialout   160 Jul  7 14:13 workflows


total 28
drwxr-xr-x  5 501 dialout   160 Jul 11 13:57 .
drwxr-xr-x 30 501 dialout   960 Jul 17 16:07 ..
-rw-r--r--  1 501 dialout    31 Jun  2 10:20 __init__.py
drwxr-xr-x  6 501 dialout   192 Jul 18 07:51 __pycache__
-rw-r--r--  1 501 dialout 23638 Jul 18 07:51 main.py


total 60
drwxr-xr-x 16 501 dialout   512 Jul 13 10:31 .
drwxr-xr-x 30 501 dialout   960 Jul 17 16:07 ..
-rw-r--r--  1 501 dialout    32 Jun  2 10:20 __init__.py
drwxr-xr-x 13 501 dialout   416 Jul 20 09:03 __pycache__
-rw-r--r--  1 501 dialout  3333 Jul 11 09:31 config.py
drwxr-xr-x  7 501 dialout   224 Jun  2 10:25 db
-rw-r--r--  1 501 dialout  4121 Jul 13 11:00 delivery_naming.py
-rw-r--r--  1 501 dialout  3219 Jul 18 07:50 hyperframes_narration.py
-rw-r--r--  1 501 dialout 28051 Jul 11 13:20 hyperframes_review.py
drwxr-xr-x  8 501 dialout   256 Jun  2 16:54 integrations
drwxr-xr-x  9 501 dialout   288 Jul 11 09:31 listings
drwxr-xr-x 10 501 dialout   320 Jul 11 15:02 media
-rw-r--r--  1 501 dialout  3134 Jul  7 14:28 models.py
drwxr-xr-x  5 501 dialout   160 Jun 13 15:25 publishing
-rw-r--r--  1 501 dialout  7378 Jul 14 19:42 review_server.py
drwxr-xr-x  9 501 dialout   288 Jul 10 11:36 workflows


total 72
drwxr-xr-x  9 501 dialout   288 Jul 10 11:36 .
drwxr-xr-x 16 501 dialout   512 Jul 13 10:31 ..
-rw-r--r--  1 501 dialout    38 Jun  2 10:20 __init__.py
drwxr-xr-x 14 501 dialout   448 Jul 13 10:37 __pycache__
-rw-r--r--  1 501 dialout  1122 Jun  2 10:20 config.py
-rw-r--r--  1 501 dialout 15867 Jul 11 11:19 listing_reel_modules.py
-rw-r--r--  1 501 dialout 33878 Jul 13 10:37 modules.py
-rw-r--r--  1 501 dialout  5264 Jul  7 14:29 runner.py
-rw-r--r--  1 501 dialout  2111 Jun  2 10:20 store.py


total 48
drwxr-xr-x  9 501 dialout   288 Jul 11 09:31 .
drwxr-xr-x 16 501 dialout   512 Jul 13 10:31 ..
-rw-r--r--  1 501 dialout   193 Jul  7 14:02 __init__.py
drwxr-xr-x 15 501 dialout   480 Jul 20 09:03 __pycache__
-rw-r--r--  1 501 dialout 12247 Jul  7 14:26 manifest.py
-rw-r--r--  1 501 dialout  3887 Jul 11 09:31 models.py
drwxr-xr-x  6 501 dialout   192 Jul 20 09:35 providers
-rw-r--r--  1 501 dialout  8543 Jul 11 09:31 review.py
-rw-r--r--  1 501 dialout 14319 Jul 11 11:19 scorecard.py


Now let me read the key files to understand the pipeline structure:


1	from __future__ import annotations
2
3	import argparse
4	import json
5	import os
6	import subprocess
7	import sys
8	from pathlib import Path
9	from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
10	from typing import Any
11
12	from core.config import load_settings
13	from core.hyperframes_narration import add_narration_to_project, delivery_video_path_for_project
14	from core.hyperframes_review import (
15	    review_hyperframes_project,
16	    review_hyperframes_script,
17	    write_hyperframes_review,
18	)
19	from core.media.ffmpeg_utils import assert_media_has_usable_audio
20	from core.review_server import RangeRequestHandler, ReviewLaunchAgentSpec
21	from core.workflows.config import load_workflow_config
22	from core.workflows.modules import LocalTranscriptionModule, default_modules
23	from core.workflows.runner import WorkflowRunner
24	from core.workflows.store import JsonJobStore
25
26
27	def main(argv: list[str] | None = None) -> int:
28	    parser = argparse.ArgumentParser(prog="creatorflow")
29	    parser.add_argument("--env-file", default=".env")
30	    subparsers = parser.add_subparsers(dest="command", required=True)
31
32	    run_parser = subparsers.add_parser("run")
33	    run_parser.add_argument("workflow")
34	    run_parser.add_argument("--input", required=True)
35	    run_parser.add_argument("--tenant", default="demo")
36	    run_parser.add_argument("--listing-description", default="")
37	    run_parser.add_argument("--use-demo-env", action="store_true", help="Use local-only demo context from .env")
38	    run_parser.add_argument("--include-demo-address", action="store_true", help="Include DEMO_PROPERTY_ADDRESS in local demo context")
39	    run_parser.add_argument("--keep-original-audio", action="store_true")
40	    run_parser.add_argument("--render", action="store_true", help="Execute local ffmpeg rendering after approval")
41	    run_parser.add_argument("--tts-provider", choices=["say", "coqui", "elevenlabs", "silent"], default="say")
42	    run_parser.add_argument("--voice-id", default=None, help="ElevenLabs voice ID (overrides DEFAULT_VOICE_ID in .env)")
43	    run_parser.add_argument("--say-voice", default=None)
44	    run_parser.add_argument("--say-rate", type=int, default=None)
45	    run_parser.add_argument(
46	        "--transcription-provider",
47	        choices=["whisper", "local-whisper", "none"],
48	        default="none",
49	        help=(
50	            "Transcription provider. "
51	            "'whisper' uses OpenAI Whisper API (requires OPENAI_API_KEY). "
52	            "'local-whisper' runs mlx-whisper on-device (Apple Silicon, no API key needed)."
53	        ),
54	    )
55
56	    status_parser = subparsers.add_parser("status")
57	    status_parser.add_argument("job_id")
58
59	    approve_parser = subparsers.add_parser("approve")
60	    approve_parser.add_argument("job_id")
61	    approve_parser.add_argument("--script", required=True)
62	    approve_parser.add_argument("--approved-by", default="operator")
63
64	    reject_parser = subparsers.add_parser("reject")
65	    reject_parser.add_argument("job_id")
66	    reject_parser.add_argument("--reason", required=True)
67
68	    resume_parser = subparsers.add_parser("resume", help="Retry an approved job from its failed step")
69	    resume_parser.add_argument("job_id")
70
71	    touch_parser = subparsers.add_parser("log-touch")
72	    touch_parser.add_argument("job_id")
73	    touch_parser.add_argument("--minutes", required=True, type=float)
74	    touch_parser.add_argument("--note", default="")
75
76	    publish_parser = subparsers.add_parser(
77	        "publish",
78	        help="Package a rendered video for manual Instagram posting (pauses for caption approval)",
79	    )
80	    publish_parser.add_argument("--input", required=True, help="Path to the rendered video to publish")
81	    publish_parser.add_argument("--tenant", default="demo")
82	    publish_parser.add_argument("--handle", default="", help="Instagram @handle for this tenant")
83	    publish_parser.add_argument("--hashtags", default="", help='Space/comma separated hashtags, e.g. "#realestate #cedarpark"')
84	    publish_parser.add_argument("--cta", default="", help="Call-to-action line, e.g. 'DM for a tour.'")
85	    publish_parser.add_argument("--listing-description", default="", help="Selling points to fold into the caption")
86
87	    review_server_parser = subparsers.add_parser(
88	        "review-server",
89	        help="Serve output artifacts for private review over a local/Tailscale-reachable HTTP endpoint",
90	    )
91	    review_server_parser.add_argument("--host", default="0.0.0.0")
92	    review_server_parser.add_argument("--port", type=int, default=8421)
93	    review_server_parser.add_argument("--directory", default=None, help="Artifact root to serve (defaults to OUTPUT_DIR)")
94
95	    review_server_install_parser = subparsers.add_parser(
96	        "review-server-install",
97	        help="Install a macOS LaunchAgent so the review server survives reboots",
98	    )
99	    review_server_install_parser.add_argument("--host", default="127.0.0.1")
100	    review_server_install_parser.add_argument("--port", type=int, default=8421)
101	    review_server_install_parser.add_argument("--directory", default=None, help="Artifact root to serve (defaults to OUTPUT_DIR)")
102	    review_server_install_parser.add_argument("--label", default="com.james.stagehand-review-server")
103
104	    for command_name, help_text in (
105	        ("hyperframes-script-review", "Run pre-render script review for a HyperFrames reel project"),
106	        ("hyperframes-video-review", "Run post-render video review for a HyperFrames reel project"),
107	        ("hyperframes-review", "Compatibility alias for HyperFrames video review"),
108	    ):
109	        parser_ref = subparsers.add_parser(command_name, help=help_text)
110	        parser_ref.add_argument("--project", required=True, help="Path to the HyperFrames project directory")
111	        parser_ref.add_argument("--reference", default=None, help="Optional reference HyperFrames project for comparison")
112	        parser_ref.add_argument("--skip-cli-checks", action="store_true")
113	        parser_ref.add_argument("--skip-frame-extraction", action="store_true")
114
115	    narration_parser = subparsers.add_parser(
116	        "hyperframes-add-narration",
117	        help="Synthesize narration and mux it into a HyperFrames reel project's final video "
118	        "(standard fix for a hyperframes-video-review missing-narration hard failure)",
119	    )
120	    narration_parser.add_argument("--project", required=True, help="Path to the HyperFrames project directory")
121	    narration_text_group = narration_parser.add_mutually_exclusive_group(required=True)
122	    narration_text_group.add_argument("--text", default=None, help="Narration text to speak")
123	    narration_text_group.add_argument("--text-file", default=None, help="Path to a text file with the narration")
124	    narration_parser.add_argument("--voice", default=None, help="say voice (defaults to SAY_VOICE in .env)")
125	    narration_parser.add_argument("--rate", type=int, default=None, help="say rate (defaults to SAY_RATE in .env)")
126
127	    audio_check_parser = subparsers.add_parser(
128	        "hyperframes-assert-audio",
129	        help="Fail unless a HyperFrames reel MP4 has a usable non-silent audio stream; does not launch Chrome",
130	    )
131	    audio_check_target = audio_check_parser.add_mutually_exclusive_group(required=True)
132	    audio_check_target.add_argument("--project", default=None, help="Path to the HyperFrames project directory")
133	    audio_check_target.add_argument("--video", default=None, help="Path to a rendered MP4")
134	    audio_check_parser.add_argument(
135	        "--final-video-name",
136	        default="final_listing_reel.mp4",
137	        help="Final video filename used with --project",
138	    )
139
140	    listing_parser = subparsers.add_parser(
141	        "listing-reel",
142	        help="Create an approved Instagram reel from an authorized listing source or local manifest",
143	    )
144	    listing_parser.add_argument(
145	        "--url",
146	        required=True,
147	        help="Canonical public listing URL (identity; may open an allowlisted demo adapter with --demo)",
148	    )
149	    listing_parser.add_argument(
150	        "--manifest",
151	        default=None,
152	        help="Authorized local listing manifest JSON (optional when using --demo / --rights-confirmed adapter intake)",
153	    )
154	    listing_parser.add_argument(
155	        "--media-root",
156	        default=None,
157	        help="Root for local photos / downloaded demo media (defaults next to the manifest or under the job workspace)",
158	    )
159	    listing_parser.add_argument(
160	        "--demo",
161	        action="store_true",
162	        help="Enable demo-only link intake for allowlisted sources (Compass). Not production rights clearance.",
163	    )
164	    listing_parser.add_argument(
165	        "--rights-confirmed",
166	        action="store_true",
167	        help="Operator asserts broker/photographer/MLS rights for this listing media (required for non-demo adapter production path).",
168	    )
169	    listing_parser.add_argument("--tenant", default="demo")
170	    listing_parser.add_argument("--agent-name", default=None)
171	    listing_parser.add_argument("--handle", default=None, help="Public Instagram handle")
172	    listing_parser.add_argument("--phone", default=None, help="Public contact phone")
173	    listing_parser.add_argument("--email", default=None, help="Public contact email")
174	    listing_parser.add_argument("--website", default=None, help="Public contact website")
175	    listing_parser.add_argument("--profile-photo", default=None, help="Public profile photo path for the contact outro")
176	    listing_parser.add_argument("--hashtags", default="#realestate #austinrealestate")
177	    listing_parser.add_argument("--target-duration", type=float, default=30.0)
178	    listing_parser.add_argument(
179	        "--tts-provider",
180	        choices=["elevenlabs", "say", "coqui", "silent"],
181	        default="elevenlabs",
182	    )
183	    listing_parser.add_argument("--voice-id", default=None, help="ElevenLabs voice ID (overrides DEFAULT_VOICE_ID)")
184	    listing_parser.add_argument("--voice-owner", default=None, help="Verified owner of the selected cloned voice")
185	    listing_parser.add_argument("--say-voice", default=None)
186	    listing_parser.add_argument("--say-rate", type=int, default=None)
187
188	    args = parser.parse_args(argv)
189	    settings = load_settings(args.env_file)
190	    store = JsonJobStore(Path(settings.output_dir) / "jobs")
191
192	    if args.command == "status":
193	        job = store.get(args.job_id)
194	        print(json.dumps({"id": job.id, "status": job.status, "approval_status": job.approval_status}, indent=2))
195	        return 0
196	    if args.command == "review-server":
197	        directory = Path(args.directory or settings.output_dir).resolve()
198	        directory.mkdir(parents=True, exist_ok=True)
199	        handler = lambda *a, **kw: RangeRequestHandler(*a, directory=str(directory), **kw)
200	        server = ThreadingHTTPServer((args.host, args.port), handler)
201	        print(json.dumps({"status": "serving", "directory": str(directory), "host": args.host, "port": args.port}, indent=2))
202	        try:
203	            server.serve_forever()
204	        except KeyboardInterrupt:
205	            server.server_close()
206	        return 0
207	    if args.command == "review-server-install":
208	        if sys.platform != "darwin":
209	            raise SystemExit("review-server-install currently supports macOS LaunchAgents only")
210	        directory = Path(args.directory or settings.output_dir).resolve()
211	        directory.mkdir(parents=True, exist_ok=True)
212	        repo_root = Path.cwd().resolve()
213	        venv_python = repo_root / ".venv" / "bin" / "python"
214	        launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
215	        launch_agents_dir.mkdir(parents=True, exist_ok=True)
216	        plist_path = launch_agents_dir / f"{args.label}.plist"
217	        spec = ReviewLaunchAgentSpec(
218	            label=args.label,
219	            python_executable=venv_python if venv_python.exists() else Path(sys.executable).resolve(),
220	            repo_root=repo_root,
221	            env_file=Path(args.env_file).resolve(),
222	            host=args.host,
223	            port=args.port,
224	            directory=directory,
225	            stdout_path=Path("/tmp") / f"{args.label}.out.log",
226	            stderr_path=Path("/tmp") / f"{args.label}.err.log",
227	        )
228	        plist_path.write_bytes(spec.plist_bytes())
229	        uid = str(os.getuid())
230	        subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], check=False, capture_output=True, text=True)
231	        subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)], check=True, capture_output=True, text=True)
232	        subprocess.run(["launchctl", "kickstart", "-k", f"gui/{uid}/{args.label}"], check=True, capture_output=True, text=True)
233	        print(json.dumps({"status": "installed", "label": args.label, "plist_path": str(plist_path), "directory": str(directory), "host": args.host, "port": args.port}, indent=2))
234	        return 0
235	    if args.command in {"hyperframes-review", "hyperframes-video-review", "hyperframes-script-review"}:
236	        project_dir = Path(args.project).resolve()
237	        reference_dir = Path(args.reference).resolve() if args.reference else None
238	        if args.command == "hyperframes-script-review":
239	            report = review_hyperframes_script(
240	                project_dir,
241	                reference_dir=reference_dir,
242	                run_cli_checks=not args.skip_cli_checks,
243	            )
244	            output_dir = project_dir / "qa" / "script_review"
245	        else:
246	            report = review_hyperframes_project(
247	                project_dir,
248	                reference_dir=reference_dir,
249	                run_cli_checks=not args.skip_cli_checks,
250	                extract_frames=not args.skip_frame_extraction,
251	            )
252	            output_dir = project_dir / "qa" / "video_review"
253	        json_path, md_path = write_hyperframes_review(report, output_dir)
254	        print(json.dumps({"report_path": str(json_path), "summary_path": str(md_path), "overall_score": report.overall_score, "recommendation": report.recommendation}, indent=2))
255	        return 0
256	    if args.command == "hyperframes-add-narration":
257	        project_dir = Path(args.project).resolve()
258	        narration_text = args.text if args.text is not None else Path(args.text_file).read_text(encoding="utf-8")
259	        final_video = add_narration_to_project(
260	            project_dir,
261	            narration_text,
262	            voice=args.voice or settings.say_voice,
263	            rate=args.rate or settings.say_rate,
264	        )
265	        print(
266	            json.dumps(
267	                {
268	                    "final_video_path": str(final_video),
269	                    "delivery_video_path": str(delivery_video_path_for_project(project_dir, final_video)),
270	                },
271	                indent=2,
272	            )
273	        )
274	        return 0
275	    if args.command == "hyperframes-assert-audio":
276	        video_path = (
277	            Path(args.video).resolve()
278	            if args.video
279	            else (Path(args.project).resolve() / args.final_video_name)
280	        )
281	        try:
282	            probe, peak_volume_db = assert_media_has_usable_audio(video_path)
283	        except ValueError as exc:
284	            raise SystemExit(str(exc)) from exc
285	        print(
286	            json.dumps(
287	                {
288	                    "video_path": str(video_path),
289	                    "duration_seconds": probe.duration_seconds,
290	                    "has_audio": probe.has_audio,
291	                    "audio_codec": probe.audio_codec,
292	                    "audio_peak_db": peak_volume_db,
293	                },
294	                indent=2,
295	            )
296	        )
297	        return 0
298
299	    existing_job = None
300	    if args.command == "publish":
301	        workflow_name = "publish_instagram"
302	    elif args.command in ("approve", "resume", "reject", "log-touch"):
303	        # Resume/operate on an existing job using the workflow it was started with,
304	        # not whatever the subcommand would default to.
305	        existing_job = store.get(args.job_id)
306	        workflow_name = existing_job.workflow_name
307	    elif args.command == "listing-reel":
308	        workflow_name = "listing_reel"
309	    else:
310	        workflow_name = getattr(args, "workflow", "video_narration")
311	    transcription_client = _build_transcription_client(args, settings)
312	    elevenlabs_client = _build_elevenlabs_client(
313	        args,
314	        settings,
315	        job_input=(
316	            existing_job.input_params
317	            if existing_job is not None and args.command in ("approve", "resume")
318	            else None
319	        ),
320	    )
321	    runner = _runner(workflow_name, store, transcription_client=transcription_client, elevenlabs_client=elevenlabs_client)
322
323	    if args.command == "run":
324	        listing_description = args.listing_description
325	        if args.use_demo_env and not listing_description:
326	            listing_description = settings.demo_listing_description(include_address=args.include_demo_address)
327	        job = runner.start(
328	            tenant_id=args.tenant,
329	            input_params={
330	                "input_path": args.input,
331	                "listing_description": listing_description,
332	                "keep_original_audio": args.keep_original_audio,
333	                "execute_render": args.render,
334	                "local_tts_provider": args.tts_provider,
335	                "say_voice": args.say_voice or settings.say_voice,
336	                "say_rate": args.say_rate or settings.say_rate,
337	                "coqui_tts_platform": settings.coqui_tts_platform,
338	                "coqui_tts_image": settings.coqui_tts_image,
339	                "coqui_tts_model": settings.coqui_tts_model,
340	            },
341	        )
342	    elif args.command == "publish":
343	        job = runner.start(
344	            tenant_id=args.tenant,
345	            input_params={
346	                "input_path": args.input,
347	                "listing_description": args.listing_description,
348	                "instagram_handle": args.handle,
349	                "instagram_hashtags": args.hashtags,
350	                "instagram_cta": args.cta,
351	            },
352	        )
353	    elif args.command == "listing-reel":
354	        provider = args.tts_provider
355	        agent_name = args.agent_name or settings.agent_name or settings.demo_agent_name
356	        agent_handle = args.handle if args.handle is not None else settings.agent_instagram_handle
357	        agent_phone = args.phone if args.phone is not None else settings.agent_phone
358	        agent_email = args.email if args.email is not None else settings.agent_email
359	        agent_website = args.website if args.website is not None else settings.agent_website
360	        agent_profile_photo = args.profile_photo if args.profile_photo is not None else settings.agent_profile_photo
361	        voice_owner = args.voice_owner or settings.voice_clone_owner
362	        if not agent_name:
363	            raise SystemExit("listing-reel requires --agent-name or AGENT_NAME in .env")
364	        if not any((agent_handle, agent_phone, agent_email, agent_website)):
365	            raise SystemExit("listing-reel requires at least one public contact field")
366	        if not args.manifest and not (args.demo or args.rights_confirmed):
367	            raise SystemExit(
368	                "listing-reel without --manifest requires --demo (pilot link intake) "
369	                "or --rights-confirmed once brokerage authorization is in hand"
370	            )
371	        media_root = args.media_root
372	        if media_root is None and args.manifest:
373	            media_root = str(Path(args.manifest).parent)
374	        job = runner.start(
375	            tenant_id=args.tenant,
376	            input_params={
377	                "listing_url": args.url,
378	                "listing_manifest_path": args.manifest,
379	                "listing_media_root": media_root,
380	                "listing_demo_intake": bool(args.demo),
381	                "listing_rights_confirmed": bool(args.rights_confirmed),
382	                "agent_name": agent_name,
383	                "agent_instagram_handle": agent_handle,
384	                "agent_phone": agent_phone,
385	                "agent_email": agent_email,
386	                "agent_website": agent_website,
387	                "agent_profile_photo": agent_profile_photo,
388	                "instagram_handle": agent_handle,
389	                "instagram_hashtags": args.hashtags,
390	                "voice_owner": voice_owner,
391	                "voice_id": args.voice_id or settings.default_voice_id,
392	                "local_tts_provider": provider,
393	                "require_audible_narration": provider != "silent",
394	                "execute_render": True,
395	                "target_duration_seconds": args.target_duration,
396	                "say_voice": args.say_voice or settings.say_voice,
397	                "say_rate": args.say_rate or settings.say_rate,
398	                "coqui_tts_platform": settings.coqui_tts_platform,
399	                "coqui_tts_image": settings.coqui_tts_image,
400	                "coqui_tts_model": settings.coqui_tts_model,
401	                "review_base_url": settings.review_base_url,
402	            },
403	        )
404	    elif args.command == "approve":
405	        job = runner.approve(args.job_id, args.script, approved_by=args.approved_by)
406	    elif args.command == "resume":
407	        job = runner.resume(args.job_id)
408	    elif args.command == "reject":
409	        job = runner.reject(args.job_id, args.reason)
410	    elif args.command == "log-touch":
411	        job = runner.log_touch_time(args.job_id, args.minutes, args.note)
412	    else:
413	        raise AssertionError(args.command)
414
415	    print(json.dumps({"id": job.id, "status": job.status, "approval_status": job.approval_status}, indent=2))
416	    return 0
417
418
419	def _build_elevenlabs_client(
420	    args: argparse.Namespace,
421	    settings: Any,
422	    job_input: dict[str, Any] | None = None,
423	) -> Any | None:
424	    job_input = job_input or {}
425	    provider = getattr(args, "tts_provider", None) or job_input.get("local_tts_provider")
426	    if provider != "elevenlabs":
427	        return None
428	    voice_id = getattr(args, "voice_id", None) or job_input.get("voice_id") or settings.default_voice_id
429	    if not voice_id:
430	        raise SystemExit("--tts-provider elevenlabs requires --voice-id or DEFAULT_VOICE_ID in .env")
431	    if not settings.elevenlabs_api_key:
432	        raise SystemExit("--tts-provider elevenlabs requires ELEVENLABS_API_KEY in .env")
433	    voice_owner = getattr(args, "voice_owner", None) or job_input.get("voice_owner") or settings.voice_clone_owner
434	    if not settings.voice_clone_consent_confirmed or not voice_owner:
435	        raise SystemExit(
436	            "ElevenLabs cloned voice use requires recorded consent: VOICE_CLONE_CONSENT_CONFIRMED=true "
437	            "and VOICE_CLONE_OWNER (or --voice-owner)"
438	        )
439	    import elevenlabs
440	    from core.integrations.elevenlabs_voice import ElevenLabsVoiceClient
441	    return ElevenLabsVoiceClient(elevenlabs.ElevenLabs(api_key=settings.elevenlabs_api_key), voice_id=voice_id)
442
443
444	def _build_transcription_client(args: argparse.Namespace, settings: Any) -> Any | None:
445	    provider = getattr(args, "transcription_provider", "none")
446	    if provider == "whisper":
447	        import openai  # lazy import keeps offline tests clean
448	        from core.integrations.openai_whisper import OpenAIWhisperClient
449	        return OpenAIWhisperClient(openai.OpenAI(api_key=settings.openai_api_key))
450	    if provider == "local-whisper":
451	        from core.integrations.local_whisper import LocalWhisperClient
452	        return LocalWhisperClient()
453	    return None
454
455
456	def _runner(
457	    workflow_name: str,
458	    store: JsonJobStore,
459	    transcription_client: Any = None,
460	    elevenlabs_client: Any = None,
461	) -> WorkflowRunner:
462	    config_path = Path("workflows") / workflow_name / "config.yaml"
463	    modules = default_modules(tts_client=elevenlabs_client)
464	    if transcription_client is not None:
465	        modules["transcription"] = LocalTranscriptionModule(transcription_client)
466	    return WorkflowRunner(load_workflow_config(config_path), modules, store)
467
468
469	if __name__ == "__main__":
470	    raise SystemExit(main())
471


1	from __future__ import annotations
2
3	from dataclasses import dataclass
4	from pathlib import Path
5	from time import perf_counter
6	from typing import Protocol
7
8	from core.models import ApprovalStatus, Job, JobStatus, JobStep, StepStatus
9	from core.workflows.config import WorkflowConfig
10	from core.workflows.store import JsonJobStore
11
12
13	class WorkflowModule(Protocol):
14	    def run(self, context: "WorkflowContext", params: dict) -> dict:
15	        ...
16
17
18	@dataclass(slots=True)
19	class WorkflowContext:
20	    job: Job
21	    workspace: Path
22	    data: dict
23
24
25	class ApprovalGate:
26	    def run(self, context: WorkflowContext, params: dict) -> dict:
27	        artifact = params.get("approval_artifact", "approved_script.md")
28	        script = context.data.get("generated_script", "")
29	        approval_path = context.workspace / artifact
30	        if not approval_path.exists():
31	            approval_path.parent.mkdir(parents=True, exist_ok=True)
32	            approval_path.write_text(script, encoding="utf-8")
33	        return {"approval_artifact": str(approval_path)}
34
35
36	class WorkflowRunner:
37	    def __init__(
38	        self,
39	        config: WorkflowConfig,
40	        modules: dict[str, WorkflowModule],
41	        store: JsonJobStore,
42	        output_root: str | Path = "output",
43	    ) -> None:
44	        self.config = config
45	        self.modules = modules
46	        self.store = store
47	        self.output_root = Path(output_root)
48
49	    def start(self, tenant_id: str, input_params: dict) -> Job:
50	        job = Job(tenant_id=tenant_id, workflow_name=self.config.name, input_params=input_params)
51	        self.store.save(job)
52	        return self.run(job.id)
53
54	    def run(self, job_id: str) -> Job:
55	        job = self.store.get(job_id)
56	        job.mark_started()
57	        context = WorkflowContext(
58	            job=job,
59	            workspace=self.output_root / job.id,
60	            data={**job.input_params, **job.output_params},
61	        )
62	        context.workspace.mkdir(parents=True, exist_ok=True)
63
64	        completed_steps = {step.step_id for step in job.steps if step.status == StepStatus.COMPLETED}
65	        for workflow_step in self.config.steps:
66	            if workflow_step.id in completed_steps:
67	                continue
68	            module = self.modules[workflow_step.module]
69	            step = JobStep(step_id=workflow_step.id, status=StepStatus.RUNNING, input_data=workflow_step.params)
70	            job.steps.append(step)
71	            started = perf_counter()
72	            try:
73	                result = module.run(context, workflow_step.params)
74	            except Exception as exc:
75	                step.duration_ms = int((perf_counter() - started) * 1000)
76	                step.status = StepStatus.FAILED
77	                step.output_data = {"error": str(exc)}
78	                job.fail(str(exc))
79	                self.store.save(job)
80	                raise
81	            step.duration_ms = int((perf_counter() - started) * 1000)
82	            step.output_data = result
83
84	            if workflow_step.module == "approval_gate" and job.approval_status != ApprovalStatus.APPROVED:
85	                step.status = StepStatus.WAITING_FOR_APPROVAL
86	                job.output_params.update(context.data)
87	                job.output_params.update(result)
88	                job.wait_for_approval()
89	                self.store.save(job)
90	                return job
91
92	            context.data.update(result)
93	            step.status = StepStatus.COMPLETED
94	            job.output_params.update(context.data)
95	            self.store.save(job)
96
97	        job.complete()
98	        self.store.save(job)
99	        return job
100
101	    def approve(self, job_id: str, approved_script: str | Path, approved_by: str = "operator") -> Job:
102	        job = self.store.get(job_id)
103	        if job.approval_status != ApprovalStatus.PENDING:
104	            raise ValueError(f"Job {job_id} is not waiting for approval")
105
106	        script_text = Path(approved_script).read_text(encoding="utf-8")
107	        job.output_params["approved_script"] = script_text
108	        job.approve()
109	        for step in job.steps:
110	            if step.status == StepStatus.WAITING_FOR_APPROVAL:
111	                step.status = StepStatus.COMPLETED
112	                step.approved_by = approved_by
113	                step.approved_at = job.approved_at
114	        self.store.save(job)
115	        return self.run(job.id)
116
117	    def resume(self, job_id: str) -> Job:
118	        job = self.store.get(job_id)
119	        if job.status != JobStatus.FAILED or job.approval_status != ApprovalStatus.APPROVED:
120	            raise ValueError(f"Job {job_id} is not an approved failed job")
121	        return self.run(job_id)
122
123	    def reject(self, job_id: str, reason: str) -> Job:
124	        job = self.store.get(job_id)
125	        job.reject(reason)
126	        self.store.save(job)
127	        return job
128
129	    def log_touch_time(self, job_id: str, minutes: float, note: str = "") -> Job:
130	        if minutes <= 0:
131	            raise ValueError("Touch time minutes must be positive")
132	        job = self.store.get(job_id)
133	        entries = list(job.output_params.get("manual_touch_log", []))
134	        entries.append({"minutes": minutes, "note": note})
135	        job.output_params["manual_touch_log"] = entries
136	        job.output_params["manual_touch_minutes"] = sum(entry["minutes"] for entry in entries)
137	        self.store.save(job)
138	        return job
139


1	from __future__ import annotations
2
3	import json
4	from dataclasses import dataclass
5	from pathlib import Path
6	from typing import Any
7
8
9	@dataclass(frozen=True, slots=True)
10	class WorkflowStep:
11	    id: str
12	    module: str
13	    params: dict[str, Any]
14
15
16	@dataclass(frozen=True, slots=True)
17	class WorkflowConfig:
18	    name: str
19	    description: str
20	    steps: list[WorkflowStep]
21
22
23	def load_workflow_config(path: str | Path) -> WorkflowConfig:
24	    config_path = Path(path)
25	    raw = config_path.read_text(encoding="utf-8")
26	    data = _load_mapping(raw)
27	    steps = [
28	        WorkflowStep(id=step["id"], module=step["module"], params=step.get("params", {}))
29	        for step in data["steps"]
30	    ]
31	    return WorkflowConfig(
32	        name=data["name"],
33	        description=data.get("description", ""),
34	        steps=steps,
35	    )
36
37
38	def _load_mapping(raw: str) -> dict[str, Any]:
39	    try:
40	        import yaml  # type: ignore
41	    except ModuleNotFoundError:
42	        return json.loads(raw)
43	    loaded = yaml.safe_load(raw)
44	    if not isinstance(loaded, dict):
45	        raise ValueError("Workflow config must be a mapping")
46	    return loaded
47


Now let me look at the workflow files themselves:


find /workspace/extra/projects/stagehand/workflows -type f -name "*.yaml" -o -name "*.yml"


1	{
2	  "name": "listing_reel",
3	  "description": "Authorized listing photos to an approved narrated Instagram reel package",
4	  "steps": [
5	    {"id": "intake", "module": "listing_intake", "params": {}},
6	    {
7	      "id": "draft_review",
8	      "module": "listing_review_draft",
9	      "params": {"target_duration_seconds": 30}
10	    },
11	    {
12	      "id": "approve_listing_reel",
13	      "module": "approval_gate",
14	      "params": {"required": true, "approval_artifact": "approved_listing_reel.json"}
15	    },
16	    {"id": "validate_approval", "module": "listing_review_approval", "params": {}},
17	    {
18	      "id": "script_review",
19	      "module": "listing_script_review",
20	      "params": {"width": 1080, "height": 1920, "fps": 60}
21	    },
22	    {
23	      "id": "synthesize_voice",
24	      "module": "voice_synthesis",
25	      "params": {"reserved_outro_seconds": 4.5}
26	    },
27	    {"id": "build_captions", "module": "captions", "params": {"style": "modern_bold"}},
28	    {"id": "build_graphics", "module": "listing_graphics", "params": {}},
29	    {
30	      "id": "render_reel",
31	      "module": "listing_photo_reel",
32	      "params": {"width": 1080, "height": 1920, "fps": 60, "outro_duration_seconds": 4.5}
33	    },
34	    {"id": "video_review", "module": "listing_video_review", "params": {}},
35	    {"id": "build_review_artifacts", "module": "listing_review_artifacts", "params": {}},
36	    {"id": "package_instagram", "module": "instagram_package", "params": {"aspect_ratio": "9:16"}}
37	  ]
38	}
39


1	{
2	  "name": "video_narration",
3	  "description": "Raw video to approved narrated and subtitled social video",
4	  "steps": [
5	    {
6	      "id": "transcribe",
7	      "module": "transcription",
8	      "params": {
9	        "model": "whisper-1"
10	      }
11	    },
12	    {
13	      "id": "generate_script",
14	      "module": "llm_script",
15	      "params": {
16	        "prompt_template": "narration_script.md",
17	        "tone": "warm_conversational",
18	        "target_duration_seconds": 60
19	      }
20	    },
21	    {
22	      "id": "approve_script",
23	      "module": "approval_gate",
24	      "params": {
25	        "required": true,
26	        "approval_artifact": "approved_script.md"
27	      }
28	    },
29	    {
30	      "id": "synthesize_voice",
31	      "module": "voice_synthesis",
32	      "params": {
33	        "provider": "elevenlabs",
34	        "voice_id": "${tenant.voice_id}"
35	      }
36	    },
37	    {
38	      "id": "burn_captions",
39	      "module": "captions",
40	      "params": {
41	        "style": "modern_bold"
42	      }
43	    },
44	    {
45	      "id": "format_output",
46	      "module": "video_format",
47	      "params": {
48	        "aspect_ratio": "9:16",
49	        "max_file_size_mb": 50
50	      }
51	    }
52	  ]
53	}
54


1	from __future__ import annotations
2
3	import hashlib
4	import json
5	import math
6	from dataclasses import dataclass
7	from pathlib import Path
8	from typing import Any
9
10	from core.listings.manifest import ListingManifestError, listing_snapshot_from_dict
11	from core.listings.models import AgentProfile, ListingPhoto, ListingSnapshot
12	from core.publishing.instagram import build_caption
13
14
15	class ReviewBundleError(ValueError):
16	    pass
17
18
19	@dataclass(frozen=True, slots=True)
20	class ApprovedListingReel:
21	    narration: str
22	    instagram_caption: str
23	    photos: tuple[ListingPhoto, ...]
24	    cover_photo: ListingPhoto
25	    outro: dict[str, Any]
26	    target_duration_seconds: float
27
28
29	def snapshot_digest(snapshot: ListingSnapshot) -> str:
30	    canonical = json.dumps(snapshot.to_dict(), sort_keys=True, separators=(",", ":"))
31	    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
32
33
34	def build_review_bundle(
35	    snapshot: ListingSnapshot,
36	    agent: AgentProfile,
37	    *,
38	    target_duration_seconds: float = 30,
39	) -> dict[str, Any]:
40	    narration = _draft_narration(snapshot, agent)
41	    cta = f"Contact {agent.name} for details or a private tour."
42	    caption = build_caption(
43	        script=_listing_summary(snapshot),
44	        handle=agent.instagram_handle,
45	        cta=cta,
46	    )
47	    return {
48	        "version": 1,
49	        "snapshot_sha256": snapshot_digest(snapshot),
50	        "listing": snapshot.to_dict(),
51	        "narration": narration,
52	        "instagram_caption": caption,
53	        "photo_order": [photo.relative_path for photo in snapshot.photos],
54	        "cover_photo_path": snapshot.cover_photo.relative_path,
55	        "outro": {
56	            "agent_name": agent.name,
57	            "cta": "Schedule a private tour",
58	            "instagram_handle": agent.instagram_handle,
59	            "phone": agent.phone,
60	            "email": agent.email,
61	            "website": agent.website,
62	            "profile_photo_path": agent.profile_photo_path,
63	        },
64	        "voice_owner": agent.voice_owner or agent.name,
65	        "target_duration_seconds": float(target_duration_seconds),
66	    }
67
68
69	def parse_approved_review(
70	    raw_text: str,
71	    snapshot: ListingSnapshot,
72	    *,
73	    media_root: str | Path,
74	) -> ApprovedListingReel:
75	    try:
76	        raw = json.loads(raw_text)
77	    except json.JSONDecodeError as exc:
78	        raise ReviewBundleError("Approved review must be valid JSON") from exc
79	    if not isinstance(raw, dict) or raw.get("version") != 1:
80	        raise ReviewBundleError("Approved review version must be 1")
81	    if raw.get("snapshot_sha256") != snapshot_digest(snapshot):
82	        raise ReviewBundleError("Approved review snapshot digest does not match intake")
83	    try:
84	        listing_snapshot_from_dict(snapshot.to_dict(), media_root=media_root, verify_media=True)
85	    except (ListingManifestError, OSError) as exc:
86	        raise ReviewBundleError(str(exc)) from exc
87
88	    narration = normalize_speech_text(str(raw.get("narration") or "").strip())
89	    caption = str(raw.get("instagram_caption") or "").strip()
90	    if not narration:
91	        raise ReviewBundleError("Approved review narration is required")
92	    if not caption:
93	        raise ReviewBundleError("Approved review Instagram caption is required")
94
95	    order = raw.get("photo_order")
96	    approved_paths = [photo.relative_path for photo in snapshot.photos]
97	    if not isinstance(order, list) or sorted(order) != sorted(approved_paths) or len(set(order)) != len(order):
98	        raise ReviewBundleError("Approved review photo_order must contain the approved photo set exactly once")
99	    by_path = {photo.relative_path: photo for photo in snapshot.photos}
100	    cover_path = str(raw.get("cover_photo_path") or "")
101	    if cover_path not in by_path:
102	        raise ReviewBundleError("Approved review cover_photo_path must reference an approved photo")
103
104	    outro = raw.get("outro")
105	    if not isinstance(outro, dict) or not str(outro.get("agent_name") or "").strip():
106	        raise ReviewBundleError("Approved review outro requires an agent name")
107	    if not any(str(outro.get(key) or "").strip() for key in ("instagram_handle", "phone", "email", "website")):
108	        raise ReviewBundleError("Approved review outro requires at least one contact method")
109	    try:
110	        duration = float(raw.get("target_duration_seconds", 30))
111	    except (TypeError, ValueError) as exc:
112	        raise ReviewBundleError("Approved review target duration must be numeric") from exc
113	    if not math.isfinite(duration) or not 20 <= duration <= 45:
114	        raise ReviewBundleError("Approved review target duration must be between 20 and 45 seconds")
115
116	    return ApprovedListingReel(
117	        narration=narration,
118	        instagram_caption=caption,
119	        photos=tuple(by_path[path] for path in order),
120	        cover_photo=by_path[cover_path],
121	        outro=dict(outro),
122	        target_duration_seconds=duration,
123	    )
124
125
126	def normalize_speech_text(text: str) -> str:
127	    """Expand abbreviations; strip bare street-type abbreviations for TTS."""
128	    import re
129
130	    value = str(text or "")
131	    if not value:
132	        return value
133	    value = re.sub(r"(?i)\bsq\.?\s*ft\.?\b", "square feet", value)
134	    value = re.sub(r"(?i)\bsqft\b", "square feet", value)
135	    value = re.sub(r"(?i)\bsquare\s*ft\.?\b", "square feet", value)
136	    value = re.sub(r"(?i)\bapprox\.?\b", "approximately", value)
137	    # Only abbreviated street suffixes after a word (so "Dr" never becomes "doctor").
138	    value = re.sub(
139	        r"(?i)(?<=\w)\s+(st|dr|rd|ln|blvd|ave|ct|cir|pl|pkwy|hwy|ter)\.?\b",
140	        "",
141	        value,
142	    )
143	    value = re.sub(r"\s+", " ", value).strip(" ,")
144	    value = re.sub(r"\s+([,.])", r"\1", value)
145	    return value
146
147
148	def spoken_location(snapshot: ListingSnapshot) -> str:
149	    """City / state only for spoken welcome.
150
151	    Skip house numbers and street names — Captions/covers still show the full address.
152	    """
153	    city = (snapshot.city or "").strip()
154	    state = (snapshot.state or "").strip()
155	    return ", ".join(part for part in (city, state) if part)
156
157
158	def _draft_narration(snapshot: ListingSnapshot, agent: AgentProfile) -> str:
159	    import re
160
161	    pieces = []
162	    place = spoken_location(snapshot)
163	    if place:
164	        pieces.append(f"Welcome to this home in {place}.")
165	    else:
166	        pieces.append("Welcome to this listing.")
167	    room_parts = []
168	    if snapshot.beds is not None:
169	        room_parts.append(f"{_number_word(snapshot.beds)}-bedroom")
170	    if snapshot.baths is not None:
171	        room_parts.append(f"{_number_word(snapshot.baths)}-bath")
172	    if room_parts:
173	        sentence = f"This {', '.join(room_parts)} home"
174	        if snapshot.square_feet is not None:
175	            sentence += f" offers {snapshot.square_feet:,} square feet"
176	        pieces.append(sentence + ".")
177	    elif snapshot.square_feet is not None:
178	        pieces.append(f"The home offers {snapshot.square_feet:,} square feet.")
179	    if snapshot.price is not None:
180	        pieces.append(f"It is offered at ${snapshot.price:,}.")
181	    if snapshot.description:
182	        description = normalize_speech_text(snapshot.description)
183	        first = re.split(r"(?<=[.!?])\s+", description, maxsplit=1)[0].strip(" .")
184	        # Keep only short non-address marketing phrases; never second "Welcome to 123...".
185	        if (
186	            first
187	            and len(first) <= 90
188	            and not re.search(r"\d{3,}", first)
189	            and not re.match(r"(?i)^welcome to\b", first)
190	        ):
191	            if not first.endswith((".", "!", "?")):
192	                first += "."
193	            pieces.append(first)
194	    pieces.append(f"Contact {agent.name} to schedule a private tour.")
195	    return normalize_speech_text(" ".join(pieces))
196
197
198	def _listing_summary(snapshot: ListingSnapshot) -> str:
199	    facts = []
200	    if snapshot.display_location:
201	        facts.append(snapshot.display_location)
202	    if snapshot.price is not None:
203	        facts.append(f"${snapshot.price:,}")
204	    rooms = []
205	    if snapshot.beds is not None:
206	        rooms.append(f"{snapshot.beds:g} bed")
207	    if snapshot.baths is not None:
208	        rooms.append(f"{snapshot.baths:g} bath")
209	    if rooms:
210	        facts.append(" · ".join(rooms))
211	    if snapshot.square_feet is not None:
212	        facts.append(f"{snapshot.square_feet:,} sq ft")
213	    if snapshot.description:
214	        facts.append(snapshot.description)
215	    return " — ".join(facts)
216
217
218	def _number_word(value: float) -> str:
219	    integer = int(value)
220	    if value == integer and 0 <= integer <= 10:
221	        return ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten")[integer]
222	    return f"{value:g}"
223


Now let me check the modules.py file to understand how the listing reel rendering and video review work:


1	from __future__ import annotations
2
3	import json
4	import re
5	import shutil
6	import subprocess
7	from collections import defaultdict
8	from pathlib import Path
9	from typing import Any
10
11	from core.delivery_naming import (
12	    format_delivery_video_filename,
13	    resolve_delivery_content_type,
14	    resolve_delivery_title,
15	)
16	from core.media.caption_image import create_caption_image
17	from core.media.coqui_tts import coqui_is_available, create_coqui_audio
18	from core.media.ffmpeg_utils import (
19	    build_render_command,
20	    build_timed_audio_command,
21	    create_silent_audio,
22	    create_thumbnail,
23	    probe_media,
24	    probe_peak_volume_db,
25	    run_render_command,
26	)
27	from core.media.local_tts import LocalTtsError, create_say_audio, say_is_available
28	from core.publishing.instagram import build_caption, build_metadata
29	from core.workflows.runner import WorkflowContext
30
31	_VIDEO_EXTENSIONS = {".mov", ".mp4", ".mpeg", ".mpg", ".avi", ".mkv", ".webm"}
32
33
34	class LocalTranscriptionModule:
35	    def __init__(self, transcription_client: Any = None) -> None:
36	        self._client = transcription_client
37
38	    def run(self, context: WorkflowContext, params: dict) -> dict:
39	        input_path = context.data["input_path"]
40
41	        client = self._client or context.data.get("transcription_client")
42	        if client is not None:
43	            audio_path = _audio_path_for_transcription(input_path, context.workspace)
44	            if hasattr(client, "transcribe_with_segments"):
45	                text, segments = client.transcribe_with_segments(audio_path)
46	                valid_segments = [s for s in segments if s["end"] > s["start"]]
47	                return {"transcript": text, "transcript_segments": valid_segments}
48	            return {"transcript": client.transcribe(audio_path)}
49
50	        # Offline/demo fallback — explicit placeholder so it's obvious in logs
51	        transcript = context.data.get(
52	            "transcript",
53	            f"[DEMO PLACEHOLDER — no transcription provider configured for {Path(input_path).name}]",
54	        )
55	        return {"transcript": transcript}
56
57
58	class LocalScriptModule:
59	    def run(self, context: WorkflowContext, params: dict) -> dict:
60	        transcript = context.data.get("transcript", "")
61	        listing_context = context.data.get("listing_description") or context.data.get("listing_context") or ""
62	        target_duration = context.data.get("target_duration_seconds") or params.get("target_duration_seconds", 60)
63	        script = _clean_transcript_for_narration(transcript)
64	        if listing_context:
65	            script = f"{script}\n\nOperator context to consider during review: {listing_context}".strip()
66	        return {"generated_script": script, "target_duration_seconds": target_duration}
67
68
69	class LocalVoiceModule:
70	    def __init__(self, tts_client: Any = None) -> None:
71	        self._tts_client = tts_client
72
73	    def run(self, context: WorkflowContext, params: dict) -> dict:
74	        script = context.data.get("approved_narration") or context.data.get("approved_script", "")
75	        transcript_segments = context.data.get("transcript_segments")
76
77	        narration_text_path = context.workspace / "narration.txt"
78	        narration_text_path.write_text(script, encoding="utf-8")
79
80	        # Total duration: prefer input video duration so timed TTS fills the full video.
81	        target_duration = float(
82	            context.data.get("input_duration_seconds")
83	            or context.data.get("target_duration_seconds")
84	            or (transcript_segments[-1]["end"] + 2.0 if transcript_segments else None)
85	            or _estimate_narration_duration_seconds(script)
86	        )
87	        reserved_outro = float(
88	            context.data.get("reserved_outro_seconds")
89	            or params.get("reserved_outro_seconds", 0)
90	        )
91	        total_duration = max(1.0, target_duration - reserved_outro)
92
93	        provider = context.data.get("local_tts_provider", "say")
94	        require_audible = bool(context.data.get("require_audible_narration", False))
95	        voice = str(context.data.get("say_voice") or "Samantha")
96	        rate = int(context.data.get("say_rate") or 200)
97	        el_client = self._tts_client
98
99	        coqui_model = str(context.data.get("coqui_tts_model") or "tts_models/en/ljspeech/vits")
100	        coqui_image = str(context.data.get("coqui_tts_image") or "ghcr.io/coqui-ai/tts-cpu")


100	        coqui_image = str(context.data.get("coqui_tts_image") or "ghcr.io/coqui-ai/tts-cpu")
101	        coqui_platform = str(context.data.get("coqui_tts_platform") or "linux/amd64")
102	        tts_fn, clip_suffix, provider_name = _resolve_tts_fn(
103	            provider,
104	            voice,
105	            rate,
106	            el_client,
107	            coqui_model,
108	            coqui_image,
109	            coqui_platform,
110	        )
111
112	        if transcript_segments and tts_fn is not None:
113	            # Timed TTS: probe actual video duration so speech lands at original timestamps.
114	            total_duration = float(
115	                context.data.get("input_duration_seconds")
116	                or _probe_duration(context.data.get("input_path"))
117	                or total_duration
118	            )
119	            tts_result = _build_timed_narration(
120	                script, transcript_segments, total_duration, context.workspace,
121	                tts_fn, clip_suffix, provider_name,
122	            )
123	        elif provider == "elevenlabs" and el_client is not None:
124	            narration_audio_path = context.workspace / "narration.mp3"
125	            try:
126	                el_client.synthesize_to_file(script, narration_audio_path)
127	                if not narration_audio_path.exists() or narration_audio_path.stat().st_size == 0:
128	                    raise RuntimeError("ElevenLabs returned empty narration audio")
129	                total_duration = probe_media(narration_audio_path).duration_seconds
130	                tts_result = {"narration_path": str(narration_audio_path), "voice_provider": "elevenlabs"}
131	            except Exception as exc:
132	                if require_audible:
133	                    raise RuntimeError("Required audible narration could not be produced by ElevenLabs") from exc
134	                narration_audio_path = context.workspace / "narration.m4a"
135	                create_silent_audio(narration_audio_path, total_duration)
136	                tts_result = {
137	                    "narration_path": str(narration_audio_path),
138	                    "voice_provider": "elevenlabs_failed_silent_audio",
139	                }
140	        elif provider == "say" and say_is_available():
141	            # Continuous TTS (no segment timestamps available).
142	            narration_audio_path = context.workspace / "narration.aiff"
143	            try:
144	                create_say_audio(script, narration_audio_path, voice=voice, rate=rate)
145	                if narration_audio_path.exists():
146	                    total_duration = probe_media(narration_audio_path).duration_seconds
147	                tts_result = {"narration_path": str(narration_audio_path), "voice_provider": "local_say"}
148	            except (LocalTtsError, subprocess.CalledProcessError):
149	                if require_audible:
150	                    raise RuntimeError("Required audible narration could not be produced by macOS say")
151	                narration_audio_path = context.workspace / "narration.m4a"
152	                create_silent_audio(narration_audio_path, total_duration)
153	                tts_result = {
154	                    "narration_path": str(narration_audio_path),
155	                    "voice_provider": "local_say_failed_silent_audio",
156	                }
157	        elif provider == "coqui" and coqui_is_available():
158	            narration_audio_path = context.workspace / "narration.wav"
159	            try:
160	                create_coqui_audio(
161	                    script,
162	                    narration_audio_path,
163	                    model_name=coqui_model,
164	                    image=coqui_image,
165	                    platform=coqui_platform,
166	                )
167	                if narration_audio_path.exists():
168	                    total_duration = probe_media(narration_audio_path).duration_seconds
169	                tts_result = {"narration_path": str(narration_audio_path), "voice_provider": "local_coqui"}
170	            except Exception:
171	                if require_audible:
172	                    raise RuntimeError("Required audible narration could not be produced by Coqui")
173	                narration_audio_path = context.workspace / "narration.m4a"
174	                create_silent_audio(narration_audio_path, total_duration)
175	                tts_result = {
176	                    "narration_path": str(narration_audio_path),
177	                    "voice_provider": "local_coqui_failed_silent_audio",
178	                }
179	        else:
180	            if require_audible:
181	                raise RuntimeError(f"Required audible narration provider '{provider}' is unavailable")
182	            narration_audio_path = context.workspace / "narration.m4a"
183	            create_silent_audio(narration_audio_path, total_duration)
184	            tts_result = {"narration_path": str(narration_audio_path), "voice_provider": "local_stub_silent_audio"}
185
186	        if require_audible:
187	            try:
188	                peak_volume_db = probe_peak_volume_db(tts_result["narration_path"])
189	            except Exception as exc:
190	                raise RuntimeError("Required audible narration could not be validated") from exc
191	            if peak_volume_db < -60.0:
192	                raise RuntimeError(
193	                    f"Required audible narration is effectively silent ({peak_volume_db:.1f} dB peak)"
194	                )
195
196	        return {
197	            **tts_result,
198	            "narration_text_path": str(narration_text_path),
199	            "narration_duration_seconds": total_duration,
200	        }
201
202
203	class LocalCaptionModule:
204	    def run(self, context: WorkflowContext, params: dict) -> dict:
205	        captions_path = context.workspace / "captions.srt"
206	        script = context.data.get("approved_narration") or context.data.get("approved_script", "")
207	        duration = float(
208	            context.data.get("narration_duration_seconds")
209	            or context.data.get("target_duration_seconds")
210	            or _estimate_narration_duration_seconds(script)
211	        )
212	        transcript_segments = context.data.get("transcript_segments")
213	        if transcript_segments:
214	            segments = _caption_segments_from_whisper(script, transcript_segments, duration)
215	        else:
216	            segments = _caption_segments(script, duration)
217	        captions_path.write_text(_srt_from_segments(segments), encoding="utf-8")
218	        overlay_dir = context.workspace / "caption_overlays"
219	        caption_overlays = []
220	        for index, segment in enumerate(segments, start=1):
221	            overlay_path = create_caption_image(segment["text"], overlay_dir / f"caption_{index:03}.png")
222	            caption_overlays.append({**segment, "path": str(overlay_path)})
223	        return {
224	            "captions_path": str(captions_path),
225	            "caption_overlays": caption_overlays,
226	            "caption_style": params.get("style", "modern_bold"),
227	        }
228
229
230	class LocalRenderModule:
231	    def run(self, context: WorkflowContext, params: dict) -> dict:
232	        output_path = context.workspace / "final_video.mp4"
233	        input_probe = probe_media(context.data["input_path"])
234	        command = build_render_command(
235	            input_video=Path(context.data["input_path"]),
236	            narration_audio=Path(context.data.get("narration_path", context.workspace / "narration.txt")),
237	            captions=Path(context.data.get("captions_path", context.workspace / "captions.srt")),
238	            output_video=output_path,
239	            aspect_ratio=params.get("aspect_ratio", "9:16"),
240	            keep_original_audio=bool(context.data.get("keep_original_audio", False)),
241	            caption_overlays=context.data.get("caption_overlays"),
242	            output_duration_seconds=input_probe.duration_seconds,
243	        )
244	        command_path = context.workspace / "render_command.txt"
245	        command_path.write_text(" ".join(command), encoding="utf-8")
246	        result = {"output_video": str(output_path), "render_command": command, "render_executed": False}
247	        if bool(context.data.get("execute_render", False)):
248	            run_render_command(command)
249	            probe = probe_media(output_path)
250	            result.update(
251	                {
252	                    "render_executed": True,
253	                    "rendered_duration_seconds": probe.duration_seconds,
254	                    "rendered_width": probe.width,
255	                    "rendered_height": probe.height,
256	                }
257	            )
258	        return result
259
260
261	class InstagramCaptionModule:
262	    """Draft an Instagram caption from the job's script/listing context.
263
264	    Writes the draft into ``generated_script`` so the shared ApprovalGate
265	    surfaces it for human editing — nothing is packaged until an operator
266	    approves the caption.
267	    """
268
269	    def run(self, context: WorkflowContext, params: dict) -> dict:
270	        listing = str(context.data.get("listing_description") or "")
271	        # Prefer the narration/script as the hook; fall back to the listing copy.
272	        # When the listing is the only source, don't also append it as context
273	        # (that would duplicate the same sentence in the caption).
274	        script = (
275	            context.data.get("approved_caption")
276	            or context.data.get("approved_script")
277	            or context.data.get("narration")
278	            or context.data.get("generated_script")
279	            or listing
280	        )
281	        listing_context = listing if script and script != listing else ""
282	        caption = build_caption(
283	            script=script,
284	            handle=str(context.data.get("instagram_handle") or ""),
285	            hashtags=context.data.get("instagram_hashtags"),
286	            cta=str(context.data.get("instagram_cta") or ""),
287	            listing_context=listing_context,
288	        )
289	        return {"generated_script": caption, "caption_draft": caption}
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


Let me continue reading the modules and look for the listing-specific modules:


1	from __future__ import annotations
2
3	import json
4	from pathlib import Path
5	from typing import Any
6
7	from core.listings.manifest import listing_snapshot_from_dict, load_listing_source, source_policy_for
8	from core.listings.models import AgentProfile
9	from core.listings.review import build_review_bundle, parse_approved_review, snapshot_digest
10	from core.listings.scorecard import (
11	    PROFILE_NAME,
12	    build_review_page,
13	    review_url_for,
14	    score_post_render,
15	    score_pre_render,
16	    write_score_report,
17	)
18	from core.media.ffmpeg_utils import probe_media
19	from core.media.listing_graphics import create_contact_outro, create_listing_cover
20	from core.media.photo_reel import build_photo_reel_command, run_photo_reel
21	from core.workflows.runner import WorkflowContext
22
23
24	class ListingIntakeModule:
25	    def run(self, context: WorkflowContext, params: dict) -> dict:
26	        listing_url = str(context.data.get("listing_url") or "")
27	        manifest_path = context.data.get("listing_manifest_path")
28	        default_root = (
29	            str(Path(str(manifest_path)).parent)
30	            if manifest_path
31	            else str(context.workspace / "listing_media")
32	        )
33	        media_root = str(context.data.get("listing_media_root") or default_root)
34	        fetcher = None
35	        if not manifest_path:
36	            from core.listings.providers.registry import build_authorized_fetcher
37
38	            Path(media_root).mkdir(parents=True, exist_ok=True)
39	            fetcher = build_authorized_fetcher(
40	                media_root=media_root,
41	                demo=bool(context.data.get("listing_demo_intake")),
42	                rights_confirmed=bool(context.data.get("listing_rights_confirmed")),
43	            )
44	        snapshot = load_listing_source(
45	            listing_url,
46	            manifest_path=manifest_path,
47	            media_root=media_root,
48	            fetcher=fetcher,
49	        )
50	        snapshot_path = context.workspace / "listing_snapshot.json"
51	        snapshot_path.write_text(json.dumps(snapshot.to_dict(), indent=2), encoding="utf-8")
52	        return {
53	            "listing_snapshot_path": str(snapshot_path),
54	            "listing_snapshot_sha256": snapshot_digest(snapshot),
55	            "listing_media_root": media_root,
56	            "listing_source_policy": source_policy_for(listing_url).value,
57	            "listing_source_provider": snapshot.source_provider,
58	        }
59
60
61	class ListingReviewDraftModule:
62	    def run(self, context: WorkflowContext, params: dict) -> dict:
63	        snapshot = _load_snapshot(context)
64	        agent = AgentProfile.from_mapping(
65	            {
66	                "name": context.data.get("agent_name"),
67	                "instagram_handle": context.data.get("agent_instagram_handle"),
68	                "phone": context.data.get("agent_phone"),
69	                "email": context.data.get("agent_email"),
70	                "website": context.data.get("agent_website"),
71	                "voice_owner": context.data.get("voice_owner"),
72	                "profile_photo_path": context.data.get("agent_profile_photo"),
73	            }
74	        )
75	        bundle = build_review_bundle(
76	            snapshot,
77	            agent,
78	            target_duration_seconds=float(
79	                context.data.get("target_duration_seconds")
80	                or params.get("target_duration_seconds", 30)
81	            ),
82	        )
83	        draft = json.dumps(bundle, indent=2)
84	        draft_path = context.workspace / "listing_review_draft.json"
85	        draft_path.write_text(draft, encoding="utf-8")
86	        return {
87	            "generated_script": draft,
88	            "listing_review_draft_path": str(draft_path),
89	        }
90
91
92	class ListingReviewApprovalModule:
93	    def run(self, context: WorkflowContext, params: dict) -> dict:
94	        snapshot = _load_snapshot(context)
95	        approved = parse_approved_review(
96	            str(context.data.get("approved_script") or ""),
97	            snapshot,
98	            media_root=context.data["listing_media_root"],
99	        )
100	        approved_path = context.workspace / "approved_listing_reel.normalized.json"
101	        approved_payload = {
102	            "narration": approved.narration,
103	            "instagram_caption": approved.instagram_caption,
104	            "photo_order": [photo.relative_path for photo in approved.photos],
105	            "cover_photo_path": approved.cover_photo.relative_path,
106	            "outro": approved.outro,
107	            "target_duration_seconds": approved.target_duration_seconds,
108	        }
109	        approved_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")
110	        return {
111	            "approved_narration": approved.narration,
112	            "approved_caption": approved.instagram_caption,
113	            "approved_photo_paths": [str(photo.path) for photo in approved.photos],
114	            "approved_cover_photo_path": str(approved.cover_photo.path),
115	            "approved_outro": approved.outro,
116	            "target_duration_seconds": approved.target_duration_seconds,
117	            "approved_listing_reel_path": str(approved_path),
118	        }
119
120
121	class ListingGraphicsModule:
122	    def run(self, context: WorkflowContext, params: dict) -> dict:
123	        snapshot = _load_snapshot(context)
124	        cover_source = Path(context.data["approved_cover_photo_path"])
125	        cover_photo = next((photo for photo in snapshot.photos if photo.path == cover_source), None)
126	        if cover_photo is None:
127	            raise ValueError("Approved cover photo is not part of the listing snapshot")
128	        fact_parts = []
129	        if snapshot.beds is not None:
130	            fact_parts.append(f"{snapshot.beds:g} bed")
131	        if snapshot.baths is not None:
132	            fact_parts.append(f"{snapshot.baths:g} bath")
133	        if snapshot.square_feet is not None:
134	            fact_parts.append(f"{snapshot.square_feet:,} sq ft")
135	        locality = ", ".join(part for part in (snapshot.city, snapshot.state) if part)
136	        title = snapshot.address or snapshot.display_location or "Featured property"
137	        subtitle_parts = ([] if title == locality else ([locality] if locality else [])) + fact_parts
138	        subtitle = " · ".join(subtitle_parts)
139	        price = f"${snapshot.price:,}" if snapshot.price is not None else ""
140	        cover_path = create_listing_cover(
141	            cover_source,
142	            context.workspace / "cover.jpg",
143	            title=title,
144	            subtitle=subtitle,
145	            price=price,
146	            focal_point=cover_photo.focal_point,
147	        )
148
149	        outro = dict(context.data.get("approved_outro") or {})
150	        outro_path = create_contact_outro(


150	        outro_path = create_contact_outro(
151	            context.workspace / "outro.png",
152	            agent_name=str(outro.get("agent_name") or ""),
153	            cta=str(outro.get("cta") or "Schedule a private tour"),
154	            instagram_handle=str(outro.get("instagram_handle") or ""),
155	            phone=str(outro.get("phone") or ""),
156	            email=str(outro.get("email") or ""),
157	            website=str(outro.get("website") or ""),
158	            profile_photo_path=str(outro.get("profile_photo_path") or ""),
159	        )
160	        return {"cover_image_path": str(cover_path), "outro_image_path": str(outro_path)}
161
162
163	class ListingScriptReviewModule:
164	    def run(self, context: WorkflowContext, params: dict) -> dict:
165	        snapshot = _load_snapshot(context)
166	        approved = _approved_reel(context)
167	        width = int(params.get("width", 1080))
168	        height = int(params.get("height", 1920))
169	        fps = int(params.get("fps", 60))
170	        report = score_pre_render(snapshot, approved, width=width, height=height, fps=fps)
171	        qa_dir = context.workspace / "qa"
172	        report_path = write_score_report(qa_dir / "script_review.json", report)
173	        return {
174	            "reel_profile": PROFILE_NAME,
175	            "script_review_path": str(report_path),
176	            "script_review": report.to_dict(),
177	            "pre_render_score_path": str(report_path),
178	            "pre_render_score": report.to_dict(),
179	        }
180
181
182	class ListingPhotoReelRenderModule:
183	    def run(self, context: WorkflowContext, params: dict) -> dict:
184	        _load_snapshot(context)  # Re-verifies approved media hashes immediately before render.
185	        narration_duration = float(
186	            context.data.get("narration_duration_seconds")
187	            or context.data.get("target_duration_seconds")
188	            or 30
189	        )
190	        outro_duration = float(params.get("outro_duration_seconds", 4.5))
191	        target_duration = float(context.data.get("target_duration_seconds") or 30)
192	        total_duration = min(
193	            45.0,
194	            max(20.0, target_duration, narration_duration + outro_duration),
195	        )
196	        output_path = context.workspace / "final_video.mp4"
197	        command = build_photo_reel_command(
198	            photo_paths=context.data.get("approved_photo_paths") or [],
199	            narration_audio=Path(context.data["narration_path"]),
200	            outro_image=Path(context.data["outro_image_path"]),
201	            caption_overlays=context.data.get("caption_overlays") or [],
202	            output_video=output_path,
203	            total_duration_seconds=total_duration,
204	            outro_duration_seconds=outro_duration,
205	            width=int(params.get("width", 1080)),
206	            height=int(params.get("height", 1920)),
207	            fps=int(params.get("fps", 60)),
208	        )
209	        command_path = context.workspace / "render_command.txt"
210	        command_path.write_text(" ".join(command), encoding="utf-8")
211	        result = {
212	            "output_video": str(output_path),
213	            "render_command": command,
214	            "render_command_path": str(command_path),
215	            "render_executed": False,
216	            "output_duration_seconds": total_duration,
217	        }
218	        if bool(context.data.get("execute_render", False)):
219	            run_photo_reel(command)
220	            probe = probe_media(output_path)
221	            result.update(
222	                {
223	                    "render_executed": True,
224	                    "rendered_duration_seconds": probe.duration_seconds,
225	                    "rendered_width": probe.width,
226	                    "rendered_height": probe.height,
227	                    "rendered_frame_rate": probe.frame_rate,
228	                    "rendered_video_codec": probe.video_codec,
229	                    "rendered_audio_codec": probe.audio_codec,
230	                }
231	            )
232	        return result
233
234
235	class ListingVideoReviewModule:
236	    def run(self, context: WorkflowContext, params: dict) -> dict:
237	        snapshot = _load_snapshot(context)
238	        approved = _approved_reel(context)
239	        output_video = Path(context.data["output_video"])
240	        probe = probe_media(output_video)
241	        has_profile_photo = bool(str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip())
242	        report = score_post_render(
243	            snapshot,
244	            approved,
245	            probe=probe,
246	            caption_overlay_count=len(context.data.get("caption_overlays") or []),
247	            has_profile_photo=has_profile_photo,
248	        )
249	        qa_dir = context.workspace / "qa"
250	        report_path = write_score_report(qa_dir / "video_review.json", report)
251	        return {
252	            "video_review_path": str(report_path),
253	            "video_review": report.to_dict(),
254	            "post_render_score_path": str(report_path),
255	            "post_render_score": report.to_dict(),
256	        }
257
258
259	class ListingReviewArtifactsModule:
260	    def run(self, context: WorkflowContext, params: dict) -> dict:
261	        snapshot = _load_snapshot(context)
262	        publish_dir = context.workspace / "publish"
263	        publish_dir.mkdir(parents=True, exist_ok=True)
264	        review_base_url = str(context.data.get("review_base_url") or "").strip()
265	        pre_report = context.data.get("script_review") or context.data.get("pre_render_score") or {}
266	        post_report = context.data.get("video_review") or context.data.get("post_render_score") or {}
267
268	        pre_report_path = _copy_optional(
269	            Path(str(context.data.get("script_review_path") or context.data.get("pre_render_score_path") or "")),
270	            publish_dir / "script_review.json",
271	        )
272	        post_report_path = _copy_optional(
273	            Path(str(context.data.get("video_review_path") or context.data.get("post_render_score_path") or "")),
274	            publish_dir / "video_review.json",
275	        )
276
277	        review_page = build_review_page(
278	            publish_dir / "review.html",
279	            title=str(snapshot.address or context.data.get("agent_name") or "Listing Reel Review"),
280	            video_filename="final_video.mp4",
281	            cover_filename=Path(str(context.data.get("cover_image_path") or "")).name if context.data.get("cover_image_path") else "",
282	            pre_report=_report_from_dict(pre_report) if pre_report else None,
283	            post_report=_report_from_dict(post_report) if post_report else None,
284	        )
285	        review_url = None
286	        if review_base_url:
287	            relative = review_page.relative_to(context.workspace).as_posix()
288	            review_url = review_url_for(review_base_url, relative)
289	        return {
290	            "review_page_path": str(review_page),
291	            "review_page_url": review_url,
292	            "script_review_publish_path": str(pre_report_path) if pre_report_path else None,
293	            "video_review_publish_path": str(post_report_path) if post_report_path else None,
294	            "pre_render_score_publish_path": str(pre_report_path) if pre_report_path else None,
295	            "post_render_score_publish_path": str(post_report_path) if post_report_path else None,
296	        }
297
298
299	ListingPreRenderScoreModule = ListingScriptReviewModule


Now let me check the scorecard module to understand how QA scoring works:


1	from __future__ import annotations
2
3	import json
4	from dataclasses import asdict, dataclass, field
5	from html import escape
6	from pathlib import Path
7	from typing import Any
8
9	from core.listings.models import ListingSnapshot
10	from core.listings.review import ApprovedListingReel
11	from core.models import MediaProbe
12
13	PROFILE_NAME = "listing_reel_instagram"
14	SCORECARD_VERSION = "2026-07-11.v1"
15	PROFILE_WEIGHTS = {
16	    "format_and_delivery": 15,
17	    "visual_composition": 25,
18	    "motion_and_transitions": 15,
19	    "narration_scene_alignment": 20,
20	    "caption_alignment_readability": 10,
21	    "branding_outro_quality": 10,
22	    "technical_polish": 5,
23	}
24
25
26	@dataclass(slots=True)
27	class ScoreIssue:
28	    code: str
29	    severity: str
30	    message: str
31	    evidence: dict[str, Any] = field(default_factory=dict)
32
33
34	@dataclass(slots=True)
35	class ScoreReport:
36	    profile: str
37	    version: str
38	    phase: str
39	    overall_score: int
40	    subscores: dict[str, int]
41	    hard_gate_warnings: list[dict[str, Any]]
42	    top_issues: list[dict[str, Any]]
43	    deterministic_checks: dict[str, Any]
44	    model_assisted_checks: dict[str, Any]
45	    recommendation: str
46	    confidence_note: str
47	    evidence: dict[str, Any]
48	    remote_review_url: str | None = None
49
50	    def to_dict(self) -> dict[str, Any]:
51	        return {
52	            "profile": self.profile,
53	            "version": self.version,
54	            "phase": self.phase,
55	            "overall_score": self.overall_score,
56	            "subscores": dict(self.subscores),
57	            "hard_gate_warnings": list(self.hard_gate_warnings),
58	            "top_issues": list(self.top_issues),
59	            "deterministic_checks": dict(self.deterministic_checks),
60	            "model_assisted_checks": dict(self.model_assisted_checks),
61	            "recommendation": self.recommendation,
62	            "confidence_note": self.confidence_note,
63	            "evidence": dict(self.evidence),
64	            "remote_review_url": self.remote_review_url,
65	        }
66
67
68	def score_pre_render(
69	    snapshot: ListingSnapshot,
70	    approved: ApprovedListingReel,
71	    *,
72	    width: int,
73	    height: int,
74	    fps: int,
75	) -> ScoreReport:
76	    warnings: list[ScoreIssue] = []
77	    issues: list[ScoreIssue] = []
78	    contact = approved.outro
79	    profile_photo_path = str(contact.get("profile_photo_path") or "").strip()
80	    has_profile_photo = bool(profile_photo_path)
81	    if not has_profile_photo:
82	        warnings.append(
83	            ScoreIssue(
84	                code="missing_profile_photo",
85	                severity="warning",
86	                message="Agent-card outro does not include a profile photo.",
87	                evidence={"field": "outro.profile_photo_path"},
88	            )
89	        )
90	    if len(approved.photos) < 3:
91	        issues.append(
92	            ScoreIssue(
93	                code="low_photo_count",
94	                severity="warning",
95	                message="Low photo count may reduce full-bleed variety and scene support.",
96	                evidence={"photo_count": len(approved.photos)},
97	            )
98	        )
99	    portrait = height > width
100	    if not portrait:
101	        warnings.append(
102	            ScoreIssue(
103	                code="non_portrait_layout",
104	                severity="warning",
105	                message="Configured reel output is not portrait.",
106	                evidence={"width": width, "height": height},
107	            )
108	        )
109
110	    subscores = {
111	        "format_and_delivery": _clamp(100 if portrait else 40),
112	        "visual_composition": _clamp(65 + min(25, len(approved.photos) * 6) + (5 if snapshot.cover_photo_index == 0 else 0)),
113	        "motion_and_transitions": _clamp(85 if fps >= 60 else 70),
114	        "narration_scene_alignment": _pre_alignment_score(snapshot, approved),
115	        "caption_alignment_readability": 75,
116	        "branding_outro_quality": _clamp(90 if has_profile_photo else 62),
117	        "technical_polish": 90,
118	    }
119	    if warnings:
120	        subscores["branding_outro_quality"] = min(subscores["branding_outro_quality"], 62)
121	    if len(approved.photos) < 3:
122	        subscores["visual_composition"] = min(subscores["visual_composition"], 68)
123
124	    return _build_report(
125	        phase="script_review",
126	        subscores=subscores,
127	        warnings=warnings,
128	        issues=issues,
129	        deterministic_checks={
130	            "portrait_expected": True,
131	            "configured_width": width,
132	            "configured_height": height,
133	            "configured_fps": fps,
134	            "photo_count": len(approved.photos),
135	            "has_profile_photo": has_profile_photo,
136	            "target_duration_seconds": approved.target_duration_seconds,
137	        },
138	        model_assisted_checks=_pending_model_assisted(),
139	        evidence={
140	            "cover_photo_path": approved.cover_photo.relative_path,
141	            "photo_order": [photo.relative_path for photo in approved.photos],
142	        },
143	    )
144
145
146	def score_post_render(
147	    snapshot: ListingSnapshot,
148	    approved: ApprovedListingReel,
149	    *,
150	    probe: MediaProbe,


150	    probe: MediaProbe,
151	    caption_overlay_count: int,
152	    has_profile_photo: bool,
153	) -> ScoreReport:
154	    warnings: list[ScoreIssue] = []
155	    issues: list[ScoreIssue] = []
156	    portrait = (probe.height or 0) > (probe.width or 0)
157	    if not portrait:
158	        warnings.append(
159	            ScoreIssue(
160	                code="artifact_not_portrait",
161	                severity="warning",
162	                message="Rendered artifact is not portrait.",
163	                evidence={"width": probe.width, "height": probe.height},
164	            )
165	        )
166	    if not probe.has_audio:
167	        warnings.append(
168	            ScoreIssue(
169	                code="missing_audio_stream",
170	                severity="warning",
171	                message="Rendered artifact is missing an audio stream.",
172	            )
173	        )
174	    if probe.duration_seconds < 20 or probe.duration_seconds > 45:
175	        issues.append(
176	            ScoreIssue(
177	                code="duration_out_of_band",
178	                severity="warning",
179	                message="Rendered reel duration falls outside the preferred 20–45 second range.",
180	                evidence={"duration_seconds": probe.duration_seconds},
181	            )
182	        )
183	    if not has_profile_photo:
184	        warnings.append(
185	            ScoreIssue(
186	                code="missing_profile_photo",
187	                severity="warning",
188	                message="Rendered reel relies on an outro without a configured profile photo.",
189	            )
190	        )
191	    duration_delta = abs(probe.duration_seconds - approved.target_duration_seconds)
192	    format_score = 100
193	    if not portrait:
194	        format_score -= 45
195	    if duration_delta > 5:
196	        format_score -= min(25, int(duration_delta * 2))
197	    elif duration_delta > 2:
198	        format_score -= 10
199
200	    subscores = {
201	        "format_and_delivery": _clamp(format_score),
202	        "visual_composition": _clamp(72 + min(18, len(approved.photos) * 4)),
203	        "motion_and_transitions": _clamp(88 if (probe.frame_rate or 0) >= 59 else 72),
204	        "narration_scene_alignment": _post_alignment_score(snapshot, approved),
205	        "caption_alignment_readability": _clamp(84 if caption_overlay_count else 50),
206	        "branding_outro_quality": _clamp(92 if has_profile_photo else 62),
207	        "technical_polish": _technical_score(probe),
208	    }
209	    if not probe.has_audio:
210	        subscores["technical_polish"] = min(subscores["technical_polish"], 35)
211	        subscores["caption_alignment_readability"] = min(subscores["caption_alignment_readability"], 60)
212
213	    return _build_report(
214	        phase="video_review",
215	        subscores=subscores,
216	        warnings=warnings,
217	        issues=issues,
218	        deterministic_checks={
219	            "artifact_width": probe.width,
220	            "artifact_height": probe.height,
221	            "artifact_duration_seconds": probe.duration_seconds,
222	            "artifact_frame_rate": probe.frame_rate,
223	            "artifact_video_codec": probe.video_codec,
224	            "artifact_audio_codec": probe.audio_codec,
225	            "artifact_has_audio": probe.has_audio,
226	            "caption_overlay_count": caption_overlay_count,
227	            "has_profile_photo": has_profile_photo,
228	        },
229	        model_assisted_checks=_pending_model_assisted(),
230	        evidence={"target_duration_seconds": approved.target_duration_seconds},
231	    )
232
233
234	def write_score_report(path: str | Path, report: ScoreReport) -> Path:
235	    target = Path(path)
236	    target.parent.mkdir(parents=True, exist_ok=True)
237	    target.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
238	    return target
239
240
241	def build_review_page(
242	    path: str | Path,
243	    *,
244	    title: str,
245	    video_filename: str,
246	    cover_filename: str = "",
247	    pre_report: ScoreReport | None = None,
248	    post_report: ScoreReport | None = None,
249	    caption_filename: str = "caption.txt",
250	    metadata_filename: str = "metadata.json",
251	) -> Path:
252	    target = Path(path)
253	    target.parent.mkdir(parents=True, exist_ok=True)
254	    pre_html = _report_html(pre_report, "Script Review") if pre_report else ""
255	    post_html = _report_html(post_report, "Video Review") if post_report else ""
256	    cover_html = (
257	        f''
258	        if cover_filename
259	        else ""
260	    )
261	    html = f"""<!doctype html>
262
263
264	  <meta charset="utf-8" />
265	  <meta name="viewport" content="width=device-width,initial-scale=1" />
266	  <title>{escape(title)} — Reel Review</title>
267
279
280
281
282
{escape(title)}

283
Private operator review surface for the rendered reel and QA scorecards.

284

285	    <video controls playsinline preload="metadata" src="{escape(video_filename)}"></video>
286

287

288
Artifacts

289
Open video · Open caption · Open metadata

290	    {cover_html}
291

292	  {pre_html}
293	  {post_html}
294
295
296
297	"""
298	    target.write_text(html, encoding="utf-8")
299	    return target


Now let me check the models to understand the Job and approval statuses:


1	from __future__ import annotations
2
3	from dataclasses import dataclass, field
4	from datetime import UTC, datetime
5	from enum import StrEnum
6	from pathlib import Path
7	from typing import Any
8	from uuid import uuid4
9
10
11	class JobStatus(StrEnum):
12	    PENDING = "pending"
13	    RUNNING = "running"
14	    WAITING_FOR_APPROVAL = "waiting_for_approval"
15	    COMPLETED = "completed"
16	    FAILED = "failed"
17
18
19	class ApprovalStatus(StrEnum):
20	    NOT_REQUIRED = "not_required"
21	    PENDING = "pending"
22	    APPROVED = "approved"
23	    REJECTED = "rejected"
24
25
26	class StepStatus(StrEnum):
27	    PENDING = "pending"
28	    RUNNING = "running"
29	    COMPLETED = "completed"
30	    WAITING_FOR_APPROVAL = "waiting_for_approval"
31	    FAILED = "failed"
32	    SKIPPED = "skipped"
33
34
35	@dataclass(slots=True)
36	class Tenant:
37	    id: str
38	    name: str
39	    industry: str | None = None
40	    config: dict[str, Any] = field(default_factory=dict)
41
42
43	@dataclass(slots=True)
44	class JobStep:
45	    step_id: str
46	    status: StepStatus = StepStatus.PENDING
47	    input_data: dict[str, Any] = field(default_factory=dict)
48	    output_data: dict[str, Any] = field(default_factory=dict)
49	    approved_by: str | None = None
50	    approved_at: str | None = None
51	    duration_ms: int | None = None
52
53
54	@dataclass(slots=True)
55	class Job:
56	    tenant_id: str
57	    workflow_name: str
58	    input_params: dict[str, Any]
59	    id: str = field(default_factory=lambda: str(uuid4()))
60	    status: JobStatus = JobStatus.PENDING
61	    output_params: dict[str, Any] = field(default_factory=dict)
62	    approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED
63	    approved_at: str | None = None
64	    error_message: str | None = None
65	    started_at: str | None = None
66	    completed_at: str | None = None
67	    steps: list[JobStep] = field(default_factory=list)
68
69	    def mark_started(self) -> None:
70	        self.status = JobStatus.RUNNING
71	        self.started_at = _now_iso()
72	        self.error_message = None
73	        self.completed_at = None
74
75	    def wait_for_approval(self) -> None:
76	        self.status = JobStatus.WAITING_FOR_APPROVAL
77	        self.approval_status = ApprovalStatus.PENDING
78
79	    def approve(self) -> None:
80	        self.status = JobStatus.PENDING
81	        self.approval_status = ApprovalStatus.APPROVED
82	        self.approved_at = _now_iso()
83
84	    def reject(self, reason: str) -> None:
85	        self.status = JobStatus.FAILED
86	        self.approval_status = ApprovalStatus.REJECTED
87	        self.error_message = reason
88	        self.completed_at = _now_iso()
89
90	    def complete(self) -> None:
91	        self.status = JobStatus.COMPLETED
92	        self.completed_at = _now_iso()
93
94	    def fail(self, reason: str) -> None:
95	        self.status = JobStatus.FAILED
96	        self.error_message = reason
97	        self.completed_at = _now_iso()
98
99
100	def _now_iso() -> str:
101	    return datetime.now(UTC).isoformat()
102
103
104	@dataclass(frozen=True, slots=True)
105	class MediaProbe:
106	    path: Path
107	    duration_seconds: float
108	    width: int | None
109	    height: int | None
110	    has_audio: bool
111	    frame_rate: float | None = None
112	    video_codec: str | None = None
113	    audio_codec: str | None = None
114	    pixel_format: str | None = None
115	    color_range: str | None = None
116


Now let me check how the hyperframes review works: