@appinternalleads/ui 0.4.2 → 0.4.3

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/tools/ask/mcp.mjs CHANGED
@@ -44,6 +44,8 @@ const PROTOCOL = '2025-06-18';
44
44
  */
45
45
  const HERE = dirname(fileURLToPath(import.meta.url));
46
46
  const PKG = resolve(HERE, '../..');
47
+ /* The server is the package's: one version, read where it is written. */
48
+ const VERSION = JSON.parse(readFileSync(resolve(PKG, 'package.json'), 'utf8')).version;
47
49
  const QUESTION_SCHEMA = JSON.parse(
48
50
  readFileSync(resolve(PKG, 'spec/question.generate.json'), 'utf8'),
49
51
  );
@@ -667,7 +669,7 @@ const CHECK_ANIMATION_REQUEST = {
667
669
  /**
668
670
  * **The neon sign — the one animation this server renders itself.** The five templates above are
669
671
  * 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`).
672
+ * ships, so a headless browser can draw it wherever the package is installed (`render.mjs`).
671
673
  *
672
674
  * It is listed, described and checked by the same three tools as every other animation (template
673
675
  * `neon-sign`). This tool carries the schema in full as well, because a model reads a tool's
@@ -677,20 +679,158 @@ const CHECK_ANIMATION_REQUEST = {
677
679
  const NEON_TEMPLATE = 'neon-sign';
678
680
  const RENDER_NEON = {
679
681
  name: 'render_neon_sign',
680
- title: 'Render a neon sign to a video file',
682
+ title: 'Render a neon sign to an image or a video file',
681
683
  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 '
684
+ 'Render a neon sign and save it as a still image (PNG, JPG) or a video (MP4, WebM, GIF): glowing '
685
+ + 'neon text, emoji or artwork on a brick wall, with natural flicker and glitches. Each element has '
686
+ + 'its own colour and font, so a sign in two colours is two `elements`. The scene contains exactly '
687
+ + 'what you send. A still is one frame drawn directly; with no timeSeconds a glitching sign is caught '
688
+ + 'mid-glitch. Returns { success, outputPath, animation, format, width, height, validationErrors } '
689
+ + 'with timestampSeconds for a still, or durationSeconds, fps and frameCount for a video; when '
690
+ + 'success is false, fix each validationErrors path and call again. `describe_animation` with template "neon-sign" lists every colour name, font '
688
691
  + 'and level; `check_animation_request` checks a request without rendering it.',
689
692
  /* Filled from the bundle on first `tools/list`: the schema lives beside the code that reads it. */
690
693
  inputSchema: { type: 'object', properties: {} },
691
694
  };
692
695
 
693
- const ANIMATION_TOOLS = ['list_animations', 'describe_animation', 'check_animation_request', 'render_neon_sign'];
696
+ /**
697
+ * **Objects built from parts.** `TypeSurface` in `@comppack/ui/motion` has no catalogue of objects:
698
+ * a tumbler is a hollow tapered cylinder, a ring and a foot. These three tools are that layer over
699
+ * MCP: read what can be built, check a request, and get a PNG or JPG. The renderer is the package's own
700
+ * (`render.mjs`), so it draws wherever the package is installed.
701
+ */
702
+ const DESCRIBE_OBJECTS = {
703
+ name: 'describe_object_construction',
704
+ title: 'What objects can be built, and from what',
705
+ description:
706
+ 'For product-style objects with text on them (a tumbler, mug, can, bottle, vase, jar, lamp, or anything round that has no name here): '
707
+ + 'the primitives (cylinder, sphere, ring, revolve, tube, box) with every parameter and its range, the manipulators each accepts '
708
+ + '(taper, flare, bulge, twist, bend, scale), the materials, how the text attaches, the recipes with their full specs to copy and edit, '
709
+ + 'the fonts, and what cannot be done. There is no object catalogue: compose a spec from these. Then check_object_request and render_object.',
710
+ inputSchema: { type: 'object', properties: {} },
711
+ };
712
+
713
+ const CHECK_OBJECT = {
714
+ name: 'check_object_request',
715
+ title: 'Check an object request without rendering it',
716
+ description:
717
+ 'Validate a render_object request. Valid → { valid: true, object: { parts, text, fontSize, pose… }, config } where `config` is the '
718
+ + 'full TypeSurface config (the reusable preset: save it, or draw it with <TypeSurfaceCanvas config>). '
719
+ + 'Invalid → { valid: false, errors: [{ path, message }] }: fix each path and check again. Costs no browser.',
720
+ inputSchema: { type: 'object', properties: { request: { type: 'object', description: 'The render_object arguments, without outputPath.' } }, required: ['request'] },
721
+ };
722
+
723
+ const RENDER_OBJECT = {
724
+ name: 'render_object',
725
+ title: 'Render an object with text on it to an image',
726
+ description:
727
+ 'Build an object from parts and save a picture of it (PNG or JPG): a recipe (tumbler, mug, can, bottle) in any colour and material, or any '
728
+ + 'spec of primitives you compose, with words wrapped on it. The text is sized to the part and inked to read on it unless you say otherwise. '
729
+ + 'Returns { success, outputPath, format, width, height, object, fonts, validationErrors }; when success is false, fix each '
730
+ + 'validationErrors path and call again. `describe_object_construction` lists every shape, manipulator, material and range.',
731
+ /* Filled from the bundle on first `tools/list`: the schema lives beside the code that reads it. */
732
+ inputSchema: { type: 'object', properties: {} },
733
+ };
734
+
735
+ /**
736
+ * **Words on a surface.** The other half of `TypeSurface`: a newspaper page, a sheet of paper, a
737
+ * bottle label, a wrapper, cloth, with the text printed into it. One tool, because the whole
738
+ * request fits in its arguments: the surface, its state, and optionally a ready-made motion whose
739
+ * timeline a video plays.
740
+ */
741
+ const RENDER_SURFACE = {
742
+ name: 'render_surface',
743
+ title: 'Render words printed on a surface to an image or a video',
744
+ description:
745
+ 'Print words on a newspaper page, a sheet of paper, a bottle label, a wrapper or fabric and save the picture (PNG, JPG) or a video '
746
+ + 'of the surface transforming (MP4, WebM, GIF): a headline on a newspaper, a note on crumpled paper, a print on cloth. `state` sets '
747
+ + 'how the surface lies (flat, folded, rolled, crumpled); `motion` picks a ready-made transformation whose timeline the video plays. '
748
+ + 'A still shows the finished picture unless timeSeconds names a moment. Returns { success, outputPath, format, width, height, '
749
+ + 'surface, validationErrors }; when success is false, fix each validationErrors path and call again. For a tumbler, mug, can or '
750
+ + 'anything built from parts, use render_object.',
751
+ /* Filled from the bundle on first `tools/list`. */
752
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
753
+ };
754
+
755
+ async function surfaceRenderTool() {
756
+ try {
757
+ const s = (await componentsModule()).SURFACE_INPUT_SCHEMA;
758
+ return { ...RENDER_SURFACE, inputSchema: { ...s, properties: { ...s.properties, outputPath: { type: 'string', description: 'Where to save the image or video, absolute or relative to the working directory. Default comppack-renders/surface-<surface>-<text>.<format>.' } } } };
759
+ } catch { return RENDER_SURFACE; }
760
+ }
761
+
762
+ const OBJECT_TOOLS = ['describe_object_construction', 'check_object_request', 'render_object', 'render_surface'];
763
+
764
+ const problems = (errors) => `${errors.length} ${errors.length === 1 ? 'problem' : 'problems'}:\n${errors.map(e => ` ${e.path || '(request)'}: ${e.message}`).join('\n')}`;
765
+
766
+ async function objectTool(id, name, args) {
767
+ let m;
768
+ try { m = await componentsModule(); } catch (e) {
769
+ return toolError(id, `The object catalogue did not load (run \`npm run ask:build\`): ${e.message}`);
770
+ }
771
+ if (name === 'render_surface') {
772
+ return renderFile(id, {
773
+ kind: 'surface', args, base: {}, formats: ['png', 'jpg', 'mp4', 'webm', 'gif'],
774
+ build: content => m.buildSurfaceConfig(content),
775
+ name: built => `surface-${`${built.config.surface.type}-${built.config.text}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)}`,
776
+ describe: summary => ({ surface: summary }),
777
+ say: summary => `: “${summary.text}” on ${summary.surface} (${summary.state})${summary.motion ? `, motion ${summary.motion}` : ''}. Look at the file before reporting it done.`,
778
+ });
779
+ }
780
+ if (name === 'describe_object_construction') {
781
+ const d = m.describeObjectConstruction();
782
+ return answer(id, d, JSON.stringify(d, null, 2));
783
+ }
784
+ if (name === 'check_object_request') {
785
+ const r = m.validateObjectRequest(args?.request);
786
+ if (!r.valid) return answer(id, r, `Not valid — ${problems(r.errors)}`, true);
787
+ return answer(id, r, `Valid — ${r.object.name}: ${r.object.composition} “${r.object.text}” on ${r.object.textOn}, size ${r.object.fontSize}. Call render_object with the same arguments to save it as PNG or JPG.`);
788
+ }
789
+
790
+ return renderFile(id, {
791
+ kind: 'object', args, base: {}, formats: ['png', 'jpg'],
792
+ build: content => m.buildObjectConfig(content),
793
+ name: built => `object-${`${built.config.object.name}-${built.config.text}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'object'}`,
794
+ describe: summary => ({ object: summary }),
795
+ say: summary => `: ${summary.composition} “${summary.text}” at size ${summary.fontSize}. Look at the file before reporting it done.`,
796
+ });
797
+ }
798
+
799
+ /** The object tool with its schema, read from the bundle; without the bundle, the tool as declared. */
800
+ async function objectRenderTool() {
801
+ try {
802
+ const s = (await componentsModule()).OBJECT_INPUT_SCHEMA;
803
+ return { ...RENDER_OBJECT, inputSchema: { ...s, properties: { ...s.properties, outputPath: { type: 'string', description: 'Where to save the image, absolute or relative to the working directory. Default comppack-renders/object-<name>-<text>.<format>.' } } } };
804
+ } catch { return RENDER_OBJECT; }
805
+ }
806
+
807
+ /**
808
+ * **The templates, rendered where the package is installed.** Their MP4s used to exist only in the
809
+ * repository, through Remotion. The package ships each template's own player, which holds any frame
810
+ * it is given, so the same headless browser that draws the neon steps a template through its frames.
811
+ */
812
+ const RENDER_ANIMATION = {
813
+ name: 'render_animation',
814
+ title: 'Render an animation template to an image or a video file',
815
+ description:
816
+ 'Render a motion/1 request (the one check_animation_request accepts) and save it as a video (MP4, WebM, GIF) '
817
+ + 'or as one still frame (PNG, JPG). The motion is the template\'s own: nothing here changes timing. For a still, '
818
+ + 'timeSeconds chooses the moment; without it the middle of the animation is used, moved on if that frame is empty. '
819
+ + 'Returns { success, outputPath, animation, format, width, height, validationErrors } with timestampSeconds for a '
820
+ + 'still, or durationSeconds, fps and frameCount for a video. list_animations and describe_animation give the '
821
+ + 'templates and their contracts; for a neon sign, render_neon_sign takes the sign directly.',
822
+ /* Filled from the bundle on first `tools/list`. */
823
+ inputSchema: { type: 'object', properties: { request: { type: 'object' } }, required: ['request'] },
824
+ };
825
+
826
+ async function animationRenderTool() {
827
+ try {
828
+ const s = (await componentsModule()).ANIMATION_INPUT_SCHEMA;
829
+ return { ...RENDER_ANIMATION, inputSchema: { ...s, properties: { ...s.properties, outputPath: { type: 'string', description: 'Where to save the image or video, absolute or relative to the working directory. Default comppack-renders/<template>.<format>.' } } } };
830
+ } catch { return RENDER_ANIMATION; }
831
+ }
832
+
833
+ const ANIMATION_TOOLS = ['list_animations', 'describe_animation', 'check_animation_request', 'render_animation', 'render_neon_sign'];
694
834
  const SITE_URL = process.env.COMPPACK_SITE_URL || 'https://comppack.vercel.app';
695
835
 
696
836
  async function animationTool(id, name, args) {
@@ -699,6 +839,7 @@ async function animationTool(id, name, args) {
699
839
  return toolError(id, `The animation catalogue did not load (run \`npm run ask:build\`): ${e.message}`);
700
840
  }
701
841
  if (name === 'render_neon_sign') return renderNeonSign(id, m, args);
842
+ if (name === 'render_animation') return renderAnimation(id, m, args);
702
843
  if (name === 'list_animations') {
703
844
  const list = [...m.listMotionTemplates(), m.listNeonAnimation()];
704
845
  return answer(id, { animations: list }, JSON.stringify(list, null, 2));
@@ -725,56 +866,83 @@ async function animationTool(id, name, args) {
725
866
  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);
726
867
  }
727
868
  const previewUrl = m.motionPreviewUrl(r.request, SITE_URL);
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.`);
869
+ 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}. Call render_animation with this request to save it as a video or a still.`);
729
870
  }
730
871
 
731
872
  /**
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.
873
+ * **One render, whatever is drawn.** The three render tools differ in what they build; from there
874
+ * the path is the same: the arguments are the request plus `outputPath`, the request is checked in
875
+ * node first (so a bad one costs no browser), the page checks it again with real fonts, and the
876
+ * result says what the file is. A path that names its format is a format.
734
877
  */
735
- async function renderNeonSign(id, m, args) {
878
+ async function renderFile(id, { kind, args, base, formats, build, name, describe, say = () => '.' }) {
736
879
  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
-
880
+ const refuse = (errors) => answer(id, { success: false, ...base, validationErrors: errors }, `Not rendered — ${problems(errors)}`, true);
741
881
  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);
882
+ const ext = wanted ? extname(wanted).slice(1).toLowerCase().replace('jpeg', 'jpg') : '';
883
+ if (content.format === undefined && formats.includes(ext)) content.format = ext;
884
+ const built = build(content);
746
885
  if (!built.valid) return refuse(built.errors);
747
886
 
748
887
  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
888
  if (ext && ext !== output.format) return refuse([{ path: 'outputPath', message: `Ends in .${ext} but the format is ${output.format}.` }]);
752
-
889
+ const outputPath = resolve(process.cwd(), wanted ?? `comppack-renders/${name(built)}.${output.format}`);
753
890
  const started = Date.now();
754
891
  let r;
755
892
  try {
756
- const { renderNeon } = await import('./neon-render.mjs');
757
- r = await renderNeon({ content, output, outputPath, backgroundColor: built.backgroundColor });
893
+ const { render } = await import('./render.mjs');
894
+ r = await render({ kind, content: built.content ?? content, output, outputPath, backgroundColor: built.backgroundColor });
758
895
  } catch (e) {
759
896
  const message = e instanceof Error ? e.message : String(e);
760
- return answer(id, { ...base, validationErrors: [], error: message }, `Not rendered: ${message}`, true);
897
+ return answer(id, { success: false, ...base, validationErrors: [], error: message }, `Not rendered: ${message}`, true);
761
898
  }
762
899
  if (r.errors.length) return refuse(r.errors);
763
900
 
901
+ const moment = output.still && (kind !== 'object') ? { timestampSeconds: r.timestampSeconds } : {};
764
902
  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,
903
+ success: true, outputPath, ...base, format: output.format, width: output.width, height: output.height,
904
+ ...(output.still ? moment : { durationSeconds: output.durationSeconds, fps: output.fps, frameCount: output.frameCount }),
905
+ validationErrors: [], bytes: r.bytes, renderSeconds: Math.round((Date.now() - started) / 100) / 10,
906
+ ...describe(r.summary), fonts: r.fonts, ...(r.blank ? { blank: true } : {}),
768
907
  };
769
908
  const fallback = (r.fonts ?? []).filter(f => !f.loaded).map(f => f.family);
909
+ const what = output.still
910
+ ? `${output.format.toUpperCase()}, ${output.width}×${output.height}${moment.timestampSeconds !== undefined ? `, the frame at ${moment.timestampSeconds}s` : ''}`
911
+ : `${output.format.toUpperCase()}, ${output.width}×${output.height}, ${output.durationSeconds}s at ${output.fps} fps (${output.frameCount} frames)`;
770
912
  return answer(id, result,
771
- `Rendered ${outputPath} — ${output.format.toUpperCase()}, ${output.width}×${output.height}, ${output.durationSeconds}s at ${output.fps} fps (${output.frameCount} frames).`
913
+ `Rendered ${outputPath} — ${what}${say(r.summary)}`
914
+ + (r.blank ? ' That frame is empty (one flat colour): the animation shows nothing at that moment. Choose another timeSeconds.' : '')
772
915
  + (fallback.length ? ` These fonts did not load and fell back: ${fallback.join(', ')}.` : ''));
773
916
  }
774
917
 
918
+ const renderNeonSign = (id, m, args) => renderFile(id, {
919
+ kind: 'neon', args, base: { animation: NEON_TEMPLATE }, formats: ['png', 'jpg', 'mp4', 'webm', 'gif'],
920
+ build: content => m.buildNeonScene(content),
921
+ name: built => `neon-${m.neonSceneSummary(built.scene).map(e => e.content).join('-').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'sign'}`,
922
+ describe: summary => ({ elements: summary }),
923
+ });
924
+
925
+ /**
926
+ * The five templates, through their own players. A `neon-sign` request goes to the neon: the same
927
+ * file comes out whichever tool was called.
928
+ */
929
+ function renderAnimation(id, m, args) {
930
+ const request = args?.request;
931
+ if (request?.template === NEON_TEMPLATE && request.content && typeof request.content === 'object') {
932
+ const output = Object.fromEntries(Object.entries(args).filter(([k]) => k !== 'request'));
933
+ return renderNeonSign(id, m, { ...request.content, ...output });
934
+ }
935
+ return renderFile(id, {
936
+ kind: 'animation', args, base: { animation: typeof request?.template === 'string' ? request.template : undefined }, formats: ['png', 'jpg', 'mp4', 'webm', 'gif'],
937
+ build: content => m.buildAnimationRender(content),
938
+ name: built => built.request.template,
939
+ describe: () => ({}),
940
+ });
941
+ }
942
+
775
943
  /** The neon tool with its schema, read from the bundle; without the bundle, the tool as declared. */
776
944
  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; }
945
+ 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 image or video, absolute or relative to the working directory. Default comppack-renders/neon-<text>.<format>.' } } } }; } catch { return RENDER_NEON; }
778
946
  }
779
947
 
780
948
  const answer = (id, structured, text, isError = false) =>
@@ -826,11 +994,11 @@ const HANDLERS = {
826
994
  initialize: (id) => ok(id, {
827
995
  protocolVersion: PROTOCOL,
828
996
  capabilities: { tools: {}, resources: {}, completions: {} },
829
- serverInfo: { name: 'comppack-ask', version: '0.1.2' },
997
+ serverInfo: { name: 'comppack-ask', version: VERSION },
830
998
  }),
831
999
  'notifications/initialized': () => {},
832
1000
  ping: id => ok(id, {}),
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()] }),
1001
+ '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 animationRenderTool(), await neonTool(), DESCRIBE_OBJECTS, CHECK_OBJECT, await objectRenderTool(), await surfaceRenderTool()] }),
834
1002
  'tools/call': (id, params) => {
835
1003
  const args = params?.arguments ?? {};
836
1004
  if (params?.name === 'ask_user') return askUser(id, args);
@@ -838,6 +1006,7 @@ const HANDLERS = {
838
1006
  if (params?.name === 'to_elicitation') return toElicitationTool(id, args);
839
1007
  if (COMPONENT_TOOLS.includes(params?.name)) return componentTool(id, params.name, args);
840
1008
  if (ANIMATION_TOOLS.includes(params?.name)) return animationTool(id, params.name, args);
1009
+ if (OBJECT_TOOLS.includes(params?.name)) return objectTool(id, params.name, args);
841
1010
  return fail(id, -32602, `Unknown tool: ${params?.name}`);
842
1011
  },
843
1012
  /* `file` is where the text lives on disk and is nobody's business over the wire — a resource
@@ -0,0 +1,248 @@
1
+ /**
2
+ * **A render request as an image or video file.** `render_neon_sign`, `render_object`, `render_surface` and
3
+ * `render_animation` call this.
4
+ *
5
+ * What they draw is SVG with filters, WebGL or animated DOM, with web fonts, so a browser has to
6
+ * draw it — the same reason `ask_user` goes to one (`serve.mjs`). The shape is the same too: a
7
+ * one-shot loopback server, a token, a prebuilt page (`dist/render.js`, the package's own
8
+ * components) and the request in the document. The difference is who looks: a headless browser
9
+ * shows the page each moment and photographs it.
10
+ *
11
+ * still one page, one moment, one screenshot written straight to the output path
12
+ * video a few pages step through every frame; ffmpeg joins them
13
+ *
14
+ * ⚠ **Playwright and ffmpeg are found, not depended on.** The library takes five peers and no
15
+ * more, so neither can be a dependency of the package. Playwright is resolved from the project
16
+ * the server was launched in, then from the package; ffmpeg from `PATH`, and only a video needs
17
+ * it. When one is missing the tool says which and how to get it, and every other tool keeps working.
18
+ *
19
+ * No sleeps: the page's `ready` promise resolves when fonts, images and the first frame are in,
20
+ * and each frame's promise resolves after the browser has painted it.
21
+ */
22
+ import { Buffer } from 'node:buffer';
23
+ import { createServer } from 'node:http';
24
+ import { createRequire } from 'node:module';
25
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
26
+ import { inflateSync } from 'node:zlib';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { spawn } from 'node:child_process';
29
+ import { cpus, tmpdir } from 'node:os';
30
+ import { dirname, join, resolve } from 'node:path';
31
+ import { fileURLToPath, pathToFileURL } from 'node:url';
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+
35
+ /** A failure the caller can act on: reported in the tool's result, not as a protocol error. */
36
+ export class RenderError extends Error {}
37
+
38
+ async function chromiumFrom() {
39
+ for (const from of [join(process.cwd(), 'package.json'), import.meta.url]) {
40
+ const require = createRequire(from);
41
+ for (const name of ['playwright', 'playwright-core', '@playwright/test']) {
42
+ try {
43
+ const m = await import(pathToFileURL(require.resolve(name)).href);
44
+ const chromium = m.chromium ?? m.default?.chromium;
45
+ if (chromium) return chromium;
46
+ } catch { /* not here; try the next */ }
47
+ }
48
+ }
49
+ throw new RenderError(
50
+ 'Rendering needs Playwright to draw the frames, and it is not installed in this project. '
51
+ + 'Run `npm install -D playwright && npx playwright install chromium`, then call again.',
52
+ );
53
+ }
54
+
55
+ /**
56
+ * How a WebGL scene gets a GL, in the order tried. The machine's own GPU first: measured on a
57
+ * newspaper page, 2.5 s for a still against 11 s, and 3 s for 90 frames against 92 s. Software GL
58
+ * second, so the same scene still draws on a machine with no GPU (a container, a CI runner).
59
+ */
60
+ const GL = [
61
+ ['--ignore-gpu-blocklist', '--enable-gpu', '--use-gl=angle', ...(process.platform === 'darwin' ? ['--use-angle=metal'] : [])],
62
+ ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'],
63
+ ];
64
+
65
+ async function launch(chromium, args) {
66
+ const options = args ? { args } : {};
67
+ try { return await chromium.launch(options); } catch (first) {
68
+ /* No downloaded browser: the machine's own Chrome draws the same page. */
69
+ try { return await chromium.launch({ ...options, channel: 'chrome' }); } catch {
70
+ throw new RenderError(`No browser to render with (${String(first.message).split('\n')[0]}). Run \`npx playwright install chromium\`, then call again.`);
71
+ }
72
+ }
73
+ }
74
+
75
+ const CODECS = {
76
+ /* JPEG frames are full-range; players expect video range, so convert rather than tag. */
77
+ mp4: ['-vf', 'scale=out_range=tv', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-crf', '16', '-preset', 'medium', '-movflags', '+faststart'],
78
+ webm: ['-vf', 'scale=out_range=tv', '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-crf', '28', '-b:v', '0'],
79
+ gif: ['-filter_complex', '[0:v]split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer'],
80
+ };
81
+
82
+ /**
83
+ * **True when a PNG is one flat colour.** Some templates open a scene on an empty frame; a still
84
+ * taken there is a picture of nothing, and the file looks like a success. Reads the screenshot's
85
+ * own bytes (8-bit RGB or RGBA, as the browser writes them) and samples every eighth pixel.
86
+ */
87
+ export function isBlankPng(png) {
88
+ let width = 0, height = 0, bpp = 0;
89
+ const idat = [];
90
+ for (let p = 8; p < png.length;) {
91
+ const length = png.readUInt32BE(p);
92
+ const type = png.toString('latin1', p + 4, p + 8);
93
+ const data = png.subarray(p + 8, p + 8 + length);
94
+ if (type === 'IHDR') {
95
+ width = data.readUInt32BE(0); height = data.readUInt32BE(4);
96
+ bpp = data[8] === 8 && data[12] === 0 ? (data[9] === 6 ? 4 : data[9] === 2 ? 3 : 0) : 0;
97
+ if (!bpp) return false;
98
+ } else if (type === 'IDAT') idat.push(data);
99
+ p += 12 + length;
100
+ }
101
+ if (!bpp) return false;
102
+ const raw = inflateSync(Buffer.concat(idat));
103
+ const stride = width * bpp;
104
+ let prev = Buffer.alloc(stride);
105
+ let first = null;
106
+ for (let y = 0; y < height; y++) {
107
+ const filter = raw[y * (stride + 1)];
108
+ const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
109
+ for (let x = 0; x < stride; x++) {
110
+ const a = x >= bpp ? line[x - bpp] : 0;
111
+ const b = prev[x];
112
+ const c = x >= bpp ? prev[x - bpp] : 0;
113
+ let add = 0;
114
+ if (filter === 1) add = a;
115
+ else if (filter === 2) add = b;
116
+ else if (filter === 3) add = (a + b) >> 1;
117
+ else if (filter === 4) { const q = a + b - c; const qa = Math.abs(q - a); const qb = Math.abs(q - b); const qc = Math.abs(q - c); add = qa <= qb && qa <= qc ? a : qb <= qc ? b : c; }
118
+ line[x] = (line[x] + add) & 255;
119
+ }
120
+ if (y % 8 === 0) {
121
+ for (let x = 0; x < width; x += 8) {
122
+ const i = x * bpp;
123
+ if (!first) first = [line[i], line[i + 1], line[i + 2]];
124
+ else if (Math.abs(line[i] - first[0]) + Math.abs(line[i + 1] - first[1]) + Math.abs(line[i + 2] - first[2]) > 12) return false;
125
+ }
126
+ }
127
+ prev = line;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ function encode(dir, output, outputPath) {
133
+ return new Promise((res, rej) => {
134
+ 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'] });
135
+ let err = '';
136
+ ff.stderr.on('data', (d) => { err += d; });
137
+ ff.on('error', (e) => rej(new RenderError(e.code === 'ENOENT'
138
+ ? 'A video needs ffmpeg to join the frames, and it is not on PATH. Install it (`brew install ffmpeg`, `apt install ffmpeg`), then call again. A png or jpg needs no ffmpeg.'
139
+ : `ffmpeg did not start: ${e.message}`)));
140
+ ff.on('exit', (code) => (code === 0 ? res() : rej(new RenderError(`ffmpeg failed (${code}): ${err.trim().split('\n').slice(-3).join(' ')}`))));
141
+ });
142
+ }
143
+
144
+ /**
145
+ * @param {{kind: 'neon' | 'object' | 'surface' | 'animation', content: object,
146
+ * output: {format: string, still: boolean, width: number, height: number, fps: number, frameCount: number, timestampSeconds?: number},
147
+ * outputPath: string, backgroundColor?: string}} job `content` is the request; `output` is what its builder resolved from it.
148
+ * @returns {Promise<{errors: object[], summary?: unknown, fonts?: object[], bytes?: number, timestampSeconds?: number, blank?: boolean}>} `errors` are the page's validation errors.
149
+ */
150
+ export async function render({ kind, content, output, outputPath, backgroundColor = 'black' }) {
151
+ let js;
152
+ try { js = readFileSync(resolve(here, 'dist/render.js'), 'utf8'); } catch {
153
+ throw new RenderError('The render page is missing (tools/ask/dist/render.js). In this repository run `npm run ask:build`; as an installed package, reinstall without --ignore-scripts.');
154
+ }
155
+ const chromium = await chromiumFrom();
156
+
157
+ const token = randomUUID();
158
+ const json = JSON.stringify({ kind, content, width: output.width }).replace(/</g, '\\u003c');
159
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>CompPack render</title>'
160
+ + `<style>html,body,#root{margin:0;width:100%;height:100%;overflow:hidden;background:${backgroundColor}}</style></head>`
161
+ + `<body><div id="root"></div><script id="comppack-render" type="application/json">${json}</script>`
162
+ + '<script type="module" src="/render.js"></script></body></html>';
163
+ const server = createServer((req, res) => {
164
+ /* A throw in a request listener takes the process down (see serve.mjs). */
165
+ let url;
166
+ try { url = new URL(req.url, 'http://127.0.0.1'); } catch { res.writeHead(400).end('bad request'); return; }
167
+ if (req.method === 'GET' && url.pathname === '/' && url.searchParams.get('t') === token) {
168
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
169
+ } else if (req.method === 'GET' && url.pathname === '/render.js' && req.headers.referer?.includes(token)) {
170
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(js);
171
+ } else res.writeHead(404).end();
172
+ });
173
+ server.on('clientError', (_e, socket) => socket.destroy());
174
+ const port = await new Promise((res, rej) => { server.once('error', rej); server.listen(0, '127.0.0.1', () => res(server.address().port)); });
175
+
176
+ const webgl = kind === 'object' || kind === 'surface';
177
+ try {
178
+ /* `COMPPACK_GL=software` skips the GPU: the same pixels on every machine, slowly. */
179
+ const attempts = !webgl ? [undefined] : process.env.COMPPACK_GL === 'software' ? GL.slice(-1) : GL;
180
+ for (const [attempt, args] of attempts.entries()) {
181
+ const last = attempt === attempts.length - 1;
182
+ try {
183
+ const r = await draw(args);
184
+ /* No GL from the GPU: the page says so, and the software one is tried. */
185
+ if (!last && r.errors.some((e) => /WebGL/i.test(e.message))) continue;
186
+ return r;
187
+ } catch (e) {
188
+ if (last || e instanceof RenderError) throw e;
189
+ }
190
+ }
191
+ } finally {
192
+ server.close();
193
+ }
194
+
195
+ async function draw(args) {
196
+ let dir;
197
+ let browser;
198
+ try {
199
+ browser = await launch(chromium, args);
200
+ const context = await browser.newContext({ viewport: { width: output.width, height: output.height }, deviceScaleFactor: 1 });
201
+ const open = async () => {
202
+ const page = await context.newPage();
203
+ await page.goto(`http://127.0.0.1:${port}/?t=${token}`);
204
+ return { page, ready: await page.evaluate(() => globalThis.__render.ready) };
205
+ };
206
+
207
+ /* The first page decides whether the request can be drawn at all, with real fonts measured. */
208
+ const first = await open();
209
+ if (first.ready.errors.length) return { errors: first.ready.errors };
210
+ mkdirSync(dirname(outputPath), { recursive: true });
211
+
212
+ if (output.still) {
213
+ /* The one frame, drawn directly: no video is made and none is cut. */
214
+ let at = output.timestampSeconds ?? 0;
215
+ let png;
216
+ let blank = false;
217
+ for (let tries = 0; ; tries++) {
218
+ await first.page.evaluate((ms) => globalThis.__render.frame(ms), at * 1000);
219
+ png = await first.page.screenshot({ type: 'png' });
220
+ blank = kind === 'animation' && isBlankPng(png);
221
+ /* A moment the request named is drawn as asked, empty or not. One this server chose moves on to the next frame that shows something. */
222
+ if (!blank || !output.timestampChosen || tries >= 16 || at + 0.2 > output.durationSeconds) break;
223
+ at = Math.round((at + 0.2) * 1000) / 1000;
224
+ }
225
+ if (output.format === 'png') writeFileSync(outputPath, png);
226
+ else await first.page.screenshot({ path: outputPath, type: 'jpeg', quality: 92 });
227
+ return { errors: [], summary: first.ready.summary, fonts: first.ready.fonts, bytes: statSync(outputPath).size, timestampSeconds: at, blank };
228
+ } else {
229
+ /* Frames are independent (each is a moment the page is asked for), so a few pages draw them side by side. */
230
+ dir = mkdtempSync(join(tmpdir(), 'comppack-render-'));
231
+ const workers = Math.max(1, Math.min(4, cpus().length - 1, Math.ceil(output.frameCount / 8)));
232
+ const pages = [first.page, ...(await Promise.all(Array.from({ length: workers - 1 }, open))).map((o) => o.page)];
233
+ let next = 0;
234
+ await Promise.all(pages.map(async (page) => {
235
+ for (let i = next++; i < output.frameCount; i = next++) {
236
+ await page.evaluate((ms) => globalThis.__render.frame(ms), (i * 1000) / output.fps);
237
+ await page.screenshot({ path: join(dir, `f-${String(i).padStart(5, '0')}.jpg`), type: 'jpeg', quality: 95 });
238
+ }
239
+ }));
240
+ await encode(dir, output, outputPath);
241
+ }
242
+ return { errors: [], summary: first.ready.summary, fonts: first.ready.fonts, bytes: statSync(outputPath).size };
243
+ } finally {
244
+ await browser?.close().catch(() => {});
245
+ if (dir) rmSync(dir, { recursive: true, force: true });
246
+ }
247
+ }
248
+ }