Project structure

What's inside a GenMotion project: project.json, Three.js scenes, assets and VIDEO.md, plus the scene API and the determinism rules every frame follows.

A GenMotion project is an ordinary folder. Everything about the video is in files you can read, diff and commit.

Files#

Text
my-video/
├── project.json        the timeline: size, fps, scenes in order, audio
├── scenes/
│   └── 01-intro.ts     one module per scene
├── components/         pieces shared between scenes
├── assets/             images, audio, video, fonts, 3D models
├── VIDEO.md            the brief and the chosen skill
├── exports/            rendered videos
├── AGENTS.md           rules and commands for agents
└── package.json        dev, check and render scripts

project.json#

project.json
{
  "name": "Moonlight launch",
  "engine": "three",
  "fps": 30,
  "width": 1920,
  "height": 1080,
  "scenes": [
    { "file": "scenes/01-intro.ts", "durationInFrames": 150 },
    { "file": "scenes/02-hero.ts", "durationInFrames": 120, "name": "Hero" }
  ],
  "audio": []
}

Scenes play in array order. npx @genmotion/cli scene add "Hero" --duration 4s creates the file and adds the entry in one step. npx @genmotion/cli info prints the timeline with each scene's start frame.

Audio clips sit on up to four tracks (track 0 to 3) with a file, startFrame, durationInFrames, and optional startFrom (seconds into the file), volume, fadeInFrames, fadeOutFrames and muted. Two clips never overlap on one track. They are mixed into the export and play in the dev studio's timeline. npx @genmotion/cli audio add assets/music.mp3 --fade-out 1s writes a valid entry for you, lane included.

A scene#

A Three.js scene default-exports a builder. The builder sets up the scene once and returns an update function that runs for every frame:

scenes/01-intro.ts
import * as THREE from "three";
import { interpolate, Easing, type ThreeSceneContext, type ThreeSceneUpdate } from "@genmotion/three-engine";

export default function buildScene({ scene, camera }: ThreeSceneContext): ThreeSceneUpdate {
  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1.6, 1.6, 1.6),
    new THREE.MeshStandardMaterial({ color: "#6ee7ff" }),
  );
  const key = new THREE.DirectionalLight(0xffffff, 3);
  key.position.set(3, 4, 5);
  scene.add(cube, key, new THREE.AmbientLight(0xffffff, 0.6));
  camera.position.z = 5;

  // Runs once per frame. Everything is a function of time.
  return ({ time, progress }) => {
    cube.rotation.set(time * 0.6, time, 0);
    cube.scale.setScalar(interpolate(progress, [0, 0.2], [0.4, 1], Easing.easeOut));
  };
}

The update receives frame, time (seconds into the scene) and progress (0 to 1 across the scene).

The determinism rules#

Every frame is a pure function of time. That is what makes the preview, check and the export agree, and what lets frames render in parallel.

  • No clocks: no THREE.Clock, requestAnimationFrame, setAnimationLoop, Date.now or timers.
  • No unseeded randomness: use a seeded generator if you need noise.
  • No hot-linked remote URLs: save files into assets/ and import them.

npx @genmotion/cli check enforces these and names the line that breaks one.