Compare commits

..
8 Commits
Author SHA1 Message Date
valknar 42e3ea6719 fix: remove obsolete posts 2026-09-13 21:08:41 +02:00
valknar d3c4a06c82 fix(style): single post tags listing 2026-09-13 18:41:08 +02:00
valknarandClaude Sonnet 5 63f29884db chore: consolidate the-reliquary-engine under 2026-09-13
Rename the bundle directory to match its frontmatter date, alongside
the other four new posts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:58:10 +02:00
valknarandClaude Sonnet 5 ad6b6b2834 fix: replace deprecated languageCode/.Site.LanguageCode with locale
Hugo v0.158.0 deprecated the languageCode config key and
.Site.LanguageCode in favor of locale and .Site.Language.Locale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:57:30 +02:00
valknarandClaude Sonnet 5 bbfba94454 feat: add the-reliquary-engine post (gothic sci-fi)
An android saint fused with machine drifts through a Gothic cathedral
rebuilt into an orbital station, generated via create-video.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:55:18 +02:00
valknarandClaude Sonnet 5 f2b2e2b79d chore: consolidate new posts under a single 2026-09-13 date
Rename the-last-lighthouse, the-ashen-choir, and the-serpents-bargain's
bundle directories to match their frontmatter dates, which were moved
onto 2026-09-13 alongside the-clockwork-confessor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:52:09 +02:00
valknarandClaude Sonnet 5 71828c2379 fix: build CSS with Hugo's native TailwindCSS instead of PostCSS
hugo server/build was failing with "binary postcss is not a Node.js
script" — pnpm's default node_modules/.bin shims are shell wrappers, not
plain Node scripts, which Hugo's exec-security check rejects. Switches
head.html from css.PostCSS to css.TailwindCSS, drops the now-unused
PostCSS toolchain, and adds pnpm-workspace.yaml's
preferSymlinkedExecutables so pnpm emits real symlinks Hugo can exec
(same fix already in use on roux).

Also updates the Dockerfile's build stage to hugomods/hugo:debian-node,
which bundles the matching Hugo Extended + Node versions, keeping ffmpeg
for the video-compression step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:50:28 +02:00
valknarandClaude Sonnet 5 e426798c42 feat: add create-video.sh and four new AI-generated posts
Adds scripts/create-video.sh, which reads a post's prompt.{scene,
environment,style,video} frontmatter and generates its banner video via
Replicate's black-forest-labs/flux-3, extracting the first frame as the
preview PNG. Adds four new posts (the-clockwork-confessor,
the-last-lighthouse, the-ashen-choir, the-serpents-bargain) generated
through this pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012E2mmywuZyXmPBgSDtNdTL
2026-09-13 17:50:15 +02:00
17 changed files with 570 additions and 551 deletions
+9 -5
View File
@@ -1,16 +1,20 @@
# Stage 1: Build # Stage 1: Build
FROM node:22-alpine AS builder # hugomods image bundles Hugo Extended + Node at matching, tested versions —
# Hugo's css.TailwindCSS execs node_modules/.bin/tailwindcss during the build,
# and pnpm-workspace.yaml's preferSymlinkedExecutables makes that a plain
# symlink Hugo's exec-security check accepts (see layouts/partials/head.html).
FROM hugomods/hugo:debian-node-0.165.0 AS builder
# Install Hugo + FFmpeg (video compression) # Install FFmpeg (video compression)
RUN apk add --no-cache hugo ffmpeg git RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
# Install pnpm # Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate RUN npm install -g pnpm
WORKDIR /app WORKDIR /app
# Copy package files first (for layer caching) # Copy package files first (for layer caching)
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy source files # Copy source files
@@ -3,7 +3,7 @@ title: "The Empty Throne"
description: "The throne room of the dead — obsidian and black iron, the river Styx visible through the far archway, Cerberus at rest beside the dais — is not empty because Hades is absent. It is empty because he is standing directly behind whoever is looking at it." description: "The throne room of the dead — obsidian and black iron, the river Styx visible through the far archway, Cerberus at rest beside the dais — is not empty because Hades is absent. It is empty because he is standing directly behind whoever is looking at it."
date: 2026-05-28T13:00:00+02:00 date: 2026-05-28T13:00:00+02:00
author: "valknar" author: "valknar"
featured: true featured: false
categories: categories:
- Dark - Dark
- Mythology - Mythology
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

@@ -0,0 +1,24 @@
---
title: "The Ashen Choir"
description: "A masked opera choir stands frozen mid-hymn in a burning gothic theatre, embers drifting through gilded ruin like slow snow as the final note refuses to end."
date: 2026-09-13T12:00:00+02:00
author: "valknar"
featured: false
categories:
- Dark
- Surrealist
tags:
- Opera
- Fire
- Masks
- Choir
- Gothic Theatre
- Embers
- Macabre
slug: the-ashen-choir
prompt:
scene: "A row of eight robed choir singers stands motionless on the stage of a grand, burning opera house, mouths open mid-note beneath featureless white porcelain masks with delicate gold cracks like veins of gilding. Wide symmetrical shot from the center of the empty stalls, looking directly at the stage. Their robes are charcoal-black velvet trimmed in tarnished gold, hems smoldering at the edges without being consumed. Behind them, the stage curtains burn in slow, silent sheets of orange flame that cast no visible smoke, embers rising instead of falling, drifting upward past the proscenium arch like a reverse snowfall. Sheet music on brass stands in front of each singer curls and blackens at the corners but remains legible. At the center, one singer — taller than the rest — has tilted their masked head back, chin lifted, as if holding an impossibly long final note."
environment: "The gilded interior of a 19th-century gothic opera house mid-collapse. Tiered balconies rise on either side, red velvet seats smoldering, gold leaf detailing blistering and curling from the heat. A vast crystal chandelier hangs precariously overhead, half its candles still lit, crystals chiming faintly as the structure shifts. Frescoed ceiling panels depicting weeping muses are scorched at the edges, paint bubbling. Ash and embers drift continuously upward through the auditorium air, catching the footlights' glow. The grand proscenium arch is carved with skeletal cherubs, several of which have cracked and fallen into the orchestra pit below, now empty of musicians."
style: "Surrealist gothic theatre horror, painted with the operatic grandeur of a Phantom-of-the-Opera production set ablaze. Rich, saturated palette of deep crimson, black, and molten gold, embers rendered as glowing particulate catching every light source. Reverse-gravity ash and ember physics rendered with total photorealistic conviction despite the impossibility. Hyper-detailed textures: cracked porcelain masks, singed velvet nap, bubbling gold leaf, crystalline chandelier refractions. 8K cinematic render, dramatic underlighting from the footlights, painterly Baroque composition, a stillness that feels suspended mid-collapse rather than static."
video: "Embers rise continuously and thickly past the singers in a dense reverse-snowfall, filling the air. The burning curtains behind them ripple and unfurl in constant shifting sheets of flame. The chandelier swings visibly overhead, its crystals chiming and catching the firelight. All eight singers' masked heads tilt further back, shoulders rising and falling visibly as the note builds and holds. Sparks scatter and drift upward continuously from the smoldering seats below. Camera holds static throughout, but everything within the frame moves continuously."
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,24 @@
---
title: "The Clockwork Confessor"
description: "A towering brass automaton priest hears confession in a gaslit cathedral, its porcelain saint's mask cracked, gears turning beneath tarnished metal as candlelight gutters through the nave."
date: 2026-09-13T10:00:00+02:00
author: "valknar"
featured: true
categories:
- Dark
- Steampunk
tags:
- Automaton
- Clockwork
- Cathedral
- Confession
- Brass
- Gothic
- Inquisition
slug: the-clockwork-confessor
prompt:
scene: "A towering brass-and-iron automaton priest kneels within an ornate confessional booth of black oak and wrought iron, half-hidden behind a velvet curtain torn at the hem. Full-figure, three-quarter shot from within the confessional's opposite side, looking through the wooden lattice screen. The automaton's body is a masterwork of exposed clockwork: articulated brass ribs housing spinning escapements, a chest cavity where a slow pendulum swings behind smoked glass, tarnished copper tubing running along both arms venting faint puffs of steam with every mechanical breath. Its hands, folded in a gesture of benediction, are long articulated fingers of blackened steel ending in delicate porcelain fingertips. Its face is a cracked porcelain mask shaped into the serene, downturned features of a saint — one long fissure running from brow to jaw, revealing a single glowing amber optic lens beneath. It holds an ancient leather-bound book, pages fluttering slightly though there is no wind. The mask tilts slowly toward the lattice, listening."
environment: "The interior of a vast, decaying Gothic cathedral repurposed as a clockwork sanctuary. Soaring ribbed vaults disappear into darkness overhead, hung with rusted gears the size of cartwheels suspended on chains, turning almost imperceptibly in unison with some unseen mechanism. Rows of empty pews stretch into gloom, dust thick on every surface, candlewax pooled in ancient drifts along the aisle. Stained-glass windows depict saints rendered as machines — halos of gear teeth, wings of articulated brass feathers — filtering fractured amber and blue light across the stone floor. A single overturned prayer kneeler lies near the confessional. Somewhere unseen, a great pendulum clock ticks, its sound echoing through the nave like a heartbeat the building cannot stop."
style: "Dark steampunk gothic realism. The visual language of Guillermo del Toro's cathedral interiors fused with Jean-Pierre Jeunet's warm-brass mechanical detailing. Chiaroscuro lighting: deep cathedral shadow pierced by shafts of dusty amber candlelight and cold blue stained-glass spill. Hyper-realistic material rendering — oxidised copper, oil-black iron, cracked antique porcelain with fine craquelure, aged velvet with worn nap. 8K photorealistic render, subtle film grain, anamorphic lens flare where light catches brass edges, painterly gothic composition reminiscent of Baroque religious portraiture."
video: "The suspended gears overhead rotate visibly and continuously, their brass teeth catching shifting light. Thick steam billows rhythmically from the automaton's neck vents in visible bursts. The automaton visibly turns its head upward toward the gears, then lowers it back to the book, the crack in its mask glinting as it moves. Its brass fingers turn a page of the open book. Candlelight flickers and dances across every surface, casting moving shadows. Camera holds static throughout, but everything within the frame moves continuously."
---
+4 -1
View File
@@ -1,5 +1,5 @@
baseURL = "https://pivoine.art/" baseURL = "https://pivoine.art/"
languageCode = "en-us" locale = "en-US"
title = "Pivoine" title = "Pivoine"
copyright = "© 2026 Pivoine Editorial" copyright = "© 2026 Pivoine Editorial"
@@ -79,3 +79,6 @@ noClasses = true
[build] [build]
writeStats = true writeStats = true
[security.exec]
allow = ['^(dart-)?sass$', '^go$', '^git$', '^node$', '^npx$', '^tailwindcss$']
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="{{ .Site.LanguageCode }}" class="scroll-smooth"> <html lang="{{ .Site.Language.Locale }}" class="scroll-smooth">
<head> <head>
{{- partial "head.html" . -}} {{- partial "head.html" . -}}
</head> </head>
+3 -3
View File
@@ -39,7 +39,7 @@
/> />
<meta property="og:url" content="{{ .Permalink }}" /> <meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" /> <meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
<meta property="og:locale" content="{{ .Site.LanguageCode | default "en_US" | replaceRE "-" "_" }}" /> <meta property="og:locale" content="{{ .Site.Language.Locale | default "en_US" | replaceRE "-" "_" }}" />
{{- $ogImage := .Site.Params.ogImage -}} {{- $ogImage := .Site.Params.ogImage -}}
{{- $ogImageAlt := .Site.Title -}} {{- $ogImageAlt := .Site.Title -}}
{{- with .Params.banner -}} {{- with .Params.banner -}}
@@ -112,8 +112,8 @@
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Share+Tech+Mono&display=swap" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Share+Tech+Mono&display=swap"
/></noscript> /></noscript>
<!-- CSS via Hugo Pipes + PostCSS + Tailwind v4 --> <!-- CSS via Hugo Pipes + Tailwind v4 (native css.TailwindCSS, no PostCSS) -->
{{- $css := resources.Get "css/main.css" | css.PostCSS -}} {{- $css := resources.Get "css/main.css" | css.TailwindCSS -}}
{{- if eq hugo.Environment "production" -}} {{- if eq hugo.Environment "production" -}}
{{- $css = $css | minify | fingerprint "sha256" -}} {{- $css = $css | minify | fingerprint "sha256" -}}
{{- end -}} {{- end -}}
+6 -4
View File
@@ -148,11 +148,13 @@
<!-- ── TAGS ─────────────────────────────────────────────────── --> <!-- ── TAGS ─────────────────────────────────────────────────── -->
{{- with .Params.tags -}} {{- with .Params.tags -}}
<footer class="gutter-x border-t border-zinc pt-10 pb-20"> <footer class="gutter-x border-t border-zinc pt-10 pb-20">
<div class="max-w-5xl mx-auto flex flex-wrap gap-3 items-center"> <div class="max-w-5xl mx-auto flex no-wrap gap-3 items-start">
<span class="label text-fog">Tagged:</span> <span class="label text-fog">Tagged:</span>
{{- range . -}} <span class="flex flex-wrap gap-3">
<a href="/tags/{{ . | urlize }}/" class="badge badge-outline"># {{ . }}</a> {{- range . -}}
{{- end -}} <a href="/tags/{{ . | urlize }}/" class="badge badge-outline"># {{ . }}</a>
{{- end -}}
</span>
</div> </div>
</footer> </footer>
{{- end -}} {{- end -}}
+1 -4
View File
@@ -9,10 +9,7 @@
"format:check": "prettier --check \"layouts/**/*.html\" \"assets/**/*.css\" \"assets/**/*.js\" \"content/**/*.md\" \"*.toml\"" "format:check": "prettier --check \"layouts/**/*.html\" \"assets/**/*.css\" \"assets/**/*.js\" \"content/**/*.md\" \"*.toml\""
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/cli": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.5.0",
"postcss-cli": "^11.0.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-go-template": "^0.0.15", "prettier-plugin-go-template": "^0.0.15",
"prettier-plugin-toml": "^2.0.6", "prettier-plugin-toml": "^2.0.6",
+261 -526
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
preferSymlinkedExecutables: true
allowBuilds:
'@parcel/watcher': true
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
+233
View File
@@ -0,0 +1,233 @@
#!/bin/sh
# Generate a post's banner video (01.mp4) and preview image (01.png) via
# the Replicate black-forest-labs/flux-3 text-to-video model, using the
# prompt.{scene,environment,style,video} fields already present in the
# post's frontmatter.
#
# Usage:
# sh scripts/create-video.sh <path-to-post-dir-or-index.md> [--force] [--dry-run]
#
# Requires: REPLICATE_API_TOKEN exported in the environment.
#
# Video settings (matching every existing post's square/silent convention,
# just at higher resolution): 1:1 aspect ratio, 1080p, 5s, no audio.
set -e
print_usage() {
echo "Usage: sh scripts/create-video.sh <post-dir-or-index.md> [options]"
echo ""
echo "Generates a post's banner video (01.mp4) via the Replicate flux-3"
echo "text-to-video model, using that post's prompt.{scene,environment,style,video}"
echo "frontmatter fields, then extracts the first frame as 01.png."
echo ""
echo "Arguments:"
echo " <post-dir-or-index.md> Path to content/posts/<slug>/ or its index.md"
echo ""
echo "Options:"
echo " -f, --force Overwrite existing 01.mp4 / 01.png"
echo " --dry-run Print the constructed prompt and request JSON, make no API call"
echo " -h, --help Show this help"
echo ""
echo "Requires REPLICATE_API_TOKEN to be exported in the environment."
}
FORCE=0
DRY_RUN=0
POST_ARG=""
for arg in "$@"; do
case "$arg" in
-f|--force) FORCE=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help) print_usage; exit 0 ;;
-*)
echo "Unknown option: $arg" >&2
exit 1
;;
*) POST_ARG="$arg" ;;
esac
done
if [ -z "$POST_ARG" ]; then
print_usage >&2
exit 1
fi
if [ -d "$POST_ARG" ]; then
BUNDLE_DIR="${POST_ARG%/}"
INDEX_MD="$BUNDLE_DIR/index.md"
else
INDEX_MD="$POST_ARG"
BUNDLE_DIR=$(dirname "$POST_ARG")
fi
if [ ! -f "$INDEX_MD" ]; then
echo "Error: $INDEX_MD not found" >&2
exit 1
fi
if [ -z "$REPLICATE_API_TOKEN" ]; then
echo "Error: REPLICATE_API_TOKEN is not set in your environment." >&2
echo "Export it before running this script, e.g.:" >&2
echo " export REPLICATE_API_TOKEN=r8_..." >&2
exit 1
fi
DEST_MP4="$BUNDLE_DIR/01.mp4"
DEST_PNG="$BUNDLE_DIR/01.png"
if [ "$FORCE" -ne 1 ]; then
if [ -e "$DEST_MP4" ]; then
echo "Error: $DEST_MP4 already exists (use --force to overwrite)" >&2
exit 1
fi
if [ -e "$DEST_PNG" ]; then
echo "Error: $DEST_PNG already exists (use --force to overwrite)" >&2
exit 1
fi
fi
PROMPT_JSON=$(yq --front-matter=extract -o=json '.prompt' "$INDEX_MD")
if [ "$PROMPT_JSON" = "null" ] || [ -z "$PROMPT_JSON" ]; then
echo "Error: $INDEX_MD has no prompt.* fields in its frontmatter" >&2
exit 1
fi
SCENE=$(printf '%s' "$PROMPT_JSON" | jq -r '.scene // empty')
ENVIRONMENT=$(printf '%s' "$PROMPT_JSON" | jq -r '.environment // empty')
STYLE=$(printf '%s' "$PROMPT_JSON" | jq -r '.style // empty')
VIDEO=$(printf '%s' "$PROMPT_JSON" | jq -r '.video // empty')
if [ -z "$SCENE" ]; then
echo "Error: prompt.scene is missing/empty in $INDEX_MD" >&2
exit 1
fi
if [ -z "$ENVIRONMENT" ]; then
echo "Error: prompt.environment is missing/empty in $INDEX_MD" >&2
exit 1
fi
if [ -z "$STYLE" ]; then
echo "Error: prompt.style is missing/empty in $INDEX_MD" >&2
exit 1
fi
if [ -z "$VIDEO" ]; then
echo "Error: prompt.video is missing/empty in $INDEX_MD" >&2
exit 1
fi
SLUG=$(yq --front-matter=extract '.slug' "$INDEX_MD")
echo "${SLUG:-$(basename "$BUNDLE_DIR")}: building prompt from frontmatter"
PROMPT="${SCENE} ${ENVIRONMENT} ${STYLE} ${VIDEO}"
INPUT_JSON=$(jq -n \
--arg prompt "$PROMPT" \
--arg duration "5" \
--arg resolution "1080p" \
--arg aspect_ratio "1:1" \
'{
input: {
prompt: $prompt,
images: [],
duration: $duration,
resolution: $resolution,
aspect_ratio: $aspect_ratio,
generate_audio: false
}
}')
if [ "$DRY_RUN" -eq 1 ]; then
echo "--- Constructed prompt ---"
echo "$PROMPT"
echo "--- Request body ---"
echo "$INPUT_JSON" | jq .
echo "(dry run — no API call made)"
exit 0
fi
echo "▶ Submitting generation request to Replicate (flux-3)..."
CREATE_RESPONSE=$(curl -sS \
-X POST \
-H "Authorization: Bearer $REPLICATE_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: wait=60" \
-d "$INPUT_JSON" \
https://api.replicate.com/v1/models/black-forest-labs/flux-3/predictions \
-w '\n%{http_code}')
HTTP_CODE=$(printf '%s' "$CREATE_RESPONSE" | tail -n1)
BODY=$(printf '%s' "$CREATE_RESPONSE" | sed '$d')
case "$HTTP_CODE" in
2??) : ;;
*)
echo "Error: Replicate API returned HTTP $HTTP_CODE" >&2
echo "$BODY" | jq -r '.detail // .error // .' >&2
exit 1
;;
esac
STATUS=$(printf '%s' "$BODY" | jq -r '.status')
GET_URL=$(printf '%s' "$BODY" | jq -r '.urls.get')
PREDICTION_ID=$(printf '%s' "$BODY" | jq -r '.id')
POLL_BODY="$BODY"
if [ -z "$GET_URL" ] || [ "$GET_URL" = "null" ]; then
echo "Error: unexpected response from Replicate (no urls.get):" >&2
echo "$BODY" >&2
exit 1
fi
POLL_INTERVAL=5
MAX_WAIT=600
ELAPSED=0
while :; do
case "$STATUS" in
succeeded) break ;;
failed|canceled)
ERR=$(printf '%s' "$POLL_BODY" | jq -r '.error // "unknown error"')
echo "Error: prediction $STATUS: $ERR" >&2
exit 1
;;
esac
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "Error: timed out after ${MAX_WAIT}s waiting on prediction $PREDICTION_ID" >&2
echo "Check status manually: curl -H \"Authorization: Bearer \$REPLICATE_API_TOKEN\" $GET_URL" >&2
exit 1
fi
sleep "$POLL_INTERVAL"
ELAPSED=$((ELAPSED + POLL_INTERVAL))
POLL_BODY=$(curl -sS -H "Authorization: Bearer $REPLICATE_API_TOKEN" "$GET_URL")
STATUS=$(printf '%s' "$POLL_BODY" | jq -r '.status')
echo "${ELAPSED}s elapsed, status: $STATUS"
done
OUTPUT_URL=$(printf '%s' "$POLL_BODY" | jq -r '.output')
if [ -z "$OUTPUT_URL" ] || [ "$OUTPUT_URL" = "null" ]; then
echo "Error: prediction succeeded but no output URL was returned" >&2
echo "$POLL_BODY" >&2
exit 1
fi
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/create-video.XXXXXX")
trap 'rm -rf "$TMP_DIR"' EXIT
TMP_MP4="$TMP_DIR/01.mp4"
TMP_PNG="$TMP_DIR/01.png"
echo "▶ Downloading generated video..."
curl -fSL -o "$TMP_MP4" "$OUTPUT_URL"
echo "▶ Extracting first frame as preview PNG..."
ffmpeg -y -loglevel error -i "$TMP_MP4" -vframes 1 "$TMP_PNG" < /dev/null
mv "$TMP_MP4" "$DEST_MP4"
mv "$TMP_PNG" "$DEST_PNG"
echo "✓ Wrote $DEST_MP4 and $DEST_PNG"