#!/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 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 marker wherever it appears in the body let marker_rows = $all_lines | enumerate | where { |e| ($e.item | str trim) == "" } 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 } }