#!/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 [--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 [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 " Path to content/posts// 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"