One page, many videos

A composition declares variables, and each scene reads them as inputs, so one page renders as many videos. This page covers declaring the variables, reading them in a scene, and the error a missing or wrong value raises.

One page, many videos

A composition can declare variables, and a scene reads them as inputs. The root lists them in data-composition-variables on its opening tag, each with an id, a type, a label and a default.

<div data-composition-id="main" data-start="0" data-duration="27"
     data-width="1280" data-height="720" data-fps="30"
     data-composition-variables='[
         {"id":"title","type":"string","label":"Title","default":"WILDFLOWER"},
         {"id":"stagger","type":"number","label":"Letter delay","default":0.06}
     ]'>

Replace the title-card scene in scenes.js with this one. RISE and the first line of the file stay.

scene('title-card', {
    inputs: {
        title:   { type: String, default: 'WILDFLOWER' },
        stagger: { type: Number, default: 0.06, validator: (v) => v > 0 }
    },
    computed: {
        letters() {
            return split(this.inputs.title).map((r) => ({
                key: r.key,
                text: r.text,
                style: r.space ? {} : styleAt(this.local - 0.2 - r.i * this.inputs.stagger, RISE)
            }));
        },
        typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
    }
});

An input has a type of String, Number or Boolean, and optionally required, default and validator. Kinetic reads the values from the composition, converts them to the declared type and validates them before init() runs, so this.inputs.stagger is a number. A string such as "0.1" becomes the number 0.1 for a Number input.

An input that is missing and required, of the wrong type, or rejected by its validator (which returns false or throws) is KN-130, a console error on every build with the scene, the input and the value. Kinetic then does not report ready, and a render stops instead of producing a wrong video.

Rendering the batch

The batch script renders the page once for each row of a file, in Chrome, through the page's own render(), with the row's values as the composition's variables. It is batch.cjs, also at scripts/batch.cjs in the package, and it needs Playwright: npm i -D playwright-core with Chrome installed, or npm i -D playwright and npx playwright install chromium. It passes HTML-in-Canvas to the browser itself, so no flags-page setting is needed.

node batch.cjs index.html rows.json
node batch.cjs index.html people.csv --out renders --name name

rows.json is an array of objects, one per video, each field a variable's id with its value. A CSV has a header row of ids, and its values arrive as text, which Kinetic converts to the input's declared type.

[
    { "title": "MEADOW", "stagger": 0.05 },
    { "title": "CLOVER", "stagger": 0.08 }
]
  • The files go into renders/, or the folder --out names, as main-1.mp4, main-2.mp4 and so on after the composition's id, or --name field names each file from that field of its row.
  • --fps, --codec, --bitrate and --shutter go to render().
  • A page given as a file is served from its folder, or from --root, so its data files load; a page given as an address is opened as it is.
  • A row whose input fails (KN-130), or whose readiness fails, stops the batch after the rows before it, with the KN line, and the script exits 1.

The same page renders once per row through HyperFrames with npx hyperframes render --batch rows.json, which splits a render across parallel workers.

<div data-composition-id="main" data-start="0" data-duration="27"
     data-width="1280" data-height="720" data-fps="30"
     data-composition-variables='[
         {"id":"title","type":"string","label":"Title","default":"WILDFLOWER"},
         {"id":"stagger","type":"number","label":"Letter delay","default":0.06}
     ]'>
    <div class="backdrop" data-component="backdrop" data-start="0" data-duration="6"></div>
    <div class="card" data-component="title-card" data-start="0.4" data-duration="5">
        <h1 data-list="letters" data-key="key">
            <template><span class="ch" data-bind="text" data-bind-style="style"></span></template>
        </h1>
        <p class="sub" data-bind="typed"></p>
    </div>
    <div class="chart" data-component="sales-chart" data-start="6" data-duration="8">
        <div class="rows" data-list="bars" data-key="id">
            <template>
                <div class="row" data-bind-style="style">
                    <span class="name" data-bind="name"></span>
                    <div class="bar" data-bind-style="bar"></div>
                    <b class="val" data-bind="shown"></b>
                </div>
            </template>
        </div>
    </div>
    <div class="captions" data-component="captions" data-start="14" data-duration="13">
        <div class="disc" data-bind-style="disc"></div>
        <p class="caption" data-list="lineWords" data-key="key">
            <template><span class="word" data-bind="text" data-bind-style="style"></span></template>
        </p>
    </div>
</div>
const { scene, split, styleAt, typewriter } = wildflower.kinetic;

const RISE = {
    opacity:    { at: [0, 0.35], to: [0, 1] },
    translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
    scale:      { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};

scene('title-card', {
    inputs: {
        title:   { type: String, default: 'WILDFLOWER' },
        stagger: { type: Number, default: 0.06, validator: (v) => v > 0 }
    },
    computed: {
        letters() {
            return split(this.inputs.title).map((r) => ({
                key: r.key,
                text: r.text,
                style: r.space ? {} : styleAt(this.local - 0.2 - r.i * this.inputs.stagger, RISE)
            }));
        },
        typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
    }
});

scene('backdrop', {
    keyframes: {
        bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
    },
    root: { style: { background: 'bg' } }
});

const { interpolate, rank, ticks, format } = wildflower.kinetic;

wildflower.query('sales', {
    from: 'data/sales.json',
    select: (data) => data.rows
});

const money = (v) => format(v, { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });

scene('sales-chart', {
    waitFor: ['sales'],
    computed: {
        rows() { return wildflower.getQuery('sales').rows; },
        scaleMax() {
            const max = Math.max(0, ...this.rows.map((r) => Math.max(...r.values)));
            const axis = ticks(0, max || 1, 5);
            return axis[axis.length - 1];
        },
        year() { return interpolate(this.local, [1, 7], [0, 5]); },
        bars() {
            return rank(this.year, this.rows, (r, k) => r.values[k]).map(({ row, value, position }) => ({
                id: row.id,
                name: row.name,
                shown: money(value),
                style: styleAt(this.local, { translateY: position * 56 }),
                bar: { width: (700 * value / this.scaleMax).toFixed(2) + 'px', background: row.color }
            }));
        }
    }
});

const { captions, beat } = wildflower.kinetic;

wildflower.query('words', { from: 'data/transcript.json' });
wildflower.query('beats', { from: 'data/beats.json', select: (file) => file.beats });

scene('captions', {
    waitFor: ['words', 'beats'],
    computed: {
        line() { return captions(this.local, wildflower.getQuery('words').rows, { maxWords: 4, hold: 0.5 }); },
        pulse() { return beat(this.local, wildflower.getQuery('beats').rows); },
        lineWords() {
            return this.line.words.map((w) => ({
                key: w.key,
                text: w.text,
                style: {
                    opacity: String(w.state === 'future' ? 0.3 : 1),
                    color: w.state === 'active' ? '#c9b8ff' : '#fff'
                }
            }));
        },
        disc() { return styleAt(this.local, { scale: 1 + 0.14 * this.pulse }); }
    }
});
html, body { margin: 0; }
[data-composition-id] {
    position: relative;
    overflow: hidden;
    background: #0b0f1a;
    color: #fff;
    font-family: system-ui, sans-serif;
}
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.card {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
.card h1 { margin: 0; font-size: 136px; }
.sub { margin: 28px 0 0; font-size: 34px; min-height: 1.3em; }
.ch { display: inline-block; white-space: pre; }
.backdrop { position: absolute; inset: 0; }
.chart { position: absolute; inset: 0; }
.rows { position: absolute; left: 80px; right: 80px; top: 120px; }
.row { position: absolute; left: 0; right: 0; top: 0; height: 56px; display: flex; align-items: center; }
.name { width: 180px; padding-right: 20px; text-align: right; font-size: 22px; }
.bar { height: 36px; border-radius: 6px; }
.val { margin-left: 14px; font-size: 20px; }
.captions { position: absolute; inset: 0; }
.disc {
    position: absolute;
    left: 50%;
    top: 40%;
    width: 180px;
    height: 180px;
    margin: -90px 0 0 -90px;
    border-radius: 50%;
    background: #9d7bff;
}
.caption {
    position: absolute;
    left: 80px;
    right: 80px;
    bottom: 96px;
    margin: 0;
    min-height: 96px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0 18px;
}
.word { font-size: 62px; font-weight: 700; line-height: 1.2; }
Composition variables