website-htmx-rustelo-code/scripts/content/generate-images.nu.bak

1138 lines
44 KiB
Text
Raw Permalink Normal View History

2026-07-10 03:44:13 +01:00
#!/usr/bin/env nu
# AI Image Generation Pipeline
#
# Discovers content items missing images, constructs typed prompts via Tera
# templates, calls the configured image provider, presents each image for human
# approval via typedialog, injects approved paths into both metadata files
# (.md + .ncl), and publishes a NATS event to trigger the content sync pipeline.
#
# Provider-agnostic: the `provider` field in image-generation.ncl selects the
# backend. Both expose the uniform Result shape { ok, actual_cost, err }:
# - openai → dall-e-3 / gpt-image-1 (POST /v1/images/generations, b64_json)
# - gemini → gemini-2.5-flash-image (POST :generateContent, inlineData)
#
# Two-phase execution:
# Phase 1 — generate: provider calls for all discovered items → images/pending/
# Phase 2 — review: typedialog batch approval → finalize approved items
#
# Prerequisites (runtime):
# - nickel CLI (`nickel export`) for config loading
# - tera nu plugin for prompt rendering
# - typedialog CLI (`^typedialog select` or `^typedialog-tui`) for approval UI
# - nats CLI (`^nats pub`) for event publishing
#
# Usage:
# nu generate-images.nu [flags]
#
# Environment:
# OPENAI_API_KEY — required when provider = "openai"
# GEMINI_API_KEY — required when provider = "gemini"
# RUSTELO_IMAGE_PROVIDER — global default provider (overrides .ncl; --provider wins)
# RUSTELO_IMAGE_MODEL — global default model (overrides .ncl; --model wins)
# RUSTELO_IMAGE_QUALITY — global default quality (overrides .ncl; --quality wins)
# NATS_NAMESPACE — NATS subject prefix (default: "rustelo")
# SITE_CONTENT_PATH — overrides --content-dir
# SITE_PUBLIC_PATH — overrides --public-dir
source lib/frontmatter.nu
# ---------------------------------------------------------------------------
# Price table — updated 2025-05. Source: platform.openai.com/docs/pricing
# Keys: "{model}:{quality}:{size}"
# ---------------------------------------------------------------------------
def image_price_table [] {
{
# dall-e-3
"dall-e-3:standard:1024x1024": 0.040,
"dall-e-3:standard:1024x1792": 0.080,
"dall-e-3:standard:1792x1024": 0.080,
"dall-e-3:hd:1024x1024": 0.080,
"dall-e-3:hd:1024x1792": 0.120,
"dall-e-3:hd:1792x1024": 0.120,
# gpt-image-1 (flat per-image equivalents from official pricing)
"gpt-image-1:low:1024x1024": 0.011,
"gpt-image-1:low:1024x1792": 0.016,
"gpt-image-1:low:1792x1024": 0.016,
"gpt-image-1:medium:1024x1024": 0.042,
"gpt-image-1:medium:1024x1792": 0.063,
"gpt-image-1:medium:1792x1024": 0.063,
"gpt-image-1:high:1024x1024": 0.167,
"gpt-image-1:high:1024x1792": 0.250,
"gpt-image-1:high:1792x1024": 0.250,
# gemini-2.5-flash-image (nano-banana): flat ~1290 output tokens/image
# at $30/1M output → ~$0.039/image regardless of aspect ratio.
# Pre-flight estimate only; actual cost comes from usageMetadata.
"gemini-2.5-flash-image:standard:1024x1024": 0.039,
"gemini-2.5-flash-image:standard:1024x1792": 0.039,
"gemini-2.5-flash-image:standard:1792x1024": 0.039,
}
}
def cost_per_image [model: string, quality: string, size: string] {
let key = $"($model):($quality):($size)"
let table = image_price_table
$table | get --optional $key | default 0.0
}
# For gpt-image-1: compute actual cost from the usage field returned in the API response.
# Token prices are far more stable than per-image equivalents.
# Source: platform.openai.com/docs/pricing (updated 2025-05)
def cost_from_usage [usage: record] {
let input_price_per_token = 0.000005 # $5.00 / 1M input tokens
let output_price_per_token = 0.00004 # $40.00 / 1M output tokens
let input_tokens = $usage | get --optional input_tokens | default 0
let output_tokens = $usage | get --optional output_tokens | default 0
($input_tokens * $input_price_per_token) + ($output_tokens * $output_price_per_token)
}
# For gemini-2.5-flash-image: compute actual cost from usageMetadata.
# Image output is billed as output tokens (~1290/image) at the flash-image rate.
# Source: ai.google.dev/gemini-api/docs/pricing (gemini-2.5-flash-image)
def cost_from_gemini_usage [usage: record] {
let input_price_per_token = 0.0000003 # $0.30 / 1M input tokens
let output_price_per_token = 0.00003 # $30.00 / 1M output (image) tokens
let input_tokens = $usage | get --optional promptTokenCount | default 0
let output_tokens = $usage | get --optional candidatesTokenCount | default 0
($input_tokens * $input_price_per_token) + ($output_tokens * $output_price_per_token)
}
def append_spend_log [log_path: string, entry: record] {
$entry | to json --raw | save --append $log_path
"\n" | save --append $log_path
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main [
--content-dir: string = "" # Content root (default: $SITE_CONTENT_PATH or site/content)
--public-dir: string = "" # Public assets root (default: $SITE_PUBLIC_PATH or site/public)
--config: string = "" # Nickel config path (default: site/config/image-generation.ncl)
--provider: string = "" # Override config provider: openai | gemini
--model: string = "" # Override config model (e.g. gpt-image-1, gemini-2.5-flash-image)
--quality: string = "" # Override config quality (standard|hd | low|medium|high)
--type: string = "" # Filter: blog | recipes | projects | activities
--lang: string = "" # Filter: en | es | ...
--category: string = "" # Filter: specific category within type
--slug: string = "" # Target one specific item
--image-type: string = "both" # thumbnail | feature | both
--budget: float = 0.0 # Abort if estimated cost exceeds this (USD). 0 = no limit
--spend-log: string = "" # Path to JSONL spend log (default: scripts/content/.image-spend.jsonl)
--propagate-langs # Write the approved path to all other language variants
--insert-hero # Insert hero image as first paragraph in the markdown body
--force # Regenerate even if thumbnail already set
--dry-run # Print prompts; no API calls, no file writes
--no-nats # Skip NATS publish after approval
--verbose # Per-item detail
] {
let content_root = resolve_env "SITE_CONTENT_PATH" $content_dir "site/content"
let public_root = resolve_env "SITE_PUBLIC_PATH" $public_dir "site/public"
let config_path = if $config != "" { $config } else { "site/config/image-generation.ncl" }
if not ($content_root | path exists) {
error make { msg: $"Content directory not found: ($content_root)" }
}
if not ($config_path | path exists) {
error make { msg: $"Config file not found: ($config_path)" }
}
let log_path = if $spend_log != "" {
$spend_log
} else {
"scripts/content/.image-spend.jsonl"
}
print $"(ansi cyan)AI Image Generation Pipeline(ansi reset)"
print $" content : ($content_root)"
print $" config : ($config_path)"
if $dry_run { print $"(ansi yellow)[dry-run] No API calls or file writes(ansi reset)" }
let cfg_file = load_config $config_path
# Effective provider/model/quality with precedence:
# CLI flag > RUSTELO_IMAGE_* env var > site config (.ncl) > built-in
# The env layer is a global, file-free default to switch every site at once
# (e.g. export RUSTELO_IMAGE_PROVIDER=gemini); a --flag still wins per run.
let eff_provider = resolve_env "RUSTELO_IMAGE_PROVIDER" $provider ""
let eff_model = resolve_env "RUSTELO_IMAGE_MODEL" $model ""
let eff_quality = resolve_env "RUSTELO_IMAGE_QUALITY" $quality ""
# Validate the effective overrides (guards: fail-fast). The NCL contract only
# validates the file; these mirror it so a bad flag OR env var is rejected
# before any spend instead of failing mid-call.
if $eff_provider != "" and $eff_provider not-in ["openai" "gemini"] {
error make { msg: $"provider must be 'openai' or 'gemini', got '($eff_provider)' (check --provider / RUSTELO_IMAGE_PROVIDER)" }
}
if $eff_quality != "" and $eff_quality not-in ["standard" "hd" "low" "medium" "high"] {
error make { msg: $"quality must be one of standard|hd|low|medium|high, got '($eff_quality)' (check --quality / RUSTELO_IMAGE_QUALITY)" }
}
mut overrides = {}
if $eff_provider != "" { $overrides = ($overrides | merge { provider: $eff_provider }) }
if $eff_model != "" { $overrides = ($overrides | merge { model: $eff_model }) }
if $eff_quality != "" { $overrides = ($overrides | merge { quality: $eff_quality }) }
let cfg = $cfg_file | merge $overrides
# Provider-aware credential resolution (guard: fail-fast before any API call)
let provider = $cfg | get --optional provider | default "openai"
let api_key = if $provider == "gemini" {
resolve_env "GEMINI_API_KEY" "" ""
} else {
resolve_env "OPENAI_API_KEY" "" ""
}
if not $dry_run and ($api_key | is-empty) {
let key_var = if $provider == "gemini" { "GEMINI_API_KEY" } else { "OPENAI_API_KEY" }
error make { msg: $"($key_var) is not set for provider '($provider)'. Export it or add to .env." }
}
let image_types = match $image_type {
"thumbnail" => ["thumbnail"],
"feature" => ["feature"],
_ => ["thumbnail", "feature"],
}
let items = discover_items $content_root $type $lang $category $slug
if ($items | is-empty) {
print "No content items found matching the given filters."
return
}
let candidates = if $force {
$items
} else {
$items | where { |it| not $it.thumbnail_set }
}
if ($candidates | is-empty) {
print "All matching items already have images set. Use --force to regenerate."
return
}
print $" found : ($candidates | length) items needing images"
# --- Pre-flight cost estimate ---
let n_calls = ($candidates | length) * ($image_types | length)
let unit_cost = $image_types | each { |t|
let size = if $t == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
cost_per_image $cfg.model $cfg.quality $size
} | math sum
let est_total = ($candidates | length) * $unit_cost
let price_known = $unit_cost > 0.0
if $price_known {
print $" model : ($cfg.model) / ($cfg.quality)"
print $" calls : ($n_calls) est. cost: $($est_total | math round --precision 3) USD"
} else {
print $" model : ($cfg.model) / ($cfg.quality) [price unknown — not in table]"
}
if not $dry_run and $budget > 0.0 and $price_known and $est_total > $budget {
error make { msg: $"Estimated cost $($est_total | math round --precision 3) exceeds --budget $($budget). Aborting." }
}
if $dry_run {
print ""
print $"(ansi cyan_bold)Dry-run prompts:(ansi reset)"
for item in $candidates {
for img_type in $image_types {
let prompt = build_prompt $item $cfg $item.ct $img_type
print $"(ansi green)── ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)](ansi reset)"
print $prompt
print ""
}
}
return
}
# --- Phase 1: Generate all images ---
print ""
print $"(ansi cyan_bold)Phase 1: Generating images...(ansi reset)"
mut pending = []
mut gen_errors = 0
for item in $candidates {
for img_type in $image_types {
let prompt = build_prompt $item $cfg $item.ct $img_type
let size = if $img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
let ts = date now | format date "%Y%m%d%H%M%S"
let pending_dir = $"($item.images_dir)/pending"
let pending_name = $"($item.slug)_($img_type)_($ts).png"
let pending_path = $"($pending_dir)/($pending_name)"
if $verbose {
print $" generating ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)]"
}
mkdir $pending_dir
let gen_result = generate_image $prompt $cfg $size $pending_path $api_key
if $gen_result.err != null {
print $" (ansi red)ERROR(ansi reset) ($item.slug) [($img_type)]: ($gen_result.err)"
$gen_errors = $gen_errors + 1
append_spend_log $log_path {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
model: $cfg.model,
quality: $cfg.quality,
size: $size,
ct: $item.ct,
lang: $item.lang,
slug: $item.slug,
img_type: $img_type,
cost_usd: 0.0,
cost_source: "none",
status: "error",
error: $gen_result.err,
}
continue
}
# Prefer actual cost from usage tokens (gpt-image-1); fall back to price table (dall-e-3)
let img_cost = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
let cost_src = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
append_spend_log $log_path {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
model: $cfg.model,
quality: $cfg.quality,
size: $size,
ct: $item.ct,
lang: $item.lang,
slug: $item.slug,
img_type: $img_type,
cost_usd: $img_cost,
cost_source: $cost_src,
status: "generated",
error: null,
}
if $verbose { print $" (ansi green)saved(ansi reset) → ($pending_path) cost: $$($img_cost) [($cost_src)]" }
$pending = ($pending | append {
item: $item,
img_type: $img_type,
pending_path: $pending_path,
prompt: $prompt,
model: $cfg.model,
quality: $cfg.quality,
size: $size,
cost_usd: $img_cost,
cost_source: $cost_src,
})
}
}
if ($pending | is-empty) {
print $"(ansi yellow)No images generated — ($gen_errors) errors.(ansi reset)"
exit 1
}
print $" generated: ($pending | length) images errors: ($gen_errors)"
# --- Phase 2: Batch review loop ---
print ""
print $"(ansi cyan_bold)Phase 2: Review and approve...(ansi reset)"
mut approved = 0
mut skipped = 0
mut to_review = $pending
loop {
if ($to_review | is-empty) { break }
let decisions = run_review_batch $to_review
mut regen_batch = []
for d in $decisions {
match $d.decision {
"approve" => {
finalize_image $d.entry $content_root $no_nats $propagate_langs $insert_hero
$approved = $approved + 1
},
"skip" => {
rm --force $d.entry.pending_path
$skipped = $skipped + 1
if $verbose { print $" skip ($d.entry.item.slug) [($d.entry.img_type)]" }
},
"regenerate" => {
rm --force $d.entry.pending_path
let size = if $d.entry.img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
let ts = date now | format date "%Y%m%d%H%M%S"
let new_pending_path = $"($d.entry.item.images_dir)/pending/($d.entry.item.slug)_($d.entry.img_type)_($ts).png"
let gen_result = generate_image $d.entry.prompt $cfg $size $new_pending_path $api_key
if $gen_result.err != null {
print $" (ansi red)REGEN ERROR(ansi reset) ($d.entry.item.slug): ($gen_result.err)"
$skipped = $skipped + 1
append_spend_log $log_path {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
model: $cfg.model,
quality: $cfg.quality,
size: $size,
ct: $d.entry.item.ct,
lang: $d.entry.item.lang,
slug: $d.entry.item.slug,
img_type: $d.entry.img_type,
cost_usd: 0.0,
cost_source: "none",
status: "regen-error",
error: $gen_result.err,
}
continue
}
let regen_cost = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
let regen_src = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
append_spend_log $log_path {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
model: $cfg.model,
quality: $cfg.quality,
size: $size,
ct: $d.entry.item.ct,
lang: $d.entry.item.lang,
slug: $d.entry.item.slug,
img_type: $d.entry.img_type,
cost_usd: $regen_cost,
cost_source: $regen_src,
status: "regenerated",
error: null,
}
$regen_batch = ($regen_batch | append ($d.entry | merge { pending_path: $new_pending_path, cost_usd: $regen_cost, cost_source: $regen_src }))
},
_ => {
rm --force $d.entry.pending_path
$skipped = $skipped + 1
},
}
}
$to_review = $regen_batch
}
# --- Spend summary ---
let session_spend = if ($log_path | path exists) {
open $log_path
| lines
| where { |l| not ($l | is-empty) }
| each { |l| $l | from json }
| where status == "generated" or status == "regenerated"
| get cost_usd
| math sum
} else {
0.0
}
print ""
print $"(ansi green)Done(ansi reset) — approved: ($approved) skipped: ($skipped) gen-errors: ($gen_errors)"
if $price_known {
print $" session spend: $$($session_spend | math round --precision 4) USD"
print $" spend log: ($log_path)"
}
if $gen_errors > 0 { exit 1 }
}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def load_config [config_path: string] {
# nickel-export requires an absolute path — plugins resolve relative to their own cwd
nickel-export ($config_path | path expand) | get image_generation
}
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def discover_items [
content_root: string,
type_filter: string,
lang_filter: string,
cat_filter: string,
slug_filter: string,
] {
let types = discover_dirs $content_root $type_filter
mut items = []
for ct in $types {
let langs = discover_dirs $"($content_root)/($ct)" $lang_filter
for language in $langs {
let lang_dir = $"($content_root)/($ct)/($language)"
let subdirs = discover_dirs $lang_dir ""
for sub in $subdirs {
let sub_dir = $"($lang_dir)/($sub)"
# Page-bundle flat layout (projects): lang_dir/slug/index.md
if ($"($sub_dir)/index.md" | path exists) {
if $cat_filter != "" { continue }
let found = collect_bundle_item $sub_dir $ct $language "" $sub $slug_filter $content_root
$items = ($items | append $found)
} else {
# Category directory: may contain page-bundles or flat .md files
if $cat_filter != "" and $sub != $cat_filter { continue }
# Page-bundle category layout: lang_dir/cat/slug/index.md
let slug_dirs = discover_dirs $sub_dir ""
for slug in $slug_dirs {
let slug_dir = $"($sub_dir)/($slug)"
let found = collect_bundle_item $slug_dir $ct $language $sub $slug $slug_filter $content_root
$items = ($items | append $found)
}
# Flat-file layout: lang_dir/cat/{slug}.md (blog posts)
let flat_items = collect_flat_items $sub_dir $ct $language $sub $slug_filter $content_root
$items = ($items | append $flat_items)
}
}
}
}
$items
}
# Collect one page-bundle item (requires slug/index.md); return [] if absent
def collect_bundle_item [
item_dir: string,
ct: string,
lang: string,
cat: string,
slug: string,
slug_filter: string,
content_root: string,
] {
if not ($"($item_dir)/index.md" | path exists) { return [] }
if $slug_filter != "" and $slug != $slug_filter { return [] }
let md_path = $"($item_dir)/index.md"
let ncl_path = $"($item_dir)/index.ncl"
# Images stored language-neutral: {ct}/_images/{cat}/{slug}/
let images_dir = if $cat == "" {
$"($content_root)/($ct)/_images/($slug)"
} else {
$"($content_root)/($ct)/_images/($cat)/($slug)"
}
let fm = extract_frontmatter $md_path
let title = extract_from_frontmatter $fm "title" "Untitled"
let excerpt = extract_from_frontmatter $fm "excerpt" ""
let subtitle = extract_from_frontmatter $fm "subtitle" ""
let category = extract_from_frontmatter $fm "category" $cat
let tags_raw = extract_from_frontmatter $fm "tags" ""
let tags = parse_tags $tags_raw
let event_type = extract_from_frontmatter $fm "event_type" "talk"
# Check thumbnail set in both .md and .ncl
let md_thumb = extract_from_frontmatter $fm "thumbnail" ""
let ncl_thumb = if ($ncl_path | path exists) {
extract_ncl_field $ncl_path "thumbnail"
} else { "" }
let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))
[{
ct: $ct,
lang: $lang,
cat: $cat,
slug: $slug,
md_path: $md_path,
ncl_path: $ncl_path,
images_dir: $images_dir,
title: $title,
excerpt: $excerpt,
subtitle: $subtitle,
category: $category,
tags: $tags,
event_type: $event_type,
thumbnail_set: $thumbnail_set,
}]
}
# Collect flat .md items from a category dir (blog pattern: cat/{slug}.md)
def collect_flat_items [
cat_dir: string,
ct: string,
lang: string,
cat: string,
slug_filter: string,
content_root: string,
] {
if not ($cat_dir | path exists) { return [] }
let md_files = (ls $cat_dir)
| where type == "file"
| get name
| each { |p| $p | path basename }
| where { |f| ($f | path parse | get extension) == "md" }
| where { |f| not ($f | str starts-with "_") }
mut results = []
for md_file in $md_files {
let slug = $md_file | path parse | get stem
if $slug_filter != "" and $slug != $slug_filter { continue }
let md_path = $"($cat_dir)/($md_file)"
let ncl_path = $"($cat_dir)/($slug).ncl"
# Images stored language-neutral: {ct}/_images/{cat}/{slug}/
let images_dir = $"($content_root)/($ct)/_images/($cat)/($slug)"
let fm = extract_frontmatter $md_path
let title = extract_from_frontmatter $fm "title" "Untitled"
let excerpt = extract_from_frontmatter $fm "excerpt" ""
let subtitle = extract_from_frontmatter $fm "subtitle" ""
let category = extract_from_frontmatter $fm "category" $cat
let tags_raw = extract_from_frontmatter $fm "tags" ""
let tags = parse_tags $tags_raw
let event_type = extract_from_frontmatter $fm "event_type" "talk"
let md_thumb = extract_from_frontmatter $fm "thumbnail" ""
let ncl_thumb = if ($ncl_path | path exists) { extract_ncl_field $ncl_path "thumbnail" } else { "" }
let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))
$results = ($results | append [{
ct: $ct,
lang: $lang,
cat: $cat,
slug: $slug,
md_path: $md_path,
ncl_path: $ncl_path,
images_dir: $images_dir,
title: $title,
excerpt: $excerpt,
subtitle: $subtitle,
category: $category,
tags: $tags,
event_type: $event_type,
thumbnail_set: $thumbnail_set,
}])
}
$results
}
# Parse YAML inline tag arrays: ["rust", "leptos"] or [rust, leptos]
def parse_tags [raw: string] {
if $raw == "" or $raw == "[]" { return [] }
$raw
| str replace --all '[' ''
| str replace --all ']' ''
| str replace --all '"' ''
| str replace --all "'" ''
| split row ','
| each { |t| $t | str trim }
| where { |t| not ($t | is-empty) }
}
# ---------------------------------------------------------------------------
# Prompt construction
# ---------------------------------------------------------------------------
def build_prompt [item: record, cfg: record, ct: string, image_type: string] {
let template_path = $"($cfg.templates_dir)/($ct).j2" | path expand
if not ($template_path | path exists) {
error make { msg: $"Prompt template not found: ($template_path)" }
}
let style = $cfg.styles | get $ct
let context = {
title: $item.title,
category: $item.category,
tags: ($item.tags | str join ", "),
excerpt: $item.excerpt,
subtitle: $item.subtitle,
event_type: $item.event_type,
style: $style,
image_type: $image_type,
}
# tera-render is a Nu plugin: pipe the context record, pass template as positional
$context | tera-render $template_path
}
# ---------------------------------------------------------------------------
# Image provider backends — dispatch + per-provider REST calls
#
# Every backend returns the uniform Result shape { ok, actual_cost, err }:
# ok — destination path on success, else null
# actual_cost — real cost from API usage tokens when available, else null
# (caller falls back to the static price table)
# err — error string on failure, else null
# ---------------------------------------------------------------------------
def generate_image [
prompt: string,
cfg: record,
size: string,
dest: string,
api_key: string,
] {
let provider = $cfg | get --optional provider | default "openai"
match $provider {
"gemini" => (call_gemini $prompt $cfg.model $size $dest $api_key),
"openai" => (call_openai $prompt $cfg.model $size $cfg.quality $dest $api_key),
_ => { ok: null, actual_cost: null, err: $"Unknown provider: ($provider)" },
}
}
# Map OpenAI-style dimensions to Gemini aspect ratios (Gemini has no size param).
def size_to_aspect [size: string] {
match $size {
"1792x1024" => "16:9",
"1024x1792" => "9:16",
_ => "1:1",
}
}
# --- OpenAI: dall-e-3 / gpt-image-1 (POST /v1/images/generations) ---
def call_openai [
prompt: string,
model: string,
size: string,
quality: string,
dest: string,
api_key: string,
] {
let body = {
model: $model,
prompt: $prompt,
n: 1,
size: $size,
quality: $quality,
response_format: "b64_json",
} | to json
# --fail-with-body: non-2xx exits non-zero AND returns the response body
# (unlike --fail which silences the body). Needed to surface rate-limit details.
let result = (^curl --silent --fail-with-body --show-error
-X POST "https://api.openai.com/v1/images/generations"
-H $"Authorization: Bearer ($api_key)"
-H "Content-Type: application/json"
-d $body) | complete
if $result.exit_code != 0 {
let api_err = if ($result.stdout | is-empty) { $result.stderr } else { $result.stdout }
return { ok: null, actual_cost: null, err: $"OpenAI request failed: ($api_err)" }
}
let parsed = $result.stdout | from json
let b64 = $parsed | get data | first | get b64_json
# Nu's `decode base64 | save` is unreliable for multi-MB payloads: it may
# write an internal temp-file path instead of the raw bytes. Use a shell
# redirect through bash so the binary lands directly in $dest.
let tmp_b64 = $"($nu.temp-dir)/dalle_b64.txt"
$b64 | save --force $tmp_b64
let decode_result = (^bash "-c" $"base64 -d < '($tmp_b64)' > '($dest)'") | complete
rm --force $tmp_b64
if $decode_result.exit_code != 0 {
rm --force $dest
return { ok: null, actual_cost: null, err: $"base64 decode failed: ($decode_result.stderr)" }
}
# gpt-image-1 returns a usage record; dall-e-3 does not.
# When present, compute actual cost from real token consumption.
let usage_field = $parsed | get --optional usage
let actual_cost = if ($usage_field | is-empty) {
null
} else {
cost_from_usage $usage_field
}
{ ok: $dest, actual_cost: $actual_cost, err: null }
}
# --- Gemini: gemini-2.5-flash-image / nano-banana (POST :generateContent) ---
#
# Gemini has no size/quality params: dimensions map to an aspectRatio hint, and
# the PNG arrives base64-encoded inside candidates[].content.parts[].inlineData.
def call_gemini [
prompt: string,
model: string,
size: string,
dest: string,
api_key: string,
] {
let body = {
contents: [{ parts: [{ text: $prompt }] }],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig: { aspectRatio: (size_to_aspect $size) },
},
} | to json
let url = $"https://generativelanguage.googleapis.com/v1beta/models/($model):generateContent"
let result = (^curl --silent --fail-with-body --show-error
-X POST $url
-H $"x-goog-api-key: ($api_key)"
-H "Content-Type: application/json"
-d $body) | complete
if $result.exit_code != 0 {
let api_err = if ($result.stdout | is-empty) { $result.stderr } else { $result.stdout }
return { ok: null, actual_cost: null, err: $"Gemini request failed: ($api_err)" }
}
let parsed = $result.stdout | from json
let candidates = $parsed | get --optional candidates | default []
if ($candidates | is-empty) {
return { ok: null, actual_cost: null, err: $"Gemini returned no candidates: ($result.stdout | str substring 0..500)" }
}
let parts = $candidates | first
| get --optional content | default {}
| get --optional parts | default []
let img_parts = $parts | where { |p| ($p | get --optional inlineData) != null }
if ($img_parts | is-empty) {
return { ok: null, actual_cost: null, err: $"Gemini returned no image part: ($result.stdout | str substring 0..500)" }
}
let b64 = $img_parts | first | get inlineData | get data
# Same multi-MB base64 caveat as OpenAI: decode via a shell redirect.
let tmp_b64 = $"($nu.temp-dir)/gemini_b64.txt"
$b64 | save --force $tmp_b64
let decode_result = (^bash "-c" $"base64 -d < '($tmp_b64)' > '($dest)'") | complete
rm --force $tmp_b64
if $decode_result.exit_code != 0 {
rm --force $dest
return { ok: null, actual_cost: null, err: $"base64 decode failed: ($decode_result.stderr)" }
}
let usage = $parsed | get --optional usageMetadata
let actual_cost = if ($usage | is-empty) {
null
} else {
cost_from_gemini_usage $usage
}
{ ok: $dest, actual_cost: $actual_cost, err: null }
}
# ---------------------------------------------------------------------------
# Approval: sequential per-item via typedialog select (Nu plugin)
# ---------------------------------------------------------------------------
def run_review_batch [pending: list] {
$pending | each { |e|
{ entry: $e, decision: (run_review_one $e) }
}
}
def run_review_one [entry: record] {
# Open image in system viewer (returns immediately on both macOS and Linux)
let viewer = if $nu.os-info.name == "macos" { "open" } else { "xdg-open" }
run-external $viewer $entry.pending_path
let label = $"($entry.item.ct)/($entry.item.lang)/($entry.item.cat)/($entry.item.slug) [($entry.img_type)]"
# typedialog select: Nu plugin — no ^ prefix, no | complete, takes list<string>
typedialog select $label ["approve" "skip" "regenerate"]
}
# ---------------------------------------------------------------------------
# Finalize approved image
# ---------------------------------------------------------------------------
def finalize_image [
entry: record,
content_root: string,
no_nats: bool,
propagate_langs: bool,
insert_hero: bool,
] {
let item = $entry.item
let img_type = $entry.img_type
let final_name = $"($item.slug)_($img_type).png"
let final_path = $"($item.images_dir)/($final_name)"
mkdir $item.images_dir
mv --force $entry.pending_path $final_path
# Language-neutral public path: /content/{ct}/_images/{cat}/{slug}/{file}
let public_path = if $item.cat == "" {
$"/content/($item.ct)/_images/($item.slug)/($final_name)"
} else {
$"/content/($item.ct)/_images/($item.cat)/($item.slug)/($final_name)"
}
let field = if $img_type == "thumbnail" { "thumbnail" } else { "image_url" }
update_ncl_field $item.ncl_path $field $public_path
if $insert_hero and $img_type == "thumbnail" {
insert_hero_in_md $item.md_path $item.title $public_path
}
if $propagate_langs {
propagate_to_other_langs $item $content_root $field $public_path
}
# Per-post approved-spend log
let expenses_dir = $"($item.ncl_path | path dirname)/_expenses"
mkdir $expenses_dir
let expense_entry = {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
model: ($entry | get --optional model | default ""),
quality: ($entry | get --optional quality | default ""),
size: ($entry | get --optional size | default ""),
img_type: $img_type,
cost_usd: ($entry | get --optional cost_usd | default 0.0),
cost_source: ($entry | get --optional cost_source | default ""),
public_path: $public_path,
}
append_spend_log $"($expenses_dir)/image-spend.jsonl" $expense_entry
# Per-type aggregate
let type_expenses_dir = $"($content_root)/($item.ct)/_expenses"
mkdir $type_expenses_dir
append_spend_log $"($type_expenses_dir)/image-spend.jsonl" ($expense_entry | insert ct $item.ct | insert lang $item.lang | insert slug $item.slug)
publish_approved $item $img_type $no_nats
}
# Insert hero image as first paragraph after frontmatter (idempotent)
def insert_hero_in_md [md_path: string, title: string, public_path: string] {
if not ($md_path | path exists) { return }
let hero = $"![($title)](($public_path))"
let all_lines = open $md_path | lines
# Primary: replace <!-- hero --> marker wherever it appears in the body
let marker_rows = $all_lines | enumerate | where { |e| ($e.item | str trim) == "<!-- hero -->" }
if not ($marker_rows | is-empty) {
let marker_idx = $marker_rows | first | get index
let new_lines = $all_lines | enumerate | each { |e|
if $e.index == $marker_idx { $hero } else { $e.item }
}
$new_lines | str join "\n" | save --force $md_path
return
}
# Fallback: insert after frontmatter closing ---
mut fm_end = -1
mut idx = 0
mut in_fm = false
for line in $all_lines {
if $idx == 0 and ($line | str trim) == "---" {
$in_fm = true
} else if $in_fm and ($line | str trim) == "---" {
$fm_end = $idx
break
}
$idx = $idx + 1
}
if $fm_end < 0 { return }
# Skip if a hero image already exists in the body (idempotency)
let body_lines = $all_lines | skip ($fm_end + 1)
let already = $body_lines | any { |l| ($l | str trim) | str starts-with "![" }
if $already { return }
let new_lines = ($all_lines | first ($fm_end + 1)) ++ ["", $hero, ""] ++ $body_lines
$new_lines | str join "\n" | save --force $md_path
}
# Write the same image path to all other language variants of the same content item
def propagate_to_other_langs [
item: record,
content_root: string,
field: string,
public_path: string,
] {
let other_langs = discover_dirs $"($content_root)/($item.ct)" ""
| where { |l| $l != $item.lang }
for lang in $other_langs {
# Flat-file layout
let md_flat = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).md"
let ncl_flat = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).ncl"
# Page-bundle layout
let md_bundle = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.md"
let ncl_bundle = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.ncl"
let md = if ($md_flat | path exists) { $md_flat } else if ($md_bundle | path exists) { $md_bundle } else { "" }
let ncl = if ($ncl_flat | path exists) { $ncl_flat } else if ($ncl_bundle | path exists) { $ncl_bundle } else { "" }
if $ncl != "" { update_ncl_field $ncl $field $public_path }
print $" propagated ($field) → ($item.ct)/($lang)/($item.cat)/($item.slug)"
}
}
# ---------------------------------------------------------------------------
# Metadata injection — Markdown YAML frontmatter
# ---------------------------------------------------------------------------
def update_md_field [md_path: string, key: string, value: string] {
if not ($md_path | path exists) { return }
let content = open $md_path
let all_lines = $content | lines
# Locate frontmatter boundaries (first two `---` lines)
mut fm_start = -1
mut fm_end = -1
mut idx = 0
for line in $all_lines {
if $idx == 0 and ($line | str trim) == "---" {
$fm_start = $idx
} else if $fm_start >= 0 and $fm_end < 0 and ($line | str trim) == "---" {
$fm_end = $idx
break
}
$idx = $idx + 1
}
if $fm_start < 0 or $fm_end < 0 {
# No frontmatter — prepend one
let new_content = $"---\n($key): \"($value)\"\n---\n\n($content)"
$new_content | save --force $md_path
return
}
# Scan frontmatter for existing key
mut found_at = -1
mut scan_idx = $fm_start + 1
while $scan_idx < $fm_end {
let line = $all_lines | get $scan_idx
if ($line | str starts-with $"($key):") {
$found_at = $scan_idx
break
}
$scan_idx = $scan_idx + 1
}
let new_line = $"($key): \"($value)\""
let found_at_imm = $found_at
let fm_end_imm = $fm_end
let updated_lines = if $found_at_imm >= 0 {
$all_lines | enumerate | each { |e|
if $e.index == $found_at_imm { $new_line } else { $e.item }
}
} else {
# Insert before the closing `---`
$all_lines | enumerate | each { |e|
if $e.index == $fm_end_imm {
[$new_line, $e.item]
} else {
[$e.item]
}
} | flatten
}
$updated_lines | str join "\n" | save --force $md_path
}
# ---------------------------------------------------------------------------
# Metadata injection — Nickel NCL
# ---------------------------------------------------------------------------
def update_ncl_field [ncl_path: string, key: string, value: string] {
if not ($ncl_path | path exists) { return }
let content = open $ncl_path
let all_lines = $content | lines
let field_prefix = $" ($key) ="
mut found_at = -1
mut idx = 0
for line in $all_lines {
if ($line | str starts-with $field_prefix) {
$found_at = $idx
break
}
$idx = $idx + 1
}
let new_line = $" ($key) = \"($value)\","
let found_at_imm = $found_at
let updated_lines = if $found_at_imm >= 0 {
$all_lines | enumerate | each { |e|
if $e.index == $found_at_imm { $new_line } else { $e.item }
}
} else {
# Find the last `}` line (closing brace of the make_* call)
let brace_rows = ($all_lines | enumerate | where { |e| ($e.item | str trim) == "}" })
let last_brace_idx = if ($brace_rows | is-empty) { -1 } else { $brace_rows | last | get index }
if $last_brace_idx < 0 {
# Unusual format — append before EOF
$all_lines | append $new_line
} else {
$all_lines | enumerate | each { |e|
if $e.index == $last_brace_idx {
[$new_line, $e.item]
} else {
[$e.item]
}
} | flatten
}
}
$updated_lines | str join "\n" | save --force $ncl_path
}
# ---------------------------------------------------------------------------
# NCL field extraction (for thumbnail_set detection)
# ---------------------------------------------------------------------------
def extract_ncl_field [ncl_path: string, key: string] {
if not ($ncl_path | path exists) { return "" }
let field_prefix = $" ($key) ="
let matches = open $ncl_path
| lines
| where { |l| $l | str starts-with $field_prefix }
if ($matches | is-empty) { return "" }
$matches
| first
| str replace --regex $"^ ($key) = " ""
| str replace --all '"' ''
| str replace --all ',' ''
| str trim
}
# ---------------------------------------------------------------------------
# NATS notification
# ---------------------------------------------------------------------------
def publish_approved [item: record, image_type: string, no_nats: bool] {
if $no_nats { return }
let ns = $env | get --optional NATS_NAMESPACE | default "rustelo"
let subject = $"($ns).content.image-approved"
# nats pub is a Nu plugin: pipe the record, pass subject as positional
{
content_type: $item.ct,
language: $item.lang,
id: $item.slug,
image_type: $image_type,
} | nats pub $subject | ignore
}
# ---------------------------------------------------------------------------
# Shared utilities (mirrors copy-content-images.nu)
# ---------------------------------------------------------------------------
def resolve_env [var_name: string, cli_arg: string, fallback: string] {
if $cli_arg != "" { return $cli_arg }
$env | get --optional $var_name | default $fallback
}
def discover_dirs [parent: string, filter: string] {
if not ($parent | path exists) { return [] }
let all = (ls $parent)
| where type == "dir"
| get name
| each { |p| $p | path basename }
| where { |d| not ($d | str starts-with ".") }
| where { |d| not ($d | str starts-with "_") } # exclude _images, _shared, etc.
if $filter != "" { $all | where { |d| $d == $filter } } else { $all }
}