Skip to content
Moku
@moku-labs/ai pages

Video shots

A keyframe, a clip from it with $ref, and a score: one build file, one run, three files on disk.

On this page

How it fits together

A shot is three items that reference each other. The runner runs references first and hands the handler the finished file.

  • image item
  • $ref
  • video item
  • out/ep01/s01.h3.mp4
ItemTaskProviderOutput
s01.keyimagecodexthe keyframe, out/ep01/s01.key.png
s01.h3videofalthe clip, out/ep01/s01.h3.mp4
s01.scoremusicfalthe music, out/ep01/s01.score.mp3

Items reference each other with $ref (another item's artifact, by id) and local files with $file (relative to the build file). Every item has an artifact key, so a second run re-bills nothing that is done. Change the keyframe prompt and only the keyframe and the clip render again.

The build file

ep01.moku.yaml
version: 1
name: ep01
items:
  - id: s01.key
    task: image
    provider: codex
    input: { prompt: "patisserie counter at night, warm lamps", aspect: "9:16",
             refs: [{ $file: refs/akari.png }] }
  - id: s01.h3
    task: video
    provider: fal
    input: { model: minimax-h3, prompt: "slow push-in", image: { $ref: s01.key }, seconds: 5 }
  - id: s01.score
    task: music
    provider: fal
    input: { model: elevenlabs-music-v2.5, prompt: "tense synth pulse", lengthMs: 30000 }
  • refs on the image item is a local sketch the keyframe follows. $file paths resolve against the build file.
  • image on the video item is the first frame. The runner renders s01.key first, then passes { path, mimeType, hash } to fal.
  • model on the music item is required. The runner hashes the input as written, so there is no provider default.

Run it

  1. Estimate. No key, no network. A model with no price fails here instead of counting as $0.

    estimate
    bunx moku estimate ep01.moku.yaml
  2. Add the keys. s01.key runs on the local Codex CLI, plan-billed. The two fal items need FAL_KEY. Export it or put it in .env.local.

  3. Run under a ceiling. The keyframe renders, then the clip, then the score. Ctrl-C is a clean pause; moku run again resumes, and the video job is polled, not paid twice.

    run
    bunx moku run ep01.moku.yaml --max-cost 10
  4. Find the files. out/ep01/s01.key.png, out/ep01/s01.h3.mp4 and out/ep01/s01.score.mp3.

Pick a video model

app.fal.models("video") lists the aliases with the price at the default resolution, audio off. The full table with resolutions and reference limits is on the fal page.

AliasFirst frameEnd frameRefsDefault price USD/s
minimax-h3yesyesnone0.06
kling-3-proyesyesnone0.112
seedance-2.5yesyesnone0.473
seedance-2.5-refyesno29 images, 10 audio, 10 video0.473
minimax-h3-max-refyesno8 images, 3 audio, 3 video0.08
veo-3.1-fastyesnonone0.10

Two fields change a shot without a new model:

ep01.moku.yaml
  - id: s02.clip
    task: video
    provider: fal
    input:
      model: minimax-h3-max-i2v
      prompt: "she opens the door"
      image: { $ref: s02.start }
      endImage: { $ref: s02.end }
  - id: s03.clip
    task: video
    provider: fal
    input:
      model: minimax-h3-max-ref
      prompt: "Image 1 walks to the window, to the beat of the music"
      image: { $ref: s03.key }
      refs: [{ $ref: s01.score }, { $ref: s02.clip }]
      seconds: 8
  1. The clip ends on this image. Only models with an end frame take it.
  2. An audio/* ref is an audio ref, a video/* ref a video ref, the rest are images. Too many of one kind fail before any upload.

The same shot from code

The one-off facades are direct and not journaled: for scripts and experiments. app.music.generate waits in process; app.video.generate submits and polls.

teaser.ts
import { writeFile } from "node:fs/promises";
import { app } from "./app";

console.log(app.fal.models("music")); // [{ id: "elevenlabs-music-v2.5", price: { usd: 0.8, per: "minute" } }, ...]

const { usd } = app.video.estimate({ model: "minimax-h3", prompt: "push-in", seconds: 5 }, { provider: "fal" });
console.log(usd); // 0.3

const track = await app.music.generate({
  prompt: "tense synth pulse",
  model: "elevenlabs-music-v2.5",
  lengthMs: 60_000
});
await writeFile("teaser.mp3", track.audio);
  1. 60 s at 0.80 per started minute: track.costUsd is 0.8.

A video from code takes a resolved file, the shape the runner hands a handler:

clip.ts
import { writeFile } from "node:fs/promises";
import { app } from "./app";

const key = { path: "out/ep01/s01.key.png", mimeType: "image/png", hash: "a".repeat(64) };
const clip = await app.video.generate(
  { model: "minimax-h3", prompt: "slow push-in", image: key, seconds: 5 },
  { provider: "fal" }
);
await writeFile("s01.mp4", clip.video);
console.log(clip.costUsd, clip.meta);
  1. hash is the sha256 of the bytes. fal caches the upload by it.
  2. meta is { endpoint, requestId, seconds }.
  • A real face. fal's Seedance endpoints refuse a real person. For faces use ark or apimodels: see Faces in video.
  • The job survives a crash. The runner journals fal's request id before it waits. A pause or a kill resumes by polling the same job.
  • music needs model. There is no default, and ElevenLabs Music on fal bills per started minute: 61 s is two minutes.
  • A resolution needs a price. seedance-2.5@1080p is not bundled. Add it to fal.priceOverrides or the estimate fails.