Loading...
Loading...
Make a clean 45–90 second product demo video for any software product, rendered from code to MP4 with music. Airy light canvas, short centred lines the camera pushes into, the product's own UI rebuilt and shown up close as it is used (typing, results, tables, clicks), integrations, and a logo close. Use when someone wants a product demo, launch video, feature walkthrough or "how it works" video for an app, SaaS or tool.
npx skill4agent add barty-bart/motion-graphics explainer-default| Length | Features | Optional sections |
|---|---|---|
| 45 s | 2–3, about 9 s each | proof or integrations, not both |
| 60 s | 3–4, about 10 s each | one of proof / integrations |
| 90 s | 4–5, about 12 s each | both |
IntroducingStill building prospect lists by hand?Call the decision maker. Directly.measureTextFound 247 matchesBacked by a database of 300M+ profilesBuilt for your existing workflow.Do all your [prospecting / research / outreach] in Product.Or just ask Claudeeasez#F7F7F8#111114#8A8A94#E7E7EC#2E6BFFwindow.VIDEO = {w, h, duration}window.seek(t)tsetTimeoutseekoffsetLeftmeasureTextMath.randomdurationseekseek(t)t % duration?renderw × h@font-facewght@100..900white-space: nowrapfilter: blur()// damped spring step response 0 -> 1. w = speed (8 slow, 14 normal, 22 snappy), z = damping (1 = no overshoot)
function spring(t, w = 14, z = 0.82) {
if (t <= 0) return 0;
if (z >= 1) return 1 - Math.exp(-w * t) * (1 + w * t);
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + (z * w / wd) * Math.sin(wd * t));
}
// a value that changes target many times, springing to each: track(start, [[time, value, w?, z?], ...]) -> f(t)
const track = (v0, keys) => t => { let v = v0, p = v0; for (const [tk, val, w, z] of keys) { v += (val - p) * spring(t - tk, w, z); p = val; } return v; };
// precise eased keyframes (best for camera moves): kf([[t0, v0], [t1, v1, easeFn?], ...]) -> f(t)
const kf = keys => t => { if (t <= keys[0][0]) return keys[0][1]; for (let i = 1; i < keys.length; i++) { const [a, va] = keys[i - 1], [b, vb, e = ease] = keys[i]; if (t <= b) return lerp(va, vb, e((t - a) / (b - a))); } return keys[keys.length - 1][1]; };
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ease = x => (x = clamp(x), x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); // easeInOutCubic
const lerp = (a, b, k) => a + (b - a) * k;
const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b]
const rng = s => () => (s = (s + 0x6D2B79F5) | 0, s = Math.imul(s ^ (s >>> 15), 1 | s), s ^= s + Math.imul(s ^ (s >>> 7), 61 | s), ((s ^ (s >>> 14)) >>> 0) / 4294967296);full.slice(0, n(t))tlerp(a, b, ease(seg(t, …)))transform: translate() scale() rotate()kftrackwindow.buildAudio(ctx)AudioContextOfflineAudioContextfetchdecodeAudioDataseeklet buf, ac, src, startAt = 0, offset = 0, playing = false;
async function play() {
ac ??= new AudioContext();
if (!buf) { const off = new OfflineAudioContext(2, Math.ceil(48000 * VIDEO.duration), 48000); await buildAudio(off); buf = await off.startRendering(); }
src = ac.createBufferSource(); src.buffer = buf; src.connect(ac.destination);
src.start(0, offset % VIDEO.duration); startAt = ac.currentTime; playing = true;
}
function pause() { if (!playing) return; offset += ac.currentTime - startAt; src.stop(); playing = false; }
function now() { return playing ? (offset + ac.currentTime - startAt) % VIDEO.duration : offset; }
(function loop() { seek(now()); requestAnimationFrame(loop); })(); // the scrubber sets offset (pause, set, play)npm i playwrightnpx playwright install chromiumrender.mjsnode render.mjs demo.html check.png --stills 0.5,1.2,…node render.mjs demo.html draft.mp4 30 1 4node render.mjs demo.html demo.mp4 60 4 8demo.mp4demo_silent.mp4// render.mjs: turn a seek(t) page into an MP4 with motion blur and its own audio.
// node render.mjs page.html out.mp4 [fps=60] [subframes=4] [workers=4]
// node render.mjs page.html sheet.png --stills 0.5,1.2,3 (full-size contact sheet for checking)
// Optional: window.buildAudio(ctx) schedules the soundtrack on any (Offline)AudioContext.
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const [, , html, out, a3 = '60', a4 = '4', a5 = '4'] = process.argv;
if (!html || !out) { console.log('usage: node render.mjs page.html out.mp4 [fps] [subframes] [workers]'); process.exit(1); }
const run = (args) => new Promise((res, rej) => { const p = spawn('ffmpeg', args, { stdio: 'inherit' }); p.on('close', c => c ? rej(new Error('ffmpeg ' + c)) : res()); });
const url = pathToFileURL(path.resolve(html)).href + '?render';
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const browser = await chromium.launch({ args: ['--allow-file-access-from-files'], ...(proxy ? { proxy: { server: proxy } } : {}) });
// Read the size first, then open every page at exactly that size (pages that measure themselves on load stay correct).
const probe = await browser.newPage();
await probe.goto(url);
await probe.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
const V = await probe.evaluate(() => window.VIDEO);
await probe.close();
async function openPage() {
const page = await browser.newPage({ viewport: { width: V.w, height: V.h }, deviceScaleFactor: 1 });
await page.goto(url);
await page.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
// load every declared font, even ones only used by scenes hidden at t=0
const bad = await page.evaluate(async () => {
await Promise.all([...document.fonts].map(f => f.load().catch(() => {})));
await document.fonts.ready;
return [...document.fonts].filter(f => f.status !== 'loaded').map(f => f.family);
});
if (bad.length) console.warn('WARNING: fonts did not load (fallback in use):', [...new Set(bad)].join(', '), '- embed them as base64.');
return page;
}
const shot = async (page, t) => { await page.evaluate(t => window.seek(t), t); return page.screenshot({ type: 'jpeg', quality: 95 }); };
if (a3 === '--stills') {
const page = await openPage();
const times = a4.split(',').map(Number), dir = out + '.frames';
mkdirSync(dir, { recursive: true });
for (let i = 0; i < times.length; i++) writeFileSync(`${dir}/${String(i).padStart(3, '0')}.jpg`, await shot(page, times[i]));
await browser.close();
const cols = Math.min(3, times.length), rows = Math.ceil(times.length / cols);
await run(['-v', 'error', '-y', '-framerate', '1', '-i', `${dir}/%03d.jpg`, '-vf', `scale=${Math.min(960, V.w)}:-1,tile=${cols}x${rows}:padding=6:color=gray`, '-frames:v', '1', out]);
rmSync(dir, { recursive: true });
console.log('stills ->', out); process.exit(0);
}
const fps = +a3, sub = Math.max(1, +a4), workers = Math.max(1, +a5), N = Math.round(V.duration * fps);
const base = out.replace(/\.mp4$/, ''), silent = base + '_silent.mp4';
const vf = sub > 1 ? ['-vf', `tmix=frames=${sub},select='eq(mod(n\\,${sub})\\,${sub - 1})',setpts=N/(${fps}*TB)`] : [];
const t0 = Date.now(); let doneFrames = 0;
const per = Math.ceil(N / workers), segs = [];
await Promise.all(Array.from({ length: workers }, async (_, w) => {
const a = w * per, b = Math.min(N, a + per); if (a >= b) return;
const seg = `${base}_seg${w}.mp4`; segs[w] = seg;
const page = await openPage();
const ff = spawn('ffmpeg', ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps * sub), '-i', '-', ...vf,
'-r', String(fps), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-preset', 'medium', seg], { stdio: ['pipe', 'inherit', 'inherit'] });
const closed = new Promise(r => ff.on('close', r));
for (let f = a; f < b; f++) {
for (let s = 0; s < sub; s++) { // 180-degree shutter: sub-frames spread over half a frame
const buf = await shot(page, (f + (sub > 1 ? (s / sub) * 0.5 : 0)) / fps);
if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
}
if (++doneFrames % 30 === 0) process.stdout.write(`\r${doneFrames}/${N} frames ${((Date.now() - t0) / 1000).toFixed(0)}s`);
}
ff.stdin.end(); await closed; await page.close();
}));
const list = base + '_segs.txt';
writeFileSync(list, segs.filter(Boolean).map(s => `file '${path.resolve(s)}'`).join('\n'));
await run(['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
segs.filter(Boolean).forEach(s => rmSync(s)); rmSync(list);
console.log(`\nvideo -> ${silent} (${((Date.now() - t0) / 1000).toFixed(0)}s)`);
const page = await openPage();
const hasAudio = await page.evaluate(() => typeof window.buildAudio === 'function');
if (hasAudio) {
const b64 = await page.evaluate(async (dur) => {
const sr = 48000, ctx = new OfflineAudioContext(2, Math.ceil(sr * dur), sr);
await window.buildAudio(ctx);
const b = await ctx.startRendering(), L = b.getChannelData(0), R = b.getChannelData(b.numberOfChannels > 1 ? 1 : 0);
const i16 = new Int16Array(L.length * 2);
for (let i = 0; i < L.length; i++) { i16[2 * i] = Math.max(-1, Math.min(1, L[i])) * 32767; i16[2 * i + 1] = Math.max(-1, Math.min(1, R[i])) * 32767; }
const u8 = new Uint8Array(i16.buffer); let s = '';
for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));
return btoa(s);
}, V.duration);
const pcm = base + '.pcm'; writeFileSync(pcm, Buffer.from(b64, 'base64'));
await run(['-v', 'error', '-y', '-i', silent, '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', pcm,
'-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', out]);
rmSync(pcm); console.log('with audio ->', out);
}
await browser.close();