234 lines
6.3 KiB
Bash
234 lines
6.3 KiB
Bash
#!/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"
|