Turns product photos into an honest 360° rotation video for a marketplace listing: photos → 3D mesh → three.js scene rendered frame by frame in headless Chrome → ffmpeg.
Marketplace listing videos are usually either a slideshow or an AI-generated clip where the product quietly changes shape between frames. Here the product actually rotates, because there is a real mesh behind it. Everything after the mesh is local and free, so re-cuts, new camera angles, new palettes and text edits cost nothing. Text lives in HTML, never in a neural net — which is also why Cyrillic renders correctly and a typo is a one-line fix rather than a re-generation.
1. Mesh (the one paid step). Four views of the product — front, profile, back, second profile,
exactly 90° apart — go through a hosted image-to-3D service, model tripo_h3_1_multiview_to_3d
with geometry_quality: detailed, texture_quality: detailed, pbr: true,
texture_alignment: original_image. 18 credits, ~7 minutes. Multi-view matters: from a single
photo the model invents the back of the product. The resulting GLB goes in assets/ and is an
asset — the pipeline never pays again.
2. Deterministic capture. src/capture.js starts its own static HTTP server (Chrome blocks
sibling-file access over file://), launches Puppeteer, waits for the GLB, then walks frames:
window.renderFrame(i) → screenshot → next. renderFrame is a pure function of the frame index,
including the DOM overlay layers — no requestAnimationFrame, no CSS animations, so timing cannot
drift and frames cannot duplicate. 180 frames at 1080×1440 = 6 s at 30 fps, and because the
product completes exactly one revolution over FRAMES, the loop is seamless.
3. Scene. src/scene.html is a three.js scene (GLTFLoader, RoomEnvironment) plus HTML/CSS
overlay layers: heading, feature plates with parallax, foreground sparks driven by a seeded LCG so
they are reproducible. Product geometry, camera, lighting, palette and all copy come from
products/<name>.json; nothing in the markup needs editing for a new product.
4. Assembly. src/assemble.sh runs ffmpeg over the PNG sequence into H.264 (yuv420p, crf 20,
faststart, no audio) and also emits a poster frame and a 250 px thumbnail — the thumbnail is the
readability check, since that is the size the listing actually shows first.
Node.js, Puppeteer, three.js, ffmpeg. No build step, no framework, no bundler.
npm install
node src/capture.js --probe --frame 100 --product sneaker # one frame: check light and layout
node src/capture.js --product sneaker # full loop: 180 frames, ~11 s
bash src/assemble.sh sneaker # → out/sneaker_card.mp4 + poster + thumbAny scene parameter can be overridden per run without touching files:
node src/capture.js --probe --frame 45 --product pan --q "fit=2.6&camY=3.0&ringMul=0"fit is the product's world size, camY/camZ the camera, camLookY the look-at point (lower
value = product sits higher in frame), rotX/rotY/rotZ re-orient a mesh that came in lying down.
The ringMul/glowMul/vigMul multipliers mute individual overlay layers one at a time — when
an unexplained artifact shows up in a frame, switching layers off is cheaper than guessing.
- Render on a real GPU.
--use-angle=metal --enable-gpuare already incapture.js. Without them Chrome falls back to software SwiftShader and the loop takes minutes instead of seconds. - Verify the loop by diffing the last frame against the first: 1–2 out of 255 is normal.
- Prompting a generated product for 3D is not the same as prompting a pretty photo: ask for fully diffuse light, no hard shadows or blown highlights, a seamless flat grey background with no gradient and no cast shadow, long lens, deep depth of field, matte materials, whole product in frame, and no logos, text or numbers. Generate the first view from text and the remaining three with it as reference, or the product drifts between views.
- Credits are billed per generation and the totals can diverge from a cost preflight — check actual spend in the provider's transaction log, not your own arithmetic.
Credentials for the 3D step come from the hosted provider's own configuration; nothing is stored in this repo.