@appinternalleads/ui 0.4.1 → 0.4.2
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/README.md +92 -0
- package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js +25 -2
- package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
- package/dist/cjs/motion/typesurface/config.js +21 -1
- package/dist/cjs/motion/typesurface/config.js.map +1 -1
- package/dist/cjs/motion/typesurface/construct.js +875 -0
- package/dist/cjs/motion/typesurface/construct.js.map +1 -0
- package/dist/cjs/motion/typesurface/engine.js +28 -4
- package/dist/cjs/motion/typesurface/engine.js.map +1 -1
- package/dist/cjs/motion/typesurface/index.js +9 -1
- package/dist/cjs/motion/typesurface/index.js.map +1 -1
- package/dist/cjs/motion/typesurface/presets.js +69 -2
- package/dist/cjs/motion/typesurface/presets.js.map +1 -1
- package/dist/cjs/motion/typesurface/renderer.js +81 -15
- package/dist/cjs/motion/typesurface/renderer.js.map +1 -1
- package/dist/cjs/motion/typesurface/shaders.js +23 -1
- package/dist/cjs/motion/typesurface/shaders.js.map +1 -1
- package/dist/cjs/motion/typesurface/surfaces.js +69 -0
- package/dist/cjs/motion/typesurface/surfaces.js.map +1 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts +11 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js +25 -2
- package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
- package/dist/esm/motion/typesurface/config.d.ts +13 -2
- package/dist/esm/motion/typesurface/config.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/config.js +21 -1
- package/dist/esm/motion/typesurface/config.js.map +1 -1
- package/dist/esm/motion/typesurface/construct.d.ts +284 -0
- package/dist/esm/motion/typesurface/construct.d.ts.map +1 -0
- package/dist/esm/motion/typesurface/construct.js +866 -0
- package/dist/esm/motion/typesurface/construct.js.map +1 -0
- package/dist/esm/motion/typesurface/engine.d.ts +8 -0
- package/dist/esm/motion/typesurface/engine.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/engine.js +28 -4
- package/dist/esm/motion/typesurface/engine.js.map +1 -1
- package/dist/esm/motion/typesurface/index.d.ts +7 -3
- package/dist/esm/motion/typesurface/index.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/index.js +6 -2
- package/dist/esm/motion/typesurface/index.js.map +1 -1
- package/dist/esm/motion/typesurface/presets.d.ts +18 -0
- package/dist/esm/motion/typesurface/presets.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/presets.js +68 -3
- package/dist/esm/motion/typesurface/presets.js.map +1 -1
- package/dist/esm/motion/typesurface/renderer.d.ts +10 -0
- package/dist/esm/motion/typesurface/renderer.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/renderer.js +82 -16
- package/dist/esm/motion/typesurface/renderer.js.map +1 -1
- package/dist/esm/motion/typesurface/shaders.d.ts +1 -1
- package/dist/esm/motion/typesurface/shaders.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/shaders.js +23 -1
- package/dist/esm/motion/typesurface/shaders.js.map +1 -1
- package/dist/esm/motion/typesurface/surfaces.d.ts +28 -1
- package/dist/esm/motion/typesurface/surfaces.d.ts.map +1 -1
- package/dist/esm/motion/typesurface/surfaces.js +67 -0
- package/dist/esm/motion/typesurface/surfaces.js.map +1 -1
- package/docs/TYPE-SURFACE.md +183 -0
- package/package.json +4 -2
- package/src/motion/typesurface/TypeSurfaceCanvas.tsx +30 -2
- package/src/motion/typesurface/config.ts +29 -3
- package/src/motion/typesurface/construct.ts +828 -0
- package/src/motion/typesurface/engine.ts +31 -5
- package/src/motion/typesurface/index.ts +7 -3
- package/src/motion/typesurface/presets.ts +75 -3
- package/src/motion/typesurface/renderer.ts +77 -17
- package/src/motion/typesurface/shaders.ts +23 -1
- package/src/motion/typesurface/surfaces.ts +78 -1
- package/tools/ask/dist/components.mjs +1138 -57
- package/tools/ask/dist/neon.js +69 -0
- package/tools/ask/mcp.mjs +90 -5
- package/tools/ask/neon-render.mjs +143 -0
package/tools/ask/mcp.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { createInterface } from 'node:readline';
|
|
21
21
|
import { readFileSync } from 'node:fs';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
|
-
import { dirname, resolve } from 'node:path';
|
|
23
|
+
import { dirname, extname, resolve } from 'node:path';
|
|
24
24
|
import { spawn } from 'node:child_process';
|
|
25
25
|
import { askInBrowser } from './serve.mjs';
|
|
26
26
|
/* The blocking policy lives in its own file so a test can check every rule name against the
|
|
@@ -664,7 +664,33 @@ const CHECK_ANIMATION_REQUEST = {
|
|
|
664
664
|
},
|
|
665
665
|
};
|
|
666
666
|
|
|
667
|
-
|
|
667
|
+
/**
|
|
668
|
+
* **The neon sign — the one animation this server renders itself.** The five templates above are
|
|
669
|
+
* rendered by Remotion from the repository; the neon is the package's own `NeonCanvas`, which
|
|
670
|
+
* ships, so a headless browser can draw it wherever the package is installed (`neon-render.mjs`).
|
|
671
|
+
*
|
|
672
|
+
* It is listed, described and checked by the same three tools as every other animation (template
|
|
673
|
+
* `neon-sign`). This tool carries the schema in full as well, because a model reads a tool's
|
|
674
|
+
* arguments before it reads a contract: the request is discoverable from `tools/list` alone.
|
|
675
|
+
* `dist/components.mjs` holds the schema and the request → scene translation (`tools/ask/neon.ts`).
|
|
676
|
+
*/
|
|
677
|
+
const NEON_TEMPLATE = 'neon-sign';
|
|
678
|
+
const RENDER_NEON = {
|
|
679
|
+
name: 'render_neon_sign',
|
|
680
|
+
title: 'Render a neon sign to a video file',
|
|
681
|
+
description:
|
|
682
|
+
'Render an animated neon sign and save it as a video (MP4, WebM or GIF): glowing neon text, '
|
|
683
|
+
+ 'emoji or artwork on a brick wall, with natural flicker and glitches. Each element has its own '
|
|
684
|
+
+ 'colour and font, so a sign in two colours is two `elements`. The scene contains exactly what '
|
|
685
|
+
+ 'you send. Returns { success, outputPath, animation, format, width, height, durationSeconds, '
|
|
686
|
+
+ 'fps, frameCount, validationErrors }; when success is false, fix each validationErrors path '
|
|
687
|
+
+ 'and call again. `describe_animation` with template "neon-sign" lists every colour name, font '
|
|
688
|
+
+ 'and level; `check_animation_request` checks a request without rendering it.',
|
|
689
|
+
/* Filled from the bundle on first `tools/list`: the schema lives beside the code that reads it. */
|
|
690
|
+
inputSchema: { type: 'object', properties: {} },
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const ANIMATION_TOOLS = ['list_animations', 'describe_animation', 'check_animation_request', 'render_neon_sign'];
|
|
668
694
|
const SITE_URL = process.env.COMPPACK_SITE_URL || 'https://comppack.vercel.app';
|
|
669
695
|
|
|
670
696
|
async function animationTool(id, name, args) {
|
|
@@ -672,18 +698,28 @@ async function animationTool(id, name, args) {
|
|
|
672
698
|
try { m = await componentsModule(); } catch (e) {
|
|
673
699
|
return toolError(id, `The animation catalogue did not load (run \`npm run ask:build\`): ${e.message}`);
|
|
674
700
|
}
|
|
701
|
+
if (name === 'render_neon_sign') return renderNeonSign(id, m, args);
|
|
675
702
|
if (name === 'list_animations') {
|
|
676
|
-
const list = m.listMotionTemplates();
|
|
703
|
+
const list = [...m.listMotionTemplates(), m.listNeonAnimation()];
|
|
677
704
|
return answer(id, { animations: list }, JSON.stringify(list, null, 2));
|
|
678
705
|
}
|
|
679
706
|
if (name === 'describe_animation') {
|
|
707
|
+
if (args?.template === NEON_TEMPLATE) {
|
|
708
|
+
const neon = m.describeNeonAnimation();
|
|
709
|
+
return answer(id, neon, JSON.stringify(neon, null, 2));
|
|
710
|
+
}
|
|
680
711
|
const d = m.describeMotionTemplate(args?.template);
|
|
681
|
-
if (!d) return toolError(id, `No animation template called ${JSON.stringify(args?.template)}. Available: ${m.listMotionTemplates().map(t => t.template).join(', ')}.`);
|
|
712
|
+
if (!d) return toolError(id, `No animation template called ${JSON.stringify(args?.template)}. Available: ${m.listMotionTemplates().map(t => t.template).join(', ')}, ${NEON_TEMPLATE}.`);
|
|
682
713
|
/* The JSON Schema is for validators; an agent reads the contract. */
|
|
683
714
|
const contract = { ...d };
|
|
684
715
|
delete contract.schema;
|
|
685
716
|
return answer(id, contract, JSON.stringify(contract, null, 2));
|
|
686
717
|
}
|
|
718
|
+
if (args?.request?.template === NEON_TEMPLATE) {
|
|
719
|
+
const n = m.validateNeonRequest(args.request);
|
|
720
|
+
if (!n.valid) return answer(id, n, `Not valid — ${n.errors.length} ${n.errors.length === 1 ? 'problem' : 'problems'}:\n${n.errors.map(e => ` ${e.path || '(request)'}: ${e.message}`).join('\n')}`, true);
|
|
721
|
+
return answer(id, n, `Valid — ${n.seconds}s of ${NEON_TEMPLATE}: ${n.elements.map(e => `${e.content} (${e.color})`).join(', ')}. Call render_neon_sign with the request's content to save it as ${n.output.format.toUpperCase()}.`);
|
|
722
|
+
}
|
|
687
723
|
const r = m.validateMotionRequest(args?.request);
|
|
688
724
|
if (!r.valid) {
|
|
689
725
|
return answer(id, r, `Not valid — ${r.errors.length} ${r.errors.length === 1 ? 'problem' : 'problems'}:\n${r.errors.map(e => ` ${e.path || '(request)'}: ${e.message}`).join('\n')}`, true);
|
|
@@ -692,6 +728,55 @@ async function animationTool(id, name, args) {
|
|
|
692
728
|
return answer(id, { valid: true, seconds: r.seconds, previewUrl, previewFormat: 'HTML + CSS (DOM), played live in the browser', request: r.request }, `Valid — ${r.seconds}s of ${r.request.template}. Preview (live HTML + CSS in the browser, not a video file): ${previewUrl}. The same request renders to MP4 (H.264) with Remotion.`);
|
|
693
729
|
}
|
|
694
730
|
|
|
731
|
+
/**
|
|
732
|
+
* `args` is the neon content plus `outputPath`. Checked in node first (the package's own
|
|
733
|
+
* `validateNeonScene`), so a bad request costs no browser; the page checks again with real fonts.
|
|
734
|
+
*/
|
|
735
|
+
async function renderNeonSign(id, m, args) {
|
|
736
|
+
const { outputPath: wanted, ...content } = args ?? {};
|
|
737
|
+
const base = { success: false, animation: NEON_TEMPLATE };
|
|
738
|
+
const refuse = (errors) => answer(id, { ...base, validationErrors: errors },
|
|
739
|
+
`Not rendered — ${errors.length} ${errors.length === 1 ? 'problem' : 'problems'}:\n${errors.map(e => ` ${e.path || '(request)'}: ${e.message}`).join('\n')}`, true);
|
|
740
|
+
|
|
741
|
+
if (wanted !== undefined && (typeof wanted !== 'string' || !wanted.trim())) return refuse([{ path: 'outputPath', message: 'Must be a file path.' }]);
|
|
742
|
+
/* A path that names its format is a format. */
|
|
743
|
+
const ext = wanted ? extname(wanted).slice(1).toLowerCase() : '';
|
|
744
|
+
if (content.format === undefined && ['mp4', 'webm', 'gif'].includes(ext)) content.format = ext;
|
|
745
|
+
const built = m.buildNeonScene(content);
|
|
746
|
+
if (!built.valid) return refuse(built.errors);
|
|
747
|
+
|
|
748
|
+
const { output } = built;
|
|
749
|
+
const slug = m.neonSceneSummary(built.scene).map(e => e.content).join('-').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'sign';
|
|
750
|
+
const outputPath = resolve(process.cwd(), wanted ?? `comppack-renders/neon-${slug}.${output.format}`);
|
|
751
|
+
if (ext && ext !== output.format) return refuse([{ path: 'outputPath', message: `Ends in .${ext} but the format is ${output.format}.` }]);
|
|
752
|
+
|
|
753
|
+
const started = Date.now();
|
|
754
|
+
let r;
|
|
755
|
+
try {
|
|
756
|
+
const { renderNeon } = await import('./neon-render.mjs');
|
|
757
|
+
r = await renderNeon({ content, output, outputPath, backgroundColor: built.backgroundColor });
|
|
758
|
+
} catch (e) {
|
|
759
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
760
|
+
return answer(id, { ...base, validationErrors: [], error: message }, `Not rendered: ${message}`, true);
|
|
761
|
+
}
|
|
762
|
+
if (r.errors.length) return refuse(r.errors);
|
|
763
|
+
|
|
764
|
+
const result = {
|
|
765
|
+
success: true, outputPath, animation: NEON_TEMPLATE, format: output.format, width: output.width, height: output.height,
|
|
766
|
+
durationSeconds: output.durationSeconds, fps: output.fps, frameCount: output.frameCount, validationErrors: [],
|
|
767
|
+
bytes: r.bytes, renderSeconds: Math.round((Date.now() - started) / 100) / 10, elements: r.elements, fonts: r.fonts,
|
|
768
|
+
};
|
|
769
|
+
const fallback = (r.fonts ?? []).filter(f => !f.loaded).map(f => f.family);
|
|
770
|
+
return answer(id, result,
|
|
771
|
+
`Rendered ${outputPath} — ${output.format.toUpperCase()}, ${output.width}×${output.height}, ${output.durationSeconds}s at ${output.fps} fps (${output.frameCount} frames).`
|
|
772
|
+
+ (fallback.length ? ` These fonts did not load and fell back: ${fallback.join(', ')}.` : ''));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** The neon tool with its schema, read from the bundle; without the bundle, the tool as declared. */
|
|
776
|
+
async function neonTool() {
|
|
777
|
+
try { return { ...RENDER_NEON, inputSchema: { ...(await componentsModule()).NEON_INPUT_SCHEMA, properties: { ...(await componentsModule()).NEON_INPUT_SCHEMA.properties, outputPath: { type: 'string', description: 'Where to save the video, absolute or relative to the working directory. Default comppack-renders/neon-<text>.<format>.' } } } }; } catch { return RENDER_NEON; }
|
|
778
|
+
}
|
|
779
|
+
|
|
695
780
|
const answer = (id, structured, text, isError = false) =>
|
|
696
781
|
ok(id, { content: [{ type: 'text', text }], structuredContent: structured, ...(isError ? { isError: true } : {}) });
|
|
697
782
|
|
|
@@ -745,7 +830,7 @@ const HANDLERS = {
|
|
|
745
830
|
}),
|
|
746
831
|
'notifications/initialized': () => {},
|
|
747
832
|
ping: id => ok(id, {}),
|
|
748
|
-
'tools/list': id => ok(id, { tools: [ASK, VALIDATE, ELICIT, SEARCH_COMPONENTS, DESCRIBE_COMPONENT, CHECK_RENDER_REQUEST, LIST_COMPONENTS, CHECK_COMPONENT_CONFIG, LIST_ANIMATIONS, DESCRIBE_ANIMATION, CHECK_ANIMATION_REQUEST] }),
|
|
833
|
+
'tools/list': async id => ok(id, { tools: [ASK, VALIDATE, ELICIT, SEARCH_COMPONENTS, DESCRIBE_COMPONENT, CHECK_RENDER_REQUEST, LIST_COMPONENTS, CHECK_COMPONENT_CONFIG, LIST_ANIMATIONS, DESCRIBE_ANIMATION, CHECK_ANIMATION_REQUEST, await neonTool()] }),
|
|
749
834
|
'tools/call': (id, params) => {
|
|
750
835
|
const args = params?.arguments ?? {};
|
|
751
836
|
if (params?.name === 'ask_user') return askUser(id, args);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **A neon request as a video file.** `render_neon_sign` calls this.
|
|
3
|
+
*
|
|
4
|
+
* The neon is an SVG with filters and web fonts, so a browser has to draw it — the same reason
|
|
5
|
+
* `ask_user` goes to one (`serve.mjs`). The shape is the same too: a one-shot loopback server, a
|
|
6
|
+
* token, a prebuilt page (`dist/neon.js`, the package's `NeonCanvas`) and the request in the
|
|
7
|
+
* document. The difference is who looks: a headless browser steps the page through every frame,
|
|
8
|
+
* photographs each, and ffmpeg joins them.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ **Playwright and ffmpeg are found, not depended on.** The library takes five peers and no
|
|
11
|
+
* more, so neither can be a dependency of the package. Playwright is resolved from the project
|
|
12
|
+
* the server was launched in, then from the package; ffmpeg from `PATH`. When one is missing the
|
|
13
|
+
* tool says which and how to get it, and every other tool of the server keeps working.
|
|
14
|
+
*
|
|
15
|
+
* No sleeps: the page's `ready` promise resolves when fonts, wall and the first frame are in,
|
|
16
|
+
* and each frame's promise resolves after the browser has painted it.
|
|
17
|
+
*/
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { cpus, tmpdir } from 'node:os';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** A failure the caller can act on: reported in the tool's result, not as a protocol error. */
|
|
30
|
+
export class NeonRenderError extends Error {}
|
|
31
|
+
|
|
32
|
+
async function chromiumFrom() {
|
|
33
|
+
const tried = [];
|
|
34
|
+
for (const from of [join(process.cwd(), 'package.json'), import.meta.url]) {
|
|
35
|
+
const require = createRequire(from);
|
|
36
|
+
for (const name of ['playwright', 'playwright-core', '@playwright/test']) {
|
|
37
|
+
try {
|
|
38
|
+
const m = await import(pathToFileURL(require.resolve(name)).href);
|
|
39
|
+
const chromium = m.chromium ?? m.default?.chromium;
|
|
40
|
+
if (chromium) return chromium;
|
|
41
|
+
} catch (e) { tried.push(`${name}: ${e.code ?? e.message}`); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new NeonRenderError(
|
|
45
|
+
'Rendering needs Playwright to draw the frames, and it is not installed in this project. '
|
|
46
|
+
+ 'Run `npm install -D playwright && npx playwright install chromium`, then call again.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function launch(chromium) {
|
|
51
|
+
try { return await chromium.launch(); } catch (first) {
|
|
52
|
+
/* No downloaded browser: the machine's own Chrome draws the same SVG. */
|
|
53
|
+
try { return await chromium.launch({ channel: 'chrome' }); } catch {
|
|
54
|
+
throw new NeonRenderError(`No browser to render with (${String(first.message).split('\n')[0]}). Run \`npx playwright install chromium\`, then call again.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CODECS = {
|
|
60
|
+
/* JPEG frames are full-range; players expect video range, so convert rather than tag. */
|
|
61
|
+
mp4: ['-vf', 'scale=out_range=tv', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-crf', '16', '-preset', 'medium', '-movflags', '+faststart'],
|
|
62
|
+
webm: ['-vf', 'scale=out_range=tv', '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-crf', '28', '-b:v', '0'],
|
|
63
|
+
gif: ['-filter_complex', '[0:v]split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer'],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function encode(dir, output, outputPath) {
|
|
67
|
+
return new Promise((res, rej) => {
|
|
68
|
+
const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-framerate', String(output.fps), '-i', join(dir, 'f-%05d.jpg'), ...CODECS[output.format], outputPath], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
69
|
+
let err = '';
|
|
70
|
+
ff.stderr.on('data', (d) => { err += d; });
|
|
71
|
+
ff.on('error', (e) => rej(new NeonRenderError(e.code === 'ENOENT'
|
|
72
|
+
? 'Rendering needs ffmpeg to join the frames into a video, and it is not on PATH. Install it (`brew install ffmpeg`, `apt install ffmpeg`), then call again.'
|
|
73
|
+
: `ffmpeg did not start: ${e.message}`)));
|
|
74
|
+
ff.on('exit', (code) => (code === 0 ? res() : rej(new NeonRenderError(`ffmpeg failed (${code}): ${err.trim().split('\n').slice(-3).join(' ')}`))));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{content: object, output: {format: string, width: number, height: number, durationSeconds: number, fps: number, frameCount: number},
|
|
80
|
+
* outputPath: string, backgroundColor?: string}} job `content` is the request; `output` is what `buildNeonScene` resolved from it.
|
|
81
|
+
* @returns {Promise<{errors: object[], elements?: object[], fonts?: object[], bytes?: number}>} `errors` are the page's validation errors.
|
|
82
|
+
*/
|
|
83
|
+
export async function renderNeon({ content, output, outputPath, backgroundColor = 'black' }) {
|
|
84
|
+
let js;
|
|
85
|
+
try { js = readFileSync(resolve(here, 'dist/neon.js'), 'utf8'); } catch {
|
|
86
|
+
throw new NeonRenderError('The neon render page is missing (tools/ask/dist/neon.js). In this repository run `npm run ask:build`; as an installed package, reinstall without --ignore-scripts.');
|
|
87
|
+
}
|
|
88
|
+
const chromium = await chromiumFrom();
|
|
89
|
+
|
|
90
|
+
const token = randomUUID();
|
|
91
|
+
const json = JSON.stringify({ content }).replace(/</g, '\\u003c');
|
|
92
|
+
const html = '<!doctype html><html><head><meta charset="utf-8"><title>CompPack neon render</title>'
|
|
93
|
+
+ `<style>html,body,#root{margin:0;width:100%;height:100%;overflow:hidden;background:${backgroundColor}}</style></head>`
|
|
94
|
+
+ `<body><div id="root"></div><script id="comppack-neon" type="application/json">${json}</script>`
|
|
95
|
+
+ '<script type="module" src="/neon.js"></script></body></html>';
|
|
96
|
+
const server = createServer((req, res) => {
|
|
97
|
+
/* A throw in a request listener takes the process down (see serve.mjs). */
|
|
98
|
+
let url;
|
|
99
|
+
try { url = new URL(req.url, 'http://127.0.0.1'); } catch { res.writeHead(400).end('bad request'); return; }
|
|
100
|
+
if (req.method === 'GET' && url.pathname === '/' && url.searchParams.get('t') === token) {
|
|
101
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
|
|
102
|
+
} else if (req.method === 'GET' && url.pathname === '/neon.js' && req.headers.referer?.includes(token)) {
|
|
103
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(js);
|
|
104
|
+
} else res.writeHead(404).end();
|
|
105
|
+
});
|
|
106
|
+
server.on('clientError', (_e, socket) => socket.destroy());
|
|
107
|
+
const port = await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', () => res(server.address().port)); });
|
|
108
|
+
|
|
109
|
+
const dir = mkdtempSync(join(tmpdir(), 'comppack-neon-'));
|
|
110
|
+
let browser;
|
|
111
|
+
try {
|
|
112
|
+
browser = await launch(chromium);
|
|
113
|
+
const context = await browser.newContext({ viewport: { width: output.width, height: output.height }, deviceScaleFactor: 1 });
|
|
114
|
+
const open = async () => {
|
|
115
|
+
const page = await context.newPage();
|
|
116
|
+
await page.goto(`http://127.0.0.1:${port}/?t=${token}`);
|
|
117
|
+
return { page, ready: await page.evaluate(() => globalThis.__neon.ready) };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/* The first page decides whether the request can be drawn at all, with real fonts measured. */
|
|
121
|
+
const first = await open();
|
|
122
|
+
if (first.ready.errors.length) return { errors: first.ready.errors };
|
|
123
|
+
|
|
124
|
+
/* Frames are independent (the canvas is pure in time), so a few pages draw them side by side. */
|
|
125
|
+
const workers = Math.max(1, Math.min(4, cpus().length - 1, Math.ceil(output.frameCount / 8)));
|
|
126
|
+
const pages = [first.page, ...(await Promise.all(Array.from({ length: workers - 1 }, open))).map((o) => o.page)];
|
|
127
|
+
let next = 0;
|
|
128
|
+
await Promise.all(pages.map(async (page) => {
|
|
129
|
+
for (let i = next++; i < output.frameCount; i = next++) {
|
|
130
|
+
await page.evaluate((ms) => globalThis.__neon.frame(ms), (i * 1000) / output.fps);
|
|
131
|
+
await page.screenshot({ path: join(dir, `f-${String(i).padStart(5, '0')}.jpg`), type: 'jpeg', quality: 95 });
|
|
132
|
+
}
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
136
|
+
await encode(dir, output, outputPath);
|
|
137
|
+
return { errors: [], elements: first.ready.elements, fonts: first.ready.fonts, bytes: statSync(outputPath).size };
|
|
138
|
+
} finally {
|
|
139
|
+
await browser?.close().catch(() => {});
|
|
140
|
+
server.close();
|
|
141
|
+
rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
}
|