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
This commit is contained in:
2026-09-13 17:50:15 +02:00
co-authored by Claude Sonnet 5
parent a379b0d00f
commit e426798c42
14 changed files with 330 additions and 1 deletions
+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"