@a-company/atelier 0.28.2 → 0.36.0

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/dist/cli.cjs CHANGED
@@ -420,7 +420,18 @@ function resolveFrame(doc, stateName, frame, overrideDeltas) {
420
420
  }
421
421
  }
422
422
  }
423
- return { id: layer.id, layer, computedProperties };
423
+ const resolvedLayer = { id: layer.id, layer, computedProperties };
424
+ if (layer.visual.type === "video") {
425
+ const video = layer.visual;
426
+ const fps = doc.canvas.fps;
427
+ const startFrame = video.startFrame ?? 0;
428
+ const sourceOffset = video.sourceOffset ?? 0;
429
+ const playbackRate = video.playbackRate ?? 1;
430
+ const relativeFrame = Math.max(0, frame - startFrame);
431
+ const sourceTime = relativeFrame / fps * playbackRate + sourceOffset;
432
+ resolvedLayer.videoSourceTime = video.sourceEnd !== void 0 ? Math.min(sourceTime, video.sourceEnd) : sourceTime;
433
+ }
434
+ return resolvedLayer;
424
435
  });
425
436
  return { frame, stateName, layers: resolvedLayers };
426
437
  }
@@ -1027,6 +1038,14 @@ function renderImage(ctx, eff, imageCache) {
1027
1038
  }
1028
1039
  ctx.drawImage(img, 0, 0, eff.width, eff.height);
1029
1040
  }
1041
+ function renderVideo(ctx, eff, sourceTime, provider) {
1042
+ const visual = eff.visual;
1043
+ const src = visual.src;
1044
+ if (!src) return;
1045
+ const frame = provider(src, sourceTime, eff.width, eff.height);
1046
+ if (!frame) return;
1047
+ ctx.drawImage(frame, 0, 0, eff.width, eff.height);
1048
+ }
1030
1049
  function renderRef(ctx, eff, opts, _parentDoc) {
1031
1050
  const visual = eff.visual;
1032
1051
  const resolver = opts?.documentResolver;
@@ -1124,6 +1143,7 @@ function renderPlaceholder(ctx, eff, label) {
1124
1143
  function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1125
1144
  let imageCache;
1126
1145
  let documentResolver;
1146
+ let videoFrameProvider;
1127
1147
  let maxRefDepth = 4;
1128
1148
  if (optsOrCache && typeof optsOrCache.get === "function") {
1129
1149
  imageCache = optsOrCache;
@@ -1131,6 +1151,7 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1131
1151
  const opts = optsOrCache;
1132
1152
  imageCache = opts.imageCache;
1133
1153
  documentResolver = opts.documentResolver;
1154
+ videoFrameProvider = opts.videoFrameProvider;
1134
1155
  maxRefDepth = opts.maxRefDepth ?? 4;
1135
1156
  }
1136
1157
  const { width, height } = doc.canvas;
@@ -1138,10 +1159,14 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1138
1159
  ctx.fillRect(0, 0, width, height);
1139
1160
  const effMap = /* @__PURE__ */ new Map();
1140
1161
  const effList = [];
1162
+ const videoSourceTimeMap = /* @__PURE__ */ new Map();
1141
1163
  for (const resolvedLayer of resolvedFrame.layers) {
1142
1164
  const eff = buildEffectiveLayer(resolvedLayer, width, height);
1143
1165
  effMap.set(resolvedLayer.layer.id, eff);
1144
1166
  effList.push(eff);
1167
+ if (resolvedLayer.videoSourceTime !== void 0) {
1168
+ videoSourceTimeMap.set(resolvedLayer.layer.id, resolvedLayer.videoSourceTime);
1169
+ }
1145
1170
  }
1146
1171
  for (const eff of effList) {
1147
1172
  const { layer } = eff;
@@ -1153,6 +1178,12 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1153
1178
  iv.src = doc.assets[iv.assetId].src;
1154
1179
  }
1155
1180
  }
1181
+ if (layer.visual.type === "video") {
1182
+ const vv = eff.visual;
1183
+ if (!vv.src && vv.assetId && doc.assets?.[vv.assetId]) {
1184
+ vv.src = doc.assets[vv.assetId].src;
1185
+ }
1186
+ }
1156
1187
  ctx.save();
1157
1188
  applyAncestorTransforms(ctx, layer.id, effMap, doc);
1158
1189
  ctx.globalAlpha = eff.opacity;
@@ -1200,6 +1231,11 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1200
1231
  case "image":
1201
1232
  if (imageCache) renderImage(renderCtx, eff, imageCache);
1202
1233
  break;
1234
+ case "video":
1235
+ if (videoFrameProvider) {
1236
+ renderVideo(renderCtx, eff, videoSourceTimeMap.get(layer.id) ?? 0, videoFrameProvider);
1237
+ }
1238
+ break;
1203
1239
  case "group":
1204
1240
  break;
1205
1241
  case "ref":
@@ -1225,6 +1261,9 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1225
1261
  case "image":
1226
1262
  if (imageCache) renderImage(offCtx, eff, imageCache);
1227
1263
  break;
1264
+ case "video":
1265
+ if (videoFrameProvider) renderVideo(offCtx, eff, videoSourceTimeMap.get(layer.id) ?? 0, videoFrameProvider);
1266
+ break;
1228
1267
  case "ref":
1229
1268
  renderRef(offCtx, eff, refOpts, doc);
1230
1269
  break;
@@ -1240,6 +1279,9 @@ function renderFrame(ctx, resolvedFrame, doc, optsOrCache) {
1240
1279
  case "image":
1241
1280
  if (imageCache) renderImage(ctx, eff, imageCache);
1242
1281
  break;
1282
+ case "video":
1283
+ if (videoFrameProvider) renderVideo(ctx, eff, videoSourceTimeMap.get(layer.id) ?? 0, videoFrameProvider);
1284
+ break;
1243
1285
  case "ref":
1244
1286
  renderRef(ctx, eff, refOpts, doc);
1245
1287
  break;
@@ -1401,6 +1443,7 @@ var import_zod11 = require("zod");
1401
1443
  var import_zod12 = require("zod");
1402
1444
  var import_zod13 = require("zod");
1403
1445
  var import_zod14 = require("zod");
1446
+ var import_zod15 = require("zod");
1404
1447
  var import_yaml = require("yaml");
1405
1448
  var PixelSchema = import_zod.z.number();
1406
1449
  var PercentageSchema = import_zod.z.string().regex(/^-?\d+(\.\d+)?%$/, {
@@ -1642,6 +1685,18 @@ var ImageVisualSchema = import_zod7.z.object({
1642
1685
  spritesheet: SpritesheetConfigSchema.optional(),
1643
1686
  frameIndex: import_zod7.z.number().int().min(0).optional()
1644
1687
  });
1688
+ var VideoVisualSchema = import_zod7.z.object({
1689
+ type: import_zod7.z.literal("video"),
1690
+ assetId: import_zod7.z.string().min(1, "assetId is required"),
1691
+ src: import_zod7.z.string().optional(),
1692
+ startFrame: import_zod7.z.number().int().min(0).optional(),
1693
+ sourceOffset: import_zod7.z.number().min(0).optional(),
1694
+ sourceEnd: import_zod7.z.number().positive().optional(),
1695
+ playbackRate: import_zod7.z.number().positive().optional(),
1696
+ volume: import_zod7.z.number().min(0).max(1).optional(),
1697
+ muted: import_zod7.z.boolean().optional(),
1698
+ objectFit: import_zod7.z.enum(["contain", "cover", "fill"]).optional()
1699
+ });
1645
1700
  var GroupVisualSchema = import_zod7.z.object({
1646
1701
  type: import_zod7.z.literal("group")
1647
1702
  });
@@ -1655,6 +1710,7 @@ var VisualSchema = import_zod7.z.discriminatedUnion("type", [
1655
1710
  ShapeVisualSchema,
1656
1711
  TextVisualSchema,
1657
1712
  ImageVisualSchema,
1713
+ VideoVisualSchema,
1658
1714
  GroupVisualSchema,
1659
1715
  RefVisualSchema
1660
1716
  ]);
@@ -1774,7 +1830,7 @@ var VariableSchema = import_zod12.z.object({
1774
1830
  default: import_zod12.z.unknown().optional(),
1775
1831
  description: import_zod12.z.string().optional()
1776
1832
  });
1777
- var AssetTypeSchema = import_zod13.z.enum(["image", "svg", "font", "animation", "audio"]);
1833
+ var AssetTypeSchema = import_zod13.z.enum(["image", "svg", "font", "animation", "audio", "video"]);
1778
1834
  var AssetSchema = import_zod13.z.object({
1779
1835
  type: AssetTypeSchema,
1780
1836
  src: import_zod13.z.string().min(1, "Asset src is required"),
@@ -1785,6 +1841,12 @@ var AssetSchema = import_zod13.z.object({
1785
1841
  frameCount: import_zod13.z.number().int().positive().optional(),
1786
1842
  frameWidth: import_zod13.z.number().positive(),
1787
1843
  frameHeight: import_zod13.z.number().positive()
1844
+ }).optional(),
1845
+ videoMeta: import_zod13.z.object({
1846
+ duration: import_zod13.z.number().positive("videoMeta.duration must be positive"),
1847
+ fps: import_zod13.z.number().positive("videoMeta.fps must be positive"),
1848
+ width: import_zod13.z.number().int().positive(),
1849
+ height: import_zod13.z.number().int().positive()
1788
1850
  }).optional()
1789
1851
  });
1790
1852
  var CanvasSchema = import_zod14.z.object({
@@ -1805,6 +1867,121 @@ var AtelierDocumentSchema = import_zod14.z.object({
1805
1867
  layers: import_zod14.z.array(LayerSchema),
1806
1868
  states: import_zod14.z.record(import_zod14.z.string(), StateSchema)
1807
1869
  });
1870
+ var SilencePolicySchema = import_zod15.z.object({
1871
+ noise: import_zod15.z.string().optional(),
1872
+ min_silence: import_zod15.z.number().nonnegative().optional(),
1873
+ default_padding_pre: import_zod15.z.number().nonnegative().optional(),
1874
+ default_padding_post: import_zod15.z.number().nonnegative().optional(),
1875
+ match_tolerance: import_zod15.z.number().nonnegative().optional()
1876
+ }).strict();
1877
+ var CaptionStyleSchema = import_zod15.z.object({
1878
+ font_family: import_zod15.z.string().optional(),
1879
+ font_size: import_zod15.z.number().positive().optional(),
1880
+ font_weight: import_zod15.z.union([import_zod15.z.literal("normal"), import_zod15.z.literal("bold"), import_zod15.z.number()]).optional(),
1881
+ text_align: import_zod15.z.enum(["left", "center", "right"]).optional(),
1882
+ color: import_zod15.z.string().optional(),
1883
+ y_ratio: import_zod15.z.number().min(0).max(1).optional(),
1884
+ width_ratio: import_zod15.z.number().min(0).max(1).optional(),
1885
+ fade_seconds: import_zod15.z.number().nonnegative().optional()
1886
+ }).strict();
1887
+ var CaptionGroupingSchema = import_zod15.z.object({
1888
+ max_words: import_zod15.z.number().int().positive().optional(),
1889
+ pause_gap: import_zod15.z.number().nonnegative().optional()
1890
+ }).strict();
1891
+ var OverlayAnchorSchema = import_zod15.z.enum([
1892
+ "top-left",
1893
+ "top-right",
1894
+ "bottom-left",
1895
+ "bottom-right"
1896
+ ]);
1897
+ var OverlayTextStyleSchema = import_zod15.z.object({
1898
+ font_family: import_zod15.z.string().optional(),
1899
+ font_size: import_zod15.z.number().positive().optional(),
1900
+ font_weight: import_zod15.z.union([import_zod15.z.literal("normal"), import_zod15.z.literal("bold"), import_zod15.z.number()]).optional(),
1901
+ color: import_zod15.z.string().optional()
1902
+ }).strict();
1903
+ function validatePageNumberFormat(format, ctx) {
1904
+ if (format.length === 0) {
1905
+ ctx.addIssue({
1906
+ code: import_zod15.z.ZodIssueCode.custom,
1907
+ message: "format must be a non-empty string"
1908
+ });
1909
+ return;
1910
+ }
1911
+ const open = (format.match(/\{/g) ?? []).length;
1912
+ const close = (format.match(/\}/g) ?? []).length;
1913
+ if (open !== close) {
1914
+ ctx.addIssue({
1915
+ code: import_zod15.z.ZodIssueCode.custom,
1916
+ message: `format has unbalanced braces (${open} '{' vs ${close} '}')`
1917
+ });
1918
+ return;
1919
+ }
1920
+ const groupRe = /\{([^{}]*)\}/g;
1921
+ const groupRule = /^(current|total)(:0\d+d)?$/;
1922
+ let m;
1923
+ let sawCurrent = false;
1924
+ let sawTotal = false;
1925
+ while ((m = groupRe.exec(format)) !== null) {
1926
+ const inner = m[1];
1927
+ if (!groupRule.test(inner)) {
1928
+ ctx.addIssue({
1929
+ code: import_zod15.z.ZodIssueCode.custom,
1930
+ message: `format placeholder "{${inner}}" is not recognized \u2014 expected {current}, {total}, {current:0Nd}, or {total:0Nd}`
1931
+ });
1932
+ return;
1933
+ }
1934
+ if (inner.startsWith("current")) sawCurrent = true;
1935
+ if (inner.startsWith("total")) sawTotal = true;
1936
+ }
1937
+ if (!sawCurrent && !sawTotal) {
1938
+ ctx.addIssue({
1939
+ code: import_zod15.z.ZodIssueCode.custom,
1940
+ message: "format must contain at least one {current} or {total} placeholder"
1941
+ });
1942
+ }
1943
+ }
1944
+ var OverlayHandleRuleSchema = import_zod15.z.object({
1945
+ text: import_zod15.z.string().min(1),
1946
+ anchor: OverlayAnchorSchema,
1947
+ margin: import_zod15.z.number().nonnegative().optional(),
1948
+ style: OverlayTextStyleSchema.optional()
1949
+ }).strict();
1950
+ var OverlayPageNumberRuleSchema = import_zod15.z.object({
1951
+ format: import_zod15.z.string().superRefine(validatePageNumberFormat),
1952
+ anchor: OverlayAnchorSchema,
1953
+ margin: import_zod15.z.number().nonnegative().optional(),
1954
+ style: OverlayTextStyleSchema.optional()
1955
+ }).strict();
1956
+ var OverlayRulesSchema = import_zod15.z.object({
1957
+ handle: OverlayHandleRuleSchema.optional(),
1958
+ page_number: OverlayPageNumberRuleSchema.optional()
1959
+ }).strict();
1960
+ var StudioRecipeSchema = import_zod15.z.object({
1961
+ version: import_zod15.z.string(),
1962
+ name: import_zod15.z.string(),
1963
+ description: import_zod15.z.string().optional(),
1964
+ author: import_zod15.z.string().optional(),
1965
+ tags: import_zod15.z.array(import_zod15.z.string()).optional(),
1966
+ silence_policy: SilencePolicySchema.optional(),
1967
+ caption_style: CaptionStyleSchema.optional(),
1968
+ caption_grouping: CaptionGroupingSchema.optional(),
1969
+ // Phase 1.5 — first-class overlay rules
1970
+ overlay_rules: OverlayRulesSchema.optional(),
1971
+ // Reserved — Phase 3 (parse-opaque)
1972
+ caption_highlight: import_zod15.z.unknown().optional(),
1973
+ transition_kit: import_zod15.z.unknown().optional(),
1974
+ palette: import_zod15.z.unknown().optional(),
1975
+ audio_policy: import_zod15.z.unknown().optional(),
1976
+ aspect_targets: import_zod15.z.array(import_zod15.z.unknown()).optional()
1977
+ }).strict();
1978
+ var RESERVED_RECIPE_FIELDS = [
1979
+ "caption_highlight",
1980
+ "transition_kit",
1981
+ "palette",
1982
+ "audio_policy",
1983
+ "aspect_targets"
1984
+ ];
1808
1985
  function formatErrors(error) {
1809
1986
  return error.issues.map((issue) => ({
1810
1987
  path: issue.path.join(".") || "(root)",
@@ -1818,6 +1995,51 @@ function validateDocument(input) {
1818
1995
  }
1819
1996
  return { success: false, errors: formatErrors(result.error) };
1820
1997
  }
1998
+ function validateVideoLayer(visual, videoMetaDuration) {
1999
+ const errors = [];
2000
+ if (!visual.assetId) {
2001
+ errors.push({ path: "assetId", message: "assetId is required" });
2002
+ }
2003
+ const sourceOffset = visual.sourceOffset ?? 0;
2004
+ if (visual.sourceEnd !== void 0) {
2005
+ if (visual.sourceEnd <= sourceOffset) {
2006
+ errors.push({
2007
+ path: "sourceEnd",
2008
+ message: `sourceEnd (${visual.sourceEnd}) must be greater than sourceOffset (${sourceOffset})`
2009
+ });
2010
+ }
2011
+ if (videoMetaDuration !== void 0 && visual.sourceEnd > videoMetaDuration) {
2012
+ errors.push({
2013
+ path: "sourceEnd",
2014
+ message: `sourceEnd (${visual.sourceEnd}) exceeds asset duration (${videoMetaDuration})`
2015
+ });
2016
+ }
2017
+ }
2018
+ if (videoMetaDuration !== void 0 && sourceOffset >= videoMetaDuration) {
2019
+ errors.push({
2020
+ path: "sourceOffset",
2021
+ message: `sourceOffset (${sourceOffset}) is at or beyond asset duration (${videoMetaDuration})`
2022
+ });
2023
+ }
2024
+ if (errors.length > 0) return { success: false, errors };
2025
+ return { success: true, data: visual };
2026
+ }
2027
+ function validateRecipe(recipe) {
2028
+ const parsed = StudioRecipeSchema.safeParse(recipe);
2029
+ if (!parsed.success) {
2030
+ return { success: false, errors: formatErrors(parsed.error) };
2031
+ }
2032
+ const warnings = [];
2033
+ const data = parsed.data;
2034
+ for (const field of RESERVED_RECIPE_FIELDS) {
2035
+ if (data[field] !== void 0) {
2036
+ warnings.push(
2037
+ `${field} is reserved for Phase 3 and currently has no effect.`
2038
+ );
2039
+ }
2040
+ }
2041
+ return { success: true, data, ...warnings.length > 0 && { warnings } };
2042
+ }
1821
2043
  function parseAtelier(yamlString) {
1822
2044
  let parsed;
1823
2045
  try {
@@ -1830,6 +2052,9 @@ function parseAtelier(yamlString) {
1830
2052
  }
1831
2053
  return validateDocument(parsed);
1832
2054
  }
2055
+ function serializeAtelier(doc) {
2056
+ return (0, import_yaml.stringify)(doc, { indent: 2 });
2057
+ }
1833
2058
 
1834
2059
  // src/commands/validate.ts
1835
2060
  init_dist2();
@@ -1857,29 +2082,1938 @@ function validateFile(filePath) {
1857
2082
  overlapErrors.push(`State "${stateName}": ${overlap.message}`);
1858
2083
  }
1859
2084
  }
1860
- if (overlapErrors.length > 0) {
1861
- return { valid: false, errors: overlapErrors };
2085
+ if (overlapErrors.length > 0) {
2086
+ return { valid: false, errors: overlapErrors };
2087
+ }
2088
+ return { valid: true, errors: [] };
2089
+ }
2090
+ function validateCommand(program2) {
2091
+ program2.command("validate <file>").description("Validate an .atelier YAML file").action((file) => {
2092
+ const { valid, errors } = validateFile(file);
2093
+ if (valid) {
2094
+ console.log("Valid");
2095
+ } else {
2096
+ console.error("Validation errors:");
2097
+ for (const error of errors) {
2098
+ console.error(` - ${error}`);
2099
+ }
2100
+ process.exit(1);
2101
+ }
2102
+ });
2103
+ }
2104
+
2105
+ // src/commands/lint.ts
2106
+ var import_node_fs2 = require("fs");
2107
+ var import_node_path2 = require("path");
2108
+ init_dist2();
2109
+ function lintFile(filePath) {
2110
+ const absPath = (0, import_node_path2.resolve)(filePath);
2111
+ let content;
2112
+ try {
2113
+ content = (0, import_node_fs2.readFileSync)(absPath, "utf-8");
2114
+ } catch {
2115
+ return {
2116
+ file: absPath,
2117
+ valid: false,
2118
+ gates: [
2119
+ {
2120
+ gate: "^valid-document",
2121
+ pass: false,
2122
+ errors: [`Cannot read file: ${absPath}`]
2123
+ }
2124
+ ]
2125
+ };
2126
+ }
2127
+ const gates = [];
2128
+ const parseResult = parseAtelier(content);
2129
+ if (!parseResult.success) {
2130
+ gates.push({
2131
+ gate: "^valid-document",
2132
+ pass: false,
2133
+ errors: parseResult.errors.map((e) => `${e.path}: ${e.message}`)
2134
+ });
2135
+ return { file: absPath, valid: false, gates };
2136
+ }
2137
+ gates.push({ gate: "^valid-document", pass: true, errors: [] });
2138
+ const doc = parseResult.data;
2139
+ const deltaErrors = [];
2140
+ for (const [stateName, state] of Object.entries(doc.states)) {
2141
+ const overlaps = validateAllDeltas(state.deltas);
2142
+ for (const overlap of overlaps) {
2143
+ deltaErrors.push(`State "${stateName}": ${overlap.message}`);
2144
+ }
2145
+ }
2146
+ gates.push({
2147
+ gate: "^valid-delta",
2148
+ pass: deltaErrors.length === 0,
2149
+ errors: deltaErrors
2150
+ });
2151
+ const videoErrors = [];
2152
+ for (const layer of doc.layers) {
2153
+ if (layer.visual.type !== "video") continue;
2154
+ const visual = layer.visual;
2155
+ const duration = doc.assets?.[visual.assetId]?.videoMeta?.duration;
2156
+ const result = validateVideoLayer(visual, duration);
2157
+ if (!result.success) {
2158
+ for (const err of result.errors) {
2159
+ videoErrors.push(`Layer "${layer.id}" (${err.path}): ${err.message}`);
2160
+ }
2161
+ }
2162
+ }
2163
+ gates.push({
2164
+ gate: "^valid-video-layer",
2165
+ pass: videoErrors.length === 0,
2166
+ errors: videoErrors
2167
+ });
2168
+ const valid = gates.every((g) => g.pass);
2169
+ return { file: absPath, valid, gates };
2170
+ }
2171
+ function formatResult(result) {
2172
+ const lines = [];
2173
+ const status = result.valid ? "PASS" : "FAIL";
2174
+ lines.push(`${status} ${result.file}`);
2175
+ for (const gate of result.gates) {
2176
+ const gateStatus = gate.pass ? " \u2713" : " \u2717";
2177
+ lines.push(`${gateStatus} ${gate.gate}`);
2178
+ for (const err of gate.errors) {
2179
+ lines.push(` ${err}`);
2180
+ }
2181
+ }
2182
+ return lines.join("\n");
2183
+ }
2184
+ function lintCommand(program2) {
2185
+ program2.command("lint <files...>").description(
2186
+ "Lint .atelier files against all gates (^valid-document, ^valid-delta, ^valid-video-layer)"
2187
+ ).option("--json", "Output results as JSON array").action((files, opts) => {
2188
+ const results = files.map(lintFile);
2189
+ if (opts.json) {
2190
+ console.log(JSON.stringify(results, null, 2));
2191
+ } else {
2192
+ for (const result of results) {
2193
+ console.log(formatResult(result));
2194
+ }
2195
+ }
2196
+ const allValid = results.every((r) => r.valid);
2197
+ if (!allValid) process.exit(1);
2198
+ });
2199
+ }
2200
+
2201
+ // src/lib/video-project.ts
2202
+ var import_node_fs3 = require("fs");
2203
+ var import_node_path3 = require("path");
2204
+ var VIDEO_PROJECT_VERSION = "1.0";
2205
+ var VIDEO_CUTLIST_VERSION = "1.1";
2206
+ var VIDEO_TRANSCRIPT_VERSION = "1.1";
2207
+ function effectiveSpan(cut, duration) {
2208
+ return {
2209
+ start: Math.max(0, cut.rawStart - cut.paddingPre),
2210
+ end: Math.min(duration, cut.rawEnd + cut.paddingPost)
2211
+ };
2212
+ }
2213
+ function loadVideoProject(dir) {
2214
+ const projectDir = (0, import_node_path3.resolve)(dir);
2215
+ const possibleExts = [".mp4", ".mov", ".webm", ".mkv", ".avi"];
2216
+ let sourceFilename = "source.mp4";
2217
+ for (const ext of possibleExts) {
2218
+ if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(projectDir, `source${ext}`))) {
2219
+ sourceFilename = `source${ext}`;
2220
+ break;
2221
+ }
2222
+ }
2223
+ const manifest = {
2224
+ source: sourceFilename,
2225
+ composition: "project.atelier",
2226
+ transcript: "transcript.json",
2227
+ cuts: "cuts.json",
2228
+ exportDir: "export/",
2229
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2230
+ version: VIDEO_PROJECT_VERSION
2231
+ };
2232
+ return {
2233
+ dir: projectDir,
2234
+ sourcePath: (0, import_node_path3.join)(projectDir, sourceFilename),
2235
+ compositionPath: (0, import_node_path3.join)(projectDir, "project.atelier"),
2236
+ transcriptPath: (0, import_node_path3.join)(projectDir, "transcript.json"),
2237
+ cutsPath: (0, import_node_path3.join)(projectDir, "cuts.json"),
2238
+ exportDir: (0, import_node_path3.join)(projectDir, "export"),
2239
+ manifest
2240
+ };
2241
+ }
2242
+ function readCutList(project) {
2243
+ if (!(0, import_node_fs3.existsSync)(project.cutsPath)) {
2244
+ return { version: VIDEO_CUTLIST_VERSION, source: project.manifest.source, cuts: [] };
2245
+ }
2246
+ const raw = JSON.parse((0, import_node_fs3.readFileSync)(project.cutsPath, "utf-8"));
2247
+ const cuts = raw.cuts.map((entry) => {
2248
+ if ("rawStart" in entry) return entry;
2249
+ return {
2250
+ rawStart: entry.start,
2251
+ rawEnd: entry.end,
2252
+ paddingPre: 0,
2253
+ paddingPost: 0,
2254
+ ...entry.label !== void 0 && { label: entry.label }
2255
+ };
2256
+ });
2257
+ return {
2258
+ version: VIDEO_CUTLIST_VERSION,
2259
+ source: raw.source,
2260
+ cuts
2261
+ };
2262
+ }
2263
+ function writeCutList(project, cuts) {
2264
+ const payload = { ...cuts, version: VIDEO_CUTLIST_VERSION };
2265
+ (0, import_node_fs3.writeFileSync)(project.cutsPath, JSON.stringify(payload, null, 2), "utf-8");
2266
+ }
2267
+ function readTranscript(project) {
2268
+ if (!(0, import_node_fs3.existsSync)(project.transcriptPath)) return null;
2269
+ const raw = JSON.parse((0, import_node_fs3.readFileSync)(project.transcriptPath, "utf-8"));
2270
+ const segments = raw.segments.map((seg) => ({
2271
+ text: seg.text,
2272
+ start: seg.start,
2273
+ end: seg.end,
2274
+ words: seg.words.map((w) => {
2275
+ if ("detected" in w) return w;
2276
+ return {
2277
+ detected: w.word,
2278
+ text: w.word,
2279
+ start: w.start,
2280
+ end: w.end,
2281
+ ...w.confidence !== void 0 && { confidence: w.confidence }
2282
+ };
2283
+ })
2284
+ }));
2285
+ return {
2286
+ version: VIDEO_TRANSCRIPT_VERSION,
2287
+ ...raw.language !== void 0 && { language: raw.language },
2288
+ segments
2289
+ };
2290
+ }
2291
+ function writeTranscript(project, transcript) {
2292
+ const payload = { ...transcript, version: VIDEO_TRANSCRIPT_VERSION };
2293
+ (0, import_node_fs3.writeFileSync)(project.transcriptPath, JSON.stringify(payload, null, 2), "utf-8");
2294
+ }
2295
+ function readComposition(project) {
2296
+ return JSON.parse((0, import_node_fs3.readFileSync)(project.compositionPath, "utf-8"));
2297
+ }
2298
+ function writeComposition(project, doc) {
2299
+ (0, import_node_fs3.writeFileSync)(project.compositionPath, JSON.stringify(doc, null, 2), "utf-8");
2300
+ }
2301
+ function rewriteCutLayers(doc, cuts, sourceFilename, sourceDuration, assetId = "src") {
2302
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("silence-trim"));
2303
+ const fps = doc.canvas.fps;
2304
+ let cumulativeFrame = 0;
2305
+ const trimLayers = cuts.map((cut, idx) => {
2306
+ const span = effectiveSpan(cut, sourceDuration);
2307
+ const sourceOffsetFrames = Math.floor(span.start * fps) / fps;
2308
+ const sourceEndFrames = Math.ceil(span.end * fps) / fps;
2309
+ const durationFrames = Math.max(1, Math.round((sourceEndFrames - sourceOffsetFrames) * fps));
2310
+ const layer = {
2311
+ id: `clip-trim-${idx}`,
2312
+ tags: ["silence-trim"],
2313
+ visual: {
2314
+ type: "video",
2315
+ assetId,
2316
+ src: sourceFilename,
2317
+ startFrame: cumulativeFrame,
2318
+ sourceOffset: sourceOffsetFrames,
2319
+ sourceEnd: sourceEndFrames,
2320
+ playbackRate: 1,
2321
+ objectFit: "contain"
2322
+ },
2323
+ frame: { x: 0, y: 0 },
2324
+ bounds: { width: doc.canvas.width, height: doc.canvas.height }
2325
+ };
2326
+ cumulativeFrame += durationFrames;
2327
+ return layer;
2328
+ });
2329
+ return { ...doc, layers: [...preserved, ...trimLayers] };
2330
+ }
2331
+
2332
+ // src/lib/silence-detect.ts
2333
+ var import_node_child_process = require("child_process");
2334
+ async function probeSilencedetect() {
2335
+ const stdout = await runCapture("ffmpeg", ["-hide_banner", "-filters"]);
2336
+ if (!/\bsilencedetect\b/.test(stdout)) {
2337
+ throw new Error(
2338
+ "Your ffmpeg build lacks the silencedetect filter. Install a standard ffmpeg \u2265 4.0 (brew install ffmpeg / apt install ffmpeg)."
2339
+ );
2340
+ }
2341
+ }
2342
+ async function probeDuration(sourcePath) {
2343
+ const stdout = await runCapture("ffprobe", [
2344
+ "-v",
2345
+ "error",
2346
+ "-show_entries",
2347
+ "format=duration",
2348
+ "-of",
2349
+ "csv=p=0",
2350
+ sourcePath
2351
+ ]);
2352
+ const n = parseFloat(stdout.trim());
2353
+ if (!Number.isFinite(n) || n <= 0) {
2354
+ throw new Error(`ffprobe returned invalid duration for ${sourcePath}: "${stdout}"`);
2355
+ }
2356
+ return n;
2357
+ }
2358
+ async function runSilenceDetect(sourcePath, options = {}) {
2359
+ const noise = options.noise ?? "-30dB";
2360
+ const minSilence = options.minSilence ?? 0.35;
2361
+ const filter = `silencedetect=noise=${noise}:d=${minSilence}`;
2362
+ const stderr = await runCaptureStderr("ffmpeg", [
2363
+ "-hide_banner",
2364
+ "-nostats",
2365
+ "-i",
2366
+ sourcePath,
2367
+ "-af",
2368
+ filter,
2369
+ "-f",
2370
+ "null",
2371
+ "-"
2372
+ ]);
2373
+ return parseSilenceDetectStderr(stderr);
2374
+ }
2375
+ function parseSilenceDetectStderr(stderr) {
2376
+ const intervals = [];
2377
+ const startRe = /silence_start:\s*(-?[\d.]+)/;
2378
+ const endRe = /silence_end:\s*(-?[\d.]+)/;
2379
+ let pendingStart = null;
2380
+ for (const line of stderr.split(/\r?\n/)) {
2381
+ const sm = line.match(startRe);
2382
+ if (sm) {
2383
+ pendingStart = parseFloat(sm[1]);
2384
+ continue;
2385
+ }
2386
+ const em = line.match(endRe);
2387
+ if (em && pendingStart !== null) {
2388
+ const end = parseFloat(em[1]);
2389
+ intervals.push({ start: Math.max(0, pendingStart), end });
2390
+ pendingStart = null;
2391
+ }
2392
+ }
2393
+ return intervals;
2394
+ }
2395
+ function runCapture(cmd, args) {
2396
+ return new Promise((resolve16, reject) => {
2397
+ const proc = (0, import_node_child_process.spawn)(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
2398
+ let stdout = "";
2399
+ proc.stdout.on("data", (b) => stdout += b.toString());
2400
+ proc.on("error", (err) => {
2401
+ const e = err;
2402
+ if (e.code === "ENOENT") {
2403
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg/ffprobe (brew install ffmpeg).`));
2404
+ } else {
2405
+ reject(err);
2406
+ }
2407
+ });
2408
+ proc.on("close", (code) => {
2409
+ if (code !== 0) reject(new Error(`${cmd} exited ${code}`));
2410
+ else resolve16(stdout);
2411
+ });
2412
+ });
2413
+ }
2414
+ function runCaptureStderr(cmd, args) {
2415
+ return new Promise((resolve16, reject) => {
2416
+ const proc = (0, import_node_child_process.spawn)(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
2417
+ let stderr = "";
2418
+ proc.stderr.on("data", (b) => stderr += b.toString());
2419
+ proc.on("error", (err) => {
2420
+ const e = err;
2421
+ if (e.code === "ENOENT") {
2422
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg (brew install ffmpeg).`));
2423
+ } else {
2424
+ reject(err);
2425
+ }
2426
+ });
2427
+ proc.on("close", () => resolve16(stderr));
2428
+ });
2429
+ }
2430
+
2431
+ // src/lib/cut-model.ts
2432
+ var DEFAULT_PADDING_PRE = 0.08;
2433
+ var DEFAULT_PADDING_POST = 0.12;
2434
+ var DEFAULT_MATCH_TOLERANCE = 0.5;
2435
+ function invertToSpeechIntervals(silences, duration) {
2436
+ if (silences.length === 0) {
2437
+ return duration > 0 ? [{ start: 0, end: duration }] : [];
2438
+ }
2439
+ const sorted = [...silences].sort((a, b) => a.start - b.start);
2440
+ const speech = [];
2441
+ if (sorted[0].start > 0) {
2442
+ speech.push({ start: 0, end: sorted[0].start });
2443
+ }
2444
+ for (let i = 0; i < sorted.length - 1; i++) {
2445
+ const gapStart = sorted[i].end;
2446
+ const gapEnd = sorted[i + 1].start;
2447
+ if (gapEnd > gapStart) {
2448
+ speech.push({ start: gapStart, end: gapEnd });
2449
+ }
2450
+ }
2451
+ const last = sorted[sorted.length - 1];
2452
+ if (last.end < duration) {
2453
+ speech.push({ start: last.end, end: duration });
2454
+ }
2455
+ return speech;
2456
+ }
2457
+ function buildInitialCuts(speech, paddingPre = DEFAULT_PADDING_PRE, paddingPost = DEFAULT_PADDING_POST) {
2458
+ return speech.map((s) => ({
2459
+ rawStart: s.start,
2460
+ rawEnd: s.end,
2461
+ paddingPre,
2462
+ paddingPost
2463
+ }));
2464
+ }
2465
+ function resolveOverlaps(cuts) {
2466
+ for (let i = 0; i < cuts.length - 1; i++) {
2467
+ const a = cuts[i];
2468
+ const b = cuts[i + 1];
2469
+ const aEnd = a.rawEnd + a.paddingPost;
2470
+ const bStart = b.rawStart - b.paddingPre;
2471
+ if (aEnd > bStart) {
2472
+ const mid = (a.rawEnd + b.rawStart) / 2;
2473
+ a.paddingPost = Math.max(0, mid - a.rawEnd);
2474
+ b.paddingPre = Math.max(0, b.rawStart - mid);
2475
+ }
2476
+ }
2477
+ }
2478
+ function clampBoundaries(cuts, duration) {
2479
+ if (cuts.length === 0) return;
2480
+ const first = cuts[0];
2481
+ if (first.rawStart - first.paddingPre < 0) {
2482
+ first.paddingPre = first.rawStart;
2483
+ }
2484
+ const last = cuts[cuts.length - 1];
2485
+ if (last.rawEnd + last.paddingPost > duration) {
2486
+ last.paddingPost = Math.max(0, duration - last.rawEnd);
2487
+ }
2488
+ }
2489
+ function mergeWithExisting(fresh, existing, tolerance = DEFAULT_MATCH_TOLERANCE) {
2490
+ return fresh.map((f) => {
2491
+ const match = existing.find(
2492
+ (e) => Math.abs(e.rawStart - f.rawStart) < tolerance && Math.abs(e.rawEnd - f.rawEnd) < tolerance
2493
+ );
2494
+ if (!match) return f;
2495
+ return {
2496
+ rawStart: f.rawStart,
2497
+ rawEnd: f.rawEnd,
2498
+ paddingPre: match.paddingPre,
2499
+ paddingPost: match.paddingPost,
2500
+ ...match.label !== void 0 && { label: match.label }
2501
+ };
2502
+ });
2503
+ }
2504
+ function applyGlobalPadding(cuts, deltaSeconds) {
2505
+ for (const cut of cuts) {
2506
+ cut.paddingPre = Math.max(0, cut.paddingPre + deltaSeconds);
2507
+ cut.paddingPost = Math.max(0, cut.paddingPost + deltaSeconds);
2508
+ }
2509
+ }
2510
+
2511
+ // src/lib/recipe.ts
2512
+ var import_node_fs4 = require("fs");
2513
+ var import_node_path4 = require("path");
2514
+ var import_node_os = require("os");
2515
+ var import_yaml2 = require("yaml");
2516
+ var RECIPE_VERSION = "1.0";
2517
+ function resolveRecipePath(pathOrName, projectDir) {
2518
+ if ((0, import_node_path4.isAbsolute)(pathOrName) || pathOrName.includes("/") || pathOrName.includes("\\")) {
2519
+ return (0, import_node_path4.resolve)(pathOrName);
2520
+ }
2521
+ const candidates = [];
2522
+ const exts = [".recipe.yaml", ".recipe.json", ".yaml", ".yml", ".json"];
2523
+ if (projectDir) {
2524
+ const projectRecipesDir = (0, import_node_path4.join)((0, import_node_path4.resolve)(projectDir), ".atelier", "recipes");
2525
+ for (const ext of exts) candidates.push((0, import_node_path4.join)(projectRecipesDir, `${pathOrName}${ext}`));
2526
+ }
2527
+ const userRecipesDir = (0, import_node_path4.join)((0, import_node_os.homedir)(), ".atelier", "recipes");
2528
+ for (const ext of exts) candidates.push((0, import_node_path4.join)(userRecipesDir, `${pathOrName}${ext}`));
2529
+ for (const candidate of candidates) {
2530
+ if ((0, import_node_fs4.existsSync)(candidate)) return candidate;
2531
+ }
2532
+ throw new Error(
2533
+ `Recipe "${pathOrName}" not found. Looked in:
2534
+ ${candidates.map((c) => ` ${c}`).join("\n")}`
2535
+ );
2536
+ }
2537
+ function loadRecipe(pathOrName, projectDir) {
2538
+ const path = resolveRecipePath(pathOrName, projectDir);
2539
+ const raw = (0, import_node_fs4.readFileSync)(path, "utf-8");
2540
+ let parsed;
2541
+ if (path.endsWith(".json")) {
2542
+ parsed = JSON.parse(raw);
2543
+ } else {
2544
+ parsed = (0, import_yaml2.parse)(raw);
2545
+ }
2546
+ const result = validateRecipe(parsed);
2547
+ if (!result.success) {
2548
+ const msg = result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
2549
+ throw new Error(`Invalid recipe at ${path}:
2550
+ ${msg}`);
2551
+ }
2552
+ return {
2553
+ recipe: result.data,
2554
+ path,
2555
+ warnings: result.warnings ?? []
2556
+ };
2557
+ }
2558
+ function scaffoldRecipeYaml(name) {
2559
+ return `# Studio Recipe \u2014 ${name}
2560
+ # Phase 1 \u2014 manual authoring + apply
2561
+ # https://github.com/ascend42/a-atelier/blob/main/.paradigm/specs/studio-recipe.md
2562
+
2563
+ version: "${RECIPE_VERSION}"
2564
+ name: "${name}"
2565
+ description: ""
2566
+ author: ""
2567
+ tags: []
2568
+
2569
+ # \u2500\u2500 Silence-trim policy \u2014 consumed by \`atelier trim\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2570
+ silence_policy:
2571
+ # silencedetect noise threshold (default: -30dB)
2572
+ noise: "-30dB"
2573
+ # Minimum silence duration to register, in seconds (default: 0.35)
2574
+ min_silence: 0.35
2575
+ # Default leading padding for new cuts, in seconds (default: 0.08)
2576
+ default_padding_pre: 0.08
2577
+ # Default trailing padding for new cuts, in seconds (default: 0.12)
2578
+ default_padding_post: 0.12
2579
+ # Re-detect match tolerance for preserving user padding, in seconds (default: 0.5)
2580
+ match_tolerance: 0.5
2581
+
2582
+ # \u2500\u2500 Caption visual style \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2583
+ caption_style:
2584
+ font_family: "Inter"
2585
+ font_size: 84
2586
+ font_weight: "bold" # normal | bold | numeric (100..900)
2587
+ text_align: "center" # left | center | right
2588
+ color: "#FFFFFF"
2589
+ y_ratio: 0.85 # 0=top, 1=bottom
2590
+ width_ratio: 0.9
2591
+ fade_seconds: 0.05
2592
+
2593
+ # \u2500\u2500 Caption phrase grouping \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2594
+ caption_grouping:
2595
+ max_words: 5
2596
+ pause_gap: 0.4
2597
+
2598
+ # \u2500\u2500 Overlay rules (Phase 1.5) \u2014 anchored handle + page-number overlays \u2500
2599
+ # overlay_rules:
2600
+ # handle:
2601
+ # text: "@username"
2602
+ # anchor: "bottom-left" # top-left | top-right | bottom-left | bottom-right
2603
+ # margin: 24 # px from anchored edges
2604
+ # style:
2605
+ # font_family: "Inter"
2606
+ # font_size: 36
2607
+ # font_weight: "bold"
2608
+ # color: "#FFFFFF"
2609
+ # page_number:
2610
+ # format: "{current}/{total}" # supports {current:02d} / {total:02d} zero-pad
2611
+ # anchor: "top-right"
2612
+ # margin: 24
2613
+ # style:
2614
+ # font_family: "Inter"
2615
+ # font_size: 36
2616
+ # font_weight: "normal"
2617
+ # color: "#FFFFFF"
2618
+
2619
+ # \u2500\u2500 Phase 3 fields (reserved \u2014 Phase 1 ignores these) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2620
+ # caption_highlight: {}
2621
+ # transition_kit: {}
2622
+ # palette: {}
2623
+ # audio_policy: {}
2624
+ # aspect_targets: []
2625
+ `;
2626
+ }
2627
+ function applyRecipeToTrimOptions(recipe, cliOptions) {
2628
+ const policy = recipe?.silence_policy;
2629
+ if (!policy) return cliOptions;
2630
+ return {
2631
+ ...cliOptions,
2632
+ noise: cliOptions.noise ?? policy.noise,
2633
+ minSilence: cliOptions.minSilence ?? policy.min_silence,
2634
+ padPre: cliOptions.padPre ?? policy.default_padding_pre,
2635
+ padPost: cliOptions.padPost ?? policy.default_padding_post,
2636
+ // matchTolerance is recipe-only at this layer (no CLI flag yet)
2637
+ matchTolerance: cliOptions.matchTolerance ?? policy.match_tolerance
2638
+ };
2639
+ }
2640
+ function applyRecipeToCaptionOptions(recipe) {
2641
+ if (!recipe) return {};
2642
+ const style = recipe.caption_style;
2643
+ const grouping = recipe.caption_grouping;
2644
+ const runtimeStyle = {};
2645
+ if (style) {
2646
+ if (style.font_family !== void 0) runtimeStyle.fontFamily = style.font_family;
2647
+ if (style.font_size !== void 0) runtimeStyle.fontSize = style.font_size;
2648
+ if (style.font_weight !== void 0) runtimeStyle.fontWeight = style.font_weight;
2649
+ if (style.text_align !== void 0) runtimeStyle.textAlign = style.text_align;
2650
+ if (style.color !== void 0) runtimeStyle.color = style.color;
2651
+ if (style.y_ratio !== void 0) runtimeStyle.yRatio = style.y_ratio;
2652
+ if (style.width_ratio !== void 0) runtimeStyle.widthRatio = style.width_ratio;
2653
+ if (style.fade_seconds !== void 0) runtimeStyle.fadeSeconds = style.fade_seconds;
2654
+ }
2655
+ return {
2656
+ ...Object.keys(runtimeStyle).length > 0 && { style: runtimeStyle },
2657
+ ...grouping?.max_words !== void 0 && { maxWords: grouping.max_words },
2658
+ ...grouping?.pause_gap !== void 0 && { pauseGap: grouping.pause_gap }
2659
+ };
2660
+ }
2661
+ function applyRecipeToTranscribeOptions(recipe, cliOptions) {
2662
+ if (!recipe) return cliOptions;
2663
+ const captionOptions = applyRecipeToCaptionOptions(recipe);
2664
+ return {
2665
+ ...cliOptions,
2666
+ captionOptions
2667
+ };
2668
+ }
2669
+ function renderRecipeWithDefaults(recipe) {
2670
+ const defaults = {
2671
+ silence_policy: {
2672
+ noise: "-30dB",
2673
+ min_silence: 0.35,
2674
+ default_padding_pre: 0.08,
2675
+ default_padding_post: 0.12,
2676
+ match_tolerance: 0.5
2677
+ },
2678
+ caption_style: {
2679
+ font_family: "Inter",
2680
+ font_size: 84,
2681
+ font_weight: "bold",
2682
+ text_align: "center",
2683
+ color: "#FFFFFF",
2684
+ y_ratio: 0.85,
2685
+ width_ratio: 0.9,
2686
+ fade_seconds: 0.05
2687
+ },
2688
+ caption_grouping: {
2689
+ max_words: 5,
2690
+ pause_gap: 0.4
2691
+ }
2692
+ };
2693
+ return {
2694
+ ...recipe,
2695
+ silence_policy: { ...defaults.silence_policy, ...recipe.silence_policy },
2696
+ caption_style: { ...defaults.caption_style, ...recipe.caption_style },
2697
+ caption_grouping: { ...defaults.caption_grouping, ...recipe.caption_grouping }
2698
+ };
2699
+ }
2700
+ function recipeToYaml(recipe) {
2701
+ return (0, import_yaml2.stringify)(recipe);
2702
+ }
2703
+ var DEFAULT_OVERLAY_MARGIN = 24;
2704
+ var DEFAULT_OVERLAY_TEXT_STYLE = {
2705
+ fontFamily: "Inter",
2706
+ fontSize: 24,
2707
+ fontWeight: 600,
2708
+ color: "#F5F5F7"
2709
+ };
2710
+ var HANDLE_LAYER_ID = "overlay-handle";
2711
+ var PAGE_NUMBER_LAYER_ID = "overlay-page-number";
2712
+ function applyRecipeToOverlay(doc, recipe, ctx) {
2713
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("overlay"));
2714
+ const overlayLayers = [];
2715
+ const rules = recipe.overlay_rules;
2716
+ if (!rules) {
2717
+ return { ...doc, layers: preserved };
2718
+ }
2719
+ if (rules.handle) {
2720
+ overlayLayers.push(buildHandleLayer(rules.handle, doc.canvas));
2721
+ }
2722
+ if (rules.page_number) {
2723
+ if (ctx?.currentIndex != null && ctx?.totalCount != null) {
2724
+ overlayLayers.push(
2725
+ buildPageNumberLayer(rules.page_number, doc.canvas, ctx.currentIndex, ctx.totalCount)
2726
+ );
2727
+ } else {
2728
+ console.warn(
2729
+ `applyRecipeToOverlay: recipe.overlay_rules.page_number present but currentIndex/totalCount not provided \u2014 skipping page_number layer.`
2730
+ );
2731
+ }
2732
+ }
2733
+ return {
2734
+ ...doc,
2735
+ layers: [...preserved, ...overlayLayers]
2736
+ };
2737
+ }
2738
+ function buildHandleLayer(rule, canvas) {
2739
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
2740
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
2741
+ return {
2742
+ id: HANDLE_LAYER_ID,
2743
+ tags: ["overlay"],
2744
+ visual: {
2745
+ type: "text",
2746
+ content: rule.text,
2747
+ style: mergeOverlayStyle(rule.style)
2748
+ },
2749
+ frame,
2750
+ bounds: { width: 600, height: 80 },
2751
+ anchorPoint
2752
+ };
2753
+ }
2754
+ function buildPageNumberLayer(rule, canvas, currentIndex, totalCount) {
2755
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
2756
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
2757
+ return {
2758
+ id: PAGE_NUMBER_LAYER_ID,
2759
+ tags: ["overlay"],
2760
+ visual: {
2761
+ type: "text",
2762
+ content: renderPageNumberFormat(rule.format, currentIndex, totalCount),
2763
+ style: mergeOverlayStyle(rule.style)
2764
+ },
2765
+ frame,
2766
+ bounds: { width: 200, height: 80 },
2767
+ anchorPoint
2768
+ };
2769
+ }
2770
+ function anchorToFrame(anchor, canvas, margin) {
2771
+ switch (anchor) {
2772
+ case "top-left":
2773
+ return { frame: { x: margin, y: margin }, anchorPoint: { x: 0, y: 0 } };
2774
+ case "top-right":
2775
+ return { frame: { x: canvas.width - margin, y: margin }, anchorPoint: { x: 1, y: 0 } };
2776
+ case "bottom-left":
2777
+ return { frame: { x: margin, y: canvas.height - margin }, anchorPoint: { x: 0, y: 1 } };
2778
+ case "bottom-right":
2779
+ return {
2780
+ frame: { x: canvas.width - margin, y: canvas.height - margin },
2781
+ anchorPoint: { x: 1, y: 1 }
2782
+ };
2783
+ }
2784
+ }
2785
+ function mergeOverlayStyle(style) {
2786
+ return {
2787
+ fontFamily: style?.font_family ?? DEFAULT_OVERLAY_TEXT_STYLE.fontFamily,
2788
+ fontSize: style?.font_size ?? DEFAULT_OVERLAY_TEXT_STYLE.fontSize,
2789
+ fontWeight: style?.font_weight ?? DEFAULT_OVERLAY_TEXT_STYLE.fontWeight,
2790
+ color: style?.color ?? DEFAULT_OVERLAY_TEXT_STYLE.color
2791
+ };
2792
+ }
2793
+ function renderPageNumberFormat(format, currentIndex, totalCount) {
2794
+ return format.replace(
2795
+ /\{(current|total)(?::0(\d+)d)?\}/g,
2796
+ (_, name, padWidth) => {
2797
+ const value = name === "current" ? currentIndex : totalCount;
2798
+ const str = String(value);
2799
+ if (padWidth) {
2800
+ const width = parseInt(padWidth, 10);
2801
+ return str.padStart(width, "0");
2802
+ }
2803
+ return str;
2804
+ }
2805
+ );
2806
+ }
2807
+
2808
+ // src/commands/trim.ts
2809
+ async function trimProject(projectDir, options = {}) {
2810
+ const project = loadVideoProject(projectDir);
2811
+ await probeSilencedetect();
2812
+ const duration = await probeDuration(project.sourcePath);
2813
+ const silences = await runSilenceDetect(project.sourcePath, {
2814
+ noise: options.noise,
2815
+ minSilence: options.minSilence
2816
+ });
2817
+ const speech = invertToSpeechIntervals(silences, duration);
2818
+ const padPre = options.padPre ?? DEFAULT_PADDING_PRE;
2819
+ const padPost = options.padPost ?? DEFAULT_PADDING_POST;
2820
+ let cuts = buildInitialCuts(speech, padPre, padPost);
2821
+ if (!options.reset) {
2822
+ const existing = readCutList(project);
2823
+ cuts = mergeWithExisting(cuts, existing.cuts, options.matchTolerance);
2824
+ }
2825
+ if (typeof options.tightenMs === "number") {
2826
+ applyGlobalPadding(cuts, -options.tightenMs / 1e3);
2827
+ }
2828
+ if (typeof options.loosenMs === "number") {
2829
+ applyGlobalPadding(cuts, options.loosenMs / 1e3);
2830
+ }
2831
+ if (typeof options.cutIndex === "number") {
2832
+ if (options.cutIndex < 0 || options.cutIndex >= cuts.length) {
2833
+ throw new Error(
2834
+ `--cut ${options.cutIndex} out of range (have ${cuts.length} cuts)`
2835
+ );
2836
+ }
2837
+ if (options.padPre !== void 0) cuts[options.cutIndex].paddingPre = options.padPre;
2838
+ if (options.padPost !== void 0) cuts[options.cutIndex].paddingPost = options.padPost;
2839
+ }
2840
+ resolveOverlaps(cuts);
2841
+ clampBoundaries(cuts, duration);
2842
+ const result = {
2843
+ projectDir: project.dir,
2844
+ duration,
2845
+ cuts,
2846
+ layerCount: cuts.length
2847
+ };
2848
+ if (options.dryRun) return result;
2849
+ writeCutList(project, {
2850
+ version: "1.1",
2851
+ source: project.manifest.source,
2852
+ cuts
2853
+ });
2854
+ const doc = readComposition(project);
2855
+ const updated = rewriteCutLayers(doc, cuts, project.manifest.source, duration);
2856
+ writeComposition(project, updated);
2857
+ return result;
2858
+ }
2859
+ function formatResult2(result) {
2860
+ const lines = [];
2861
+ lines.push(`Trimmed ${result.projectDir}`);
2862
+ lines.push(` source duration: ${result.duration.toFixed(2)}s`);
2863
+ lines.push(` cuts: ${result.cuts.length}`);
2864
+ for (let i = 0; i < result.cuts.length; i++) {
2865
+ const c = result.cuts[i];
2866
+ const effStart = Math.max(0, c.rawStart - c.paddingPre);
2867
+ const effEnd = Math.min(result.duration, c.rawEnd + c.paddingPost);
2868
+ const dur = effEnd - effStart;
2869
+ lines.push(
2870
+ ` [${i}] ${effStart.toFixed(2)}s \u2192 ${effEnd.toFixed(2)}s (${dur.toFixed(2)}s, pad ${c.paddingPre.toFixed(2)}/${c.paddingPost.toFixed(2)})` + (c.label ? ` "${c.label}"` : "")
2871
+ );
2872
+ }
2873
+ return lines.join("\n");
2874
+ }
2875
+ function trimCommand(program2) {
2876
+ program2.command("trim <project>").description(
2877
+ "Detect silence and rewrite project.atelier with parametric cuts (silence-trim tagged layers). Preserves user padding overrides on re-run."
2878
+ ).option("--noise <dB>", "Silence threshold (default: -30dB)", "-30dB").option(
2879
+ "--min-silence <seconds>",
2880
+ "Minimum silence duration to register (default: 0.35)",
2881
+ (v) => parseFloat(v),
2882
+ 0.35
2883
+ ).option(
2884
+ "--pad-pre <seconds>",
2885
+ `Default leading padding (default: ${DEFAULT_PADDING_PRE})`,
2886
+ (v) => parseFloat(v)
2887
+ ).option(
2888
+ "--pad-post <seconds>",
2889
+ `Default trailing padding (default: ${DEFAULT_PADDING_POST})`,
2890
+ (v) => parseFloat(v)
2891
+ ).option("--tighten <ms>", "Reduce all current padding by N ms", (v) => parseInt(v, 10)).option("--loosen <ms>", "Increase all current padding by N ms", (v) => parseInt(v, 10)).option(
2892
+ "--cut <index>",
2893
+ "Apply --pad-pre / --pad-post to one specific cut only",
2894
+ (v) => parseInt(v, 10)
2895
+ ).option("--reset", "Discard existing padding; re-detect from scratch").option("--recipe <name>", "Apply a Studio Recipe's silence_policy as the baseline").option("--dry-run", "Print computed cuts; don't write files").option("--json", "Output result as JSON for piping").action(async (project, opts) => {
2896
+ try {
2897
+ let trimOpts = {
2898
+ noise: opts.noise,
2899
+ minSilence: opts.minSilence,
2900
+ padPre: opts.padPre,
2901
+ padPost: opts.padPost,
2902
+ tightenMs: opts.tighten,
2903
+ loosenMs: opts.loosen,
2904
+ cutIndex: opts.cut,
2905
+ reset: opts.reset,
2906
+ dryRun: opts.dryRun
2907
+ };
2908
+ if (opts.recipe) {
2909
+ const { recipe } = loadRecipe(opts.recipe, project);
2910
+ trimOpts = applyRecipeToTrimOptions(recipe, trimOpts);
2911
+ }
2912
+ const result = await trimProject(project, trimOpts);
2913
+ if (opts.json) {
2914
+ console.log(JSON.stringify(result, null, 2));
2915
+ } else {
2916
+ console.log(formatResult2(result));
2917
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
2918
+ }
2919
+ } catch (err) {
2920
+ const msg = err instanceof Error ? err.message : String(err);
2921
+ console.error(`atelier trim: ${msg}`);
2922
+ process.exit(1);
2923
+ }
2924
+ });
2925
+ }
2926
+
2927
+ // src/lib/whisper.ts
2928
+ var import_node_child_process2 = require("child_process");
2929
+ var import_node_fs5 = require("fs");
2930
+ async function probeWhisper() {
2931
+ if (await commandExists("whisper-cli")) return "whisper-cpp";
2932
+ if (process.env.OPENAI_API_KEY) return "openai-api";
2933
+ return "none";
2934
+ }
2935
+ async function runWhisperCpp(sourcePath, options = {}) {
2936
+ const model = options.modelPath ?? options.model ?? "base.en";
2937
+ const args = [
2938
+ sourcePath,
2939
+ "--model",
2940
+ model,
2941
+ "--output-json",
2942
+ "--word-thold",
2943
+ "0.01",
2944
+ // emit word-level timestamps
2945
+ "--print-progress",
2946
+ "false"
2947
+ ];
2948
+ if (options.language) {
2949
+ args.push("--language", options.language);
2950
+ }
2951
+ return runCaptureStdout("whisper-cli", args);
2952
+ }
2953
+ function parseWhisperCppJson(jsonStr) {
2954
+ const raw = JSON.parse(jsonStr);
2955
+ const segments = (raw.transcription ?? []).map((seg) => {
2956
+ const segStart = (seg.offsets?.from ?? 0) / 1e3;
2957
+ const segEnd = (seg.offsets?.to ?? 0) / 1e3;
2958
+ const segText = seg.text.trim();
2959
+ let words;
2960
+ if (seg.tokens && seg.tokens.length > 0) {
2961
+ words = seg.tokens.filter((t) => t.text.trim().length > 0 && !t.text.startsWith("[_")).map((t) => ({
2962
+ detected: t.text.trim(),
2963
+ text: t.text.trim(),
2964
+ start: (t.offsets?.from ?? segStart * 1e3) / 1e3,
2965
+ end: (t.offsets?.to ?? segEnd * 1e3) / 1e3,
2966
+ ...t.p !== void 0 && { confidence: t.p }
2967
+ }));
2968
+ } else {
2969
+ const tokens = segText.split(/\s+/).filter((t) => t.length > 0);
2970
+ const span = segEnd - segStart;
2971
+ const per = tokens.length > 0 ? span / tokens.length : 0;
2972
+ words = tokens.map((tok, i) => ({
2973
+ detected: tok,
2974
+ text: tok,
2975
+ start: segStart + i * per,
2976
+ end: segStart + (i + 1) * per
2977
+ }));
2978
+ }
2979
+ return {
2980
+ text: segText,
2981
+ start: segStart,
2982
+ end: segEnd,
2983
+ words
2984
+ };
2985
+ });
2986
+ return {
2987
+ version: "1.1",
2988
+ ...raw.result?.language !== void 0 && { language: raw.result.language },
2989
+ segments
2990
+ };
2991
+ }
2992
+ async function commandExists(name) {
2993
+ if (name.startsWith("/") || name.match(/^[A-Z]:\\/)) {
2994
+ return (0, import_node_fs5.existsSync)(name);
2995
+ }
2996
+ return new Promise((resolve16) => {
2997
+ const probe = (0, import_node_child_process2.spawn)(process.platform === "win32" ? "where" : "which", [name], {
2998
+ stdio: ["ignore", "ignore", "ignore"]
2999
+ });
3000
+ probe.on("error", () => resolve16(false));
3001
+ probe.on("close", (code) => resolve16(code === 0));
3002
+ });
3003
+ }
3004
+ function runCaptureStdout(cmd, args) {
3005
+ return new Promise((resolve16, reject) => {
3006
+ const proc = (0, import_node_child_process2.spawn)(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3007
+ let stdout = "";
3008
+ let stderr = "";
3009
+ proc.stdout.on("data", (b) => stdout += b.toString());
3010
+ proc.stderr.on("data", (b) => stderr += b.toString());
3011
+ proc.on("error", (err) => {
3012
+ const e = err;
3013
+ if (e.code === "ENOENT") {
3014
+ reject(new Error(
3015
+ `${cmd} not found on PATH. Install whisper.cpp (brew install whisper-cpp) or run \`pnpm add @xenova/transformers\` for the ONNX fallback.`
3016
+ ));
3017
+ } else {
3018
+ reject(err);
3019
+ }
3020
+ });
3021
+ proc.on("close", (code) => {
3022
+ if (code !== 0) {
3023
+ reject(new Error(`${cmd} exited ${code}
3024
+ ${stderr}`));
3025
+ } else {
3026
+ resolve16(stdout);
3027
+ }
3028
+ });
3029
+ });
3030
+ }
3031
+
3032
+ // src/lib/transcript-model.ts
3033
+ var DEFAULT_TRANSCRIPT_MATCH_TOLERANCE = 0.3;
3034
+ var DEFAULT_PHRASE_MAX_WORDS = 5;
3035
+ var DEFAULT_PHRASE_PAUSE_GAP_SECONDS = 0.4;
3036
+ var END_PUNCT = /[.!?,;:]\s*$/;
3037
+ function flattenWords(transcript) {
3038
+ return transcript.segments.flatMap((s) => s.words);
3039
+ }
3040
+ function groupIntoPhrases(transcript, options = {}) {
3041
+ const maxWords = options.maxWords ?? DEFAULT_PHRASE_MAX_WORDS;
3042
+ const pauseGap = options.pauseGap ?? DEFAULT_PHRASE_PAUSE_GAP_SECONDS;
3043
+ const phrases = [];
3044
+ const visibleWords = flattenWords(transcript).filter((w) => !w.hidden);
3045
+ let current = [];
3046
+ for (let i = 0; i < visibleWords.length; i++) {
3047
+ const w = visibleWords[i];
3048
+ current.push(w);
3049
+ const next = visibleWords[i + 1];
3050
+ const gap = next ? next.start - w.end : 0;
3051
+ const endsOnPunct = END_PUNCT.test(w.text);
3052
+ const atMax = current.length >= maxWords;
3053
+ if (!next || atMax || endsOnPunct || gap > pauseGap) {
3054
+ phrases.push({
3055
+ start: current[0].start,
3056
+ end: current[current.length - 1].end,
3057
+ text: current.map((c) => c.text).join(" "),
3058
+ words: current
3059
+ });
3060
+ current = [];
3061
+ }
3062
+ }
3063
+ return phrases;
3064
+ }
3065
+ function mergeTranscriptWithExisting(fresh, existing, tolerance = DEFAULT_TRANSCRIPT_MATCH_TOLERANCE) {
3066
+ const existingWords = flattenWords(existing);
3067
+ const merged = {
3068
+ version: "1.1",
3069
+ ...fresh.language !== void 0 && { language: fresh.language },
3070
+ segments: fresh.segments.map((seg) => ({
3071
+ text: seg.text,
3072
+ start: seg.start,
3073
+ end: seg.end,
3074
+ words: seg.words.map((freshWord) => {
3075
+ const match = existingWords.find(
3076
+ (e) => !e.userAdded && Math.abs(e.start - freshWord.start) < tolerance && e.detected === freshWord.detected
3077
+ );
3078
+ if (!match) return freshWord;
3079
+ return {
3080
+ detected: freshWord.detected,
3081
+ text: match.text,
3082
+ start: freshWord.start,
3083
+ end: freshWord.end,
3084
+ ...freshWord.confidence !== void 0 && { confidence: freshWord.confidence },
3085
+ ...match.userEdited && { userEdited: true },
3086
+ ...match.hidden && { hidden: true }
3087
+ };
3088
+ })
3089
+ }))
3090
+ };
3091
+ const orphans = existingWords.filter((w) => w.userAdded);
3092
+ for (const orphan of orphans) {
3093
+ const segIdx = merged.segments.findIndex(
3094
+ (s) => orphan.start >= s.start && orphan.start <= s.end
3095
+ );
3096
+ const targetSeg = segIdx >= 0 ? merged.segments[segIdx] : merged.segments[merged.segments.length - 1];
3097
+ if (!targetSeg) continue;
3098
+ const insertIdx = targetSeg.words.findIndex((w) => w.start > orphan.start);
3099
+ if (insertIdx === -1) targetSeg.words.push(orphan);
3100
+ else targetSeg.words.splice(insertIdx, 0, orphan);
3101
+ }
3102
+ return merged;
3103
+ }
3104
+ function applyTextEdit(transcript, wordIndex, newText) {
3105
+ return mapWord(transcript, wordIndex, (w) => ({
3106
+ ...w,
3107
+ text: newText,
3108
+ userEdited: true
3109
+ }));
3110
+ }
3111
+ function applyBatchReplace(transcript, find, replace) {
3112
+ return {
3113
+ ...transcript,
3114
+ segments: transcript.segments.map((seg) => ({
3115
+ ...seg,
3116
+ words: seg.words.map(
3117
+ (w) => w.detected === find ? { ...w, text: replace, userEdited: true } : w
3118
+ )
3119
+ }))
3120
+ };
3121
+ }
3122
+ function applyHide(transcript, wordIndex) {
3123
+ return mapWord(transcript, wordIndex, (w) => ({ ...w, hidden: true }));
3124
+ }
3125
+ function applyAdd(transcript, afterIndex, text, duration = 0.15) {
3126
+ const flat = flattenWords(transcript);
3127
+ if (afterIndex < 0 || afterIndex >= flat.length) {
3128
+ throw new Error(`afterIndex ${afterIndex} out of range (have ${flat.length} words)`);
3129
+ }
3130
+ const anchor = flat[afterIndex];
3131
+ const newWord = {
3132
+ detected: text,
3133
+ text,
3134
+ start: anchor.end,
3135
+ end: anchor.end + duration,
3136
+ userAdded: true
3137
+ };
3138
+ let cursor = 0;
3139
+ return {
3140
+ ...transcript,
3141
+ segments: transcript.segments.map((seg) => {
3142
+ const segStart = cursor;
3143
+ cursor += seg.words.length;
3144
+ if (afterIndex < segStart || afterIndex >= cursor) return seg;
3145
+ const localIdx = afterIndex - segStart;
3146
+ return {
3147
+ ...seg,
3148
+ words: [...seg.words.slice(0, localIdx + 1), newWord, ...seg.words.slice(localIdx + 1)]
3149
+ };
3150
+ })
3151
+ };
3152
+ }
3153
+ function applyMerge(transcript, firstIndex) {
3154
+ const flat = flattenWords(transcript);
3155
+ if (firstIndex < 0 || firstIndex >= flat.length - 1) {
3156
+ throw new Error(`firstIndex ${firstIndex} out of range for merge`);
3157
+ }
3158
+ const a = flat[firstIndex];
3159
+ const b = flat[firstIndex + 1];
3160
+ const mergedWord = {
3161
+ detected: a.detected + b.detected,
3162
+ text: a.text + b.text,
3163
+ start: a.start,
3164
+ end: b.end,
3165
+ userEdited: true
3166
+ };
3167
+ let cursor = 0;
3168
+ return {
3169
+ ...transcript,
3170
+ segments: transcript.segments.map((seg) => {
3171
+ const segStart = cursor;
3172
+ cursor += seg.words.length;
3173
+ if (firstIndex < segStart || firstIndex >= cursor - 1) return seg;
3174
+ const localIdx = firstIndex - segStart;
3175
+ return {
3176
+ ...seg,
3177
+ words: [...seg.words.slice(0, localIdx), mergedWord, ...seg.words.slice(localIdx + 2)]
3178
+ };
3179
+ })
3180
+ };
3181
+ }
3182
+ function applySplit(transcript, wordIndex, fraction, firstText, secondText) {
3183
+ if (fraction <= 0 || fraction >= 1) {
3184
+ throw new Error(`split fraction must be in (0, 1), got ${fraction}`);
3185
+ }
3186
+ const flat = flattenWords(transcript);
3187
+ if (wordIndex < 0 || wordIndex >= flat.length) {
3188
+ throw new Error(`wordIndex ${wordIndex} out of range`);
3189
+ }
3190
+ const w = flat[wordIndex];
3191
+ const splitTime = w.start + (w.end - w.start) * fraction;
3192
+ const cutChar = Math.max(1, Math.floor(w.text.length * fraction));
3193
+ const first = {
3194
+ detected: w.detected,
3195
+ text: firstText ?? w.text.slice(0, cutChar),
3196
+ start: w.start,
3197
+ end: splitTime,
3198
+ userEdited: true
3199
+ };
3200
+ const second = {
3201
+ detected: w.detected,
3202
+ text: secondText ?? w.text.slice(cutChar),
3203
+ start: splitTime,
3204
+ end: w.end,
3205
+ userEdited: true
3206
+ };
3207
+ let cursor = 0;
3208
+ return {
3209
+ ...transcript,
3210
+ segments: transcript.segments.map((seg) => {
3211
+ const segStart = cursor;
3212
+ cursor += seg.words.length;
3213
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
3214
+ const localIdx = wordIndex - segStart;
3215
+ return {
3216
+ ...seg,
3217
+ words: [...seg.words.slice(0, localIdx), first, second, ...seg.words.slice(localIdx + 1)]
3218
+ };
3219
+ })
3220
+ };
3221
+ }
3222
+ function mapWord(transcript, wordIndex, fn) {
3223
+ const flat = flattenWords(transcript);
3224
+ if (wordIndex < 0 || wordIndex >= flat.length) {
3225
+ throw new Error(`wordIndex ${wordIndex} out of range (have ${flat.length} words)`);
3226
+ }
3227
+ let cursor = 0;
3228
+ return {
3229
+ ...transcript,
3230
+ segments: transcript.segments.map((seg) => {
3231
+ const segStart = cursor;
3232
+ cursor += seg.words.length;
3233
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
3234
+ const localIdx = wordIndex - segStart;
3235
+ return {
3236
+ ...seg,
3237
+ words: seg.words.map((w, i) => i === localIdx ? fn(w) : w)
3238
+ };
3239
+ })
3240
+ };
3241
+ }
3242
+
3243
+ // src/lib/caption-builder.ts
3244
+ var DEFAULT_STYLE = {
3245
+ fontFamily: "Inter",
3246
+ fontSize: 84,
3247
+ fontWeight: "bold",
3248
+ textAlign: "center",
3249
+ color: "#FFFFFF",
3250
+ yRatio: 0.85,
3251
+ widthRatio: 0.9,
3252
+ fadeSeconds: 0.05
3253
+ };
3254
+ function buildCaptionLayers(transcript, canvas, options = {}) {
3255
+ const style = { ...DEFAULT_STYLE, ...options.style };
3256
+ const phrases = groupIntoPhrases(transcript, {
3257
+ maxWords: options.maxWords,
3258
+ pauseGap: options.pauseGap
3259
+ });
3260
+ const layers = [];
3261
+ const deltas = [];
3262
+ const fps = canvas.fps;
3263
+ phrases.forEach((phrase, idx) => {
3264
+ const layerId = `caption-${idx}`;
3265
+ layers.push(buildPhraseLayer(layerId, phrase, canvas, style));
3266
+ deltas.push(...buildPhraseDeltas(layerId, phrase, style.fadeSeconds, fps));
3267
+ });
3268
+ return { layers, deltas };
3269
+ }
3270
+ function rewriteCaptionLayers(doc, transcript, options = {}) {
3271
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("caption"));
3272
+ const { layers: captionLayers, deltas: captionDeltas } = buildCaptionLayers(
3273
+ transcript,
3274
+ doc.canvas,
3275
+ options
3276
+ );
3277
+ const captionLayerIds = new Set(captionLayers.map((l) => l.id));
3278
+ const stateNames = Object.keys(doc.states);
3279
+ const targetStateName = stateNames.includes("default") ? "default" : stateNames[0];
3280
+ const states = { ...doc.states };
3281
+ if (targetStateName && states[targetStateName]) {
3282
+ const existing = states[targetStateName];
3283
+ const preservedDeltas = existing.deltas.filter(
3284
+ (d) => !captionLayerIds.has(d.layer)
3285
+ );
3286
+ states[targetStateName] = {
3287
+ ...existing,
3288
+ deltas: [...preservedDeltas, ...captionDeltas]
3289
+ };
3290
+ }
3291
+ return {
3292
+ ...doc,
3293
+ layers: [...preserved, ...captionLayers],
3294
+ states
3295
+ };
3296
+ }
3297
+ function buildPhraseLayer(id, phrase, canvas, style) {
3298
+ return {
3299
+ id,
3300
+ tags: ["caption"],
3301
+ visual: {
3302
+ type: "text",
3303
+ content: phrase.text,
3304
+ style: {
3305
+ fontFamily: style.fontFamily,
3306
+ fontSize: style.fontSize,
3307
+ fontWeight: style.fontWeight,
3308
+ textAlign: style.textAlign,
3309
+ color: style.color
3310
+ }
3311
+ },
3312
+ frame: {
3313
+ x: canvas.width / 2,
3314
+ y: canvas.height * style.yRatio
3315
+ },
3316
+ bounds: {
3317
+ width: canvas.width * style.widthRatio,
3318
+ height: Math.max(120, style.fontSize * 1.6)
3319
+ },
3320
+ anchorPoint: { x: 0.5, y: 0.5 },
3321
+ opacity: 0
3322
+ };
3323
+ }
3324
+ function buildPhraseDeltas(layerId, phrase, fadeSeconds, fps) {
3325
+ const fadeFrames = Math.max(1, Math.round(fadeSeconds * fps));
3326
+ const startFrame = Math.floor(phrase.start * fps);
3327
+ const endFrame = Math.ceil(phrase.end * fps);
3328
+ const fadeOutStart = Math.max(startFrame + 1, endFrame - fadeFrames);
3329
+ return [
3330
+ // Fade in to visible at phrase start
3331
+ {
3332
+ layer: layerId,
3333
+ property: "opacity",
3334
+ range: [Math.max(0, startFrame - fadeFrames), startFrame],
3335
+ from: 0,
3336
+ to: 1,
3337
+ easing: "ease-out"
3338
+ },
3339
+ // Hold visible through the phrase
3340
+ // (no explicit delta needed — value persists between deltas)
3341
+ // Fade out at phrase end
3342
+ {
3343
+ layer: layerId,
3344
+ property: "opacity",
3345
+ range: [fadeOutStart, endFrame],
3346
+ from: 1,
3347
+ to: 0,
3348
+ easing: "ease-in"
3349
+ }
3350
+ ];
3351
+ }
3352
+
3353
+ // src/commands/transcribe.ts
3354
+ async function transcribeProject(projectDir, options = {}) {
3355
+ const project = loadVideoProject(projectDir);
3356
+ const backend = await probeWhisper();
3357
+ if (backend === "none") {
3358
+ throw new Error(
3359
+ "No Whisper backend available. Install whisper.cpp (brew install whisper-cpp) or set OPENAI_API_KEY and pass --use-api."
3360
+ );
3361
+ }
3362
+ if (backend === "openai-api") {
3363
+ throw new Error(
3364
+ "OpenAI API backend is not yet implemented. Install whisper.cpp for local transcription."
3365
+ );
3366
+ }
3367
+ const rawJson = await runWhisperCpp(project.sourcePath, {
3368
+ model: options.model,
3369
+ language: options.language
3370
+ });
3371
+ let transcript = parseWhisperCppJson(rawJson);
3372
+ if (!options.reset) {
3373
+ const existing = readTranscript(project);
3374
+ if (existing) {
3375
+ transcript = mergeTranscriptWithExisting(transcript, existing);
3376
+ }
3377
+ }
3378
+ const wordCount = transcript.segments.reduce((n, s) => n + s.words.length, 0);
3379
+ const result = {
3380
+ projectDir: project.dir,
3381
+ backend,
3382
+ transcript,
3383
+ wordCount,
3384
+ captionsGenerated: false
3385
+ };
3386
+ if (options.dryRun) return result;
3387
+ writeTranscript(project, transcript);
3388
+ if (!options.noCaptions) {
3389
+ const doc = readComposition(project);
3390
+ const updated = rewriteCaptionLayers(doc, transcript, options.captionOptions);
3391
+ writeComposition(project, updated);
3392
+ result.captionsGenerated = true;
3393
+ }
3394
+ return result;
3395
+ }
3396
+ function formatResult3(result) {
3397
+ const lines = [];
3398
+ lines.push(`Transcribed ${result.projectDir} via ${result.backend}`);
3399
+ if (result.transcript.language) {
3400
+ lines.push(` language: ${result.transcript.language}`);
3401
+ }
3402
+ lines.push(` segments: ${result.transcript.segments.length}`);
3403
+ lines.push(` words: ${result.wordCount}`);
3404
+ if (result.captionsGenerated) {
3405
+ lines.push(` captions: written to project.atelier`);
3406
+ } else {
3407
+ lines.push(` captions: skipped`);
3408
+ }
3409
+ return lines.join("\n");
3410
+ }
3411
+ function transcribeCommand(program2) {
3412
+ program2.command("transcribe <project>").description(
3413
+ "Transcribe source video via Whisper, write transcript.json, and rewrite caption-tagged TextVisual layers in project.atelier. Preserves user transcript edits on re-run."
3414
+ ).option("--model <name>", "Whisper model: tiny|base|small|medium|large-v3", "base.en").option("--language <code>", "BCP-47 language hint (omit for autodetect)").option("--reset", "Discard existing user edits; full fresh transcript").option("--no-captions", "Write transcript.json only; skip caption layer generation").option("--recipe <name>", "Apply a Studio Recipe's caption_style + caption_grouping").option("--dry-run", "Print transcript; don't write files").option("--json", "Output result as JSON for piping").action(async (project, opts) => {
3415
+ try {
3416
+ let transcribeOpts = {
3417
+ model: opts.model,
3418
+ language: opts.language,
3419
+ reset: opts.reset,
3420
+ noCaptions: !opts.captions,
3421
+ dryRun: opts.dryRun
3422
+ };
3423
+ if (opts.recipe) {
3424
+ const { recipe } = loadRecipe(opts.recipe, project);
3425
+ transcribeOpts = applyRecipeToTranscribeOptions(recipe, transcribeOpts);
3426
+ }
3427
+ const result = await transcribeProject(project, transcribeOpts);
3428
+ if (opts.json) {
3429
+ console.log(JSON.stringify(result, null, 2));
3430
+ } else {
3431
+ console.log(formatResult3(result));
3432
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
3433
+ }
3434
+ } catch (err) {
3435
+ const msg = err instanceof Error ? err.message : String(err);
3436
+ console.error(`atelier transcribe: ${msg}`);
3437
+ process.exit(1);
3438
+ }
3439
+ });
3440
+ }
3441
+
3442
+ // src/commands/transcript.ts
3443
+ function loadOrThrow(projectDir) {
3444
+ const project = loadVideoProject(projectDir);
3445
+ const transcript = readTranscript(project);
3446
+ if (!transcript) {
3447
+ throw new Error(
3448
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
3449
+ );
3450
+ }
3451
+ return { project, transcript };
3452
+ }
3453
+ function save(project, transcript, noRegenerate) {
3454
+ writeTranscript(project, transcript);
3455
+ if (!noRegenerate) {
3456
+ const doc = readComposition(project);
3457
+ const updated = rewriteCaptionLayers(doc, transcript);
3458
+ writeComposition(project, updated);
3459
+ }
3460
+ }
3461
+ function transcriptCommand(program2) {
3462
+ const transcript = program2.command("transcript").description("Edit transcript.json \u2014 fix, add, delete, merge, split, list");
3463
+ transcript.command("fix <project>").description("Apply text correction(s). Use --replace 'wrong=right' for batch, or --word <idx> --text '<correction>' for single edit.").option("--replace <pair...>", "Batch find/replace, format: 'detected=replacement' (repeatable)").option("--word <index>", "Single-word edit by index", (v) => parseInt(v, 10)).option("--text <text>", "Correction text (paired with --word)").option("--no-regenerate", "Skip caption layer regeneration after edit").action(async (projectDir, opts) => {
3464
+ try {
3465
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
3466
+ let updated = transcript2;
3467
+ if (opts.replace?.length) {
3468
+ for (const pair of opts.replace) {
3469
+ const [find, repl] = pair.split("=");
3470
+ if (!find || repl === void 0) {
3471
+ throw new Error(`Invalid --replace pair: "${pair}". Use 'detected=replacement'.`);
3472
+ }
3473
+ updated = applyBatchReplace(updated, find, repl);
3474
+ }
3475
+ }
3476
+ if (typeof opts.word === "number") {
3477
+ if (opts.text === void 0) {
3478
+ throw new Error("--word requires --text <correction>");
3479
+ }
3480
+ updated = applyTextEdit(updated, opts.word, opts.text);
3481
+ }
3482
+ if (!opts.replace?.length && opts.word === void 0) {
3483
+ throw new Error("Provide --replace 'wrong=right' or --word <idx> --text '...'");
3484
+ }
3485
+ save(project, updated, !opts.regenerate);
3486
+ console.log(`Updated transcript in ${project.dir}`);
3487
+ } catch (err) {
3488
+ console.error(`atelier transcript fix: ${err instanceof Error ? err.message : err}`);
3489
+ process.exit(1);
3490
+ }
3491
+ });
3492
+ transcript.command("add <project>").description("Insert a user-added word after the anchor word.").requiredOption("--after-word <index>", "Anchor word index", (v) => parseInt(v, 10)).requiredOption("--text <text>", "Text of the new word").option("--duration <seconds>", "Word duration in seconds (default: 0.15)", (v) => parseFloat(v), 0.15).option("--no-regenerate", "Skip caption layer regeneration").action(async (projectDir, opts) => {
3493
+ try {
3494
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
3495
+ const updated = applyAdd(transcript2, opts.afterWord, opts.text, opts.duration);
3496
+ save(project, updated, !opts.regenerate);
3497
+ console.log(`Inserted "${opts.text}" after word ${opts.afterWord} in ${project.dir}`);
3498
+ } catch (err) {
3499
+ console.error(`atelier transcript add: ${err instanceof Error ? err.message : err}`);
3500
+ process.exit(1);
3501
+ }
3502
+ });
3503
+ transcript.command("delete <project>").description("Hide a word (excluded from captions, kept in transcript).").requiredOption("--word <index>", "Word index", (v) => parseInt(v, 10)).option("--no-regenerate", "Skip caption layer regeneration").action(async (projectDir, opts) => {
3504
+ try {
3505
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
3506
+ const updated = applyHide(transcript2, opts.word);
3507
+ save(project, updated, !opts.regenerate);
3508
+ console.log(`Hidden word ${opts.word} in ${project.dir}`);
3509
+ } catch (err) {
3510
+ console.error(`atelier transcript delete: ${err instanceof Error ? err.message : err}`);
3511
+ process.exit(1);
3512
+ }
3513
+ });
3514
+ transcript.command("merge <project>").description("Merge two adjacent words at indices i and i+1.").requiredOption("--word <index>", "First of the two words to merge", (v) => parseInt(v, 10)).option("--no-regenerate", "Skip caption layer regeneration").action(async (projectDir, opts) => {
3515
+ try {
3516
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
3517
+ const updated = applyMerge(transcript2, opts.word);
3518
+ save(project, updated, !opts.regenerate);
3519
+ console.log(`Merged words ${opts.word} and ${opts.word + 1} in ${project.dir}`);
3520
+ } catch (err) {
3521
+ console.error(`atelier transcript merge: ${err instanceof Error ? err.message : err}`);
3522
+ process.exit(1);
3523
+ }
3524
+ });
3525
+ transcript.command("split <project>").description("Split one word at a fractional point.").requiredOption("--word <index>", "Word to split", (v) => parseInt(v, 10)).requiredOption("--at <fraction>", "Split point (0\u20131)", (v) => parseFloat(v)).option("--first <text>", "Override text for first half").option("--second <text>", "Override text for second half").option("--no-regenerate", "Skip caption layer regeneration").action(async (projectDir, opts) => {
3526
+ try {
3527
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
3528
+ const updated = applySplit(transcript2, opts.word, opts.at, opts.first, opts.second);
3529
+ save(project, updated, !opts.regenerate);
3530
+ console.log(`Split word ${opts.word} at ${opts.at} in ${project.dir}`);
3531
+ } catch (err) {
3532
+ console.error(`atelier transcript split: ${err instanceof Error ? err.message : err}`);
3533
+ process.exit(1);
3534
+ }
3535
+ });
3536
+ transcript.command("list <project>").description("Print all words with their indices for reference.").option("--json", "Output as JSON").action(async (projectDir, opts) => {
3537
+ try {
3538
+ const { transcript: transcript2 } = loadOrThrow(projectDir);
3539
+ const words = flattenWords(transcript2);
3540
+ if (opts.json) {
3541
+ console.log(JSON.stringify(words.map((w, i) => ({ index: i, ...w })), null, 2));
3542
+ } else {
3543
+ for (let i = 0; i < words.length; i++) {
3544
+ const w = words[i];
3545
+ const flags = [];
3546
+ if (w.userEdited) flags.push("edited");
3547
+ if (w.userAdded) flags.push("added");
3548
+ if (w.hidden) flags.push("hidden");
3549
+ const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
3550
+ const editedDisplay = w.text !== w.detected ? ` \u2190 "${w.detected}"` : "";
3551
+ console.log(
3552
+ ` [${i.toString().padStart(4)}] ${w.start.toFixed(2).padStart(7)}s "${w.text}"${editedDisplay}${flagStr}`
3553
+ );
3554
+ }
3555
+ }
3556
+ } catch (err) {
3557
+ console.error(`atelier transcript list: ${err instanceof Error ? err.message : err}`);
3558
+ process.exit(1);
3559
+ }
3560
+ });
3561
+ }
3562
+
3563
+ // src/commands/captions.ts
3564
+ function captionsCommand(program2) {
3565
+ const captions = program2.command("captions").description("Manage caption layers in a VideoProject");
3566
+ captions.command("regenerate <project>").description(
3567
+ "Re-derive caption-tagged TextVisual layers from the current transcript.json. Used when caption styling changes without re-running Whisper."
3568
+ ).option("--recipe <name>", "Apply a Studio Recipe's caption_style + caption_grouping").action(async (projectDir, opts) => {
3569
+ try {
3570
+ const project = loadVideoProject(projectDir);
3571
+ const transcript = readTranscript(project);
3572
+ if (!transcript) {
3573
+ throw new Error(
3574
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
3575
+ );
3576
+ }
3577
+ const captionOptions = opts.recipe ? applyRecipeToCaptionOptions(loadRecipe(opts.recipe, projectDir).recipe) : {};
3578
+ const doc = readComposition(project);
3579
+ const updated = rewriteCaptionLayers(doc, transcript, captionOptions);
3580
+ writeComposition(project, updated);
3581
+ const captionLayers = updated.layers.filter(
3582
+ (l) => (l.tags ?? []).includes("caption")
3583
+ );
3584
+ console.log(
3585
+ `Regenerated ${captionLayers.length} caption layer${captionLayers.length === 1 ? "" : "s"} in ${project.dir}`
3586
+ );
3587
+ } catch (err) {
3588
+ console.error(`atelier captions regenerate: ${err instanceof Error ? err.message : err}`);
3589
+ process.exit(1);
3590
+ }
3591
+ });
3592
+ }
3593
+
3594
+ // src/commands/recipe.ts
3595
+ var import_node_fs6 = require("fs");
3596
+ var import_node_path5 = require("path");
3597
+ function recipeCommand(program2) {
3598
+ const recipe = program2.command("recipe").description("Manage Studio Recipes \u2014 reusable style presets");
3599
+ recipe.command("new <name>").description("Scaffold a starter recipe YAML with all current defaults filled in").option("--dir <path>", "Where to write the recipe (default: ./.atelier/recipes/)").action((name, opts) => {
3600
+ try {
3601
+ const baseDir = opts.dir ?? (0, import_node_path5.join)((0, import_node_path5.resolve)(process.cwd()), ".atelier", "recipes");
3602
+ if (!(0, import_node_fs6.existsSync)(baseDir)) {
3603
+ (0, import_node_fs6.mkdirSync)(baseDir, { recursive: true });
3604
+ }
3605
+ const hasKnownExt = /\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i.test(name);
3606
+ const fileName = hasKnownExt ? name : `${name}.recipe.yaml`;
3607
+ const outPath = (0, import_node_path5.join)(baseDir, fileName);
3608
+ if ((0, import_node_fs6.existsSync)(outPath)) {
3609
+ throw new Error(`Recipe already exists at ${outPath} \u2014 refusing to overwrite.`);
3610
+ }
3611
+ const recipeName = name.replace(/\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i, "");
3612
+ const yaml = scaffoldRecipeYaml(recipeName);
3613
+ (0, import_node_fs6.writeFileSync)(outPath, yaml, "utf-8");
3614
+ console.log(`Created ${outPath}`);
3615
+ } catch (err) {
3616
+ console.error(`atelier recipe new: ${err instanceof Error ? err.message : err}`);
3617
+ process.exit(1);
3618
+ }
3619
+ });
3620
+ recipe.command("validate <path>").description("Validate a recipe against the schema (^valid-recipe gate). Warns on reserved Phase 3 fields.").option("--json", "Output result as JSON").action((path, opts) => {
3621
+ try {
3622
+ const loaded = loadRecipe(path, process.cwd());
3623
+ if (opts.json) {
3624
+ console.log(JSON.stringify({
3625
+ valid: true,
3626
+ path: loaded.path,
3627
+ warnings: loaded.warnings
3628
+ }, null, 2));
3629
+ } else {
3630
+ console.log(`PASS ${loaded.path}`);
3631
+ for (const w of loaded.warnings) {
3632
+ console.log(` \u26A0 ${w}`);
3633
+ }
3634
+ }
3635
+ } catch (err) {
3636
+ const msg = err instanceof Error ? err.message : String(err);
3637
+ if (opts.json) {
3638
+ console.log(JSON.stringify({ valid: false, error: msg }, null, 2));
3639
+ } else {
3640
+ console.error(`FAIL ${path}`);
3641
+ console.error(` ${msg}`);
3642
+ }
3643
+ process.exit(1);
3644
+ }
3645
+ });
3646
+ recipe.command("show <path>").description("Print a recipe; --with-defaults fills in every omitted field with its code default.").option("--with-defaults", "Overlay code defaults onto omitted fields").action((path, opts) => {
3647
+ try {
3648
+ const loaded = loadRecipe(path, process.cwd());
3649
+ const out = opts.withDefaults ? renderRecipeWithDefaults(loaded.recipe) : loaded.recipe;
3650
+ console.log(recipeToYaml(out));
3651
+ for (const w of loaded.warnings) {
3652
+ console.error(`# \u26A0 ${w}`);
3653
+ }
3654
+ } catch (err) {
3655
+ console.error(`atelier recipe show: ${err instanceof Error ? err.message : err}`);
3656
+ process.exit(1);
3657
+ }
3658
+ });
3659
+ }
3660
+
3661
+ // src/commands/apply-recipe.ts
3662
+ function applyRecipeCommand(program2) {
3663
+ program2.command("apply-recipe <project> <recipe>").description(
3664
+ "Apply a Studio Recipe by running atelier trim + atelier transcribe with the same recipe, in that order."
3665
+ ).option("--reset", "Apply --reset to both pipelines (destructive \u2014 discards existing user padding and transcript edits)").option("--no-trim", "Skip the atelier trim step").option("--no-transcribe", "Skip the atelier transcribe step").action(async (project, recipeRef, opts) => {
3666
+ try {
3667
+ const { recipe, path, warnings } = loadRecipe(recipeRef, project);
3668
+ console.log(`Loaded recipe ${path}`);
3669
+ for (const w of warnings) console.log(` \u26A0 ${w}`);
3670
+ if (opts.trim) {
3671
+ const trimOpts = applyRecipeToTrimOptions(recipe, { reset: opts.reset });
3672
+ console.log(`
3673
+ Running atelier trim...`);
3674
+ const r = await trimProject(project, trimOpts);
3675
+ console.log(` ${r.cuts.length} cut${r.cuts.length === 1 ? "" : "s"} written`);
3676
+ }
3677
+ if (opts.transcribe) {
3678
+ const transcribeOpts = applyRecipeToTranscribeOptions(recipe, { reset: opts.reset });
3679
+ console.log(`
3680
+ Running atelier transcribe...`);
3681
+ const r = await transcribeProject(project, transcribeOpts);
3682
+ console.log(` ${r.wordCount} words, ${r.captionsGenerated ? "captions written" : "captions skipped"}`);
3683
+ }
3684
+ if (recipe.overlay_rules) {
3685
+ const vp = loadVideoProject(project);
3686
+ const doc = readComposition(vp);
3687
+ const updated = applyRecipeToOverlay(doc, recipe);
3688
+ writeComposition(vp, updated);
3689
+ console.log(`
3690
+ Applied overlay rules to ${vp.compositionPath}`);
3691
+ }
3692
+ console.log(`
3693
+ Done.`);
3694
+ } catch (err) {
3695
+ console.error(`atelier apply-recipe: ${err instanceof Error ? err.message : err}`);
3696
+ process.exit(1);
3697
+ }
3698
+ });
3699
+ }
3700
+
3701
+ // src/commands/carousel.ts
3702
+ var import_node_fs7 = require("fs");
3703
+ var import_node_path6 = require("path");
3704
+
3705
+ // src/lib/render-image.ts
3706
+ init_dist2();
3707
+ var import_canvas = require("@napi-rs/canvas");
3708
+ function fitImageToCanvas(canvas, natural) {
3709
+ const cw = canvas.width;
3710
+ const ch = canvas.height;
3711
+ const iw = natural.width;
3712
+ const ih = natural.height;
3713
+ if (!iw || !ih) {
3714
+ return { bounds: { width: cw, height: ch }, frame: { x: cw / 2, y: ch / 2 } };
3715
+ }
3716
+ const imageAspect = iw / ih;
3717
+ const canvasAspect = cw / ch;
3718
+ let width;
3719
+ let height;
3720
+ if (imageAspect > canvasAspect) {
3721
+ width = cw;
3722
+ height = cw / imageAspect;
3723
+ } else {
3724
+ height = ch;
3725
+ width = ch * imageAspect;
3726
+ }
3727
+ width = Math.min(width, cw);
3728
+ height = Math.min(height, ch);
3729
+ return { bounds: { width, height }, frame: { x: cw / 2, y: ch / 2 } };
3730
+ }
3731
+ var CanvasUnavailableError = class extends Error {
3732
+ constructor() {
3733
+ super(
3734
+ "The '@napi-rs/canvas' rasterizer could not be loaded.\nThis package ships prebuilt platform binaries \u2014 no system libraries needed.\nTry reinstalling dependencies (e.g. `npm install`) to fetch the binary for this platform."
3735
+ );
3736
+ this.name = "CanvasUnavailableError";
3737
+ }
3738
+ };
3739
+ async function loadCanvasModule() {
3740
+ if (typeof import_canvas.createCanvas !== "function" || typeof import_canvas.loadImage !== "function") {
3741
+ throw new CanvasUnavailableError();
3742
+ }
3743
+ return {
3744
+ createCanvas: import_canvas.createCanvas,
3745
+ loadImage: import_canvas.loadImage
3746
+ };
3747
+ }
3748
+ function resolveExportDimensions(docWidth, docHeight, width, height) {
3749
+ if (width !== void 0 && height !== void 0) {
3750
+ return { width, height };
3751
+ }
3752
+ if (width !== void 0) {
3753
+ const h = Math.max(1, Math.round(width * docHeight / docWidth));
3754
+ return { width, height: h };
3755
+ }
3756
+ if (height !== void 0) {
3757
+ const w = Math.max(1, Math.round(height * docWidth / docHeight));
3758
+ return { width: w, height };
3759
+ }
3760
+ return { width: docWidth, height: docHeight };
3761
+ }
3762
+ async function preloadImages(doc, loadImage2) {
3763
+ const { ImageCache: ImageCache2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
3764
+ const sources = /* @__PURE__ */ new Set();
3765
+ for (const layer of doc.layers) {
3766
+ if (layer.visual.type === "image") {
3767
+ const iv = layer.visual;
3768
+ if (iv.src) sources.add(iv.src);
3769
+ else if (iv.assetId && doc.assets?.[iv.assetId]) sources.add(doc.assets[iv.assetId].src);
3770
+ }
3771
+ }
3772
+ if (sources.size === 0) {
3773
+ return { imageCache: new ImageCache2(), loaded: /* @__PURE__ */ new Map() };
3774
+ }
3775
+ const preloaded = /* @__PURE__ */ new Map();
3776
+ await Promise.all(
3777
+ [...sources].map(async (src) => {
3778
+ try {
3779
+ preloaded.set(src, await loadImage2(src));
3780
+ } catch {
3781
+ }
3782
+ })
3783
+ );
3784
+ const imageCache = new ImageCache2({
3785
+ createImage: (src, onLoad, onError) => {
3786
+ const img = preloaded.get(src);
3787
+ if (img) {
3788
+ process.nextTick(onLoad);
3789
+ return img;
3790
+ }
3791
+ process.nextTick(onError);
3792
+ return {};
3793
+ }
3794
+ });
3795
+ for (const src of preloaded.keys()) imageCache.load(src);
3796
+ await new Promise((resolve16) => process.nextTick(resolve16));
3797
+ return { imageCache, loaded: preloaded };
3798
+ }
3799
+ async function renderDocumentToPng(doc, opts = {}) {
3800
+ const stateNames = Object.keys(doc.states);
3801
+ if (stateNames.length === 0) {
3802
+ throw new Error("Document has no states");
3803
+ }
3804
+ const stateName = opts.state ?? stateNames[0];
3805
+ if (!doc.states[stateName]) {
3806
+ throw new Error(`State "${stateName}" not found. Available: ${stateNames.join(", ")}`);
3807
+ }
3808
+ const frameNumber = opts.frame ?? 0;
3809
+ if (!Number.isInteger(frameNumber) || frameNumber < 0) {
3810
+ throw new Error(`Invalid frame number: ${frameNumber}`);
3811
+ }
3812
+ const duration = doc.states[stateName].duration;
3813
+ if (frameNumber >= duration) {
3814
+ throw new Error(
3815
+ `Frame ${frameNumber} is out of range for state "${stateName}" (duration ${duration})`
3816
+ );
3817
+ }
3818
+ const { createCanvas: createCanvas2, loadImage: loadImage2 } = await loadCanvasModule();
3819
+ const { renderFrame: renderFrame2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
3820
+ const { imageCache, loaded } = await preloadImages(doc, loadImage2);
3821
+ let renderDoc = doc;
3822
+ if (opts.refitImageBounds && loaded.size > 0) {
3823
+ renderDoc = {
3824
+ ...doc,
3825
+ layers: doc.layers.map((layer) => {
3826
+ if (layer.visual.type !== "image") return layer;
3827
+ const iv = layer.visual;
3828
+ const src = iv.src ?? (iv.assetId ? doc.assets?.[iv.assetId]?.src : void 0);
3829
+ const natural = src ? loaded.get(src) : void 0;
3830
+ if (!natural || !natural.width || !natural.height) return layer;
3831
+ const fit = opts.refitImageBounds({ canvas: doc.canvas, natural });
3832
+ return { ...layer, bounds: fit.bounds, frame: fit.frame };
3833
+ })
3834
+ };
3835
+ }
3836
+ const { width: outW, height: outH } = resolveExportDimensions(
3837
+ renderDoc.canvas.width,
3838
+ renderDoc.canvas.height,
3839
+ opts.width,
3840
+ opts.height
3841
+ );
3842
+ const resolved = resolveFrame(renderDoc, stateName, frameNumber);
3843
+ const cvs = createCanvas2(outW, outH);
3844
+ const ctx = cvs.getContext("2d");
3845
+ const sx = outW / renderDoc.canvas.width;
3846
+ const sy = outH / renderDoc.canvas.height;
3847
+ if (sx !== 1 || sy !== 1) ctx.scale(sx, sy);
3848
+ renderFrame2(ctx, resolved, renderDoc, imageCache);
3849
+ return cvs.toBuffer("image/png");
3850
+ }
3851
+
3852
+ // src/commands/carousel.ts
3853
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
3854
+ var DEFAULT_CANVAS = 1080;
3855
+ function expandInputs(pattern) {
3856
+ const abs = (0, import_node_path6.resolve)(pattern);
3857
+ let isDir = false;
3858
+ try {
3859
+ isDir = (0, import_node_fs7.statSync)(abs).isDirectory();
3860
+ } catch {
3861
+ isDir = false;
3862
+ }
3863
+ if (isDir) {
3864
+ return listImages(abs);
3865
+ }
3866
+ const dir = (0, import_node_path6.dirname)(abs);
3867
+ const base = (0, import_node_path6.basename)(abs);
3868
+ if (base.includes("*")) {
3869
+ const matcher = globToRegExp(base);
3870
+ let entries;
3871
+ try {
3872
+ entries = (0, import_node_fs7.readdirSync)(dir);
3873
+ } catch {
3874
+ return [];
3875
+ }
3876
+ return entries.filter((name) => matcher.test(name) && isImageFile(name)).map((name) => (0, import_node_path6.join)(dir, name)).sort();
1862
3877
  }
1863
- return { valid: true, errors: [] };
3878
+ return isImageFile(base) ? [abs] : [];
1864
3879
  }
1865
- function validateCommand(program2) {
1866
- program2.command("validate <file>").description("Validate an .atelier YAML file").action((file) => {
1867
- const { valid, errors } = validateFile(file);
1868
- if (valid) {
1869
- console.log("Valid");
1870
- } else {
1871
- console.error("Validation errors:");
1872
- for (const error of errors) {
1873
- console.error(` - ${error}`);
3880
+ function listImages(dir) {
3881
+ let entries;
3882
+ try {
3883
+ entries = (0, import_node_fs7.readdirSync)(dir);
3884
+ } catch {
3885
+ return [];
3886
+ }
3887
+ return entries.filter(isImageFile).map((name) => (0, import_node_path6.join)(dir, name)).sort();
3888
+ }
3889
+ function isImageFile(name) {
3890
+ return IMAGE_EXTS.has((0, import_node_path6.extname)(name).toLowerCase());
3891
+ }
3892
+ function globToRegExp(glob) {
3893
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3894
+ const pattern = escaped.replace(/\*/g, `[^${import_node_path6.sep === "\\" ? "\\\\" : import_node_path6.sep}]*`).replace(/\?/g, ".");
3895
+ return new RegExp(`^${pattern}$`);
3896
+ }
3897
+ function composeCarouselFrameDoc(args) {
3898
+ const { imagePath, index, total, width, height, recipe } = args;
3899
+ const canvas = { width, height };
3900
+ const fit = fitImageToCanvas(canvas, { width: 0, height: 0 });
3901
+ const assetId = "carousel-image-asset";
3902
+ const baseDoc = {
3903
+ version: "1.0",
3904
+ name: `carousel-${index}`,
3905
+ canvas: { width, height, fps: 30, background: args.background ?? "#000000" },
3906
+ assets: { [assetId]: { type: "image", src: imagePath } },
3907
+ layers: [
3908
+ {
3909
+ id: "carousel-image",
3910
+ visual: { type: "image", assetId, src: imagePath },
3911
+ frame: fit.frame,
3912
+ bounds: fit.bounds,
3913
+ anchorPoint: { x: 0.5, y: 0.5 },
3914
+ opacity: 1
3915
+ }
3916
+ ],
3917
+ states: { default: { duration: 1, deltas: [] } }
3918
+ };
3919
+ return applyRecipeToOverlay(baseDoc, recipe, { currentIndex: index, totalCount: total });
3920
+ }
3921
+ function carouselFileName(index, total, imagePath) {
3922
+ const padWidth = Math.max(2, String(total).length);
3923
+ const prefix = String(index).padStart(padWidth, "0");
3924
+ const ext = (0, import_node_path6.extname)(imagePath);
3925
+ const stem = (0, import_node_path6.basename)(imagePath, ext);
3926
+ return `${prefix}-${stem}.png`;
3927
+ }
3928
+ function parseDim(raw, name) {
3929
+ if (raw === void 0) return void 0;
3930
+ const n = parseInt(raw, 10);
3931
+ if (isNaN(n) || n <= 0) {
3932
+ console.error(`Invalid --${name}: ${raw}`);
3933
+ process.exit(1);
3934
+ }
3935
+ return n;
3936
+ }
3937
+ function carouselCommand(program2) {
3938
+ program2.command("carousel <recipe>").description(
3939
+ "Batch-compose a folder of images into recipe-overlaid PNGs. Each image becomes a fit-to-canvas post with the recipe's handle + page-number ('i/N') overlays, written to --out-dir as zero-padded PNGs."
3940
+ ).requiredOption("-i, --inputs <glob>", "Input images: a directory, file, or single-segment *-glob").requiredOption("-d, --out-dir <dir>", "Destination directory for composed PNGs").option("--width <number>", "Canvas width (px)", String(DEFAULT_CANVAS)).option("--height <number>", "Canvas height (px)", String(DEFAULT_CANVAS)).option("-f, --frame <number>", "Frame to render (defaults to 0)", "0").action(async (recipeRef, options) => {
3941
+ let recipe;
3942
+ try {
3943
+ const loaded = loadRecipe(recipeRef);
3944
+ recipe = loaded.recipe;
3945
+ console.log(`Loaded recipe ${loaded.path}`);
3946
+ for (const w of loaded.warnings) console.log(` \u26A0 ${w}`);
3947
+ } catch (err) {
3948
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
3949
+ process.exit(1);
3950
+ return;
3951
+ }
3952
+ const inputs = expandInputs(options.inputs);
3953
+ if (inputs.length === 0) {
3954
+ console.error(
3955
+ `atelier carousel: no image files matched --inputs "${options.inputs}" (accepted: ${[...IMAGE_EXTS].join(", ")})`
3956
+ );
3957
+ process.exit(1);
3958
+ return;
3959
+ }
3960
+ const width = parseDim(options.width, "width") ?? DEFAULT_CANVAS;
3961
+ const height = parseDim(options.height, "height") ?? DEFAULT_CANVAS;
3962
+ const frame = parseInt(options.frame, 10);
3963
+ if (isNaN(frame) || frame < 0) {
3964
+ console.error(`Invalid frame number: ${options.frame}`);
3965
+ process.exit(1);
3966
+ return;
3967
+ }
3968
+ const outDir = (0, import_node_path6.resolve)(options.outDir);
3969
+ try {
3970
+ (0, import_node_fs7.mkdirSync)(outDir, { recursive: true });
3971
+ } catch (err) {
3972
+ console.error(`atelier carousel: cannot create out-dir ${outDir}: ${err.message}`);
3973
+ process.exit(1);
3974
+ return;
3975
+ }
3976
+ const total = inputs.length;
3977
+ console.log(`Composing ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
3978
+ try {
3979
+ for (let n = 0; n < total; n++) {
3980
+ const index = n + 1;
3981
+ const imagePath = inputs[n];
3982
+ const doc = composeCarouselFrameDoc({
3983
+ imagePath,
3984
+ index,
3985
+ total,
3986
+ width,
3987
+ height,
3988
+ recipe
3989
+ });
3990
+ const buffer = await renderDocumentToPng(doc, {
3991
+ frame,
3992
+ // Re-fit the image to the canvas using real natural dims once decoded.
3993
+ refitImageBounds: ({ canvas, natural }) => fitImageToCanvas(canvas, natural)
3994
+ });
3995
+ const outName = carouselFileName(index, total, imagePath);
3996
+ (0, import_node_fs7.writeFileSync)((0, import_node_path6.join)(outDir, outName), buffer);
3997
+ console.log(` [${index}/${total}] ${(0, import_node_path6.basename)(imagePath)} \u2192 ${outName}`);
3998
+ }
3999
+ } catch (err) {
4000
+ if (err instanceof CanvasUnavailableError) {
4001
+ console.error(err.message);
4002
+ process.exit(1);
4003
+ return;
1874
4004
  }
4005
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
1875
4006
  process.exit(1);
4007
+ return;
1876
4008
  }
4009
+ console.log(`
4010
+ Done. ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
1877
4011
  });
1878
4012
  }
1879
4013
 
1880
4014
  // src/commands/info.ts
1881
- var import_node_fs2 = require("fs");
1882
- var import_node_path2 = require("path");
4015
+ var import_node_fs8 = require("fs");
4016
+ var import_node_path7 = require("path");
1883
4017
  function getInfo(doc) {
1884
4018
  return {
1885
4019
  name: doc.name,
@@ -1936,10 +4070,10 @@ function formatInfo(info) {
1936
4070
  return lines.join("\n");
1937
4071
  }
1938
4072
  function readAndParse(file) {
1939
- const absPath = (0, import_node_path2.resolve)(file);
4073
+ const absPath = (0, import_node_path7.resolve)(file);
1940
4074
  let content;
1941
4075
  try {
1942
- content = (0, import_node_fs2.readFileSync)(absPath, "utf-8");
4076
+ content = (0, import_node_fs8.readFileSync)(absPath, "utf-8");
1943
4077
  } catch {
1944
4078
  console.error(`Cannot read file: ${absPath}`);
1945
4079
  return process.exit(1);
@@ -1963,8 +4097,8 @@ function infoCommand(program2) {
1963
4097
  }
1964
4098
 
1965
4099
  // src/commands/still.ts
1966
- var import_node_fs3 = require("fs");
1967
- var import_node_path3 = require("path");
4100
+ var import_node_fs9 = require("fs");
4101
+ var import_node_path8 = require("path");
1968
4102
  init_dist2();
1969
4103
  function resolveStill(doc, stateName, frame) {
1970
4104
  const stateNames = Object.keys(doc.states);
@@ -1981,10 +4115,10 @@ function resolveStill(doc, stateName, frame) {
1981
4115
  return resolveFrame(doc, resolvedStateName, resolvedFrame);
1982
4116
  }
1983
4117
  function readAndParse2(file) {
1984
- const absPath = (0, import_node_path3.resolve)(file);
4118
+ const absPath = (0, import_node_path8.resolve)(file);
1985
4119
  let content;
1986
4120
  try {
1987
- content = (0, import_node_fs3.readFileSync)(absPath, "utf-8");
4121
+ content = (0, import_node_fs9.readFileSync)(absPath, "utf-8");
1988
4122
  } catch {
1989
4123
  console.error(`Cannot read file: ${absPath}`);
1990
4124
  return process.exit(1);
@@ -2035,27 +4169,19 @@ function stillCommand(program2) {
2035
4169
  if (options.format === "json") {
2036
4170
  const json = JSON.stringify(resolved, null, 2);
2037
4171
  if (options.output) {
2038
- (0, import_node_fs3.writeFileSync)((0, import_node_path3.resolve)(options.output), json, "utf-8");
4172
+ (0, import_node_fs9.writeFileSync)((0, import_node_path8.resolve)(options.output), json, "utf-8");
2039
4173
  } else {
2040
4174
  console.log(json);
2041
4175
  }
2042
4176
  } else {
2043
- let canvasMod;
2044
- try {
2045
- canvasMod = await import("canvas");
2046
- } catch {
2047
- console.error("PNG output requires the 'canvas' package. Install it: npm i canvas");
2048
- process.exit(1);
2049
- return;
2050
- }
4177
+ const { createCanvas: createCanvas2 } = await import("@napi-rs/canvas");
2051
4178
  const { renderFrame: renderFrame2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
2052
- const { createCanvas } = canvasMod;
2053
- const cvs = createCanvas(doc.canvas.width, doc.canvas.height);
4179
+ const cvs = createCanvas2(doc.canvas.width, doc.canvas.height);
2054
4180
  const ctx = cvs.getContext("2d");
2055
4181
  renderFrame2(ctx, resolved, doc);
2056
4182
  const buffer = cvs.toBuffer("image/png");
2057
4183
  if (options.output) {
2058
- (0, import_node_fs3.writeFileSync)((0, import_node_path3.resolve)(options.output), buffer);
4184
+ (0, import_node_fs9.writeFileSync)((0, import_node_path8.resolve)(options.output), buffer);
2059
4185
  } else {
2060
4186
  process.stdout.write(buffer);
2061
4187
  }
@@ -2071,18 +4197,18 @@ function stillCommand(program2) {
2071
4197
  }
2072
4198
 
2073
4199
  // src/commands/render.ts
2074
- var import_node_fs4 = require("fs");
2075
- var import_node_path4 = require("path");
4200
+ var import_node_fs10 = require("fs");
4201
+ var import_node_path9 = require("path");
2076
4202
 
2077
4203
  // src/commands/render-pipeline.ts
2078
- var import_node_child_process = require("child_process");
4204
+ var import_node_child_process3 = require("child_process");
2079
4205
  init_dist2();
2080
4206
  init_dist3();
2081
4207
  async function checkFfmpeg() {
2082
- return new Promise((resolve10) => {
2083
- const proc = (0, import_node_child_process.spawn)("ffmpeg", ["-version"], { stdio: "pipe" });
2084
- proc.on("error", () => resolve10(false));
2085
- proc.on("close", (code) => resolve10(code === 0));
4208
+ return new Promise((resolve16) => {
4209
+ const proc = (0, import_node_child_process3.spawn)("ffmpeg", ["-version"], { stdio: "pipe" });
4210
+ proc.on("error", () => resolve16(false));
4211
+ proc.on("close", (code) => resolve16(code === 0));
2086
4212
  });
2087
4213
  }
2088
4214
  function buildFfmpegArgs(width, height, fps, format, output) {
@@ -2124,7 +4250,7 @@ function buildFfmpegArgs(width, height, fps, format, output) {
2124
4250
  output
2125
4251
  ];
2126
4252
  }
2127
- async function preloadImages(doc, loadImage) {
4253
+ async function preloadImages2(doc, loadImage2) {
2128
4254
  const sources = /* @__PURE__ */ new Set();
2129
4255
  for (const layer of doc.layers) {
2130
4256
  if (layer.visual.type === "image") {
@@ -2143,7 +4269,7 @@ async function preloadImages(doc, loadImage) {
2143
4269
  await Promise.all(
2144
4270
  [...sources].map(async (src) => {
2145
4271
  try {
2146
- preloaded.set(src, await loadImage(src));
4272
+ preloaded.set(src, await loadImage2(src));
2147
4273
  } catch {
2148
4274
  }
2149
4275
  })
@@ -2162,20 +4288,20 @@ async function preloadImages(doc, loadImage) {
2162
4288
  for (const src of preloaded.keys()) {
2163
4289
  imageCache.load(src);
2164
4290
  }
2165
- await new Promise((resolve10) => process.nextTick(resolve10));
4291
+ await new Promise((resolve16) => process.nextTick(resolve16));
2166
4292
  return imageCache;
2167
4293
  }
2168
4294
  async function renderDocument(doc, opts) {
2169
4295
  const canvasModuleName = "canvas";
2170
- let createCanvas;
2171
- let loadImage;
4296
+ let createCanvas2;
4297
+ let loadImage2;
2172
4298
  try {
2173
4299
  const canvasModule = await import(
2174
4300
  /* webpackIgnore: true */
2175
4301
  canvasModuleName
2176
4302
  );
2177
- createCanvas = canvasModule.createCanvas;
2178
- loadImage = canvasModule.loadImage;
4303
+ createCanvas2 = canvasModule.createCanvas;
4304
+ loadImage2 = canvasModule.loadImage;
2179
4305
  } catch {
2180
4306
  throw new Error(
2181
4307
  "The 'canvas' package is not installed.\nInstall it with:\n npm install canvas\nPrerequisites vary by OS:\n macOS: brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman\n Ubuntu: sudo apt install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev\nSee: https://github.com/Automattic/node-canvas#compiling"
@@ -2204,11 +4330,11 @@ async function renderDocument(doc, opts) {
2204
4330
  if (totalFrames === 0) {
2205
4331
  throw new Error("Nothing to render \u2014 all states have duration 0");
2206
4332
  }
2207
- const imageCache = await preloadImages(doc, loadImage);
2208
- const canvas = createCanvas(width, height);
4333
+ const imageCache = await preloadImages2(doc, loadImage2);
4334
+ const canvas = createCanvas2(width, height);
2209
4335
  const ctx = canvas.getContext("2d");
2210
4336
  const ffmpegArgs = buildFfmpegArgs(width, height, fps, format, output);
2211
- const ffmpeg = (0, import_node_child_process.spawn)("ffmpeg", ffmpegArgs, {
4337
+ const ffmpeg = (0, import_node_child_process3.spawn)("ffmpeg", ffmpegArgs, {
2212
4338
  stdio: ["pipe", "pipe", "pipe"]
2213
4339
  });
2214
4340
  let stderrOutput = "";
@@ -2226,7 +4352,7 @@ async function renderDocument(doc, opts) {
2226
4352
  const canWrite = ffmpeg.stdin.write(raw);
2227
4353
  if (!canWrite) {
2228
4354
  await new Promise(
2229
- (resolve10) => ffmpeg.stdin.once("drain", resolve10)
4355
+ (resolve16) => ffmpeg.stdin.once("drain", resolve16)
2230
4356
  );
2231
4357
  }
2232
4358
  frameIndex++;
@@ -2239,8 +4365,8 @@ async function renderDocument(doc, opts) {
2239
4365
  }
2240
4366
  }
2241
4367
  ffmpeg.stdin.end();
2242
- const exitCode = await new Promise((resolve10) => {
2243
- ffmpeg.on("close", resolve10);
4368
+ const exitCode = await new Promise((resolve16) => {
4369
+ ffmpeg.on("close", resolve16);
2244
4370
  });
2245
4371
  if (exitCode !== 0) {
2246
4372
  throw new Error(
@@ -2259,10 +4385,10 @@ ${stderrOutput.slice(-500)}`
2259
4385
 
2260
4386
  // src/commands/render.ts
2261
4387
  function readAndParse3(file) {
2262
- const absPath = (0, import_node_path4.resolve)(file);
4388
+ const absPath = (0, import_node_path9.resolve)(file);
2263
4389
  let content;
2264
4390
  try {
2265
- content = (0, import_node_fs4.readFileSync)(absPath, "utf-8");
4391
+ content = (0, import_node_fs10.readFileSync)(absPath, "utf-8");
2266
4392
  } catch {
2267
4393
  console.error(`Cannot read file: ${absPath}`);
2268
4394
  return process.exit(1);
@@ -2279,7 +4405,7 @@ function readAndParse3(file) {
2279
4405
  }
2280
4406
  function inferFormat(output) {
2281
4407
  if (!output) return "mp4";
2282
- const ext = (0, import_node_path4.extname)(output).toLowerCase();
4408
+ const ext = (0, import_node_path9.extname)(output).toLowerCase();
2283
4409
  if (ext === ".gif") return "gif";
2284
4410
  return "mp4";
2285
4411
  }
@@ -2313,12 +4439,12 @@ function renderCommand(program2) {
2313
4439
  } else {
2314
4440
  format = inferFormat(options.output);
2315
4441
  }
2316
- const inputName = (0, import_node_path4.basename)(file, (0, import_node_path4.extname)(file));
4442
+ const inputName = (0, import_node_path9.basename)(file, (0, import_node_path9.extname)(file));
2317
4443
  const output = options.output ?? `${inputName}.${format}`;
2318
4444
  const startTime = Date.now();
2319
4445
  try {
2320
4446
  const result = await renderDocument(doc, {
2321
- output: (0, import_node_path4.resolve)(output),
4447
+ output: (0, import_node_path9.resolve)(output),
2322
4448
  format,
2323
4449
  states: options.state,
2324
4450
  onProgress: ({ frame, totalFrames, state, percent }) => {
@@ -2344,8 +4470,8 @@ function renderCommand(program2) {
2344
4470
  }
2345
4471
 
2346
4472
  // src/commands/export-svg.ts
2347
- var import_node_fs5 = require("fs");
2348
- var import_node_path5 = require("path");
4473
+ var import_node_fs11 = require("fs");
4474
+ var import_node_path10 = require("path");
2349
4475
 
2350
4476
  // ../svg/dist/index.js
2351
4477
  init_dist2();
@@ -2810,10 +4936,10 @@ function renderRefSVG(eff, visual, _parentDoc, opts, _depth, _visitedRefs) {
2810
4936
 
2811
4937
  // src/commands/export-svg.ts
2812
4938
  function readAndParse4(file) {
2813
- const absPath = (0, import_node_path5.resolve)(file);
4939
+ const absPath = (0, import_node_path10.resolve)(file);
2814
4940
  let content;
2815
4941
  try {
2816
- content = (0, import_node_fs5.readFileSync)(absPath, "utf-8");
4942
+ content = (0, import_node_fs11.readFileSync)(absPath, "utf-8");
2817
4943
  } catch {
2818
4944
  console.error(`Cannot read file: ${absPath}`);
2819
4945
  return process.exit(1);
@@ -2855,7 +4981,7 @@ function exportSvgCommand(program2) {
2855
4981
  xmlDeclaration: options.xmlDeclaration
2856
4982
  });
2857
4983
  if (options.output) {
2858
- (0, import_node_fs5.writeFileSync)((0, import_node_path5.resolve)(options.output), svg, "utf-8");
4984
+ (0, import_node_fs11.writeFileSync)((0, import_node_path10.resolve)(options.output), svg, "utf-8");
2859
4985
  } else {
2860
4986
  console.log(svg);
2861
4987
  }
@@ -2868,8 +4994,8 @@ function exportSvgCommand(program2) {
2868
4994
  }
2869
4995
 
2870
4996
  // src/commands/export-lottie.ts
2871
- var import_node_fs6 = require("fs");
2872
- var import_node_path6 = require("path");
4997
+ var import_node_fs12 = require("fs");
4998
+ var import_node_path11 = require("path");
2873
4999
 
2874
5000
  // ../lottie/dist/index.js
2875
5001
  function colorToLottie(color) {
@@ -3438,10 +5564,10 @@ function exportToLottie(doc, opts) {
3438
5564
 
3439
5565
  // src/commands/export-lottie.ts
3440
5566
  function readAndParse5(file) {
3441
- const absPath = (0, import_node_path6.resolve)(file);
5567
+ const absPath = (0, import_node_path11.resolve)(file);
3442
5568
  let content;
3443
5569
  try {
3444
- content = (0, import_node_fs6.readFileSync)(absPath, "utf-8");
5570
+ content = (0, import_node_fs12.readFileSync)(absPath, "utf-8");
3445
5571
  } catch {
3446
5572
  console.error(`Cannot read file: ${absPath}`);
3447
5573
  return process.exit(1);
@@ -3469,7 +5595,7 @@ function exportLottieCommand(program2) {
3469
5595
  }
3470
5596
  const output = JSON.stringify(json, null, 2);
3471
5597
  if (options.output) {
3472
- (0, import_node_fs6.writeFileSync)((0, import_node_path6.resolve)(options.output), output, "utf-8");
5598
+ (0, import_node_fs12.writeFileSync)((0, import_node_path11.resolve)(options.output), output, "utf-8");
3473
5599
  } else {
3474
5600
  console.log(output);
3475
5601
  }
@@ -3481,9 +5607,73 @@ function exportLottieCommand(program2) {
3481
5607
  );
3482
5608
  }
3483
5609
 
5610
+ // src/commands/export-image.ts
5611
+ var import_node_fs13 = require("fs");
5612
+ var import_node_path12 = require("path");
5613
+ function readAndParse6(file) {
5614
+ const absPath = (0, import_node_path12.resolve)(file);
5615
+ let content;
5616
+ try {
5617
+ content = (0, import_node_fs13.readFileSync)(absPath, "utf-8");
5618
+ } catch {
5619
+ console.error(`Cannot read file: ${absPath}`);
5620
+ return process.exit(1);
5621
+ }
5622
+ const result = parseAtelier(content);
5623
+ if (!result.success) {
5624
+ console.error("Parse errors:");
5625
+ for (const error of result.errors) {
5626
+ console.error(` - ${error.path}: ${error.message}`);
5627
+ }
5628
+ return process.exit(1);
5629
+ }
5630
+ return result.data;
5631
+ }
5632
+ function parseDim2(raw, name) {
5633
+ if (raw === void 0) return void 0;
5634
+ const n = parseInt(raw, 10);
5635
+ if (isNaN(n) || n <= 0) {
5636
+ console.error(`Invalid --${name}: ${raw}`);
5637
+ process.exit(1);
5638
+ }
5639
+ return n;
5640
+ }
5641
+ function exportImageCommand(program2) {
5642
+ program2.command("export-image <file>").description(
5643
+ "Export a single frame as a PNG image. Aspect-preserving when only one of --width/--height is set; if both are set the renderer uses both verbatim (may squash)."
5644
+ ).requiredOption("-o, --out <path>", "Output PNG file path").option("-s, --state <name>", "State name (defaults to first state)").option("-f, --frame <number>", "Frame number (defaults to 0)", "0").option("--width <number>", "Override output width (px)").option("--height <number>", "Override output height (px)").action(async (file, options) => {
5645
+ const doc = readAndParse6(file);
5646
+ const frameNumber = parseInt(options.frame, 10);
5647
+ if (isNaN(frameNumber) || frameNumber < 0) {
5648
+ console.error(`Invalid frame number: ${options.frame}`);
5649
+ process.exit(1);
5650
+ return;
5651
+ }
5652
+ const width = parseDim2(options.width, "width");
5653
+ const height = parseDim2(options.height, "height");
5654
+ try {
5655
+ const buffer = await renderDocumentToPng(doc, {
5656
+ state: options.state,
5657
+ frame: frameNumber,
5658
+ width,
5659
+ height
5660
+ });
5661
+ (0, import_node_fs13.writeFileSync)((0, import_node_path12.resolve)(options.out), buffer);
5662
+ } catch (err) {
5663
+ if (err instanceof CanvasUnavailableError) {
5664
+ console.error(err.message);
5665
+ process.exit(1);
5666
+ return;
5667
+ }
5668
+ console.error(err.message);
5669
+ process.exit(1);
5670
+ }
5671
+ });
5672
+ }
5673
+
3484
5674
  // src/commands/assets.ts
3485
- var import_node_fs7 = require("fs");
3486
- var import_node_path7 = require("path");
5675
+ var import_node_fs14 = require("fs");
5676
+ var import_node_path13 = require("path");
3487
5677
  function getAssets(doc) {
3488
5678
  const assets = doc.assets ?? {};
3489
5679
  return Object.entries(assets).map(([assetId, asset]) => {
@@ -3514,11 +5704,11 @@ function formatAssets(assets) {
3514
5704
  }
3515
5705
  return lines.join("\n");
3516
5706
  }
3517
- function readAndParse6(file) {
3518
- const absPath = (0, import_node_path7.resolve)(file);
5707
+ function readAndParse7(file) {
5708
+ const absPath = (0, import_node_path13.resolve)(file);
3519
5709
  let content;
3520
5710
  try {
3521
- content = (0, import_node_fs7.readFileSync)(absPath, "utf-8");
5711
+ content = (0, import_node_fs14.readFileSync)(absPath, "utf-8");
3522
5712
  } catch {
3523
5713
  console.error(`Cannot read file: ${absPath}`);
3524
5714
  return process.exit(1);
@@ -3535,15 +5725,15 @@ function readAndParse6(file) {
3535
5725
  }
3536
5726
  function assetsCommand(program2) {
3537
5727
  program2.command("assets <file>").description("List all assets in an .atelier file with usage info").action((file) => {
3538
- const doc = readAndParse6(file);
5728
+ const doc = readAndParse7(file);
3539
5729
  const assets = getAssets(doc);
3540
5730
  console.log(formatAssets(assets));
3541
5731
  });
3542
5732
  }
3543
5733
 
3544
5734
  // src/commands/variables.ts
3545
- var import_node_fs8 = require("fs");
3546
- var import_node_path8 = require("path");
5735
+ var import_node_fs15 = require("fs");
5736
+ var import_node_path14 = require("path");
3547
5737
  init_dist2();
3548
5738
  function getVariables(doc) {
3549
5739
  const variables = doc.variables ?? {};
@@ -3578,11 +5768,11 @@ function formatVariables(info) {
3578
5768
  }
3579
5769
  return lines.join("\n");
3580
5770
  }
3581
- function readAndParse7(file) {
3582
- const absPath = (0, import_node_path8.resolve)(file);
5771
+ function readAndParse8(file) {
5772
+ const absPath = (0, import_node_path14.resolve)(file);
3583
5773
  let content;
3584
5774
  try {
3585
- content = (0, import_node_fs8.readFileSync)(absPath, "utf-8");
5775
+ content = (0, import_node_fs15.readFileSync)(absPath, "utf-8");
3586
5776
  } catch {
3587
5777
  console.error(`Cannot read file: ${absPath}`);
3588
5778
  return process.exit(1);
@@ -3599,48 +5789,160 @@ function readAndParse7(file) {
3599
5789
  }
3600
5790
  function variablesCommand(program2) {
3601
5791
  program2.command("variables <file>").description("List all variables in an .atelier file with usage info").action((file) => {
3602
- const doc = readAndParse7(file);
5792
+ const doc = readAndParse8(file);
3603
5793
  const info = getVariables(doc);
3604
5794
  console.log(formatVariables(info));
3605
5795
  });
3606
5796
  }
3607
5797
 
3608
5798
  // src/commands/studio.ts
3609
- var import_node_path9 = require("path");
3610
- var import_node_fs9 = require("fs");
3611
- var import_node_os = require("os");
5799
+ var import_node_path15 = require("path");
5800
+ var import_node_fs16 = require("fs");
5801
+ var import_node_os2 = require("os");
3612
5802
  var import_node_crypto = require("crypto");
3613
- var import_node_child_process2 = require("child_process");
5803
+ var import_node_child_process4 = require("child_process");
5804
+ var import_atelier_mcp = require("@a-company/atelier-mcp");
5805
+ var import_ws = require("ws");
3614
5806
  var import_meta = {};
3615
5807
  function findAtelierFiles(dir, base = dir) {
3616
5808
  const results = [];
3617
5809
  let entries;
3618
5810
  try {
3619
- entries = (0, import_node_fs9.readdirSync)(dir);
5811
+ entries = (0, import_node_fs16.readdirSync)(dir);
3620
5812
  } catch {
3621
5813
  return results;
3622
5814
  }
3623
5815
  for (const entry of entries) {
3624
5816
  if (entry === "node_modules" || entry === "dist" || entry === ".git") continue;
3625
- const full = (0, import_node_path9.join)(dir, entry);
5817
+ const full = (0, import_node_path15.join)(dir, entry);
3626
5818
  let stat;
3627
5819
  try {
3628
- stat = (0, import_node_fs9.statSync)(full);
5820
+ stat = (0, import_node_fs16.statSync)(full);
3629
5821
  } catch {
3630
5822
  continue;
3631
5823
  }
3632
5824
  if (stat.isDirectory()) {
3633
5825
  results.push(...findAtelierFiles(full, base));
3634
5826
  } else if (entry.endsWith(".atelier")) {
3635
- results.push((0, import_node_path9.relative)(base, full));
5827
+ results.push((0, import_node_path15.relative)(base, full));
3636
5828
  }
3637
5829
  }
3638
5830
  return results.sort();
3639
5831
  }
3640
5832
  function isSafePath(filePath) {
3641
- if (!filePath || filePath.includes("..") || filePath.startsWith("/")) return false;
3642
- const resolved = (0, import_node_path9.resolve)(process.cwd(), filePath);
3643
- return resolved.startsWith(process.cwd());
5833
+ if (!filePath || filePath.startsWith("/")) return false;
5834
+ const cwd = process.cwd();
5835
+ const resolved = (0, import_node_path15.resolve)(cwd, filePath);
5836
+ return resolved === cwd || resolved.startsWith(cwd + import_node_path15.sep);
5837
+ }
5838
+ function writeFileEnsuringDir(absPath, body) {
5839
+ (0, import_node_fs16.mkdirSync)((0, import_node_path15.dirname)(absPath), { recursive: true });
5840
+ (0, import_node_fs16.writeFileSync)(absPath, body, "utf-8");
5841
+ }
5842
+ function isAllowedOrigin(origin, port) {
5843
+ if (!origin) return false;
5844
+ return origin === `http://localhost:${port}` || origin === `http://127.0.0.1:${port}`;
5845
+ }
5846
+ function isAllowedMcpOrigin(origin, port) {
5847
+ return origin === void 0 || isAllowedOrigin(origin, port);
5848
+ }
5849
+ function shouldBroadcastMutation(source) {
5850
+ return source === "llm";
5851
+ }
5852
+ function attachBridgeClient(ws, state, clients, loadDocFromDisk, persistHumanPatch) {
5853
+ clients.add(ws);
5854
+ const clientId = (0, import_node_crypto.randomBytes)(6).toString("hex");
5855
+ const send = (env) => {
5856
+ if (ws.readyState !== ws.OPEN) return;
5857
+ ws.send(JSON.stringify(env));
5858
+ };
5859
+ send({ type: "hello", clientId, protocolVersion: import_atelier_mcp.BRIDGE_PROTOCOL_VERSION });
5860
+ if (state.currentDocId) {
5861
+ const existing = state.store.get(state.currentDocId);
5862
+ if (existing) {
5863
+ send({ type: "doc:loaded", documentId: state.currentDocId, doc: existing });
5864
+ } else {
5865
+ const raw = loadDocFromDisk(state.currentDocId);
5866
+ if (raw) {
5867
+ const parsed = parseAtelier(raw);
5868
+ if (parsed.success) {
5869
+ state.store.set(state.currentDocId, parsed.data, "system");
5870
+ send({ type: "doc:loaded", documentId: state.currentDocId, doc: parsed.data });
5871
+ }
5872
+ }
5873
+ }
5874
+ }
5875
+ const onMessage = (data) => {
5876
+ let text;
5877
+ if (typeof data === "string") text = data;
5878
+ else if (data instanceof Buffer) text = data.toString("utf-8");
5879
+ else if (Array.isArray(data)) text = Buffer.concat(data).toString("utf-8");
5880
+ else text = String(data);
5881
+ let env;
5882
+ try {
5883
+ env = JSON.parse(text);
5884
+ } catch {
5885
+ send({ type: "error", code: "parse_error", message: "invalid JSON" });
5886
+ return;
5887
+ }
5888
+ if (!(0, import_atelier_mcp.isBridgeEnvelope)(env)) {
5889
+ send({ type: "error", code: "invalid_envelope", message: "unknown envelope shape" });
5890
+ return;
5891
+ }
5892
+ if (env.type === "doc:patch") {
5893
+ try {
5894
+ state.store.set(env.documentId, env.doc, "human");
5895
+ state.currentDocId = env.documentId;
5896
+ persistHumanPatch(env.documentId, env.doc);
5897
+ } catch (err) {
5898
+ const msg = err instanceof Error ? err.message : String(err);
5899
+ send({ type: "error", code: "persist_failed", message: msg, opId: env.opId });
5900
+ }
5901
+ return;
5902
+ }
5903
+ if (env.type === "doc:load") {
5904
+ const existing = state.store.get(env.documentId);
5905
+ if (existing) {
5906
+ send({ type: "doc:loaded", documentId: env.documentId, doc: existing });
5907
+ return;
5908
+ }
5909
+ const raw = loadDocFromDisk(env.documentId);
5910
+ if (raw) {
5911
+ const parsed = parseAtelier(raw);
5912
+ if (parsed.success) {
5913
+ state.store.set(env.documentId, parsed.data, "system");
5914
+ send({ type: "doc:loaded", documentId: env.documentId, doc: parsed.data });
5915
+ return;
5916
+ }
5917
+ }
5918
+ send({ type: "error", code: "not_found", message: `document ${env.documentId} not found` });
5919
+ return;
5920
+ }
5921
+ };
5922
+ const onClose = () => {
5923
+ clients.delete(ws);
5924
+ };
5925
+ ws.on("message", onMessage);
5926
+ ws.on("close", onClose);
5927
+ ws.on("error", onClose);
5928
+ return () => {
5929
+ clients.delete(ws);
5930
+ try {
5931
+ ws.close();
5932
+ } catch {
5933
+ }
5934
+ };
5935
+ }
5936
+ function broadcastToBridge(clients, envelope) {
5937
+ const payload = JSON.stringify(envelope);
5938
+ for (const ws of clients) {
5939
+ if (ws.readyState === ws.OPEN) {
5940
+ try {
5941
+ ws.send(payload);
5942
+ } catch {
5943
+ }
5944
+ }
5945
+ }
3644
5946
  }
3645
5947
  function getInlineHTML() {
3646
5948
  return `<!DOCTYPE html>
@@ -3659,431 +5961,59 @@ function getInlineHTML() {
3659
5961
  </body>
3660
5962
  </html>`;
3661
5963
  }
3662
- function getInlineApp(initialFile) {
5964
+ function getInlineApp(initialFile, cliPackageDir) {
3663
5965
  const initialFileStr = initialFile ? JSON.stringify(initialFile) : "null";
3664
- return `import { AtelierStudio, exportDocument, ImageCache } from "@a-company/atelier-studio";
3665
- import "@a-company/atelier-studio/styles.css";
3666
- import { parseAtelier, serializeAtelier } from "@a-company/atelier-schema";
3667
-
3668
- // \u2500\u2500 Types \u2500\u2500
3669
- interface FileEntry {
3670
- path: string;
3671
- name: string;
3672
- folder: string;
3673
- }
3674
-
3675
- // \u2500\u2500 State \u2500\u2500
3676
- let studio: AtelierStudio | null = null;
3677
- let currentFile: string | null = null;
3678
- let files: FileEntry[] = [];
3679
- let saveTimeout: ReturnType<typeof setTimeout> | null = null;
3680
-
3681
- // \u2500\u2500 API helpers \u2500\u2500
3682
- async function fetchFiles(): Promise<FileEntry[]> {
3683
- const res = await fetch("/api/files");
3684
- return res.json();
3685
- }
3686
-
3687
- async function fetchFileContent(path: string): Promise<string> {
3688
- const res = await fetch("/api/file?path=" + encodeURIComponent(path));
3689
- return res.text();
3690
- }
3691
-
3692
- async function saveFileContent(path: string, content: string): Promise<void> {
3693
- await fetch("/api/file?path=" + encodeURIComponent(path), {
3694
- method: "POST",
3695
- headers: { "Content-Type": "text/plain" },
3696
- body: content,
3697
- });
3698
- }
3699
-
3700
- async function saveExportBlob(path: string, blob: Blob): Promise<void> {
3701
- const buf = await blob.arrayBuffer();
3702
- await fetch("/api/export?path=" + encodeURIComponent(path), {
3703
- method: "POST",
3704
- headers: { "Content-Type": "application/octet-stream" },
3705
- body: buf,
3706
- });
5966
+ const appModulePath = (0, import_node_path15.join)(cliPackageDir, "src", "web", "inline-app.ts").split(import_node_path15.sep).join("/");
5967
+ return `import { bootStudioApp } from ${JSON.stringify(appModulePath)};
5968
+ bootStudioApp({ initialFile: ${initialFileStr} });
5969
+ `;
3707
5970
  }
3708
-
3709
- async function exportAll(format: "gif" | "mp4" | "webm"): Promise<void> {
3710
- if (files.length === 0) return;
3711
-
3712
- // Create progress overlay
3713
- const overlay = document.createElement("div");
3714
- overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;z-index:10000";
3715
- const card = document.createElement("div");
3716
- card.style.cssText = "background:#333;border:1px solid #4A4A4A;border-radius:8px;padding:32px 40px;min-width:360px;color:#F5F0EB;font-family:'Cormorant Garamond',Georgia,serif";
3717
- overlay.appendChild(card);
3718
- document.body.appendChild(overlay);
3719
-
3720
- const title = document.createElement("div");
3721
- title.style.cssText = "font-size:18px;margin-bottom:16px;font-weight:600";
3722
- title.textContent = "Exporting All Files\u2026";
3723
- card.appendChild(title);
3724
-
3725
- const fileLabel = document.createElement("div");
3726
- fileLabel.style.cssText = "font-size:13px;color:#A89F95;margin-bottom:8px;font-family:'SF Mono','Fira Code',monospace";
3727
- card.appendChild(fileLabel);
3728
-
3729
- const progress = document.createElement("progress");
3730
- progress.style.cssText = "width:100%;height:6px;appearance:none;-webkit-appearance:none";
3731
- progress.max = files.length;
3732
- progress.value = 0;
3733
- card.appendChild(progress);
3734
-
3735
- const statusText = document.createElement("div");
3736
- statusText.style.cssText = "font-size:12px;color:#A89F95;margin-top:8px";
3737
- card.appendChild(statusText);
3738
-
3739
- let exported = 0;
3740
- let errors = 0;
3741
-
3742
- for (const file of files) {
3743
- fileLabel.textContent = file.path;
3744
- statusText.textContent = (exported + errors + 1) + " / " + files.length;
3745
-
3746
- try {
3747
- const content = await fetchFileContent(file.path);
3748
- const result = parseAtelier(content);
3749
- if (!result.success) {
3750
- errors++;
3751
- progress.value = exported + errors;
3752
- continue;
3753
- }
3754
-
3755
- const doc = result.data;
3756
- const w = doc.canvas.width;
3757
- const h = doc.canvas.height;
3758
- const canvas = document.createElement("canvas");
3759
- canvas.width = w;
3760
- canvas.height = h;
3761
- const imageCache = new ImageCache();
3762
-
3763
- const exportResult = await exportDocument(doc, canvas, imageCache, {
3764
- format,
3765
- onProgress: ({ percent }) => {
3766
- statusText.textContent = (exported + errors + 1) + " / " + files.length + " \u2014 " + percent + "%";
3767
- },
3768
- });
3769
-
3770
- // Save alongside the source file: e.g. "dir/my-anim.atelier" \u2192 "dir/my-anim.gif"
3771
- const outPath = file.path.replace(/\\.atelier$/, "." + format);
3772
- await saveExportBlob(outPath, exportResult.blob);
3773
- exported++;
3774
- } catch (e) {
3775
- console.error("Export failed:", file.path, e);
3776
- errors++;
5971
+ function resolveCliPackageDir() {
5972
+ const here = (0, import_node_path15.dirname)(new URL(import_meta.url).pathname);
5973
+ const candidates = [
5974
+ (0, import_node_path15.resolve)(here, ".."),
5975
+ (0, import_node_path15.resolve)(here, "..", "..")
5976
+ ];
5977
+ for (const c of candidates) {
5978
+ if ((0, import_node_fs16.existsSync)((0, import_node_path15.join)(c, "package.json"))) {
5979
+ return c;
3777
5980
  }
3778
- progress.value = exported + errors;
3779
5981
  }
3780
-
3781
- // Done
3782
- title.textContent = "Export Complete";
3783
- fileLabel.textContent = "";
3784
- statusText.textContent = exported + " exported" + (errors > 0 ? ", " + errors + " failed" : "");
3785
- if (errors > 0) console.warn("Export All finished with " + errors + " error(s). Check console for details.");
3786
-
3787
- const closeBtn = document.createElement("button");
3788
- closeBtn.style.cssText = "margin-top:16px;padding:6px 20px;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px";
3789
- closeBtn.textContent = "Close";
3790
- closeBtn.addEventListener("click", () => document.body.removeChild(overlay));
3791
- card.appendChild(closeBtn);
5982
+ return candidates[0];
3792
5983
  }
3793
-
3794
- // \u2500\u2500 Theme (matches branded theme from showcase) \u2500\u2500
3795
- const theme = {
3796
- bg: "#2C2C2C",
3797
- bgSecondary: "#333333",
3798
- bgTertiary: "#3D3D3D",
3799
- text: "#F5F0EB",
3800
- textMuted: "#A89F95",
3801
- textAccent: "#F5F0EB",
3802
- border: "#4A4A4A",
3803
- buttonBg: "#3D3D3D",
3804
- buttonHover: "#4A4A4A",
3805
- buttonActive: "#555555",
3806
- accent: "#C75B39",
3807
- accentHover: "#D4724E",
3808
- sliderTrack: "#4A4A4A",
3809
- sliderThumb: "#C75B39",
3810
- fontFamily: "'Cormorant Garamond', Georgia, serif",
3811
- fontMono: "'SF Mono', 'Fira Code', monospace",
3812
- canvasShadow: "0 4px 60px rgba(199, 91, 57, 0.12), 0 0 40px rgba(0,0,0,0.4)",
3813
- };
3814
-
3815
- // \u2500\u2500 Styles \u2500\u2500
3816
- const style = document.createElement("style");
3817
- style.textContent = \`
3818
- * { margin: 0; padding: 0; box-sizing: border-box; }
3819
- html, body { height: 100%; overflow: hidden; background: #2C2C2C; color: #F5F0EB; }
3820
- body { font-family: 'Cormorant Garamond', Georgia, serif; }
3821
- #studio { display: flex; height: 100vh; width: 100vw; }
3822
-
3823
- .sidebar {
3824
- width: 260px;
3825
- min-width: 260px;
3826
- background: #333333;
3827
- border-right: 1px solid #4A4A4A;
3828
- display: flex;
3829
- flex-direction: column;
3830
- overflow: hidden;
3831
- }
3832
- .sidebar__header {
3833
- padding: 16px 20px;
3834
- border-bottom: 1px solid #4A4A4A;
3835
- font-size: 11px;
3836
- font-weight: 600;
3837
- letter-spacing: 2px;
3838
- text-transform: uppercase;
3839
- color: #A89F95;
3840
- display: flex;
3841
- align-items: center;
3842
- gap: 8px;
3843
- }
3844
- .sidebar__header span {
3845
- color: #C75B39;
3846
- font-size: 13px;
3847
- }
3848
- .sidebar__list {
3849
- flex: 1;
3850
- overflow-y: auto;
3851
- padding: 8px 0;
3852
- }
3853
- .sidebar__list::-webkit-scrollbar { width: 6px; }
3854
- .sidebar__list::-webkit-scrollbar-track { background: transparent; }
3855
- .sidebar__list::-webkit-scrollbar-thumb { background: #4A4A4A; border-radius: 3px; }
3856
-
3857
- .sidebar__folder {
3858
- padding: 10px 20px 4px;
3859
- font-size: 10px;
3860
- font-weight: 600;
3861
- letter-spacing: 1.5px;
3862
- text-transform: uppercase;
3863
- color: #A89F95;
3864
- }
3865
- .sidebar__item {
3866
- padding: 8px 20px 8px 28px;
3867
- font-size: 13px;
3868
- cursor: pointer;
3869
- color: #A89F95;
3870
- transition: background 0.15s, color 0.15s;
3871
- white-space: nowrap;
3872
- overflow: hidden;
3873
- text-overflow: ellipsis;
3874
- font-family: 'SF Mono', 'Fira Code', monospace;
3875
- font-size: 11.5px;
3876
- }
3877
- .sidebar__item:hover { background: #363636; color: #F5F0EB; }
3878
- .sidebar__item--active {
3879
- background: rgba(199, 91, 57, 0.12) !important;
3880
- color: #C75B39 !important;
5984
+ function scaffoldWelcomeIfEmpty(cwd, cliPackageDir) {
5985
+ const existing = findAtelierFiles(cwd);
5986
+ if (existing.length > 0) return null;
5987
+ const templatesDir = (0, import_node_path15.join)(cliPackageDir, "templates");
5988
+ const welcomeSrc = (0, import_node_path15.join)(templatesDir, "welcome.atelier");
5989
+ if (!(0, import_node_fs16.existsSync)(welcomeSrc)) return null;
5990
+ const welcomeDest = (0, import_node_path15.join)(cwd, "welcome.atelier");
5991
+ if ((0, import_node_fs16.existsSync)(welcomeDest)) return null;
5992
+ try {
5993
+ (0, import_node_fs16.copyFileSync)(welcomeSrc, welcomeDest);
5994
+ } catch {
5995
+ return null;
3881
5996
  }
3882
-
3883
- .main {
3884
- flex: 1;
3885
- display: flex;
3886
- flex-direction: column;
3887
- overflow: hidden;
3888
- }
3889
- .main__status {
3890
- height: 32px;
3891
- min-height: 32px;
3892
- display: flex;
3893
- align-items: center;
3894
- padding: 0 16px;
3895
- background: #333333;
3896
- border-bottom: 1px solid #4A4A4A;
3897
- font-size: 11px;
3898
- color: #A89F95;
3899
- font-family: 'SF Mono', 'Fira Code', monospace;
3900
- gap: 12px;
3901
- }
3902
- .main__status .save-indicator {
3903
- display: inline-flex;
3904
- align-items: center;
3905
- gap: 4px;
3906
- margin-left: auto;
3907
- transition: opacity 0.3s;
3908
- }
3909
- .main__status .save-indicator--saving { color: #C75B39; }
3910
- .main__status .save-indicator--saved { color: #6B8E6B; }
3911
- .main__editor {
3912
- flex: 1;
3913
- overflow: hidden;
3914
- }
3915
- .main__empty {
3916
- flex: 1;
3917
- display: flex;
3918
- align-items: center;
3919
- justify-content: center;
3920
- color: #A89F95;
3921
- font-size: 18px;
3922
- }
3923
- \`;
3924
- document.head.appendChild(style);
3925
-
3926
- // \u2500\u2500 Build UI \u2500\u2500
3927
- const root = document.getElementById("studio")!;
3928
- const sidebar = document.createElement("div");
3929
- sidebar.className = "sidebar";
3930
-
3931
- const sidebarHeader = document.createElement("div");
3932
- sidebarHeader.className = "sidebar__header";
3933
- sidebarHeader.innerHTML = '<span>&#9670;</span> ATELIER STUDIO';
3934
- sidebar.appendChild(sidebarHeader);
3935
-
3936
- const sidebarList = document.createElement("div");
3937
- sidebarList.className = "sidebar__list";
3938
- sidebar.appendChild(sidebarList);
3939
-
3940
- const sidebarFooter = document.createElement("div");
3941
- sidebarFooter.style.cssText = "padding:12px 16px;border-top:1px solid #4A4A4A;display:flex;gap:8px;align-items:center";
3942
- const exportAllSelect = document.createElement("select");
3943
- exportAllSelect.style.cssText = "flex:1;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;padding:4px 8px;font-size:11px;font-family:'SF Mono','Fira Code',monospace;cursor:pointer";
3944
- for (const [val, label] of [["gif","GIF"],["mp4","MP4"],["webm","WebM"]] as const) {
3945
- const o = document.createElement("option");
3946
- o.value = val;
3947
- o.textContent = label;
3948
- exportAllSelect.appendChild(o);
3949
- }
3950
- sidebarFooter.appendChild(exportAllSelect);
3951
- const exportAllBtn = document.createElement("button");
3952
- exportAllBtn.style.cssText = "background:#C75B39;color:#F5F0EB;border:none;border-radius:4px;padding:5px 12px;font-size:11px;font-family:inherit;cursor:pointer;white-space:nowrap";
3953
- exportAllBtn.textContent = "Export All";
3954
- exportAllBtn.addEventListener("click", () => {
3955
- exportAll(exportAllSelect.value as "gif" | "mp4" | "webm");
3956
- });
3957
- sidebarFooter.appendChild(exportAllBtn);
3958
- sidebar.appendChild(sidebarFooter);
3959
-
3960
- const main = document.createElement("div");
3961
- main.className = "main";
3962
-
3963
- const statusBar = document.createElement("div");
3964
- statusBar.className = "main__status";
3965
- main.appendChild(statusBar);
3966
-
3967
- const editorContainer = document.createElement("div");
3968
- editorContainer.className = "main__editor";
3969
- main.appendChild(editorContainer);
3970
-
3971
- root.appendChild(sidebar);
3972
- root.appendChild(main);
3973
-
3974
- // \u2500\u2500 File list rendering \u2500\u2500
3975
- function renderFileList(): void {
3976
- sidebarList.innerHTML = "";
3977
- let lastFolder = "";
3978
-
3979
- for (const file of files) {
3980
- if (file.folder && file.folder !== lastFolder) {
3981
- lastFolder = file.folder;
3982
- const folder = document.createElement("div");
3983
- folder.className = "sidebar__folder";
3984
- folder.textContent = file.folder;
3985
- sidebarList.appendChild(folder);
5997
+ const bgSrc = (0, import_node_path15.join)(templatesDir, "welcome-bg.png");
5998
+ if ((0, import_node_fs16.existsSync)(bgSrc)) {
5999
+ const bgDest = (0, import_node_path15.join)(cwd, "welcome-bg.png");
6000
+ if (!(0, import_node_fs16.existsSync)(bgDest)) {
6001
+ try {
6002
+ (0, import_node_fs16.copyFileSync)(bgSrc, bgDest);
6003
+ } catch {
6004
+ }
3986
6005
  }
3987
-
3988
- const item = document.createElement("div");
3989
- item.className = "sidebar__item" + (file.path === currentFile ? " sidebar__item--active" : "");
3990
- item.textContent = file.name;
3991
- item.title = file.path;
3992
- item.addEventListener("click", () => loadFile(file.path));
3993
- sidebarList.appendChild(item);
3994
- }
3995
- }
3996
-
3997
- // \u2500\u2500 Load a file into the studio \u2500\u2500
3998
- async function loadFile(path: string): Promise<void> {
3999
- currentFile = path;
4000
- renderFileList();
4001
-
4002
- const content = await fetchFileContent(path);
4003
- const result = parseAtelier(content);
4004
-
4005
- if (!result.success) {
4006
- editorContainer.innerHTML = "";
4007
- const err = document.createElement("div");
4008
- err.className = "main__empty";
4009
- err.style.flexDirection = "column";
4010
- err.style.gap = "8px";
4011
- err.innerHTML = '<div style="color:#C75B39">Parse Error</div><div style="font-size:13px;font-family:monospace">' +
4012
- result.errors.map(e => e.path + ": " + e.message).join("<br>") + "</div>";
4013
- editorContainer.appendChild(err);
4014
- return;
4015
- }
4016
-
4017
- statusBar.innerHTML = '<span>' + path + '</span><span class="save-indicator save-indicator--saved">&#10003; saved</span>';
4018
-
4019
- if (studio) {
4020
- studio.destroy();
4021
- studio = null;
4022
6006
  }
4023
-
4024
- // Set filename for export downloads (strip path and .atelier extension)
4025
- const baseName = path.split("/").pop()?.replace(/\\.atelier$/, "") || null;
4026
-
4027
- studio = new AtelierStudio(editorContainer, {
4028
- mode: "full",
4029
- initialTab: "yaml",
4030
- allowSave: true,
4031
- onDocumentChange: (doc) => {
4032
- // Auto-save with debounce
4033
- const indicator = statusBar.querySelector(".save-indicator");
4034
- if (indicator) {
4035
- indicator.className = "save-indicator save-indicator--saving";
4036
- indicator.innerHTML = "&#9679; saving...";
4037
- }
4038
-
4039
- if (saveTimeout) clearTimeout(saveTimeout);
4040
- saveTimeout = setTimeout(async () => {
4041
- if (!currentFile) return;
4042
- const yaml = serializeAtelier(doc);
4043
- await saveFileContent(currentFile, yaml);
4044
- const ind = statusBar.querySelector(".save-indicator");
4045
- if (ind) {
4046
- ind.className = "save-indicator save-indicator--saved";
4047
- ind.innerHTML = "&#10003; saved";
4048
- }
4049
- }, 800);
4050
- },
4051
- });
4052
- studio.setTheme(theme);
4053
- studio.setFilename(baseName);
4054
- studio.loadDocument(result.data);
6007
+ return "welcome.atelier";
4055
6008
  }
4056
-
4057
- // \u2500\u2500 Boot \u2500\u2500
4058
- async function boot(): Promise<void> {
4059
- files = await fetchFiles();
4060
-
4061
- if (files.length === 0) {
4062
- editorContainer.innerHTML = "";
4063
- const empty = document.createElement("div");
4064
- empty.className = "main__empty";
4065
- empty.textContent = "No .atelier files found in this directory";
4066
- editorContainer.appendChild(empty);
4067
- statusBar.textContent = "No files";
4068
- renderFileList();
4069
- return;
4070
- }
4071
-
4072
- renderFileList();
4073
-
4074
- const initialFile = ${initialFileStr};
4075
- const target = initialFile
4076
- ? files.find(f => f.path === initialFile || f.path.endsWith(initialFile))
4077
- : files[0];
4078
-
4079
- if (target) {
4080
- await loadFile(target.path);
6009
+ function readCliVersion(cliPackageDir) {
6010
+ try {
6011
+ const pkg = JSON.parse((0, import_node_fs16.readFileSync)((0, import_node_path15.join)(cliPackageDir, "package.json"), "utf-8"));
6012
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
6013
+ } catch {
6014
+ return "unknown";
4081
6015
  }
4082
6016
  }
4083
-
4084
- boot();
4085
- `;
4086
- }
4087
6017
  function studioCommand(program2) {
4088
6018
  program2.command("studio [file]").description("Launch the browser-based Atelier editor").option("-p, --port <number>", "Port to serve on", "4321").option("--no-open", "Don't auto-open browser").action(
4089
6019
  async (file, options) => {
@@ -4093,21 +6023,27 @@ function studioCommand(program2) {
4093
6023
  process.exit(1);
4094
6024
  }
4095
6025
  const cwd = process.cwd();
4096
- const cliPackageDir = (0, import_node_path9.resolve)((0, import_node_path9.dirname)(new URL(import_meta.url).pathname), "..");
6026
+ const cliPackageDir = resolveCliPackageDir();
6027
+ const version = readCliVersion(cliPackageDir);
6028
+ const scaffolded = scaffoldWelcomeIfEmpty(cwd, cliPackageDir);
6029
+ console.log("");
6030
+ console.log(` Atelier Studio \xB7 v${version}`);
6031
+ if (scaffolded) {
6032
+ console.log(` Scaffolded ${scaffolded} \u2014 opening\u2026`);
6033
+ }
4097
6034
  const tmpId = (0, import_node_crypto.randomBytes)(4).toString("hex");
4098
- const tmpDirRaw = (0, import_node_path9.join)((0, import_node_os.tmpdir)(), `atelier-studio-${tmpId}`);
4099
- (0, import_node_fs9.mkdirSync)(tmpDirRaw, { recursive: true });
4100
- const tmpDir = (0, import_node_fs9.realpathSync)(tmpDirRaw);
4101
- (0, import_node_fs9.writeFileSync)((0, import_node_path9.join)(tmpDir, "index.html"), getInlineHTML());
4102
- (0, import_node_fs9.writeFileSync)((0, import_node_path9.join)(tmpDir, "main.ts"), getInlineApp(file ?? null));
4103
- const cliNodeModules = (0, import_node_path9.join)(cliPackageDir, "node_modules");
4104
- if ((0, import_node_fs9.existsSync)(cliNodeModules)) {
6035
+ const tmpDirRaw = (0, import_node_path15.join)((0, import_node_os2.tmpdir)(), `atelier-studio-${tmpId}`);
6036
+ (0, import_node_fs16.mkdirSync)(tmpDirRaw, { recursive: true });
6037
+ const tmpDir = (0, import_node_fs16.realpathSync)(tmpDirRaw);
6038
+ (0, import_node_fs16.writeFileSync)((0, import_node_path15.join)(tmpDir, "index.html"), getInlineHTML());
6039
+ (0, import_node_fs16.writeFileSync)((0, import_node_path15.join)(tmpDir, "main.ts"), getInlineApp(file ?? null, cliPackageDir));
6040
+ const cliNodeModules = (0, import_node_path15.join)(cliPackageDir, "node_modules");
6041
+ if ((0, import_node_fs16.existsSync)(cliNodeModules)) {
4105
6042
  try {
4106
- (0, import_node_fs9.symlinkSync)(cliNodeModules, (0, import_node_path9.join)(tmpDir, "node_modules"), "dir");
6043
+ (0, import_node_fs16.symlinkSync)(cliNodeModules, (0, import_node_path15.join)(tmpDir, "node_modules"), "dir");
4107
6044
  } catch {
4108
6045
  }
4109
6046
  }
4110
- console.log(`Starting Atelier Studio...`);
4111
6047
  console.log(` Working directory: ${cwd}`);
4112
6048
  let vite;
4113
6049
  try {
@@ -4118,9 +6054,42 @@ function studioCommand(program2) {
4118
6054
  process.exit(1);
4119
6055
  return;
4120
6056
  }
6057
+ const HOSTNAME = "127.0.0.1";
6058
+ const bridgeState = {
6059
+ store: new import_atelier_mcp.DocumentStore(),
6060
+ currentDocId: null
6061
+ };
6062
+ const bridgeClients = /* @__PURE__ */ new Set();
6063
+ const loadDocFromDisk = (docId) => {
6064
+ if (!isSafePath(docId)) return null;
6065
+ try {
6066
+ return (0, import_node_fs16.readFileSync)((0, import_node_path15.resolve)(cwd, docId), "utf-8");
6067
+ } catch {
6068
+ return null;
6069
+ }
6070
+ };
6071
+ const persistHumanPatch = (docId, doc) => {
6072
+ if (!isSafePath(docId)) return;
6073
+ writeFileEnsuringDir((0, import_node_path15.resolve)(cwd, docId), serializeAtelier(doc));
6074
+ };
6075
+ bridgeState.store.onChange((id, doc, source) => {
6076
+ if (!shouldBroadcastMutation(source)) return;
6077
+ if (doc === null) return;
6078
+ try {
6079
+ persistHumanPatch(id, doc);
6080
+ } catch {
6081
+ }
6082
+ broadcastToBridge(bridgeClients, {
6083
+ type: "llm:mutation",
6084
+ documentId: id,
6085
+ doc,
6086
+ source
6087
+ });
6088
+ });
4121
6089
  const server = await vite.createServer({
4122
6090
  root: tmpDir,
4123
6091
  server: {
6092
+ host: HOSTNAME,
4124
6093
  port,
4125
6094
  strictPort: false,
4126
6095
  fs: {
@@ -4131,8 +6100,21 @@ function studioCommand(program2) {
4131
6100
  {
4132
6101
  name: "atelier-api",
4133
6102
  configureServer(server2) {
6103
+ const allowedOrigins = /* @__PURE__ */ new Set([
6104
+ `http://localhost:${port}`,
6105
+ `http://127.0.0.1:${port}`
6106
+ ]);
6107
+ const MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
4134
6108
  server2.middlewares.use((req, res, next) => {
4135
- const url2 = new URL(req.url ?? "/", `http://localhost:${port}`);
6109
+ const url2 = new URL(req.url ?? "/", `http://${HOSTNAME}:${port}`);
6110
+ if (req.method && MUTATING.has(req.method) && url2.pathname.startsWith("/api/")) {
6111
+ const origin = req.headers.origin;
6112
+ if (!origin || !allowedOrigins.has(origin)) {
6113
+ res.statusCode = 403;
6114
+ res.end("Forbidden: cross-origin mutating request rejected");
6115
+ return;
6116
+ }
6117
+ }
4136
6118
  if (url2.pathname === "/api/files") {
4137
6119
  const atelierFiles2 = findAtelierFiles(cwd);
4138
6120
  const entries = atelierFiles2.map((p) => {
@@ -4154,10 +6136,15 @@ function studioCommand(program2) {
4154
6136
  res.end("Invalid path");
4155
6137
  return;
4156
6138
  }
4157
- const absPath = (0, import_node_path9.resolve)(cwd, filePath);
6139
+ const absPath = (0, import_node_path15.resolve)(cwd, filePath);
4158
6140
  if (req.method === "GET") {
4159
6141
  try {
4160
- const content = (0, import_node_fs9.readFileSync)(absPath, "utf-8");
6142
+ const content = (0, import_node_fs16.readFileSync)(absPath, "utf-8");
6143
+ const parsed = parseAtelier(content);
6144
+ if (parsed.success) {
6145
+ bridgeState.store.set(filePath, parsed.data, "system");
6146
+ bridgeState.currentDocId = filePath;
6147
+ }
4161
6148
  res.setHeader("Content-Type", "text/plain");
4162
6149
  res.end(content);
4163
6150
  } catch {
@@ -4173,11 +6160,17 @@ function studioCommand(program2) {
4173
6160
  });
4174
6161
  req.on("end", () => {
4175
6162
  try {
4176
- (0, import_node_fs9.writeFileSync)(absPath, body, "utf-8");
6163
+ writeFileEnsuringDir(absPath, body);
6164
+ const parsed = parseAtelier(body);
6165
+ if (parsed.success) {
6166
+ bridgeState.store.set(filePath, parsed.data, "human");
6167
+ bridgeState.currentDocId = filePath;
6168
+ }
4177
6169
  res.end("OK");
4178
- } catch {
6170
+ } catch (e) {
6171
+ const msg = e instanceof Error ? e.message : String(e);
4179
6172
  res.statusCode = 500;
4180
- res.end("Write failed");
6173
+ res.end(msg);
4181
6174
  }
4182
6175
  });
4183
6176
  return;
@@ -4190,19 +6183,20 @@ function studioCommand(program2) {
4190
6183
  res.end("Invalid path");
4191
6184
  return;
4192
6185
  }
4193
- const absPath = (0, import_node_path9.resolve)(cwd, filePath);
6186
+ const absPath = (0, import_node_path15.resolve)(cwd, filePath);
4194
6187
  const chunks = [];
4195
6188
  req.on("data", (chunk) => {
4196
6189
  chunks.push(chunk);
4197
6190
  });
4198
6191
  req.on("end", () => {
4199
6192
  try {
4200
- (0, import_node_fs9.mkdirSync)((0, import_node_path9.dirname)(absPath), { recursive: true });
4201
- (0, import_node_fs9.writeFileSync)(absPath, Buffer.concat(chunks));
6193
+ (0, import_node_fs16.mkdirSync)((0, import_node_path15.dirname)(absPath), { recursive: true });
6194
+ (0, import_node_fs16.writeFileSync)(absPath, Buffer.concat(chunks));
4202
6195
  res.end("OK");
4203
- } catch {
6196
+ } catch (e) {
6197
+ const msg = e instanceof Error ? e.message : String(e);
4204
6198
  res.statusCode = 500;
4205
- res.end("Write failed");
6199
+ res.end(msg);
4206
6200
  }
4207
6201
  });
4208
6202
  return;
@@ -4220,6 +6214,51 @@ function studioCommand(program2) {
4220
6214
  logLevel: "warn"
4221
6215
  });
4222
6216
  await server.listen();
6217
+ const httpServer = server.httpServer;
6218
+ if (httpServer) {
6219
+ const wssBridge = new import_ws.WebSocketServer({ noServer: true });
6220
+ const wssMcp = new import_ws.WebSocketServer({ noServer: true });
6221
+ httpServer.on("upgrade", (req, socket, head) => {
6222
+ const url2 = new URL(req.url ?? "/", `http://${HOSTNAME}:${port}`);
6223
+ if (url2.pathname !== "/bridge" && url2.pathname !== "/mcp") return;
6224
+ const origin = req.headers.origin;
6225
+ const originOk = url2.pathname === "/mcp" ? isAllowedMcpOrigin(origin, port) : isAllowedOrigin(origin, port);
6226
+ if (!originOk) {
6227
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
6228
+ socket.destroy();
6229
+ return;
6230
+ }
6231
+ if (url2.pathname === "/bridge") {
6232
+ wssBridge.handleUpgrade(req, socket, head, (ws) => {
6233
+ wssBridge.emit("connection", ws, req);
6234
+ });
6235
+ return;
6236
+ }
6237
+ wssMcp.handleUpgrade(req, socket, head, (ws) => {
6238
+ wssMcp.emit("connection", ws, req);
6239
+ });
6240
+ });
6241
+ wssBridge.on("connection", (ws) => {
6242
+ attachBridgeClient(
6243
+ ws,
6244
+ bridgeState,
6245
+ bridgeClients,
6246
+ loadDocFromDisk,
6247
+ persistHumanPatch
6248
+ );
6249
+ });
6250
+ wssMcp.on("connection", (ws) => {
6251
+ const { server: mcpServer } = (0, import_atelier_mcp.createServer)(bridgeState.store);
6252
+ const transport = new import_atelier_mcp.WebSocketServerTransport(ws);
6253
+ mcpServer.connect(transport).catch((err) => {
6254
+ console.error("MCP-over-WS connect failed:", err);
6255
+ try {
6256
+ ws.close();
6257
+ } catch {
6258
+ }
6259
+ });
6260
+ });
6261
+ }
4223
6262
  const resolvedUrl = server.resolvedUrls?.local[0] ?? `http://localhost:${port}`;
4224
6263
  const url = resolvedUrl;
4225
6264
  console.log(` Server running at: ${url}`);
@@ -4231,13 +6270,13 @@ function studioCommand(program2) {
4231
6270
  console.log(` Press Ctrl+C to stop
4232
6271
  `);
4233
6272
  if (options.open) {
4234
- (0, import_node_child_process2.exec)(`open "${url}"`);
6273
+ (0, import_node_child_process4.exec)(`open "${url}"`);
4235
6274
  }
4236
6275
  const cleanup = () => {
4237
6276
  console.log("\nShutting down...");
4238
6277
  server.close();
4239
6278
  try {
4240
- (0, import_node_fs9.rmSync)(tmpDir, { recursive: true, force: true });
6279
+ (0, import_node_fs16.rmSync)(tmpDir, { recursive: true, force: true });
4241
6280
  } catch {
4242
6281
  }
4243
6282
  process.exit(0);
@@ -4253,11 +6292,20 @@ var import_meta2 = {};
4253
6292
  var program = new import_commander.Command();
4254
6293
  program.name("atelier").description("Atelier animation CLI").version((0, import_node_module.createRequire)(import_meta2.url)("../package.json").version);
4255
6294
  validateCommand(program);
6295
+ lintCommand(program);
6296
+ trimCommand(program);
6297
+ transcribeCommand(program);
6298
+ transcriptCommand(program);
6299
+ captionsCommand(program);
6300
+ recipeCommand(program);
6301
+ applyRecipeCommand(program);
6302
+ carouselCommand(program);
4256
6303
  infoCommand(program);
4257
6304
  stillCommand(program);
4258
6305
  renderCommand(program);
4259
6306
  exportSvgCommand(program);
4260
6307
  exportLottieCommand(program);
6308
+ exportImageCommand(program);
4261
6309
  assetsCommand(program);
4262
6310
  variablesCommand(program);
4263
6311
  studioCommand(program);