diff --git a/scripts/README.md b/scripts/README.md index 365dd6d..816502e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,6 +6,18 @@ API, polls the returned `polling_url`, downloads the signed `result.sample` image immediately, and writes a metadata receipt beside the image. +For Ghostlight visual-plan modifiers, the script uses the durable continuity +workflow: + +1. Generate the scene base image from the assembled base prompt. +2. Save the base image locally. +3. Send that base image back as `input_image`. +4. Send only the selected modifier prompt as the edit instruction. + +Dry-runs write `.base.prompt.txt` and, when modifiers are selected, +`.edit.prompt.txt` separately so the two prompts can be inspected before +credits get thrown into the machine. + The script reads the API key from `BFL_API_KEY` first. If that is not set, it falls back to `E:\Projects\gamecult-ops\bfl-api.txt`. It never prints the key. @@ -21,12 +33,21 @@ Dry-run one Ghostlight visual scene prompt: npm run images:bfl -- --visual-plan examples\visual\pallas-species-strikes.branch-and-fold.v0.visual.json --scene-id pallas_yard_wake --dry-run ``` +Dry-run a base scene plus one edit modifier: + +```powershell +npm run images:bfl -- --visual-plan examples\visual\pallas-species-strikes.branch-and-fold.v0.visual.json --scene-id pallas_ilya_arrival --global-modifier-id formal_stoppage_visible --dry-run +``` + Submit one paid render: ```powershell npm run images:bfl -- --visual-plan examples\visual\pallas-species-strikes.branch-and-fold.v0.visual.json --scene-id pallas_yard_wake --width 1536 --height 864 --output-format png --yes ``` +If modifier ids are supplied, one visual scene costs two BFL jobs: one base +generation and one edit pass from that base image. + Render every base scene from a visual plan: ```powershell diff --git a/scripts/generate_bfl_images.py b/scripts/generate_bfl_images.py index c310c93..7eae5aa 100644 --- a/scripts/generate_bfl_images.py +++ b/scripts/generate_bfl_images.py @@ -1,8 +1,10 @@ """Generate Ghostlight images through the Black Forest Labs API. The script can submit a plain prompt or assemble prompts from a Ghostlight -`.visual.json` plan. It writes each downloaded image beside a metadata receipt -that preserves the prompt, request id, polling result, and source scene. +`.visual.json` plan. For modifier renders, it generates the base image first, +then sends that image back to BFL with only the modifier prompt as the edit +instruction. It writes downloaded images beside metadata receipts that preserve +the prompt, request id, polling result, and source scene. """ from __future__ import annotations @@ -38,6 +40,7 @@ class RenderJob: source_ref: str scene_id: str | None = None modifier_ids: tuple[str, ...] = () + edit_prompt: str | None = None def load_json(path: Path) -> dict[str, Any]: @@ -146,14 +149,11 @@ def selected_global_modifiers( return modifiers -def assemble_visual_prompt( +def assemble_visual_base_prompt( data: dict[str, Any], scene: dict[str, Any], - scene_modifier_ids: set[str], - global_modifier_ids: set[str], -) -> tuple[str, tuple[str, ...]]: +) -> str: plan = data.get("visual_scene_plan", {}) - scene_id = scene["visual_scene_id"] sections: list[str] = [] style = plan.get("global_style_cue", "").strip() @@ -173,19 +173,25 @@ def assemble_visual_prompt( sections.append("Visible character references:\n" + "\n".join(character_lines)) sections.append("Scene base image:\n" + scene.get("base_image_prompt", "").strip()) + return "\n\n".join(section for section in sections if section.strip()) + +def assemble_visual_edit_prompt( + data: dict[str, Any], + scene: dict[str, Any], + scene_modifier_ids: set[str], + global_modifier_ids: set[str], +) -> tuple[str | None, tuple[str, ...]]: + plan = data.get("visual_scene_plan", {}) + scene_id = scene["visual_scene_id"] applied: list[tuple[str, str]] = [] applied.extend(selected_scene_modifiers(scene, scene_modifier_ids)) applied.extend(selected_global_modifiers(scene_id, plan, global_modifier_ids)) - if applied: - sections.append( - "Apply these additive visible state changes:\n" - + "\n".join(f"- {modifier_id}: {prompt}" for modifier_id, prompt in applied if prompt) - ) + if not applied: + return None, () - return "\n\n".join(section for section in sections if section.strip()), tuple( - modifier_id for modifier_id, _ in applied - ) + prompt = "\n".join(prompt for _modifier_id, prompt in applied if prompt).strip() + return prompt, tuple(modifier_id for modifier_id, _ in applied) def build_visual_jobs(args: argparse.Namespace) -> list[RenderJob]: @@ -212,7 +218,13 @@ def build_visual_jobs(args: argparse.Namespace) -> list[RenderJob]: jobs: list[RenderJob] = [] for scene in selected: scene_id = scene["visual_scene_id"] - prompt, applied = assemble_visual_prompt(data, scene, scene_modifier_ids, global_modifier_ids) + prompt = assemble_visual_base_prompt(data, scene) + edit_prompt, applied = assemble_visual_edit_prompt( + data, + scene, + scene_modifier_ids, + global_modifier_ids, + ) suffix = "-".join(slugify(item) for item in applied) render_id = slugify(f"{visual_plan_id}-{scene_id}" + (f"-{suffix}" if suffix else "")) jobs.append( @@ -222,6 +234,7 @@ def build_visual_jobs(args: argparse.Namespace) -> list[RenderJob]: source_ref=f"{args.visual_plan}#{scene_id}", scene_id=scene_id, modifier_ids=applied, + edit_prompt=edit_prompt, ) ) return jobs @@ -260,7 +273,7 @@ def download_file(url: str, output_path: Path) -> None: output_path.write_bytes(response.read()) -def build_payload(args: argparse.Namespace, prompt: str) -> dict[str, Any]: +def build_payload(args: argparse.Namespace, prompt: str, input_images: list[str] | None = None) -> dict[str, Any]: payload: dict[str, Any] = { "prompt": prompt, "output_format": args.output_format, @@ -278,12 +291,24 @@ def build_payload(args: argparse.Namespace, prompt: str) -> dict[str, Any]: payload["webhook_url"] = args.webhook_url if args.webhook_secret: payload["webhook_secret"] = args.webhook_secret - for index, value in enumerate(args.input_image or [], start=1): + for index, value in enumerate(input_images if input_images is not None else (args.input_image or []), start=1): key = "input_image" if index == 1 else f"input_image_{index}" payload[key] = encode_input_image(value) return payload +def receipt_payload(payload: dict[str, Any]) -> dict[str, Any]: + receipt = dict(payload) + for key, value in list(receipt.items()): + if key.startswith("input_image") and isinstance(value, str): + parsed = urllib.parse.urlparse(value) + if parsed.scheme in {"http", "https"}: + receipt[key] = value + else: + receipt[key] = f"" + return receipt + + def poll_until_ready( polling_url: str, api_key: str, @@ -316,11 +341,34 @@ def extension_for(args: argparse.Namespace, sample_url: str | None) -> str: return guessed.lstrip(".") +def submit_and_download( + args: argparse.Namespace, + api_key: str, + payload: dict[str, Any], + output_base: Path, + label: str, +) -> tuple[Path, dict[str, Any], dict[str, Any]]: + print(f"submitting {label}") + submit = request_json(args.endpoint, api_key, method="POST", payload=payload) + polling_url = submit.get("polling_url") + if not polling_url: + raise RuntimeError(f"BFL response did not include polling_url: {json.dumps(submit)}") + + result = poll_until_ready(polling_url, api_key, args.poll_interval, args.timeout) + sample_url = (result.get("result") or {}).get("sample") + if not sample_url: + raise RuntimeError(f"BFL ready result did not include result.sample: {json.dumps(result)}") + + image_path = output_base.with_suffix("." + extension_for(args, sample_url)) + download_file(sample_url, image_path) + return image_path, submit, result + + def run_job(args: argparse.Namespace, api_key: str | None, job: RenderJob, index: int, total: int) -> None: output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) render_base = output_dir / f"{index:03d}-{job.render_id}" - payload = build_payload(args, job.prompt) + base_payload = build_payload(args, job.prompt) metadata: dict[str, Any] = { "render_id": job.render_id, @@ -328,39 +376,65 @@ def run_job(args: argparse.Namespace, api_key: str | None, job: RenderJob, index "scene_id": job.scene_id, "modifier_ids": list(job.modifier_ids), "endpoint": args.endpoint, - "request_payload": payload, + "base_request_payload": receipt_payload(base_payload), } - prompt_path = render_base.with_suffix(".prompt.txt") + prompt_path = render_base.with_suffix(".base.prompt.txt") prompt_path.write_text(job.prompt + "\n", encoding="utf-8") + if job.edit_prompt: + edit_prompt_path = render_base.with_suffix(".edit.prompt.txt") + edit_prompt_path.write_text(job.edit_prompt + "\n", encoding="utf-8") + metadata["edit_prompt_path"] = display_path(edit_prompt_path) if args.dry_run: metadata["dry_run"] = True + if job.edit_prompt: + edit_payload = build_payload(args, job.edit_prompt, input_images=[]) + edit_payload["input_image"] = "" + metadata["edit_request_payload"] = edit_payload write_json(render_base.with_suffix(".metadata.json"), metadata) print(f"[{index}/{total}] dry-run wrote {display_path(prompt_path)}") + if job.edit_prompt: + print(f"[{index}/{total}] dry-run wrote {display_path(render_base.with_suffix('.edit.prompt.txt'))}") return if api_key is None: raise SystemExit("Internal error: api_key missing outside dry-run mode.") - print(f"[{index}/{total}] submitting {job.render_id}") - submit = request_json(args.endpoint, api_key, method="POST", payload=payload) - metadata["submit_response"] = submit - polling_url = submit.get("polling_url") - if not polling_url: - raise RuntimeError(f"BFL response did not include polling_url: {json.dumps(submit)}") + print(f"[{index}/{total}] generating base {job.render_id}") + base_image_path, base_submit, base_result = submit_and_download( + args, + api_key, + base_payload, + render_base.with_name(render_base.name + ".base"), + f"{job.render_id} base", + ) + metadata["base_submit_response"] = base_submit + metadata["base_poll_result"] = base_result + metadata["base_image"] = display_path(base_image_path) - result = poll_until_ready(polling_url, api_key, args.poll_interval, args.timeout) - metadata["poll_result"] = result - sample_url = (result.get("result") or {}).get("sample") - if not sample_url: - raise RuntimeError(f"BFL ready result did not include result.sample: {json.dumps(result)}") + if job.edit_prompt: + print(f"[{index}/{total}] editing from base {job.render_id}") + edit_payload = build_payload(args, job.edit_prompt, input_images=[str(base_image_path)]) + metadata["edit_request_payload"] = receipt_payload(edit_payload) + edit_image_path, edit_submit, edit_result = submit_and_download( + args, + api_key, + edit_payload, + render_base.with_name(render_base.name + ".edit"), + f"{job.render_id} edit", + ) + metadata["edit_submit_response"] = edit_submit + metadata["edit_poll_result"] = edit_result + metadata["edited_image"] = display_path(edit_image_path) + metadata["downloaded_image"] = display_path(edit_image_path) + write_json(render_base.with_suffix(".metadata.json"), metadata) + print(f"[{index}/{total}] wrote {display_path(edit_image_path)}") + return - image_path = render_base.with_suffix("." + extension_for(args, sample_url)) - download_file(sample_url, image_path) - metadata["downloaded_image"] = display_path(image_path) + metadata["downloaded_image"] = display_path(base_image_path) write_json(render_base.with_suffix(".metadata.json"), metadata) - print(f"[{index}/{total}] wrote {display_path(image_path)}") + print(f"[{index}/{total}] wrote {display_path(base_image_path)}") def parse_args() -> argparse.Namespace: diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 817efb3..3bb870d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -59,3 +59,4 @@ {"ts": "2026-05-03T10:55:19+00:00", "type": "review", "status": "ok", "note": "Ran narrative and visual reviewer subagents on the Pallas fixture, patched required revisions, scoped global visual modifiers by scene, added the red-alert worker-judgment visual beat, and resolved the final Orrin/Nara route-memory staging mismatch."} {"ts": "2026-05-03T10:58:29+00:00", "type": "doctrine", "status": "ok", "note": "Corrected visual reviewer scope: it reviews playable Ink plus .visual.json for illustrated replay and website value, ignores clean-run prose receipts, and treats visual artifacts as auxiliary corpus presentation rather than core social-model training signal."} {"ts": "2026-05-03T11:53:40+00:00", "type": "implementation", "status": "ok", "note": "Added BFL image generation script for direct prompts and Ghostlight .visual.json plans. It reads BFL_API_KEY or gamecult-ops/bfl-api.txt, supports dry-run prompt receipts, submits FLUX.2 pro preview jobs, polls returned polling_url, downloads signed result.sample images immediately, and keeps generated outputs under ignored experiments/images/."} +{"ts": "2026-05-03T12:00:19+00:00", "type": "implementation", "status": "ok", "note": "Updated BFL visual-plan rendering so modifiers use the intended two-pass continuity workflow: generate the base image from the base prompt, then edit that saved image with only the selected modifier prompt. Dry-run receipts now emit separate .base.prompt.txt and .edit.prompt.txt files."}