mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-11-03 09:02:19 +01:00
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// Retakes every picture the videos are cut from, in real VS Code, against the
|
|
// demo site in ../demo and nothing else.
|
|
//
|
|
// node scripts/capture.mjs everything
|
|
// node scripts/capture.mjs shots the stills only
|
|
// node scripts/capture.mjs clips the recorded beats only
|
|
// node scripts/capture.mjs edit media just those, by name
|
|
//
|
|
// Needs the packaged extension: `npm run vsix` first, and again after any change
|
|
// to the extension that should show up in the video.
|
|
//
|
|
// Stills land in public/shots/<name>.png at 2x. Recorded beats land in
|
|
// public/clips/<name>.mp4 at a constant 30 fps, with public/clips.json naming
|
|
// how long each one is — the compositions read that rather than carrying a table
|
|
// of frame numbers, so re-recording a beat does not mean re-cutting the piece
|
|
// around it.
|
|
//
|
|
// The stills share one VS Code. Every recorded beat gets a VS Code of its own,
|
|
// on a fresh copy of the site: a beat that edits a post would otherwise leave
|
|
// that edit in every shot after it.
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
dashboard, hidePointer, openApp, panel, pointTo, pressMouse, releaseMouse, runCommand, settle,
|
|
showPointer, VIEWPORT,
|
|
} from './harness.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const PUBLIC = resolve(HERE, '../public');
|
|
const SHOTS_DIR = join(PUBLIC, 'shots');
|
|
const CLIPS_DIR = join(PUBLIC, 'clips');
|
|
const TMP = join(PUBLIC, '.frames');
|
|
|
|
export const CLIP_FPS = 30;
|
|
|
|
// ---- driving the app -----------------------------------------------------
|
|
|
|
const pause = (page, ms) => page.waitForTimeout(ms);
|
|
|
|
/** Glide the pointer to the middle of `target` — a locator in the workbench or
|
|
* inside a webview; either way its box comes back in window coordinates. */
|
|
async function moveTo(page, target, { steps = 26, dx = 0.5, dy = 0.5 } = {}) {
|
|
await target.scrollIntoViewIfNeeded().catch(() => {});
|
|
const box = await target.boundingBox();
|
|
if (!box) {
|
|
throw new Error('moveTo: target has no box');
|
|
}
|
|
await pointTo(page, box.x + box.width * dx, box.y + box.height * dy, { steps });
|
|
return box;
|
|
}
|
|
|
|
/** Move, settle for a beat so the hover state reads, then click. */
|
|
async function clickOn(page, target, { before = 320, after = 500, ...move } = {}) {
|
|
await moveTo(page, target, move);
|
|
await pause(page, before);
|
|
await pressMouse(page);
|
|
await pause(page, 90);
|
|
await releaseMouse(page);
|
|
await pause(page, after);
|
|
}
|
|
|
|
/** Type at something near a human rate. */
|
|
async function typeText(page, text, { delay = 55 } = {}) {
|
|
await page.keyboard.type(text, { delay });
|
|
}
|
|
|
|
/** Scroll whatever is under the pointer, in small wheel steps a hand would make. */
|
|
async function wheel(page, dy, { steps = 10 } = {}) {
|
|
for (let i = 0; i < steps; i++) {
|
|
await page.mouse.wheel(0, dy / steps);
|
|
await pause(page, 30);
|
|
}
|
|
}
|
|
|
|
/** Send the pointer somewhere nothing reacts to it: the middle of the title
|
|
* bar. */
|
|
async function park(page) {
|
|
await pointTo(page, VIEWPORT.width / 2 + 180, 12, { steps: 6 });
|
|
await pause(page, 260);
|
|
}
|
|
|
|
/** The dashboard on its Contents tab, the sidebar closed, nothing else open.
|
|
* Every shot starts here so one shot's leftovers never reach the next. */
|
|
async function reset(page) {
|
|
await page.keyboard.press('Escape');
|
|
await runCommand(page, 'File: Save All Files');
|
|
await runCommand(page, 'View: Close All Editor Groups');
|
|
await runCommand(page, 'View: Single Column Editor Layout');
|
|
await runCommand(page, 'Front Matter: Open dashboard');
|
|
// The palette offers only toggles for the side bars, so each is toggled only
|
|
// when it is actually showing.
|
|
if (await page.locator('.part.sidebar').isVisible()) {
|
|
await runCommand(page, 'View: Toggle Primary Side Bar Visibility');
|
|
}
|
|
if (await page.locator('.part.auxiliarybar').isVisible()) {
|
|
await runCommand(page, 'View: Toggle Secondary Side Bar Visibility');
|
|
}
|
|
const dash = await dashboard(page);
|
|
await dash.locator('img').first().waitFor({ state: 'visible' });
|
|
await park(page);
|
|
await settle(page, 800);
|
|
return dash;
|
|
}
|
|
|
|
/** A dashboard tab, by its label. */
|
|
const tab = (dash, name) => dash.getByRole('button', { name, exact: true }).or(dash.getByText(name, { exact: true })).first();
|
|
|
|
/** Open a post the way a user does from the dashboard, without recording it. */
|
|
async function openPost(page, title) {
|
|
const dash = await reset(page);
|
|
await dash.getByText(title, { exact: true }).first().click();
|
|
const side = await panel(page);
|
|
await side.locator('.metadata_field__input').first().waitFor({ state: 'visible' });
|
|
await settle(page, 1500);
|
|
return side;
|
|
}
|
|
|
|
// ---- stills --------------------------------------------------------------
|
|
//
|
|
// One per screen.
|
|
|
|
const SHOTS = {
|
|
async contents(page) {
|
|
await reset(page);
|
|
},
|
|
|
|
async drafts(page) {
|
|
const dash = await reset(page);
|
|
await dash.getByRole('button', { name: /In draft/ }).first().click();
|
|
await settle(page, 800);
|
|
},
|
|
|
|
async editor(page) {
|
|
await openPost(page, 'Why we moved our client sites to Astro');
|
|
},
|
|
|
|
async media(page) {
|
|
const dash = await reset(page);
|
|
await tab(dash, 'Media').click();
|
|
await dash.getByText('images', { exact: true }).first().click();
|
|
await dash.locator('img').first().waitFor({ state: 'visible' });
|
|
await settle(page, 2500);
|
|
},
|
|
|
|
async snippets(page) {
|
|
const dash = await reset(page);
|
|
await tab(dash, 'Snippets').click();
|
|
await settle(page, 1200);
|
|
},
|
|
|
|
async data(page) {
|
|
const dash = await reset(page);
|
|
await tab(dash, 'Data').click();
|
|
await dash.getByText('Authors', { exact: true }).first().click();
|
|
await settle(page, 800);
|
|
// One entry open in the form, so the shot shows a filled-in record rather
|
|
// than an empty "add new" form.
|
|
await dash.getByText('Mara Lindqvist', { exact: true }).first().click();
|
|
await settle(page, 1200);
|
|
},
|
|
|
|
async taxonomy(page) {
|
|
const dash = await reset(page);
|
|
await tab(dash, 'Taxonomies').click();
|
|
await settle(page, 1500);
|
|
},
|
|
};
|
|
|
|
// ---- recorded beats ------------------------------------------------------
|
|
//
|
|
// Three, no more: the moments where something has to *happen*. Everything else
|
|
// is a still with a caption.
|
|
//
|
|
// Each is `{ prepare, run }` and only `run` is recorded.
|
|
|
|
const CLIPS = {
|
|
/** A draft, opened from the dashboard, given a description and published —
|
|
* in the panel, with the YAML in the editor following along. */
|
|
edit: {
|
|
prepare: reset,
|
|
async run(page) {
|
|
await pause(page, 600);
|
|
const dash = await dashboard(page);
|
|
await clickOn(page, dash.getByText('Typography scales, explained', { exact: true }).first(), { after: 1800 });
|
|
const side = await panel(page);
|
|
await side.locator('.metadata_field__textarea').first().waitFor({ state: 'visible' });
|
|
await clickOn(page, side.locator('.metadata_field__textarea').first(), { after: 300 });
|
|
await typeText(page, 'A modular scale in four lines of CSS, and how to pick its ratio.');
|
|
await pause(page, 1400);
|
|
// Down to the Draft switch, by wheel over the panel.
|
|
await moveTo(page, side.locator('.metadata_field__label', { hasText: 'Author' }).first(), { steps: 14 });
|
|
await wheel(page, 520);
|
|
await pause(page, 500);
|
|
const draft = side.locator('.metadata_field__label', { hasText: /^Draft$/ }).locator('xpath=following::label[contains(@class,"field__toggle")][1]');
|
|
await clickOn(page, draft, { after: 2200 });
|
|
},
|
|
},
|
|
|
|
/** A new post from the dashboard: pick the folder, name it, and the file opens
|
|
* with its front matter already filled in. */
|
|
create: {
|
|
prepare: reset,
|
|
async run(page) {
|
|
await pause(page, 600);
|
|
const dash = await dashboard(page);
|
|
await clickOn(page, dash.getByRole('button', { name: /Create content/ }).first(), { after: 900 });
|
|
await clickOn(page, page.locator('.quick-input-list .monaco-list-row', { hasText: 'Journal' }).first(), { after: 900 });
|
|
await typeText(page, 'Choosing a static site generator in 2026');
|
|
await pause(page, 500);
|
|
await page.keyboard.press('Enter');
|
|
const side = await panel(page);
|
|
await side.locator('.metadata_field__input').first().waitFor({ state: 'visible' });
|
|
await pause(page, 2600);
|
|
},
|
|
},
|
|
|
|
/** A snippet into the post: the sentence under the cursor becomes a callout. */
|
|
snippet: {
|
|
async prepare(page) {
|
|
await openPost(page, 'Why we moved our client sites to Astro');
|
|
// Put the selection on the paragraph the callout will wrap.
|
|
await page.locator('.monaco-editor .view-lines').first().click();
|
|
await page.keyboard.press('Control+G');
|
|
await typeText(page, '26', { delay: 0 });
|
|
await page.keyboard.press('Enter');
|
|
// The whole paragraph, not the first wrapped line of it: Home/End follow
|
|
// the soft wrap. Expand to the line, then give back its line break.
|
|
await page.keyboard.press('Meta+L');
|
|
await page.keyboard.press('Shift+ArrowLeft');
|
|
await park(page);
|
|
await settle(page, 600);
|
|
},
|
|
async run(page) {
|
|
await pause(page, 600);
|
|
await clickOn(page, page.locator('.editor-actions .action-label[aria-label*="snippet" i]').first(), { after: 1600 });
|
|
const dash = await dashboard(page);
|
|
// The card's own insert button: the title opens nothing, and a loose
|
|
// match on "Insert" finds whichever card comes last.
|
|
const card = dash.locator('li', { has: dash.getByText('Callout', { exact: true }) }).first();
|
|
await moveTo(page, card, { steps: 18 });
|
|
await pause(page, 400);
|
|
await clickOn(page, card.locator('button[title="Insert snippet"]'), { after: 1400 });
|
|
// The dialog comes up with the selected sentence already in the Text
|
|
// field; Insert wraps it.
|
|
await clickOn(page, dash.getByRole('button', { name: 'Insert', exact: true }), { after: 2600 });
|
|
},
|
|
},
|
|
};
|
|
|
|
// ---- the screencast ------------------------------------------------------
|
|
|
|
/** Record `clip.run` and write it out as constant-rate h264.
|
|
*
|
|
* CDP emits a frame when the page paints and not otherwise, so what comes back
|
|
* is a variable-rate stream with a timestamp on each frame. Resampling it onto
|
|
* a fixed 30 fps grid here — rather than handing ffmpeg the timestamps — is
|
|
* what makes a frame number in the timeline mean a fixed number of
|
|
* milliseconds, which is the whole basis of the cut. */
|
|
async function record(page, name, clip) {
|
|
await clip.prepare?.(page);
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const frames = [];
|
|
cdp.on('Page.screencastFrame', async ({ data, sessionId, metadata }) => {
|
|
frames.push({ t: metadata.timestamp, data });
|
|
await cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
});
|
|
|
|
await showPointer(page);
|
|
await cdp.send('Page.startScreencast', {
|
|
format: 'jpeg',
|
|
quality: 92,
|
|
maxWidth: 4096,
|
|
maxHeight: 4096,
|
|
everyNthFrame: 1,
|
|
});
|
|
await clip.run(page);
|
|
await pause(page, 500);
|
|
await cdp.send('Page.stopScreencast');
|
|
await cdp.detach().catch(() => {});
|
|
|
|
if (frames.length < 2) {
|
|
throw new Error(`${name}: the page painted ${frames.length} times — nothing to cut`);
|
|
}
|
|
|
|
const src = join(TMP, name, 'src');
|
|
const seq = join(TMP, name, 'seq');
|
|
rmSync(join(TMP, name), { recursive: true, force: true });
|
|
mkdirSync(src, { recursive: true });
|
|
mkdirSync(seq, { recursive: true });
|
|
|
|
frames.sort((a, b) => a.t - b.t);
|
|
const paths = frames.map((frame, i) => {
|
|
const path = join(src, `${String(i).padStart(5, '0')}.jpg`);
|
|
writeFileSync(path, Buffer.from(frame.data, 'base64'));
|
|
return path;
|
|
});
|
|
|
|
const t0 = frames[0].t;
|
|
const count = Math.max(2, Math.round((frames[frames.length - 1].t - t0) * CLIP_FPS));
|
|
let cursor = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
const at = t0 + i / CLIP_FPS;
|
|
while (cursor + 1 < frames.length && frames[cursor + 1].t <= at) {
|
|
cursor++;
|
|
}
|
|
// Hard links rather than copies: most frames of a beat are repeats.
|
|
linkSync(paths[cursor], join(seq, `${String(i).padStart(5, '0')}.jpg`));
|
|
}
|
|
|
|
mkdirSync(CLIPS_DIR, { recursive: true });
|
|
const out = join(CLIPS_DIR, `${name}.mp4`);
|
|
execFileSync('ffmpeg', [
|
|
'-y', '-hide_banner', '-loglevel', 'error',
|
|
'-framerate', String(CLIP_FPS),
|
|
'-i', join(seq, '%05d.jpg'),
|
|
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
|
'-c:v', 'libx264', '-crf', '15', '-preset', 'slow',
|
|
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
out,
|
|
], { stdio: 'inherit' });
|
|
rmSync(join(TMP, name), { recursive: true, force: true });
|
|
|
|
console.log(` ${name}.mp4 — ${count} frames, ${(count / CLIP_FPS).toFixed(1)}s (${frames.length} painted)`);
|
|
return { frames: count, fps: CLIP_FPS };
|
|
}
|
|
|
|
// ---- run -----------------------------------------------------------------
|
|
|
|
const argv = process.argv.slice(2);
|
|
const wants = (kind, name) => argv.length === 0 || argv.includes(kind) || argv.includes(name);
|
|
|
|
const shotNames = Object.keys(SHOTS).filter((n) => wants('shots', n));
|
|
if (shotNames.length) {
|
|
console.log('shots');
|
|
mkdirSync(SHOTS_DIR, { recursive: true });
|
|
const { app, page } = await openApp({ pointer: true });
|
|
for (const name of shotNames) {
|
|
await showPointer(page);
|
|
await SHOTS[name](page);
|
|
// Nothing under the pointer: the row it was left on keeps its hover state
|
|
// even after the dot is hidden.
|
|
await park(page);
|
|
await hidePointer(page);
|
|
await settle(page, 400);
|
|
await page.screenshot({ path: join(SHOTS_DIR, `${name}.png`) });
|
|
console.log(` ${name}.png`);
|
|
}
|
|
await app.close();
|
|
}
|
|
|
|
const clipNames = Object.keys(CLIPS).filter((n) => wants('clips', n));
|
|
if (clipNames.length) {
|
|
console.log('clips');
|
|
mkdirSync(TMP, { recursive: true });
|
|
const manifestPath = join(PUBLIC, 'clips.json');
|
|
// Merged rather than replaced, so re-recording one beat by name leaves the
|
|
// lengths of the others alone.
|
|
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
|
for (const name of clipNames) {
|
|
const { app, page } = await openApp({ pointer: true });
|
|
try {
|
|
manifest[name] = await record(page, name, CLIPS[name]);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
rmSync(TMP, { recursive: true, force: true });
|
|
}
|
|
|
|
if (existsSync(SHOTS_DIR)) {
|
|
console.log(`\n${readdirSync(SHOTS_DIR).length} stills in public/shots`);
|
|
}
|