Render video
Describe how the camera or your objects move on a timeline, then call
renderVideo() to get a clip back. You can scrub the move in your own
viewport before you pay for a single frame.
Render a turntable
import { useBakery3 } from '@oeave/bakery3/r3f';
function TurntableButton() {
const { timeline, renderVideo, result } = useBakery3({ token: getToken });
function onClick() {
const tl = timeline({ duration: 5, fps: 30 });
tl.push({ object: 'camera', orbitY: 90 });
renderVideo({ timeline: tl, format: 'mp4' });
}
return (
<>
<button onClick={onClick}>Render the turntable</button>
{result && <video src={result.url} controls loop />}
</>
);
}timeline() uses your attached scene and camera, and push() starts from where
the camera is now. Without at and duration, a move lasts the whole
timeline. A timeline made before attach() picks up the camera you attach
later. To build one without attaching, pass them:
bakery3.timeline({ scene, camera, duration: 5 }).
The viewport clip is the timeline driving your own scene at its own frame rate. The traced clip is every frame of that same timeline rendered.
240 frames at 60fps·preview preset
Choose size, format and a price limit
const job = await bakery3.renderVideo({
timeline: tl,
format: 'mp4', // or 'webm', or 'png-sequence' (below)
fps: 24, // overrides the timeline's own rate, this render only
quality: 'studio',
width: 1280,
height: 720,
maxCost: 4,
});
job.on('progress', ({ percent }) => setProgress(percent));
const video = await job.result(); // { url, format, bytes, cost, … }A clip is priced by its number of frames, and a frame costs about a fifth of a
single image the same size. estimate({ frames }) gives you the quote,
and maxCost is checked before anything uploads. A clip has no quick, grainy
preview. Its first finished frame is the preview instead.
Get every frame as a PNG with transparency
const job = await bakery3.renderVideo({
timeline: tl,
format: 'png-sequence',
});
const frames = await job.result(); // frames.url is one .tar fileUse this when you put the product on top of your own background or under your
own overlays. Each frame is the PNG that render() would give you for that
moment: a transparent background, the shadow in the alpha channel, and the
same colors.
The download is one uncompressed .tar file. It opens with frames.json,
then holds frame_00000.png, frame_00001.png and so on, counting from zero:
{
"fps": 30,
"frameCount": 240,
"width": 1400,
"height": 1504,
"bitDepth": 16,
"files": ["frame_00000.png", "frame_00001.png", "…"]
}Frames are 16 bits per channel, like the PNG from render(), so you can grade
them without banding. If everything after the render is 8-bit, pass
bitDepth: 8: the frames are less than half the size and look the same once
they are in an 8-bit video. Either way it is priced the same as an mp4 of the
same frames.
Move the camera
// A straight line between two points. It can pass through your product.
tl.to('camera', { position: [0, 1.4, 3] }, { at: 0, duration: 3 });
// An orbit: the same distance all the way round, always looking at one point.
tl.orbit('camera', { yaw: 360, at: 0, duration: 6, easing: 'linear' });
// The same move, spelled shorter. orbitX lifts, dolly pushes in or out.
tl.push({ object: 'camera', orbitY: 15, orbitX: 10, dolly: -0.5 });Angles are in degrees and are never wrapped. yaw: 370 is a full turn plus ten
degrees, and yaw: -540 is one and a half turns the other way.
An orbit goes around the middle of your scene. Pass lookAt, as a vector or an
Object3D, to orbit something else.
orbit() takes yaw, pitch, radius, dolly and lookAt, plus at,
duration and easing. push() is the same move under shorter names, and
passes any other key on to to().
An orbit around a bust, and a dolly into a room:
Marble is the case a rasteriser cannot fake. The light that makes it read as stone goes in, scatters, and comes back out somewhere else. The viewport approximates that with a roughness value. Bakery3 traces it.
240 frames at 60fps·preview preset
Daylight through one window. The camera moves in toward the sofa, and the shaft of light on the floor stays where it fell in both versions.
180 frames at 60fps·preview preset
Animate objects and materials
tl.to(mesh, { color: '#ff2d95' }, { at: 1, duration: 2, easing: 'ease-in-out' });
tl.fromTo(mesh, { opacity: 0 }, { opacity: 1 }, { at: 0, duration: 1 });
tl.to('camera', { fov: 28 }, { at: 2, duration: 1.5 });
tl.duration = 8; // moves the end; keyframes keep their times
tl.scaleToFit(8); // rescales every keyframe proportionally insteadIt looks like gsap, with one difference: calls do not queue up after each
other. at defaults to 0 and duration to the rest of the timeline, so give
each move its own at.
to() starts from an earlier move on the same property, or from the object's
current value. fromTo() uses the two values you give it.
Easings are 'linear', 'ease-in', 'ease-out' and 'ease-in-out', the same
curves as in CSS, or your own cubic-bezier as [x1, y1, x2, y2].
You can animate position, rotation, scale, color,
emissive, emissiveIntensity, roughness, metalness and
opacity on a mesh, and intensity and color on any light. On a
directional or spot light, target is the point it shines at, in world
space. Moving the light or its target turns the light, as it does in Three.js.
A HemisphereLight also has groundColor. An AmbientLight or a
HemisphereLight can change its light but cannot move. On your Scene,
environmentIntensity and environmentRotation dim and turn the HDRI.
On the camera: position, lookAt and fov. Any other property throws
an error on the line where you wrote it.
tl.to(fill, { intensity: 0.1 }, { at: 0, duration: 2 }); // an AmbientLight
tl.to(sky, { groundColor: '#2a1d14' }, { at: 0, duration: 2 }); // a HemisphereLight
tl.to(scene, { environmentRotation: [0, Math.PI, 0] }, { at: 0, duration: 6 });Scrub it in your own app
import { Bakery3Timeline } from '@oeave/bakery3/r3f';
<>
<Canvas>…</Canvas>
{process.env.NODE_ENV === 'development' && (
<Bakery3Timeline
timeline={tl}
targets={[{ object: hero, label: 'hero' }]}
onPlaybackChange={(active) => { controls.current.enabled = !active; }}
onRenderVideo={(tl) => renderVideo({ timeline: tl })}
/>
)}
</>Mount it next to the canvas, not inside it. The scrubber moves your real Three.js objects, so your viewport is the preview, and the render follows the same keyframes frame for frame. Change the duration or the fps and the panel shows how many frames the render will be.
+ on a lane records the property's current value at the playhead.
Keyframe camera records where the camera is and what it looks at. Pass
controlsTarget to timeline() to use your orbit controls' target for that.
Turn your orbit controls off while onPlaybackChange reports active, and on
again when it reports false. Both write to the camera.
Limits
- 1800 frames at most. That is one minute at 30 fps.
- An mp4 or webm has no transparency. A transparent background is rendered
over black, with a
VideoHasNoAlphawarning. Set a background color or an environment first, or useformat: 'png-sequence'. - mp4 needs an even width and height.
render()on an animated scene gives you the first frame, with anAnimationIgnoredwarning.
Use a gsap or Theatre.js timeline
import { timelineFromGsap, timelineFromTheatre, type AdapterOptions } from '@oeave/bakery3';
const options = {
fps: 30,
camera,
scene,
tracks: [
{ object: 'camera', property: 'position' },
{ object: 'camera', property: 'lookAt' },
{ object: hero, property: 'color' },
],
} satisfies AdapterOptions;
const fromGsap = timelineFromGsap(myGsapTimeline, options);
const fromTheatre = timelineFromTheatre(sheet.sequence, options);Neither library is a dependency. The adapter plays your timeline frame by frame and records where the objects you named end up, so custom easings, staggers and plugins all come through. Your timeline is left as it was.
tracks is required, and only what you name is recorded. Pass duration if
your timeline repeats forever. You get a normal Timeline back, which works
with the panel, seek() and renderVideo(). To change the timing, change your
own timeline and convert it again.
Save a timeline
import { Timeline } from '@oeave/bakery3';
const json = tl.toJSON();
const restored = Timeline.fromJSON(json, {
camera,
scene,
resolve: (id) => scene.getObjectByName(NAMES[id]),
});Tracks are saved by object uuid, and Three.js gives every object a new uuid on
each reload. Pass resolve to map the saved ids to your own objects. An id
that does not resolve throws an error. Copy JSON in the panel gives you the
same document.
Keep a timeline working when the scene is rebuilt
A hot reload or a model swap replaces the objects your tracks point at.
reconcile() points them at the new ones.
const { healed, stale } = tl.reconcile(scene);
healed; // [{ trackId, property, label }], now pointing at the new object
stale; // [{ trackId, property, label, reason }], and why it could not beA track is matched by userData.bakery3Id first, then by name if exactly one
object has that name. What cannot be matched comes back in stale with the
reason.
