Split BFL base and edit prompts
This commit is contained in:
parent
a4382f5bb9
commit
664b8a2ced
3 changed files with 133 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"<base64 image omitted: {len(value)} chars>"
|
||||
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"] = "<base image generated by this job>"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue