fui-studio-mcp 0.1.0 → 0.1.1

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 CHANGED
@@ -22,6 +22,8 @@ on your computer when it needs it. You don't run it yourself.
22
22
 
23
23
  Make a folder for your FUI projects, for example `FUI-Studio` in your home
24
24
  folder. The assistant saves there, and the studio watches the same folder.
25
+ If the config names no folder, the server uses `FUI-Studio` in your home
26
+ folder.
25
27
 
26
28
  ## 2. Add the server to your AI app
27
29
 
@@ -82,7 +84,8 @@ over stdio. Instead of `--projects`, you can set the environment variable
82
84
  ## 3. Open the same folder in the studio
83
85
 
84
86
  In FUI Studio, click **folder…** in the toolbar and choose your projects
85
- folder. Once the assistant has saved a project, open it from **Open**. From
87
+ folder, the same one as in step 1. The assistant also tells you the folder
88
+ when it first saves. Once it has saved a project, open it from **Open**. From
86
89
  then on, every save the assistant makes shows up within a second, on the page
87
90
  you're looking at. One undo takes a change back.
88
91
 
@@ -130,14 +133,19 @@ projects fit together.
130
133
 
131
134
  ## Troubleshooting
132
135
 
133
- - **The assistant says it has no projects folder.** Your config is missing
134
- `--projects`.
136
+ - **The assistant saved somewhere else.** Its save message names the folder.
137
+ Without `--projects` in the config, it saves in `FUI-Studio` in your home
138
+ folder.
139
+ - **Changing the folder.** For the assistant, change the path after
140
+ `--projects` in its config and restart your AI app; with Claude Code, run
141
+ `claude mcp remove fui-studio` and the add command again. For the studio,
142
+ choose **change folder…** at the bottom of the **Open** dialog.
135
143
  - **"npx: command not found", or the server won't start.** Install Node.js,
136
144
  then restart your AI app. Apps started from the Dock or Start menu may not
137
145
  see a Node installed through nvm; in that case, put the full path to `npx`
138
146
  in `"command"`.
139
147
  - **The studio doesn't update.** Check that it has the same folder open
140
- (**folder…**), and that you opened the very project the assistant is
148
+ (the **Open** dialog shows which), and that you opened the very project the assistant is
141
149
  saving.
142
150
  - **Which version is this?** Run `npx fui-studio-mcp --version`.
143
151
 
@@ -11,7 +11,7 @@ import {
11
11
  localFontBytes,
12
12
  parseBdf,
13
13
  resolveFace
14
- } from "./chunk-W7L36Z34.js";
14
+ } from "./chunk-NDQB3ETD.js";
15
15
 
16
16
  // ../../packages/fonts/dist/chunk-MTHRWFM6.js
17
17
  import * as opentype from "opentype.js";
@@ -2294,7 +2294,6 @@ function makeCookContext(input) {
2294
2294
  params,
2295
2295
  inputs,
2296
2296
  wires,
2297
- wiredOutputs: input.wiredOutputs ?? /* @__PURE__ */ new Set(),
2298
2297
  inputList: (name) => multiInputs[name] ?? (inputs[name] ? [inputs[name]] : []),
2299
2298
  scope,
2300
2299
  num: (key, i, n, vars) => resolveNumber(params, key, fallbackNum(key), scope(i, n, vars)),
@@ -2805,10 +2804,6 @@ function evaluate(project, registry, opts = {}) {
2805
2804
  }
2806
2805
  const def = registry.requireNode(node);
2807
2806
  const edges = incoming.get(node.id) ?? [];
2808
- const claiming = def.outputs.filter((o) => o.claims).map((o) => o.name);
2809
- const wiredOutputs = new Set(
2810
- claiming.length === 0 ? [] : project.graph.edges.filter((e) => e.from === node.id && claiming.includes(e.fromPort)).map((e) => e.fromPort)
2811
- );
2812
2807
  const seed = node.seed ?? project.seed;
2813
2808
  let wires = wiresOf.get(node.id);
2814
2809
  if (!wires) {
@@ -2891,16 +2886,12 @@ function evaluate(project, registry, opts = {}) {
2891
2886
  continue;
2892
2887
  }
2893
2888
  if (opts.vectors && readsPixels(def)) {
2889
+ const shapes = opts.vectors(node.id);
2890
+ const outPort = def.outputs[0];
2894
2891
  const out = {};
2895
- const keys = [];
2896
- for (const port of def.outputs) {
2897
- if (port.type !== "shapes") continue;
2898
- const shapes = opts.vectors(node.id, port.name);
2899
- if (shapes) out[port.name] = shapesVal(shapes);
2900
- keys.push(shapes ? String(identityKey(shapes)) : "none");
2901
- }
2892
+ if (shapes && outPort) out[outPort.name] = shapesVal(shapes);
2902
2893
  outputs.set(node.id, withTaps(node.id, out));
2903
- hashes.set(node.id, hashOf("provided", node.id, ...keys));
2894
+ hashes.set(node.id, hashOf("provided", node.id, shapes ? String(identityKey(shapes)) : "none"));
2904
2895
  continue;
2905
2896
  }
2906
2897
  if (node.disabled) {
@@ -2936,9 +2927,7 @@ function evaluate(project, registry, opts = {}) {
2936
2927
  timeDeps.has(node.id) ? String(tNode) : "",
2937
2928
  // The outliner arriving mid-session must invalidate text cooked without
2938
2929
  // it — one global salt, one full re-cook, then business as usual.
2939
- opts.outliner ? "o1" : "o0",
2940
- // A claiming output wired or not reshapes the others (Contours' major).
2941
- [...wiredOutputs].sort().join(",")
2930
+ opts.outliner ? "o1" : "o0"
2942
2931
  );
2943
2932
  hashes.set(node.id, nodeHash);
2944
2933
  const hit = upstreamTainted ? void 0 : cache22?.get(node.id, nodeHash);
@@ -2947,7 +2936,6 @@ function evaluate(project, registry, opts = {}) {
2947
2936
  continue;
2948
2937
  }
2949
2938
  const ctx = makeCookContext({
2950
- wiredOutputs,
2951
2939
  nodeId: node.id,
2952
2940
  name: node.name,
2953
2941
  seed,
@@ -9025,6 +9013,16 @@ var transformNode = {
9025
9013
  description: "With a Ref wired: the point on its bounds the pivot lands on. Move X/Y are measured from it; Rotate and Scale turn about it.",
9026
9014
  dimWhen: refUnwired
9027
9015
  },
9016
+ {
9017
+ key: "refMix",
9018
+ label: "Mix",
9019
+ type: "number",
9020
+ default: 1,
9021
+ step: 0.01,
9022
+ group: "Ref",
9023
+ description: "How far along the way to the Ref point: 0 leaves the shape where it was authored, 1 lands it on the point. Wire a Seq or Mapping in to glide it there; past 0\u20131 it overshoots.",
9024
+ dimWhen: refUnwired
9025
+ },
9028
9026
  {
9029
9027
  key: "refEach",
9030
9028
  label: "Per Shape",
@@ -9057,10 +9055,11 @@ var transformNode = {
9057
9055
  const b = each ? shapeBounds(ref[i % ref.length]) : whole;
9058
9056
  if (!b) return shape;
9059
9057
  const { fx, fy } = REF_POINTS[point];
9060
- const R = { x: b.x + fx * b.w, y: b.y + fy * b.h };
9058
+ const P = pivotFor(ctx, pivotMode, shape, i, n);
9059
+ const mix = ctx.num("refMix", i, n);
9060
+ const R = { x: P.x + mix * (b.x + fx * b.w - P.x), y: P.y + mix * (b.y + fy * b.h - P.y) };
9061
9061
  const tx = ctx.num("tx", i, n);
9062
9062
  const ty = ctx.num("ty", i, n);
9063
- const P = pivotFor(ctx, pivotMode, shape, i, n);
9064
9063
  const rot = ctx.num("rotation", i, n);
9065
9064
  const sx = ctx.num("sx", i, n);
9066
9065
  const sy = ctx.num("sy", i, n);
@@ -14942,10 +14941,16 @@ function fieldRef(p, shape, world, n, defs) {
14942
14941
  defs.push(`<pattern id="${id}" ${attrs.join(" ")}>${image}</pattern>`);
14943
14942
  return `url(#${id})`;
14944
14943
  }
14945
- function styleAttrs(fill, stroke, style, n) {
14944
+ function paintAttr(name, color, n) {
14945
+ const h = color.startsWith("#") ? splitHex(color) : void 0;
14946
+ if (!h) return [`${name}="${color}"`];
14947
+ return h.a < 1 ? [`${name}="${h.rgb}"`, `${name}-opacity="${n(h.a)}"`] : [`${name}="${color}"`];
14948
+ }
14949
+ function styleAttrs(fill, stroke, style, n, split = true) {
14946
14950
  const out = [];
14947
- if (fill !== void 0) out.push(`fill="${fill}"`);
14948
- if (stroke !== void 0 && stroke !== "none") out.push(`stroke="${stroke}"`);
14951
+ const attr = (name, color) => split ? paintAttr(name, color, n) : [`${name}="${color}"`];
14952
+ if (fill !== void 0) out.push(...attr("fill", fill));
14953
+ if (stroke !== void 0 && stroke !== "none") out.push(...attr("stroke", stroke));
14949
14954
  if (style.strokeWidth !== void 0 && stroke !== "none") {
14950
14955
  out.push(`stroke-width="${n(style.strokeWidth)}"`);
14951
14956
  }
@@ -14978,7 +14983,7 @@ function renderShape(shape, opts, depth, pretty, parent = IDENTITY) {
14978
14983
  const defs = [];
14979
14984
  const fill = paintRef(shape.style.fill, shape, world, n, defs);
14980
14985
  const stroke = paintRef(shape.style.stroke, shape, world, n, defs);
14981
- const element = renderElement(shape, opts, depth, pretty, world, styleAttrs(fill, stroke, shape.style, n));
14986
+ const element = renderElement(shape, opts, depth, pretty, world, styleAttrs(fill, stroke, shape.style, n, shape.kind !== "group"));
14982
14987
  if (defs.length === 0) return element;
14983
14988
  return `${pad}<g>${nl}${pad}<defs>${defs.join("")}</defs>${nl}${element}${pad}</g>${nl}`;
14984
14989
  }
@@ -15067,7 +15072,7 @@ function renderSvg(scene, options = {}) {
15067
15072
  const defs = options.defs ? `${pretty ? " " : ""}<defs>${options.defs}</defs>${nl}` : "";
15068
15073
  const ox = scene.origin === "center" ? -scene.width / 2 : 0;
15069
15074
  const oy = scene.origin === "center" ? -scene.height / 2 : 0;
15070
- const bg = scene.background && scene.background !== "none" ? `${pretty ? " " : ""}<rect x="${n(ox)}" y="${n(oy)}" width="${n(scene.width)}" height="${n(scene.height)}" fill="${scene.background}"/>${nl}` : "";
15075
+ const bg = scene.background && scene.background !== "none" ? `${pretty ? " " : ""}<rect x="${n(ox)}" y="${n(oy)}" width="${n(scene.width)}" height="${n(scene.height)}" ${paintAttr("fill", scene.background, n).join(" ")}/>${nl}` : "";
15071
15076
  const view = options.crop ?? { x: ox, y: oy, w: scene.width, h: scene.height };
15072
15077
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(view.w)}" height="${n(view.h)}" viewBox="${n(view.x)} ${n(view.y)} ${n(view.w)} ${n(view.h)}">${nl}${defs}${bg}${body}</svg>${nl}`;
15073
15078
  }
@@ -28,7 +28,7 @@ import {
28
28
  stripPost,
29
29
  templateFiles,
30
30
  usedFonts
31
- } from "./chunk-W7L36Z34.js";
31
+ } from "./chunk-NDQB3ETD.js";
32
32
  export {
33
33
  OGRAF_KINDS,
34
34
  SCHEMA_URL,
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  allTtfBuffers,
5
5
  fontDefs,
6
6
  nodeOutliner
7
- } from "./chunk-RAHWGTHJ.js";
7
+ } from "./chunk-EFZHSV66.js";
8
8
  import {
9
9
  DEFAULT_FONT_FAMILY,
10
10
  Keyframe,
@@ -38,7 +38,7 @@ import {
38
38
  wouldCycle,
39
39
  yuvFrameBytes,
40
40
  yuvToRgb
41
- } from "./chunk-W7L36Z34.js";
41
+ } from "./chunk-NDQB3ETD.js";
42
42
 
43
43
  // src/index.ts
44
44
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -46,6 +46,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
46
46
  import { z as z2 } from "zod";
47
47
  import { createRequire as createRequire3 } from "module";
48
48
  import { homedir } from "os";
49
+ import { dirname as dirname3, join as join4 } from "path";
49
50
 
50
51
  // src/session.ts
51
52
  import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync, writeFileSync, mkdirSync } from "fs";
@@ -755,6 +756,14 @@ var Session = class {
755
756
  * come from that folder instead.
756
757
  */
757
758
  held = /* @__PURE__ */ new Map();
759
+ /**
760
+ * Nodes added this session without a position. A save lays them out —
761
+ * columns by wire depth, as the studio's L does — so the graph reads in
762
+ * the studio instead of arriving as one endless row. Nodes with a
763
+ * position of their own (the model's, or the person's in a file that
764
+ * was opened) never move.
765
+ */
766
+ placed = /* @__PURE__ */ new Set();
758
767
  bumpIds() {
759
768
  for (const n of this.project.graph.nodes) {
760
769
  const m = /(\d+)$/.exec(n.id);
@@ -770,6 +779,7 @@ var Session = class {
770
779
  this.path = void 0;
771
780
  this.stamp = void 0;
772
781
  this.held.clear();
782
+ this.placed.clear();
773
783
  return this.project;
774
784
  }
775
785
  open(path) {
@@ -781,6 +791,7 @@ var Session = class {
781
791
  this.path = abs;
782
792
  this.stamp = stamp;
783
793
  this.held.clear();
794
+ this.placed.clear();
784
795
  this.bumpIds();
785
796
  return { project, warnings };
786
797
  }
@@ -802,6 +813,7 @@ var Session = class {
802
813
  );
803
814
  }
804
815
  }
816
+ this.arrange();
805
817
  mkdirSync(dirname2(target), { recursive: true });
806
818
  this.writeHeldAssets(target);
807
819
  writeFileSync(target, serializeProject(this.project));
@@ -923,6 +935,7 @@ var Session = class {
923
935
  }
924
936
  }
925
937
  this.project.graph.nodes.push(node);
938
+ if (!options.position) this.placed.add(id);
926
939
  return node;
927
940
  }
928
941
  /**
@@ -943,9 +956,47 @@ var Session = class {
943
956
  const last = nodes[nodes.length - 1];
944
957
  return last ? { x: last.position.x + 260, y: last.position.y } : { x: 0, y: 0 };
945
958
  }
959
+ /**
960
+ * Lay out the nodes this session placed, page by page (the root, and each
961
+ * Layer's own). A page of nothing but such nodes is arranged whole, every
962
+ * save, so it stays tidy as it grows; on a page that also holds nodes with
963
+ * positions of their own, the new ones are arranged among themselves and
964
+ * set below what is already there.
965
+ */
966
+ arrange() {
967
+ const pages = /* @__PURE__ */ new Map();
968
+ for (const n of this.project.graph.nodes) {
969
+ const key = n.layer ?? "";
970
+ const list = pages.get(key);
971
+ if (list) list.push(n);
972
+ else pages.set(key, [n]);
973
+ }
974
+ for (const nodes of pages.values()) {
975
+ const moving = nodes.filter((n) => this.placed.has(n.id));
976
+ if (moving.length === 0) continue;
977
+ const staying = nodes.filter((n) => !this.placed.has(n.id));
978
+ const ids = new Set(moving.map((n) => n.id));
979
+ const edges = this.project.graph.edges.filter((e) => ids.has(e.from) && ids.has(e.to));
980
+ const origin = staying.length === 0 ? { x: 0, y: 0 } : {
981
+ x: Math.min(...staying.map((n) => n.position.x)),
982
+ y: Math.max(...staying.map((n) => n.position.y + nodeSize(n, registry.forNode(n)).height)) + 120
983
+ };
984
+ const at = columnLayout(moving, edges, origin);
985
+ for (const n of moving) n.position = at.get(n.id) ?? n.position;
986
+ }
987
+ }
988
+ /**
989
+ * Root-page nodes that draw straight onto the page because nothing takes
990
+ * their shapes — valid, but in the studio they read as forgotten wires.
991
+ */
992
+ looseDrawers() {
993
+ const feeding = new Set(this.project.graph.edges.map((e) => e.from));
994
+ return this.project.graph.nodes.filter((n) => n.layer === void 0 && !feeding.has(n.id)).filter((n) => registry.forNode(n)?.outputs.some((o) => o.type === "shapes")).map((n) => n.id);
995
+ }
946
996
  removeNode(id) {
947
997
  this.node(id);
948
998
  this.project.graph.nodes = this.project.graph.nodes.filter((n) => n.id !== id);
999
+ this.placed.delete(id);
949
1000
  this.project.graph.edges = this.project.graph.edges.filter((e) => e.from !== id && e.to !== id);
950
1001
  }
951
1002
  setParam(nodeId, key, value) {
@@ -987,7 +1038,12 @@ var Session = class {
987
1038
  }
988
1039
  if (paramDef && !isExposable(paramDef)) fail(`${toNode.type}.${toPort} is not wireable.`);
989
1040
  if (wouldCycle(this.project.graph, from, to, fromPort, toPort)) fail(`Connecting ${from} \u2192 ${to} would make a loop.`);
990
- this.project.graph.edges = this.project.graph.edges.filter((e) => !(e.to === to && e.toPort === toPort));
1041
+ if (toDef.inputs.find((i) => i.name === toPort)?.multi) {
1042
+ const same = this.project.graph.edges.find((e) => e.from === from && e.fromPort === fromPort && e.to === to && e.toPort === toPort);
1043
+ if (same) return same;
1044
+ } else {
1045
+ this.project.graph.edges = this.project.graph.edges.filter((e) => !(e.to === to && e.toPort === toPort));
1046
+ }
991
1047
  const edge = { id: makeId("edge"), from, fromPort, to, toPort };
992
1048
  this.project.graph.edges.push(edge);
993
1049
  const outType = fromDef.outputs.find((o) => o.name === fromPort)?.type;
@@ -997,10 +1053,13 @@ var Session = class {
997
1053
  }
998
1054
  return edge;
999
1055
  }
1000
- disconnect(to, toPort) {
1056
+ /** Remove the wires into a socket — only the one from `from`, when given (a Merge's `in` holds several). */
1057
+ disconnect(to, toPort, from) {
1001
1058
  const before = this.project.graph.edges.length;
1002
- this.project.graph.edges = this.project.graph.edges.filter((e) => !(e.to === to && e.toPort === toPort));
1003
- if (this.project.graph.edges.length === before) fail(`Nothing is wired into ${to}.${toPort}.`);
1059
+ this.project.graph.edges = this.project.graph.edges.filter(
1060
+ (e) => !(e.to === to && e.toPort === toPort && (from === void 0 || e.from === from))
1061
+ );
1062
+ if (this.project.graph.edges.length === before) fail(`Nothing${from ? ` from ${from}` : ""} is wired into ${to}.${toPort}.`);
1004
1063
  }
1005
1064
  /**
1006
1065
  * Keyframes write into the same param model everything else uses — a track
@@ -1140,12 +1199,113 @@ var Session = class {
1140
1199
  return svgToPng(await this.renderSvgString(t, false), { scale });
1141
1200
  }
1142
1201
  };
1202
+ function nodeSize(node, def) {
1203
+ if (node.micro) return { width: 80, height: 24 };
1204
+ const width = def?.wide ? 456 : 228;
1205
+ const outputs = def?.outputs.filter((o) => !o.hideWhen?.(node.params ?? {})).length ?? 0;
1206
+ const height = node.collapsed ? 44 : 66 + Math.max(def?.inputs.length ?? 0, outputs) * 20 + (def?.params.length ?? 0) * 24;
1207
+ return { width, height };
1208
+ }
1209
+ var COLUMN_HEIGHT = 1600;
1210
+ var ROW_GAP = 28;
1211
+ var COLUMN_GAP = 70;
1212
+ var BLOCK_GAP = 160;
1213
+ function columnLayout(nodes, edges, origin) {
1214
+ const root = new Map(nodes.map((n) => [n.id, n.id]));
1215
+ const find = (id) => {
1216
+ let r = id;
1217
+ while (root.get(r) !== r) r = root.get(r);
1218
+ root.set(id, r);
1219
+ return r;
1220
+ };
1221
+ for (const e of edges) if (root.has(e.from) && root.has(e.to)) root.set(find(e.from), find(e.to));
1222
+ const wired = new Set(edges.flatMap((e) => [e.from, e.to]));
1223
+ const blocks = /* @__PURE__ */ new Map();
1224
+ const loose = [];
1225
+ for (const n of nodes) {
1226
+ if (!wired.has(n.id)) {
1227
+ loose.push(n);
1228
+ continue;
1229
+ }
1230
+ const key = find(n.id);
1231
+ const list = blocks.get(key);
1232
+ if (list) list.push(n);
1233
+ else blocks.set(key, [n]);
1234
+ }
1235
+ const placed = /* @__PURE__ */ new Map();
1236
+ let y = origin.y;
1237
+ for (const block of [...blocks.values(), ...loose.length ? [loose] : []]) {
1238
+ const bottom = layoutBlock(block, edges, { x: origin.x, y }, placed);
1239
+ y = bottom + BLOCK_GAP;
1240
+ }
1241
+ return placed;
1242
+ }
1243
+ function layoutBlock(nodes, allEdges, origin, placed) {
1244
+ const ids = new Set(nodes.map((n) => n.id));
1245
+ const edges = allEdges.filter((e) => ids.has(e.from) && ids.has(e.to));
1246
+ const incoming = /* @__PURE__ */ new Map();
1247
+ for (const e of edges) {
1248
+ const list = incoming.get(e.to);
1249
+ if (list) list.push(e.from);
1250
+ else incoming.set(e.to, [e.from]);
1251
+ }
1252
+ const colOf = /* @__PURE__ */ new Map();
1253
+ const visiting = /* @__PURE__ */ new Set();
1254
+ const col = (id) => {
1255
+ const known = colOf.get(id);
1256
+ if (known !== void 0) return known;
1257
+ if (visiting.has(id)) return 0;
1258
+ visiting.add(id);
1259
+ const ins = incoming.get(id) ?? [];
1260
+ const c = ins.length === 0 ? 0 : Math.max(...ins.map(col)) + 1;
1261
+ visiting.delete(id);
1262
+ colOf.set(id, c);
1263
+ return c;
1264
+ };
1265
+ const columns = /* @__PURE__ */ new Map();
1266
+ nodes.forEach((n) => {
1267
+ const c = col(n.id);
1268
+ const list = columns.get(c);
1269
+ if (list) list.push(n);
1270
+ else columns.set(c, [n]);
1271
+ });
1272
+ const order = new Map(nodes.map((n, i) => [n.id, i]));
1273
+ const rowOf = /* @__PURE__ */ new Map();
1274
+ let x = origin.x;
1275
+ let bottom = origin.y;
1276
+ for (const c of [...columns.keys()].sort((a, b) => a - b)) {
1277
+ const list = columns.get(c);
1278
+ const key = (n) => {
1279
+ const ins = (incoming.get(n.id) ?? []).map((id) => rowOf.get(id) ?? 0);
1280
+ return ins.length > 0 ? ins.reduce((sum, v) => sum + v, 0) / ins.length : -1;
1281
+ };
1282
+ list.sort((a, b) => key(a) - key(b) || order.get(a.id) - order.get(b.id));
1283
+ let y = origin.y;
1284
+ let widest = 0;
1285
+ list.forEach((n, i) => {
1286
+ const { width, height } = nodeSize(n, registry.forNode(n));
1287
+ if (y > origin.y && y + height > origin.y + COLUMN_HEIGHT) {
1288
+ x += widest + ROW_GAP;
1289
+ y = origin.y;
1290
+ widest = 0;
1291
+ }
1292
+ rowOf.set(n.id, i);
1293
+ placed.set(n.id, { x, y });
1294
+ y += height + ROW_GAP;
1295
+ widest = Math.max(widest, width);
1296
+ bottom = Math.max(bottom, y - ROW_GAP);
1297
+ });
1298
+ x += widest + COLUMN_GAP;
1299
+ }
1300
+ return bottom;
1301
+ }
1143
1302
 
1144
1303
  // src/index.ts
1304
+ var DEFAULT_PROJECTS = "FUI-Studio";
1145
1305
  function projectsFolder() {
1146
1306
  const at = process.argv.indexOf("--projects");
1147
1307
  const given = at >= 0 && process.argv[at + 1] ? process.argv[at + 1] : process.env.FUI_PROJECTS || void 0;
1148
- return given?.replace(/^~(?=$|[\\/])/, homedir());
1308
+ return given?.replace(/^~(?=$|[\\/])/, homedir()) ?? join4(homedir(), DEFAULT_PROJECTS);
1149
1309
  }
1150
1310
  function version() {
1151
1311
  try {
@@ -1167,7 +1327,9 @@ How to work:
1167
1327
  2. Learn the library once: list_node_types, optionally by category. Generators draw shapes; modifiers change them; layout repeats and arranges; drivers (value, lfo, noise, time\u2026) produce numbers; post nodes are pixel effects.
1168
1328
  3. Build: add_node with params, then connect. Every param is also an input socket: connect(from: "lfo1", to: "ring1", toPort: "radius") makes the radius move. That is how things animate; set_keyframe is the other way, and params also take expressions ({"base": 5, "expr": "sin(t) * 10"}).
1169
1329
  4. Look: render_preview after each few edits and actually check the picture. Pass t (seconds) to see a moment of the animation.
1170
- 5. Save: save_project with a bare name ("radar") saves radar.tui in the projects folder.
1330
+ 5. Save: save_project with a bare name ("radar") saves radar.tui in the projects folder. The first time you save, tell the user the full path the save reports, and that the studio shows the project live once they choose that same folder there (folder\u2026 in the toolbar, then Open).
1331
+
1332
+ Keep the graph readable, because the user opens it in the studio. A node whose output is wired to nothing draws straight onto the page. That works, but a page of loose nodes looks like a graph with its wires missing. Gather the parts of one element (a radar's rings, ticks, labels and crosshair) into a Merge node: add_node merge, then connect each part to its "in" port. Merge draws in connection order, so the first wire is at the bottom. Give the Merge a name ("radar", "readout") so the studio shows what it is. Leave position out of add_node: on save, nodes without one are laid out in columns that follow the wires.
1171
1333
 
1172
1334
  Layers: a Layer node is a self-contained composition with its own page and its own clock. add_node with layer: "<layer id>" puts a node inside it. Arrange Layers in time with add_clip; a document with no clips plays every Layer all the time. list_layers explains the rules.
1173
1335
 
@@ -1223,7 +1385,15 @@ server.registerTool(
1223
1385
  description: 'Save the project. Without a path, writes back to where it was opened from. A relative path or bare name (".tui" is added) saves into the projects folder, where an open studio shows it at once. Writing back over a file someone saved since you opened it is refused unless overwrite is true.',
1224
1386
  inputSchema: { path: z2.string().optional(), overwrite: z2.boolean().default(false) }
1225
1387
  },
1226
- guard(({ path, overwrite }) => text(`saved ${session.save(path, { overwrite })}`))
1388
+ guard(({ path, overwrite }) => {
1389
+ const saved = session.save(path, { overwrite });
1390
+ const loose = session.looseDrawers();
1391
+ const nudge = loose.length > 3 ? `
1392
+ ${loose.length} nodes draw with nothing wired to their output (${loose.join(", ")}). In the studio that reads as missing wires: gather each element's parts into a named Merge (connect in drawing order, first at the bottom) and save again.` : "";
1393
+ const folder = session.root && dirname3(saved).startsWith(session.root) ? session.root : dirname3(saved);
1394
+ return text(`saved ${saved}
1395
+ In FUI Studio, choose the folder ${folder} (folder\u2026 in the toolbar, or change folder\u2026 under Open) and open it from Open.${nudge}`);
1396
+ })
1227
1397
  );
1228
1398
  server.registerTool(
1229
1399
  "list_projects",
@@ -1377,10 +1547,13 @@ server.registerTool(
1377
1547
  );
1378
1548
  server.registerTool(
1379
1549
  "disconnect",
1380
- { description: "Remove the wire feeding a socket.", inputSchema: { to: z2.string(), toPort: z2.string() } },
1381
- guard(({ to, toPort }) => {
1382
- session.disconnect(to, toPort);
1383
- return text(`disconnected ${to}.${toPort}`);
1550
+ {
1551
+ description: `Remove the wire feeding a socket. A multi input (a Merge's or Layer's "in") holds several: pass from to remove just that one, or leave it out to remove them all.`,
1552
+ inputSchema: { to: z2.string(), toPort: z2.string(), from: z2.string().optional() }
1553
+ },
1554
+ guard(({ to, toPort, from }) => {
1555
+ session.disconnect(to, toPort, from);
1556
+ return text(`disconnected ${from ? `${from} \u2192 ` : ""}${to}.${toPort}`);
1384
1557
  })
1385
1558
  );
1386
1559
  server.registerTool(
@@ -1454,12 +1627,11 @@ server.registerTool(
1454
1627
  },
1455
1628
  guard(async ({ path, t, scale }) => {
1456
1629
  const { writeFileSync: writeFileSync2, mkdirSync: mkdirSync2 } = await import("fs");
1457
- const { dirname: dirname3 } = await import("path");
1458
1630
  const abs = session.resolvePath(path);
1459
1631
  mkdirSync2(dirname3(abs), { recursive: true });
1460
1632
  if (/\.(ograf|drfx)$/i.test(abs)) {
1461
- const { exportTemplate } = await import("./node-4RLELQ7B.js");
1462
- const { packDrfx, packOgraf } = await import("./dist-3URVYEPT.js");
1633
+ const { exportTemplate } = await import("./node-3VDUDD3U.js");
1634
+ const { packDrfx, packOgraf } = await import("./dist-RTISZO3Q.js");
1463
1635
  const template = exportTemplate(session.project, void 0, session.assetResolver());
1464
1636
  const errors = template.lint.filter((i) => i.level === "error");
1465
1637
  if (errors.length > 0) throw new SessionError(`The manifest has errors: ${errors.map((i) => `${i.where}: ${i.message}`).join("; ")}`);
@@ -1489,7 +1661,6 @@ server.registerTool(
1489
1661
  guard(async ({ dir, from, to, fps, scale }) => {
1490
1662
  if (to <= from) throw new SessionError("`to` must be after `from`.");
1491
1663
  const { writeFileSync: writeFileSync2, mkdirSync: mkdirSync2 } = await import("fs");
1492
- const { join: join4 } = await import("path");
1493
1664
  const abs = session.resolvePath(dir);
1494
1665
  mkdirSync2(abs, { recursive: true });
1495
1666
  const frames = Math.round((to - from) * fps);
@@ -1502,3 +1673,6 @@ server.registerTool(
1502
1673
  })
1503
1674
  );
1504
1675
  await server.connect(new StdioServerTransport());
1676
+ export {
1677
+ DEFAULT_PROJECTS
1678
+ };
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  loadFaces,
4
4
  nodeOutliner
5
- } from "./chunk-RAHWGTHJ.js";
5
+ } from "./chunk-EFZHSV66.js";
6
6
  import {
7
7
  buildTemplate
8
- } from "./chunk-W7L36Z34.js";
8
+ } from "./chunk-NDQB3ETD.js";
9
9
 
10
10
  // ../../packages/ograf/dist/node.js
11
11
  import { existsSync, readFileSync } from "fs";