@appinternalleads/ui 0.4.1 → 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.
Files changed (76) hide show
  1. package/README.md +108 -0
  2. package/dist/cjs/motion/player.js +3 -0
  3. package/dist/cjs/motion/player.js.map +1 -1
  4. package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js +25 -2
  5. package/dist/cjs/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
  6. package/dist/cjs/motion/typesurface/config.js +21 -1
  7. package/dist/cjs/motion/typesurface/config.js.map +1 -1
  8. package/dist/cjs/motion/typesurface/construct.js +875 -0
  9. package/dist/cjs/motion/typesurface/construct.js.map +1 -0
  10. package/dist/cjs/motion/typesurface/engine.js +28 -4
  11. package/dist/cjs/motion/typesurface/engine.js.map +1 -1
  12. package/dist/cjs/motion/typesurface/index.js +9 -1
  13. package/dist/cjs/motion/typesurface/index.js.map +1 -1
  14. package/dist/cjs/motion/typesurface/presets.js +69 -2
  15. package/dist/cjs/motion/typesurface/presets.js.map +1 -1
  16. package/dist/cjs/motion/typesurface/renderer.js +81 -15
  17. package/dist/cjs/motion/typesurface/renderer.js.map +1 -1
  18. package/dist/cjs/motion/typesurface/shaders.js +23 -1
  19. package/dist/cjs/motion/typesurface/shaders.js.map +1 -1
  20. package/dist/cjs/motion/typesurface/surfaces.js +69 -0
  21. package/dist/cjs/motion/typesurface/surfaces.js.map +1 -1
  22. package/dist/esm/motion/player.d.ts.map +1 -1
  23. package/dist/esm/motion/player.js +3 -0
  24. package/dist/esm/motion/player.js.map +1 -1
  25. package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts +11 -1
  26. package/dist/esm/motion/typesurface/TypeSurfaceCanvas.d.ts.map +1 -1
  27. package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js +25 -2
  28. package/dist/esm/motion/typesurface/TypeSurfaceCanvas.js.map +1 -1
  29. package/dist/esm/motion/typesurface/config.d.ts +13 -2
  30. package/dist/esm/motion/typesurface/config.d.ts.map +1 -1
  31. package/dist/esm/motion/typesurface/config.js +21 -1
  32. package/dist/esm/motion/typesurface/config.js.map +1 -1
  33. package/dist/esm/motion/typesurface/construct.d.ts +284 -0
  34. package/dist/esm/motion/typesurface/construct.d.ts.map +1 -0
  35. package/dist/esm/motion/typesurface/construct.js +866 -0
  36. package/dist/esm/motion/typesurface/construct.js.map +1 -0
  37. package/dist/esm/motion/typesurface/engine.d.ts +8 -0
  38. package/dist/esm/motion/typesurface/engine.d.ts.map +1 -1
  39. package/dist/esm/motion/typesurface/engine.js +28 -4
  40. package/dist/esm/motion/typesurface/engine.js.map +1 -1
  41. package/dist/esm/motion/typesurface/index.d.ts +7 -3
  42. package/dist/esm/motion/typesurface/index.d.ts.map +1 -1
  43. package/dist/esm/motion/typesurface/index.js +6 -2
  44. package/dist/esm/motion/typesurface/index.js.map +1 -1
  45. package/dist/esm/motion/typesurface/presets.d.ts +18 -0
  46. package/dist/esm/motion/typesurface/presets.d.ts.map +1 -1
  47. package/dist/esm/motion/typesurface/presets.js +68 -3
  48. package/dist/esm/motion/typesurface/presets.js.map +1 -1
  49. package/dist/esm/motion/typesurface/renderer.d.ts +10 -0
  50. package/dist/esm/motion/typesurface/renderer.d.ts.map +1 -1
  51. package/dist/esm/motion/typesurface/renderer.js +82 -16
  52. package/dist/esm/motion/typesurface/renderer.js.map +1 -1
  53. package/dist/esm/motion/typesurface/shaders.d.ts +1 -1
  54. package/dist/esm/motion/typesurface/shaders.d.ts.map +1 -1
  55. package/dist/esm/motion/typesurface/shaders.js +23 -1
  56. package/dist/esm/motion/typesurface/shaders.js.map +1 -1
  57. package/dist/esm/motion/typesurface/surfaces.d.ts +28 -1
  58. package/dist/esm/motion/typesurface/surfaces.d.ts.map +1 -1
  59. package/dist/esm/motion/typesurface/surfaces.js +67 -0
  60. package/dist/esm/motion/typesurface/surfaces.js.map +1 -1
  61. package/docs/TYPE-SURFACE.md +188 -0
  62. package/package.json +4 -2
  63. package/src/motion/player.ts +3 -0
  64. package/src/motion/typesurface/TypeSurfaceCanvas.tsx +30 -2
  65. package/src/motion/typesurface/config.ts +29 -3
  66. package/src/motion/typesurface/construct.ts +828 -0
  67. package/src/motion/typesurface/engine.ts +31 -5
  68. package/src/motion/typesurface/index.ts +7 -3
  69. package/src/motion/typesurface/presets.ts +75 -3
  70. package/src/motion/typesurface/renderer.ts +77 -17
  71. package/src/motion/typesurface/shaders.ts +23 -1
  72. package/src/motion/typesurface/surfaces.ts +78 -1
  73. package/tools/ask/dist/components.mjs +4235 -908
  74. package/tools/ask/dist/render.js +589 -0
  75. package/tools/ask/mcp.mjs +261 -7
  76. package/tools/ask/render.mjs +248 -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
@@ -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
  );
@@ -664,7 +666,171 @@ const CHECK_ANIMATION_REQUEST = {
664
666
  },
665
667
  };
666
668
 
667
- const ANIMATION_TOOLS = ['list_animations', 'describe_animation', 'check_animation_request'];
669
+ /**
670
+ * **The neon sign — the one animation this server renders itself.** The five templates above are
671
+ * rendered by Remotion from the repository; the neon is the package's own `NeonCanvas`, which
672
+ * ships, so a headless browser can draw it wherever the package is installed (`render.mjs`).
673
+ *
674
+ * It is listed, described and checked by the same three tools as every other animation (template
675
+ * `neon-sign`). This tool carries the schema in full as well, because a model reads a tool's
676
+ * arguments before it reads a contract: the request is discoverable from `tools/list` alone.
677
+ * `dist/components.mjs` holds the schema and the request → scene translation (`tools/ask/neon.ts`).
678
+ */
679
+ const NEON_TEMPLATE = 'neon-sign';
680
+ const RENDER_NEON = {
681
+ name: 'render_neon_sign',
682
+ title: 'Render a neon sign to an image or a video file',
683
+ description:
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 '
691
+ + 'and level; `check_animation_request` checks a request without rendering it.',
692
+ /* Filled from the bundle on first `tools/list`: the schema lives beside the code that reads it. */
693
+ inputSchema: { type: 'object', properties: {} },
694
+ };
695
+
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'];
668
834
  const SITE_URL = process.env.COMPPACK_SITE_URL || 'https://comppack.vercel.app';
669
835
 
670
836
  async function animationTool(id, name, args) {
@@ -672,24 +838,111 @@ async function animationTool(id, name, args) {
672
838
  try { m = await componentsModule(); } catch (e) {
673
839
  return toolError(id, `The animation catalogue did not load (run \`npm run ask:build\`): ${e.message}`);
674
840
  }
841
+ if (name === 'render_neon_sign') return renderNeonSign(id, m, args);
842
+ if (name === 'render_animation') return renderAnimation(id, m, args);
675
843
  if (name === 'list_animations') {
676
- const list = m.listMotionTemplates();
844
+ const list = [...m.listMotionTemplates(), m.listNeonAnimation()];
677
845
  return answer(id, { animations: list }, JSON.stringify(list, null, 2));
678
846
  }
679
847
  if (name === 'describe_animation') {
848
+ if (args?.template === NEON_TEMPLATE) {
849
+ const neon = m.describeNeonAnimation();
850
+ return answer(id, neon, JSON.stringify(neon, null, 2));
851
+ }
680
852
  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(', ')}.`);
853
+ if (!d) return toolError(id, `No animation template called ${JSON.stringify(args?.template)}. Available: ${m.listMotionTemplates().map(t => t.template).join(', ')}, ${NEON_TEMPLATE}.`);
682
854
  /* The JSON Schema is for validators; an agent reads the contract. */
683
855
  const contract = { ...d };
684
856
  delete contract.schema;
685
857
  return answer(id, contract, JSON.stringify(contract, null, 2));
686
858
  }
859
+ if (args?.request?.template === NEON_TEMPLATE) {
860
+ const n = m.validateNeonRequest(args.request);
861
+ 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);
862
+ 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()}.`);
863
+ }
687
864
  const r = m.validateMotionRequest(args?.request);
688
865
  if (!r.valid) {
689
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);
690
867
  }
691
868
  const previewUrl = m.motionPreviewUrl(r.request, SITE_URL);
692
- 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.`);
870
+ }
871
+
872
+ /**
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.
877
+ */
878
+ async function renderFile(id, { kind, args, base, formats, build, name, describe, say = () => '.' }) {
879
+ const { outputPath: wanted, ...content } = args ?? {};
880
+ const refuse = (errors) => answer(id, { success: false, ...base, validationErrors: errors }, `Not rendered — ${problems(errors)}`, true);
881
+ if (wanted !== undefined && (typeof wanted !== 'string' || !wanted.trim())) return refuse([{ path: 'outputPath', message: 'Must be a file path.' }]);
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);
885
+ if (!built.valid) return refuse(built.errors);
886
+
887
+ const { output } = built;
888
+ if (ext && ext !== output.format) return refuse([{ path: 'outputPath', message: `Ends in .${ext} but the format is ${output.format}.` }]);
889
+ const outputPath = resolve(process.cwd(), wanted ?? `comppack-renders/${name(built)}.${output.format}`);
890
+ const started = Date.now();
891
+ let r;
892
+ try {
893
+ const { render } = await import('./render.mjs');
894
+ r = await render({ kind, content: built.content ?? content, output, outputPath, backgroundColor: built.backgroundColor });
895
+ } catch (e) {
896
+ const message = e instanceof Error ? e.message : String(e);
897
+ return answer(id, { success: false, ...base, validationErrors: [], error: message }, `Not rendered: ${message}`, true);
898
+ }
899
+ if (r.errors.length) return refuse(r.errors);
900
+
901
+ const moment = output.still && (kind !== 'object') ? { timestampSeconds: r.timestampSeconds } : {};
902
+ const result = {
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 } : {}),
907
+ };
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)`;
912
+ return answer(id, result,
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.' : '')
915
+ + (fallback.length ? ` These fonts did not load and fell back: ${fallback.join(', ')}.` : ''));
916
+ }
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
+
943
+ /** The neon tool with its schema, read from the bundle; without the bundle, the tool as declared. */
944
+ async function neonTool() {
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; }
693
946
  }
694
947
 
695
948
  const answer = (id, structured, text, isError = false) =>
@@ -741,11 +994,11 @@ const HANDLERS = {
741
994
  initialize: (id) => ok(id, {
742
995
  protocolVersion: PROTOCOL,
743
996
  capabilities: { tools: {}, resources: {}, completions: {} },
744
- serverInfo: { name: 'comppack-ask', version: '0.1.2' },
997
+ serverInfo: { name: 'comppack-ask', version: VERSION },
745
998
  }),
746
999
  'notifications/initialized': () => {},
747
1000
  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] }),
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()] }),
749
1002
  'tools/call': (id, params) => {
750
1003
  const args = params?.arguments ?? {};
751
1004
  if (params?.name === 'ask_user') return askUser(id, args);
@@ -753,6 +1006,7 @@ const HANDLERS = {
753
1006
  if (params?.name === 'to_elicitation') return toElicitationTool(id, args);
754
1007
  if (COMPONENT_TOOLS.includes(params?.name)) return componentTool(id, params.name, args);
755
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);
756
1010
  return fail(id, -32602, `Unknown tool: ${params?.name}`);
757
1011
  },
758
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
+ }