@homie-rocks/studio 0.51.4 → 0.51.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/SKILL.md +1 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +45 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capture-game.mjs — real gameplay from a studio's own game, for a trailer: the
|
|
4
|
+
* game's big-screen view (/<id>/tv: a spectator in a live public room) recorded
|
|
5
|
+
* off a headless GPU Chrome, picture and the game's own
|
|
6
|
+
* sound on one clock.
|
|
7
|
+
* A server rules room needs a player to keep ticking; use --view play when
|
|
8
|
+
* filming an otherwise empty room. A spectator alone does not wake it.
|
|
9
|
+
*
|
|
10
|
+
* node capture-game.mjs --url <site> --game <id> --seconds 60 --out <dir>
|
|
11
|
+
* [--view tv|play] [--fps 30] [--width 1920] [--height 1080] [--scale 1] [--settle 4] [--min-free-gb 10]
|
|
12
|
+
*
|
|
13
|
+
* Size and speed: --width/--height is the film's size, --fps its frame rate. --scale renders the page smaller
|
|
14
|
+
* (0.25 to 1: 0.67 is a 1280x720 page for a 1920x1080 film) and scales the frames up to the film's size when it
|
|
15
|
+
* encodes. A heavy game paints far more frames at a smaller size (measured: 8 frames a second at 1080p, 39 at
|
|
16
|
+
* 720p), and a frame the page did not paint in time is a held frame. The result says the page's own frame rate;
|
|
17
|
+
* when it is well under --fps it names a --scale that would paint enough.
|
|
18
|
+
*
|
|
19
|
+
* Picture: the compositor's own frame stream (CDP Page.startScreencast), every
|
|
20
|
+
* frame kept with the time Chrome presented it, then laid onto a constant
|
|
21
|
+
* frame rate (a frame the page did not paint in time is held, and counted).
|
|
22
|
+
* Sound: tap.js copies what the game sends to its speaker (WebAudio), stamped
|
|
23
|
+
* with the audio clock, which is fitted to the page clock; the browser is
|
|
24
|
+
* muted, so nothing plays out loud on this computer.
|
|
25
|
+
* Out: <dir>/capture.mp4 (H.264 + AAC when the game made sound), <dir>/capture.json
|
|
26
|
+
* (what was captured, frame rates, held frames, the room, the sound). The raw
|
|
27
|
+
* frames are deleted once encoded.
|
|
28
|
+
*
|
|
29
|
+
* It never presses anything in the game: what it records is the game as it plays.
|
|
30
|
+
*/
|
|
31
|
+
import { mkdirSync, readFileSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { cpus, loadavg } from 'node:os';
|
|
33
|
+
import { dirname, join, resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { launch } from './lib/browser.mjs';
|
|
36
|
+
import { TapReader, constantRate, encodeMp4, fitAudio, startScreencast, writeConcat } from './lib/frames.mjs';
|
|
37
|
+
import { findStudio } from '../../music/scripts/lib/studio.mjs';
|
|
38
|
+
|
|
39
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const opt = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
42
|
+
const URL_ = String(opt('url', '')).replace(/\/+$/, '');
|
|
43
|
+
const GAME = opt('game');
|
|
44
|
+
const SECONDS = Number(opt('seconds', 60));
|
|
45
|
+
const OUT = resolve(String(opt('out', '')));
|
|
46
|
+
const VIEW = opt('view', 'tv');
|
|
47
|
+
const FPS = Number(opt('fps', 30));
|
|
48
|
+
const W = Number(opt('width', 1920)); const H = Number(opt('height', 1080));
|
|
49
|
+
const SCALE = Number(opt('scale', 1));
|
|
50
|
+
// The page's own size: even numbers (the encoder's rule), scaled up to W x H when the film is made.
|
|
51
|
+
const RW = Math.max(2, Math.round((W * SCALE) / 2) * 2); const RH = Math.max(2, Math.round((H * SCALE) / 2) * 2);
|
|
52
|
+
const SETTLE = Number(opt('settle', 4));
|
|
53
|
+
const MIN_FREE_GB = Number(opt('min-free-gb', 10));
|
|
54
|
+
const fail = (m) => { process.stderr.write(`capture-game: ${m}\n`); process.exit(2); };
|
|
55
|
+
if (!/^https?:\/\//.test(URL_)) fail('--url <the studio site: http://127.0.0.1:8787 from npm run dev, or the live site>');
|
|
56
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(GAME ?? ''))) fail('--game <id>');
|
|
57
|
+
if (!(SECONDS >= 3 && SECONDS <= 600)) fail('--seconds 3..600');
|
|
58
|
+
if (!(SCALE >= 0.25 && SCALE <= 1)) fail('--scale 0.25..1 (the page renders at that part of --width x --height, and the film is scaled up to it)');
|
|
59
|
+
if (!(FPS >= 1 && FPS <= 60)) fail('--fps 1..60');
|
|
60
|
+
if (!opt('out')) fail('--out <folder> (the video job\'s work/capture folder)');
|
|
61
|
+
const freeGB = () => { try { const f = statfsSync(OUT); return (f.bavail * f.bsize) / 1e9; } catch { return 99; } };
|
|
62
|
+
mkdirSync(OUT, { recursive: true });
|
|
63
|
+
if (freeGB() < MIN_FREE_GB + 2) fail(`only ${freeGB().toFixed(1)} GB free; a capture needs room (stopping below ${MIN_FREE_GB} GB)`);
|
|
64
|
+
|
|
65
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
66
|
+
const log = (k, v = {}) => process.stderr.write(`${JSON.stringify({ t: +(process.uptime()).toFixed(1), k, ...v })}\n`);
|
|
67
|
+
const TAP = readFileSync(join(HERE, 'tap.js'), 'utf8');
|
|
68
|
+
const root = findStudio(OUT) ?? findStudio();
|
|
69
|
+
|
|
70
|
+
const frameDir = join(OUT, 'frames');
|
|
71
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
72
|
+
mkdirSync(frameDir, { recursive: true });
|
|
73
|
+
let frames = []; // { file, t } t = presentation time (epoch seconds)
|
|
74
|
+
const pcmFile = join(OUT, 'game-audio.s16le');
|
|
75
|
+
const tap = new TapReader(pcmFile);
|
|
76
|
+
const report = { ran: new Date().toISOString(), url: `${URL_}/${GAME}/${VIEW}`, seconds: SECONDS, fps: FPS, size: [W, H], ...(SCALE !== 1 ? { scale: SCALE, rendered: [RW, RH] } : {}), notes: [] };
|
|
77
|
+
|
|
78
|
+
const { browser, close, pid } = await launch(root, { width: RW, height: RH, extra: ['--autoplay-policy=no-user-gesture-required', '--mute-audio'] });
|
|
79
|
+
log('browser', { pid });
|
|
80
|
+
const stop = async (why) => { log('stop', { why }); tap.close(); await close(); process.exit(3); };
|
|
81
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
82
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const page = await browser.newPage();
|
|
86
|
+
await page.setViewport({ width: RW, height: RH, deviceScaleFactor: 1 });
|
|
87
|
+
await page.evaluateOnNewDocument(TAP);
|
|
88
|
+
const errors = [];
|
|
89
|
+
page.on('pageerror', (e) => { if (errors.length < 30) errors.push(String(e.message).slice(0, 200)); });
|
|
90
|
+
const target = VIEW === 'play' ? `${URL_}/${GAME}/play?hand=desk&name=Camera` : `${URL_}/${GAME}/tv`;
|
|
91
|
+
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
92
|
+
// The shell's own furniture (status chip, join card, results card) is not the game.
|
|
93
|
+
await page.addStyleTag({ content: '[data-chip],[data-join],[data-screen],[data-results],[data-room-ui],[data-toast]{display:none!important} *{cursor:none!important}' });
|
|
94
|
+
let game = null;
|
|
95
|
+
for (let i = 0; i < 200 && !game; i++) {
|
|
96
|
+
for (const f of page.frames()) if (/\/__game\//.test(f.url()) && !f.isDetached()) game = f;
|
|
97
|
+
if (!game) await sleep(150);
|
|
98
|
+
}
|
|
99
|
+
if (!game) throw new Error('the game frame never loaded');
|
|
100
|
+
// A static/older imported game need not publish a netplay probe. Its module
|
|
101
|
+
// still finishes loading before complete; rules games publish their probe there.
|
|
102
|
+
await game.waitForFunction(() => document.readyState === 'complete', { timeout: 30000 });
|
|
103
|
+
report.room = await page.evaluate(() => window.__shell?.room ?? null).catch(() => null);
|
|
104
|
+
const rulesBefore = await game.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), tick: p.tick?.() } : null; }).catch(() => null);
|
|
105
|
+
await sleep(SETTLE * 1000);
|
|
106
|
+
if (VIEW === 'tv' && rulesBefore?.hosted === 'server') {
|
|
107
|
+
const tick = await game.evaluate(() => window.__homieNet?.probe?.tick?.()).catch(() => null);
|
|
108
|
+
if (!(tick > rulesBefore.tick)) throw new Error('The server room is paused: a TV is not a participant. Keep a real player open in this same room, or capture with --view play, then record again.');
|
|
109
|
+
}
|
|
110
|
+
await page.keyboard.press('Shift').catch(() => {}); // a first gesture, for games that start their sound on one
|
|
111
|
+
const tapStart = await game.evaluate(() => window.__homieTap?.start?.() ?? null).catch(() => null);
|
|
112
|
+
report.tap = tapStart;
|
|
113
|
+
log('ready', { room: report.room, tap: tapStart });
|
|
114
|
+
|
|
115
|
+
const cast = await startScreencast(page, { dir: frameDir, width: RW, height: RH });
|
|
116
|
+
frames = cast.frames;
|
|
117
|
+
const until = Date.now() + SECONDS * 1000 + 600;
|
|
118
|
+
let lastLog = 0;
|
|
119
|
+
while (Date.now() < until) {
|
|
120
|
+
try { await tap.poll(game); } catch (error) { report.notes.push(`poll: ${String(error.message).slice(0, 120)}`); }
|
|
121
|
+
if (Date.now() - lastLog > 10_000) { lastLog = Date.now(); log('recording', { frames: frames.length, audioS: tap.rate ? +(tap.frames / tap.rate).toFixed(1) : 0, freeGB: +freeGB().toFixed(1) }); }
|
|
122
|
+
if (freeGB() < MIN_FREE_GB) { report.notes.push('stopped early: disk low'); break; }
|
|
123
|
+
await sleep(250);
|
|
124
|
+
}
|
|
125
|
+
await cast.stop();
|
|
126
|
+
await sleep(300);
|
|
127
|
+
report.errors = errors;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
report.failed = String(error.stack ?? error);
|
|
130
|
+
} finally {
|
|
131
|
+
tap.close();
|
|
132
|
+
await close();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (report.failed || frames.length < 10) {
|
|
136
|
+
writeFileSync(join(OUT, 'capture.json'), JSON.stringify({ ...report, frames: frames.length }, null, 1));
|
|
137
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
138
|
+
fail(report.failed ? `capture failed: ${report.failed.split('\n')[0]}` : `the page painted only ${frames.length} frames`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ---- picture onto a constant frame rate: output frame k shows the newest frame presented by t0 + k/fps (lib/frames.mjs) */
|
|
142
|
+
const cfr = constantRate(frames, FPS);
|
|
143
|
+
const { t0, total, list, held, distinct } = cfr;
|
|
144
|
+
const concat = writeConcat(join(OUT, 'frames.txt'), list, FPS);
|
|
145
|
+
|
|
146
|
+
/* ---- sound onto the same clock: fit epoch(ms) = a + b * contextTime from the output timestamps */
|
|
147
|
+
const audio = fitAudio(tap, { t0, total, fps: FPS, out: join(OUT, 'game-audio.raw') });
|
|
148
|
+
if (audio && !audio.peakDb) audio.note = 'the game made no sound while it was recorded';
|
|
149
|
+
rmSync(pcmFile, { force: true });
|
|
150
|
+
|
|
151
|
+
const mp4 = join(OUT, 'capture.mp4');
|
|
152
|
+
const enc = encodeMp4({ concat, audio, fps: FPS, width: W, height: H, seconds: total / FPS, out: mp4 });
|
|
153
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
154
|
+
rmSync(concat, { force: true });
|
|
155
|
+
if (audio) delete audio.raw;
|
|
156
|
+
if (!enc.ok) fail(`encode failed: ${enc.why}`);
|
|
157
|
+
const spanS = cfr.frames.at(-1).t - t0;
|
|
158
|
+
const sourceFps = +(frames.length / spanS).toFixed(1);
|
|
159
|
+
// Too few frames from the page: a smaller page paints more (the frame count grows about with the pixels saved).
|
|
160
|
+
let advice = null;
|
|
161
|
+
if (sourceFps < FPS * 0.75) {
|
|
162
|
+
// Below half size a film is too soft to scale up, and a page that slow is a busy computer as much as a heavy game.
|
|
163
|
+
const want = Math.max(0.5, Math.min(1, +(SCALE * Math.sqrt(Math.max(0.05, sourceFps / FPS))).toFixed(2)));
|
|
164
|
+
const load = +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(1);
|
|
165
|
+
advice = `the page painted ${sourceFps} frames a second for a ${FPS} fps film (${held} frames held)${load > 1.5 ? `, on a busy computer (load ${load} a core)` : ''}: `
|
|
166
|
+
+ (want < SCALE ? `capture again with --scale ${want} (a ${Math.round(W * want)}x${Math.round(H * want)} page, scaled up)${load > 1.5 ? ' when it is quieter' : ''}, or a lower --fps` : `capture with a lower --fps${load > 1.5 ? ', or when the computer is quieter' : ''}`);
|
|
167
|
+
}
|
|
168
|
+
const result = {
|
|
169
|
+
...report, mode: 'live', file: 'capture.mp4', seconds: +(total / FPS).toFixed(3), outputFrames: total, sourceFrames: frames.length,
|
|
170
|
+
sourceFps, distinctFramesUsed: distinct, heldFrames: held, audio, ...(advice ? { advice } : {}),
|
|
171
|
+
honesty: 'Recorded from the game in a live public room, with no scripted gameplay or drawn overlay. Any bots shown are the game\'s own bots.',
|
|
172
|
+
};
|
|
173
|
+
writeFileSync(join(OUT, 'capture.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
174
|
+
process.stdout.write(`${JSON.stringify({ ok: true, file: mp4, seconds: result.seconds, sourceFps: result.sourceFps, heldFrames: held, ...(advice ? { advice } : {}), audio: audio ? { seconds: audio.seconds, peakDb: audio.peakDb, covered: audio.covered } : null })}\n`);
|
|
175
|
+
process.exit(0);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* clock.js: one virtual clock for a page that is being rendered frame by frame (record-fixed.mjs).
|
|
3
|
+
* Injected ahead of the page's own scripts (puppeteer evaluateOnNewDocument), into every frame.
|
|
4
|
+
*
|
|
5
|
+
* A film at 30 frames a second needs the page to be exactly 1/30 s older in each frame, however
|
|
6
|
+
* long the frame took to draw and to copy. So time in the page stops being the wall's: it moves
|
|
7
|
+
* only when the recorder calls window.__homieClock.step(ms), and then everything that tells the
|
|
8
|
+
* page the time agrees:
|
|
9
|
+
*
|
|
10
|
+
* performance.now() the virtual time, in ms, from 0 when the document began
|
|
11
|
+
* Date.now(), new Date() the wall time the document began at, plus the virtual time
|
|
12
|
+
* requestAnimationFrame called once a step, with the virtual time
|
|
13
|
+
* setTimeout / setInterval fired in order, each at its own virtual time, inside the step
|
|
14
|
+
* CSS animations and transitions, paused and set to their place by the virtual time every step
|
|
15
|
+
* element.animate() (Web Animations) (document.getAnimations()), finished when they reach their end
|
|
16
|
+
*
|
|
17
|
+
* A countdown driven by setInterval, a callout that fades with a CSS animation and a game loop on
|
|
18
|
+
* requestAnimationFrame therefore stay in step with one another: with only some of them virtual,
|
|
19
|
+
* a one-second callout is over between two frames and never appears in the film.
|
|
20
|
+
*
|
|
21
|
+
* NOT on this clock, because the page cannot reach them or they are not time the page reads:
|
|
22
|
+
* - AudioContext.currentTime and anything scheduled on it (the sound is rebuilt from a log)
|
|
23
|
+
* - <video> and <audio> elements, which play at their own rate
|
|
24
|
+
* - Web Workers and worklets (their timers and performance.now are their own)
|
|
25
|
+
* - event.timeStamp, document.timeline.currentTime, performance.timeOrigin
|
|
26
|
+
* - the network: a server keeps real time, so a page fed by one sees it run fast or slow
|
|
27
|
+
* - an animation the page itself paused before the clock first saw it is left alone
|
|
28
|
+
*
|
|
29
|
+
* Nothing here draws, and nothing changes what a callback does: only when it is called.
|
|
30
|
+
*/
|
|
31
|
+
(function () {
|
|
32
|
+
'use strict';
|
|
33
|
+
if (window.__homieClock) return;
|
|
34
|
+
const real = {
|
|
35
|
+
raf: window.requestAnimationFrame.bind(window),
|
|
36
|
+
setTimeout: window.setTimeout.bind(window),
|
|
37
|
+
clearTimeout: window.clearTimeout.bind(window),
|
|
38
|
+
Date: window.Date,
|
|
39
|
+
};
|
|
40
|
+
const epoch = real.Date.now();
|
|
41
|
+
let now = 0; // virtual ms since this document began
|
|
42
|
+
let seq = 1;
|
|
43
|
+
const timers = new Map(); // id -> { at, fn, args, every }
|
|
44
|
+
let rafs = new Map(); // id -> fn
|
|
45
|
+
const anims = new WeakMap(); // Animation -> { t0, ours }
|
|
46
|
+
const errors = [];
|
|
47
|
+
const guard = (fn, args) => { try { fn(...args); } catch (e) { if (errors.length < 20) errors.push(String(e && e.message || e)); real.setTimeout(() => { throw e; }, 0); } };
|
|
48
|
+
|
|
49
|
+
/* ---- the clocks the page reads */
|
|
50
|
+
try { Object.defineProperty(performance, 'now', { configurable: true, value: () => now }); } catch (e) { /* frozen */ }
|
|
51
|
+
// A function, not a class: Date() without `new` is legal and returns a string.
|
|
52
|
+
function VDate(...a) {
|
|
53
|
+
if (!new.target) return new real.Date(epoch + now).toString();
|
|
54
|
+
return Reflect.construct(real.Date, a.length ? a : [epoch + now], new.target);
|
|
55
|
+
}
|
|
56
|
+
VDate.prototype = real.Date.prototype;
|
|
57
|
+
VDate.now = () => epoch + Math.floor(now);
|
|
58
|
+
VDate.parse = real.Date.parse; VDate.UTC = real.Date.UTC;
|
|
59
|
+
window.Date = VDate;
|
|
60
|
+
|
|
61
|
+
/* ---- timers */
|
|
62
|
+
const addTimer = (fn, ms, args, every) => {
|
|
63
|
+
const id = seq++;
|
|
64
|
+
const wait = Math.max(0, Number(ms) || 0);
|
|
65
|
+
timers.set(id, { at: now + wait, fn: typeof fn === 'function' ? fn : () => (0, eval)(String(fn)), args, every: every ? Math.max(1, wait) : 0 });
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
window.setTimeout = (fn, ms, ...args) => addTimer(fn, ms, args, false);
|
|
69
|
+
window.setInterval = (fn, ms, ...args) => addTimer(fn, ms, args, true);
|
|
70
|
+
window.clearTimeout = window.clearInterval = (id) => { timers.delete(id); };
|
|
71
|
+
window.requestAnimationFrame = (fn) => { const id = seq++; rafs.set(id, fn); return id; };
|
|
72
|
+
window.cancelAnimationFrame = (id) => { rafs.delete(id); };
|
|
73
|
+
|
|
74
|
+
/** Run every timer due by `to`, each at its own time, earliest first (a timer set by a timer runs in the same step when it is due). */
|
|
75
|
+
function runTimers(to) {
|
|
76
|
+
for (let guardCount = 0; guardCount < 20000; guardCount++) {
|
|
77
|
+
let next = null; let nextId = 0;
|
|
78
|
+
for (const [id, t] of timers) if (t.at <= to && (next === null || t.at < next.at || (t.at === next.at && id < nextId))) { next = t; nextId = id; }
|
|
79
|
+
if (!next) break;
|
|
80
|
+
if (next.at > now) now = next.at;
|
|
81
|
+
if (next.every) next.at += next.every; else timers.delete(nextId);
|
|
82
|
+
guard(next.fn, next.args);
|
|
83
|
+
}
|
|
84
|
+
now = to;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every animation in the document, at its place by the virtual clock. */
|
|
88
|
+
function runAnimations() {
|
|
89
|
+
let list = [];
|
|
90
|
+
try { list = document.getAnimations(); } catch (e) { return 0; }
|
|
91
|
+
for (const a of list) {
|
|
92
|
+
let s = anims.get(a);
|
|
93
|
+
if (!s) {
|
|
94
|
+
// First sight: one the page paused itself stays the page's; a running one becomes ours from where it is.
|
|
95
|
+
s = { ours: a.playState !== 'paused', t0: now - (Number(a.currentTime) || 0) / (a.playbackRate || 1) };
|
|
96
|
+
anims.set(a, s);
|
|
97
|
+
if (s.ours) { try { a.pause(); } catch (e) { s.ours = false; } }
|
|
98
|
+
}
|
|
99
|
+
if (!s.ours) continue;
|
|
100
|
+
const at = (now - s.t0) * (a.playbackRate || 1);
|
|
101
|
+
let end = Infinity;
|
|
102
|
+
try { end = a.effect ? a.effect.getComputedTiming().endTime : Infinity; } catch (e) { /* no effect */ }
|
|
103
|
+
try {
|
|
104
|
+
if (Number(end) <= at) { a.finish(); anims.delete(a); } else a.currentTime = at;
|
|
105
|
+
} catch (e) { /* an animation that cannot be sought is left where it is */ }
|
|
106
|
+
}
|
|
107
|
+
return list.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One frame: the clock moves on by `ms`, the timers due in it fire, then the frame callbacks, then
|
|
112
|
+
* the animations are placed. All of it inside a real animation frame of the browser, and the promise
|
|
113
|
+
* resolves a real frame later, so what was drawn has been presented when the recorder copies it.
|
|
114
|
+
*/
|
|
115
|
+
function step(ms) {
|
|
116
|
+
return new Promise((done) => {
|
|
117
|
+
let ran = false;
|
|
118
|
+
const run = () => {
|
|
119
|
+
if (ran) return; ran = true;
|
|
120
|
+
const to = now + Math.max(0, Number(ms) || 0);
|
|
121
|
+
runTimers(to);
|
|
122
|
+
const due = rafs; rafs = new Map();
|
|
123
|
+
for (const fn of due.values()) guard(fn, [now]);
|
|
124
|
+
const n = runAnimations();
|
|
125
|
+
let settled = false;
|
|
126
|
+
const finish = () => { if (settled) return; settled = true; done({ now, timers: timers.size, frames: due.size, animations: n, errors: errors.splice(0) }); };
|
|
127
|
+
real.raf(() => finish());
|
|
128
|
+
real.setTimeout(finish, 250); // a frame the browser is not painting (hidden, off screen) has no animation frames
|
|
129
|
+
};
|
|
130
|
+
real.raf(run);
|
|
131
|
+
real.setTimeout(run, 250);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
window.__homieClock = { step, get now() { return now; }, epoch, virtual: true };
|
|
136
|
+
})();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* film.js — every frame is a pure function of time: renderAt(t) draws the frame at
|
|
3
|
+
* t seconds and returns it as a JPEG data URL, so `video.mjs film render` can shoot
|
|
4
|
+
* it frame-exactly in a headless browser, and any frame can be re-rendered alone.
|
|
5
|
+
*
|
|
6
|
+
* Inputs (served from the video's folder):
|
|
7
|
+
* film/shots.json the shot list on the song's bar grid (at, dur, base frames, where the subject is, the type)
|
|
8
|
+
* grid.json the song's beats, bars and sung words (video.mjs grid)
|
|
9
|
+
* work/frames/<id>/f00001.jpg … a generated clip's frames at 24 fps (video.mjs film frames)
|
|
10
|
+
* The look itself is look.js: change it, not this file.
|
|
11
|
+
*/
|
|
12
|
+
import { drawBase, drawType } from './look.js';
|
|
13
|
+
|
|
14
|
+
const params = new URLSearchParams(location.search);
|
|
15
|
+
const MODE = params.get('mode') === 'v' ? 'v' : 'h';
|
|
16
|
+
const W = MODE === 'v' ? 1080 : 1920;
|
|
17
|
+
const H = MODE === 'v' ? 1920 : 1080;
|
|
18
|
+
const canvas = document.getElementById('c');
|
|
19
|
+
canvas.width = W; canvas.height = H;
|
|
20
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
21
|
+
let SHOTS = null; let GRID = null;
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
|
|
24
|
+
function image(src) {
|
|
25
|
+
if (cache.has(src)) return cache.get(src);
|
|
26
|
+
const p = new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => ok(null); i.src = src; });
|
|
27
|
+
cache.set(src, p);
|
|
28
|
+
if (cache.size > 96) cache.delete(cache.keys().next().value);
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function boot() {
|
|
33
|
+
SHOTS = await (await fetch('./shots.json', { cache: 'no-store' })).json();
|
|
34
|
+
GRID = await (await fetch('../grid.json', { cache: 'no-store' })).json().catch(() => ({ beats: [], words: [] }));
|
|
35
|
+
await document.fonts?.ready;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Deterministic noise: the same t and seed always give the same number. */
|
|
39
|
+
export const rand = (seed) => { const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
|
40
|
+
|
|
41
|
+
window.renderAt = async (t) => {
|
|
42
|
+
const shot = SHOTS.shots.find((s) => t >= s.at && t < s.at + s.dur) ?? SHOTS.shots[SHOTS.shots.length - 1];
|
|
43
|
+
const local = t - shot.at + (shot.baseStart ?? 0);
|
|
44
|
+
let img = null;
|
|
45
|
+
if (shot.base) {
|
|
46
|
+
const n = Math.max(1, Math.floor(local * 24 + 1e-6) + 1);
|
|
47
|
+
img = await image(`../${shot.base}/f${String(n).padStart(5, '0')}.jpg`);
|
|
48
|
+
for (let k = n - 1; !img && k >= 1 && k > n - 48; k--) img = await image(`../${shot.base}/f${String(k).padStart(5, '0')}.jpg`); // past the clip's end: hold its last frame
|
|
49
|
+
}
|
|
50
|
+
const songT = t + (SHOTS.songStart ?? 0);
|
|
51
|
+
const beats = GRID.beats ?? [];
|
|
52
|
+
let beatPhase = 1;
|
|
53
|
+
for (let i = beats.length - 1; i >= 0; i--) if (beats[i] <= songT) { beatPhase = Math.min(1, (songT - beats[i]) / (GRID.beat || 0.5)); break; }
|
|
54
|
+
const env = { t, songT, W, H, mode: MODE, shot, palette: SHOTS.palette, font: SHOTS.font, beatPhase, words: GRID.words ?? [], rand };
|
|
55
|
+
drawBase(ctx, img, env);
|
|
56
|
+
drawType(ctx, env);
|
|
57
|
+
return canvas.toDataURL('image/jpeg', 0.92);
|
|
58
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><title>film</title>
|
|
3
|
+
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style></head>
|
|
4
|
+
<body><canvas id="c"></canvas>
|
|
5
|
+
<script type="module">
|
|
6
|
+
import { boot } from './film.js';
|
|
7
|
+
boot().then(() => { window.READY = true; }).catch((e) => { window.FAIL = String(e && e.stack || e); });
|
|
8
|
+
</script>
|
|
9
|
+
</body></html>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* look.js — the look of the film. A starting point, meant to be changed for every
|
|
3
|
+
* video: a two-ink print (paper, ink, and an accent) where the generated clip is the
|
|
4
|
+
* motion underneath and the drawing is what you see, plus kinetic lyric type.
|
|
5
|
+
*
|
|
6
|
+
* drawBase(ctx, img, env): the picture. env: { t, songT, W, H, mode, shot, palette, beatPhase, words, rand }.
|
|
7
|
+
* drawType(ctx, env): the words. Big and present for shots marked "big", quiet for "quiet", none for "none".
|
|
8
|
+
*/
|
|
9
|
+
const SCREEN = 9; // halftone cell, px
|
|
10
|
+
let small = null;
|
|
11
|
+
|
|
12
|
+
export function drawBase(ctx, img, env) {
|
|
13
|
+
const { W, H, palette, shot, rand } = env;
|
|
14
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
15
|
+
ctx.fillStyle = palette.paper;
|
|
16
|
+
ctx.fillRect(0, 0, W, H);
|
|
17
|
+
if (!img) return;
|
|
18
|
+
// Cover the frame, keeping the subject (shot.focus, 0..1) in view; a small push on each beat.
|
|
19
|
+
const bump = 1 + 0.018 * Math.max(0, 1 - env.beatPhase * 4);
|
|
20
|
+
const s = Math.max(W / img.width, H / img.height) * bump;
|
|
21
|
+
const [fx, fy] = shot.focus ?? [0.5, 0.5];
|
|
22
|
+
const dw = img.width * s; const dh = img.height * s;
|
|
23
|
+
const dx = Math.min(0, Math.max(W - dw, W / 2 - fx * dw));
|
|
24
|
+
const dy = Math.min(0, Math.max(H - dh, H / 2 - fy * dh));
|
|
25
|
+
// The hand: the plate shifts a little, twelve times a second (the "boil").
|
|
26
|
+
const tick = Math.floor(env.t * 12);
|
|
27
|
+
const jx = (rand(tick) - 0.5) * 3; const jy = (rand(tick + 99) - 0.5) * 3;
|
|
28
|
+
// Line work: the base in grey, multiplied onto the paper.
|
|
29
|
+
ctx.save();
|
|
30
|
+
ctx.filter = 'grayscale(1) contrast(1.6) brightness(1.08)';
|
|
31
|
+
ctx.globalAlpha = 0.5;
|
|
32
|
+
ctx.globalCompositeOperation = 'multiply';
|
|
33
|
+
ctx.drawImage(img, dx + jx, dy + jy, dw, dh);
|
|
34
|
+
ctx.restore();
|
|
35
|
+
// Ink: halftone dots sized by how dark each cell is.
|
|
36
|
+
const cw = Math.ceil(W / SCREEN); const chh = Math.ceil(H / SCREEN);
|
|
37
|
+
if (!small || small.width !== cw) { small = document.createElement('canvas'); small.width = cw; small.height = chh; }
|
|
38
|
+
const sc = small.getContext('2d', { willReadFrequently: true });
|
|
39
|
+
sc.filter = 'contrast(1.3)';
|
|
40
|
+
sc.drawImage(img, dx / SCREEN, dy / SCREEN, dw / SCREEN, dh / SCREEN);
|
|
41
|
+
const px = sc.getImageData(0, 0, cw, chh).data;
|
|
42
|
+
// Two screens: the ink prints the dark, the accent prints warm light (red well above blue),
|
|
43
|
+
// on a grid offset by half a cell and misregistered a little from the ink, like a second pass.
|
|
44
|
+
const plate = (colour, amount, ox0, oy0) => {
|
|
45
|
+
ctx.fillStyle = colour;
|
|
46
|
+
ctx.beginPath();
|
|
47
|
+
for (let y = 0; y < chh; y++) {
|
|
48
|
+
for (let x = 0; x < cw; x++) {
|
|
49
|
+
const r = amount(px, (y * cw + x) * 4) * SCREEN * 0.62;
|
|
50
|
+
if (r < 0.6) continue;
|
|
51
|
+
const cx = x * SCREEN + (y % 2) * SCREEN * 0.5 + ox0; const cy = y * SCREEN + SCREEN / 2 + oy0;
|
|
52
|
+
ctx.moveTo(cx + r, cy);
|
|
53
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
ctx.fill();
|
|
57
|
+
};
|
|
58
|
+
ctx.globalCompositeOperation = 'multiply';
|
|
59
|
+
plate(palette.ink, (p, i) => 1 - (0.3 * p[i] + 0.59 * p[i + 1] + 0.11 * p[i + 2]) / 255, jx, jy);
|
|
60
|
+
const mx = (rand(tick + 5) - 0.5) * 3 + SCREEN / 3; const my = (rand(tick + 17) - 0.5) * 3 + SCREEN / 3;
|
|
61
|
+
plate(palette.accent, (p, i) => Math.max(0, Math.min(1, (p[i] - p[i + 2] - 40) / 140)), mx, my);
|
|
62
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
63
|
+
// Paper tooth: fixed to the card, not to time.
|
|
64
|
+
ctx.fillStyle = 'rgba(0,0,0,0.035)';
|
|
65
|
+
for (let i = 0; i < 1400; i++) ctx.fillRect(rand(i) * W, rand(i + 7) * H, 1 + rand(i + 3) * 2, 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The line being sung at songT, and its words up to now. */
|
|
69
|
+
function lineAt(words, songT) {
|
|
70
|
+
if (!words.length) return null;
|
|
71
|
+
let i = words.findIndex((w) => w.s > songT);
|
|
72
|
+
if (i < 0) i = words.length;
|
|
73
|
+
const past = words.slice(0, i);
|
|
74
|
+
if (!past.length) return null;
|
|
75
|
+
const last = past[past.length - 1];
|
|
76
|
+
if (songT - last.e > 1.2) return null;
|
|
77
|
+
// A line: the words since the last pause of 0.45 s or more.
|
|
78
|
+
let k = past.length - 1;
|
|
79
|
+
while (k > 0 && past[k].s - past[k - 1].e < 0.45) k--;
|
|
80
|
+
return past.slice(k);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function drawType(ctx, env) {
|
|
84
|
+
const { W, H, palette, shot, songT, words, font } = env;
|
|
85
|
+
if (shot.type === 'none') return;
|
|
86
|
+
const line = lineAt(words, songT);
|
|
87
|
+
if (!line) return;
|
|
88
|
+
const big = shot.type === 'big';
|
|
89
|
+
const size = big ? (env.mode === 'v' ? 150 : 170) : (env.mode === 'v' ? 64 : 58);
|
|
90
|
+
const left = shot.side !== 'right';
|
|
91
|
+
const x0 = env.mode === 'v' ? W * 0.08 : (left ? W * 0.06 : W * 0.56);
|
|
92
|
+
const maxW = env.mode === 'v' ? W * 0.84 : W * 0.38;
|
|
93
|
+
ctx.save();
|
|
94
|
+
ctx.font = font.replace('1px', `${size}px`);
|
|
95
|
+
ctx.textBaseline = 'alphabetic';
|
|
96
|
+
let x = x0; let y = env.mode === 'v' ? H * 0.2 : (big ? H * 0.28 : H * 0.8);
|
|
97
|
+
for (const w of line) {
|
|
98
|
+
const word = big ? w.w.toUpperCase() : w.w;
|
|
99
|
+
const wWidth = ctx.measureText(`${word} `).width;
|
|
100
|
+
if (x + wWidth > x0 + maxW && x > x0) { x = x0; y += size * 0.95; }
|
|
101
|
+
const age = songT - w.s;
|
|
102
|
+
const pop = big ? 1 + 0.35 * Math.max(0, 1 - age / 0.12) : 1;
|
|
103
|
+
ctx.save();
|
|
104
|
+
ctx.translate(x, y);
|
|
105
|
+
ctx.scale(pop, pop);
|
|
106
|
+
// The newest word in the accent ink, the rest in ink, all knocked out of a paper box so they read over any picture.
|
|
107
|
+
const newest = w === line[line.length - 1];
|
|
108
|
+
if (big) { ctx.fillStyle = palette.paper; ctx.fillRect(-6, -size * 0.82, wWidth - size * 0.18, size * 0.98); }
|
|
109
|
+
ctx.fillStyle = newest ? palette.accent : palette.ink;
|
|
110
|
+
ctx.fillText(word, 0, 0);
|
|
111
|
+
ctx.restore();
|
|
112
|
+
x += wWidth;
|
|
113
|
+
}
|
|
114
|
+
ctx.restore();
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fps": 24,
|
|
3
|
+
"length": 8,
|
|
4
|
+
"audio": "work/song-cut.wav",
|
|
5
|
+
"songStart": 0,
|
|
6
|
+
"palette": { "paper": "#f1ead8", "ink": "#1d1b22", "accent": "#e2462f" },
|
|
7
|
+
"font": "900 1px ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif",
|
|
8
|
+
"shots": [
|
|
9
|
+
{ "at": 0, "dur": 4, "base": "work/frames/shot-01", "baseStart": 0, "focus": [0.62, 0.45], "type": "big", "side": "left" },
|
|
10
|
+
{ "at": 4, "dur": 4, "base": "work/frames/shot-02", "baseStart": 0, "focus": [0.4, 0.5], "type": "quiet", "side": "right" }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A headless Chrome on the GPU, driven with puppeteer-core from the studio's own
|
|
3
|
+
* node_modules (it comes with @homie-rocks/studio). A software renderer runs a
|
|
4
|
+
* WebGL game at about one frame a second and makes it look stuck, so the GPU
|
|
5
|
+
* flags are not optional.
|
|
6
|
+
*
|
|
7
|
+
* On Linux with no GPU (a cloud session, CI) Chrome draws WebGL with SwiftShader (it needs
|
|
8
|
+
* --enable-unsafe-swiftshader since Chrome 137), and a Chrome for Testing on an Ubuntu that restricts unprivileged
|
|
9
|
+
* user namespaces (23.10 and later), or a Chrome run as root, starts only without its sandbox: the same rules as
|
|
10
|
+
* @homie-rocks/studio's own checks (lib/chrome.mjs). Pages are opened in throwaway profiles.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { homedir, tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const CHROMES = [
|
|
18
|
+
process.env.CHROME_PATH,
|
|
19
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
20
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
21
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome',
|
|
22
|
+
].filter(Boolean);
|
|
23
|
+
|
|
24
|
+
function noSandboxNeeded() {
|
|
25
|
+
if (process.platform !== 'linux') return false;
|
|
26
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) return true;
|
|
27
|
+
try { return readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const GPU_FLAGS = process.platform === 'darwin'
|
|
31
|
+
? ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist']
|
|
32
|
+
: ['--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--use-gl=angle', '--enable-unsafe-swiftshader', '--disable-dev-shm-usage', ...(noSandboxNeeded() ? ['--no-sandbox'] : [])];
|
|
33
|
+
|
|
34
|
+
/** The newest build in a Chrome for Testing cache (`homie-studio chrome install` keeps one in .cache/homie-studio in the home folder). */
|
|
35
|
+
function newestIn(dir, rel) {
|
|
36
|
+
if (!existsSync(dir)) return null;
|
|
37
|
+
const builds = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
|
|
38
|
+
for (const b of builds) for (const r of rel) { const path = join(dir, b, ...r); if (existsSync(path)) return path; }
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function chromePath() {
|
|
43
|
+
return CHROMES.find((p) => existsSync(p))
|
|
44
|
+
?? newestIn(join(homedir(), '.cache', 'homie-studio', 'chrome', 'chrome'), [['chrome-linux64', 'chrome'], ['chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'], ['chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']])
|
|
45
|
+
?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadPuppeteer(root) {
|
|
49
|
+
const tries = [root ? join(root, 'package.json') : null, join(process.cwd(), 'package.json')].filter(Boolean);
|
|
50
|
+
for (const from of tries) {
|
|
51
|
+
try { const m = createRequire(from)('puppeteer-core'); return m.default ?? m; } catch { /* next */ }
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Launch; returns { browser, close } where close() also deletes the throwaway profile. */
|
|
57
|
+
export async function launch(root, { width = 1920, height = 1080, extra = [] } = {}) {
|
|
58
|
+
const exe = chromePath();
|
|
59
|
+
if (!exe) throw new Error('no Chrome found (set CHROME_PATH to a Chrome or Chromium; on Linux, `npx --no-install homie-studio chrome install` in a studio fetches Chrome for Testing)');
|
|
60
|
+
const puppeteer = loadPuppeteer(root);
|
|
61
|
+
if (!puppeteer) throw new Error('puppeteer-core is not installed (it comes with @homie-rocks/studio: run npm install in the studio)');
|
|
62
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-video-'));
|
|
63
|
+
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
64
|
+
const browser = await puppeteer.launch({
|
|
65
|
+
executablePath: exe, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
|
|
66
|
+
args: [...GPU_FLAGS, `--window-size=${width},${height}`, '--hide-scrollbars', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--force-color-profile=srgb', ...extra],
|
|
67
|
+
});
|
|
68
|
+
const close = async () => {
|
|
69
|
+
try { await Promise.race([browser.close(), new Promise((r) => setTimeout(r, 8000))]); } catch { /* */ }
|
|
70
|
+
try { browser.process()?.kill('SIGKILL'); } catch { /* */ }
|
|
71
|
+
rmSync(profile, { recursive: true, force: true });
|
|
72
|
+
};
|
|
73
|
+
return { browser, close, pid: browser.process()?.pid ?? null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render HTML to a PNG at an exact size (cards, contact sheets): text comes out right with no font setup. */
|
|
77
|
+
export async function htmlToPng(root, html, out, { width, height }) {
|
|
78
|
+
const { browser, close } = await launch(root, { width, height });
|
|
79
|
+
try {
|
|
80
|
+
const page = await browser.newPage();
|
|
81
|
+
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
|
82
|
+
await page.setContent(html, { waitUntil: 'load' });
|
|
83
|
+
await page.evaluate(() => document.fonts?.ready);
|
|
84
|
+
await page.screenshot({ path: out, clip: { x: 0, y: 0, width, height } });
|
|
85
|
+
} finally { await close(); }
|
|
86
|
+
return out;
|
|
87
|
+
}
|