Compare commits
8
Commits
a379b0d00f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e3ea6719 | ||
|
|
d3c4a06c82 | ||
|
|
63f29884db | ||
|
|
ad6b6b2834 | ||
|
|
bbfba94454 | ||
|
|
f2b2e2b79d | ||
|
|
71828c2379 | ||
|
|
e426798c42 |
+9
-5
@@ -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.
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."
|
||||||
|
---
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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 -}}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+261
-526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
preferSymlinkedExecutables: true
|
||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Executable
+233
@@ -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"
|
||||||
Reference in New Issue
Block a user