@a-company/atelier 0.29.0 → 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/index.cjs CHANGED
@@ -1423,22 +1423,76 @@ var init_dist3 = __esm({
1423
1423
  // src/index.ts
1424
1424
  var index_exports = {};
1425
1425
  __export(index_exports, {
1426
+ CanvasUnavailableError: () => CanvasUnavailableError,
1427
+ RECIPE_VERSION: () => RECIPE_VERSION,
1428
+ VIDEO_CUTLIST_VERSION: () => VIDEO_CUTLIST_VERSION,
1429
+ VIDEO_PROJECT_VERSION: () => VIDEO_PROJECT_VERSION,
1430
+ VIDEO_TRANSCRIPT_VERSION: () => VIDEO_TRANSCRIPT_VERSION,
1431
+ applyAdd: () => applyAdd,
1432
+ applyBatchReplace: () => applyBatchReplace,
1433
+ applyHide: () => applyHide,
1434
+ applyMerge: () => applyMerge,
1435
+ applyRecipeCommand: () => applyRecipeCommand,
1436
+ applyRecipeToCaptionOptions: () => applyRecipeToCaptionOptions,
1437
+ applyRecipeToTranscribeOptions: () => applyRecipeToTranscribeOptions,
1438
+ applyRecipeToTrimOptions: () => applyRecipeToTrimOptions,
1439
+ applySplit: () => applySplit,
1440
+ applyTextEdit: () => applyTextEdit,
1426
1441
  assetsCommand: () => assetsCommand,
1442
+ buildCaptionLayers: () => buildCaptionLayers,
1427
1443
  buildFfmpegArgs: () => buildFfmpegArgs,
1444
+ captionsCommand: () => captionsCommand,
1445
+ carouselCommand: () => carouselCommand,
1446
+ carouselFileName: () => carouselFileName,
1428
1447
  checkFfmpeg: () => checkFfmpeg,
1448
+ composeCarouselFrameDoc: () => composeCarouselFrameDoc,
1449
+ createVideoProject: () => createVideoProject,
1450
+ effectiveSpan: () => effectiveSpan,
1451
+ expandInputs: () => expandInputs,
1452
+ exportImageCommand: () => exportImageCommand,
1429
1453
  exportLottieCommand: () => exportLottieCommand,
1430
1454
  exportSvgCommand: () => exportSvgCommand,
1455
+ fitImageToCanvas: () => fitImageToCanvas,
1456
+ flattenWords: () => flattenWords,
1431
1457
  getAssets: () => getAssets,
1432
1458
  getInfo: () => getInfo,
1433
1459
  getVariables: () => getVariables,
1460
+ groupIntoPhrases: () => groupIntoPhrases,
1434
1461
  infoCommand: () => infoCommand,
1462
+ loadCanvasModule: () => loadCanvasModule,
1463
+ loadRecipe: () => loadRecipe,
1464
+ loadVideoProject: () => loadVideoProject,
1465
+ mergeTranscriptWithExisting: () => mergeTranscriptWithExisting,
1466
+ parseWhisperCppJson: () => parseWhisperCppJson,
1467
+ probeWhisper: () => probeWhisper,
1468
+ readComposition: () => readComposition,
1469
+ readCutList: () => readCutList,
1470
+ readTranscript: () => readTranscript,
1471
+ recipeCommand: () => recipeCommand,
1472
+ recipeToYaml: () => recipeToYaml,
1435
1473
  renderCommand: () => renderCommand,
1436
1474
  renderDocument: () => renderDocument,
1475
+ renderDocumentToPng: () => renderDocumentToPng,
1476
+ renderRecipeWithDefaults: () => renderRecipeWithDefaults,
1477
+ resolveExportDimensions: () => resolveExportDimensions,
1478
+ resolveRecipePath: () => resolveRecipePath,
1437
1479
  resolveStill: () => resolveStill,
1480
+ rewriteCaptionLayers: () => rewriteCaptionLayers,
1481
+ rewriteCutLayers: () => rewriteCutLayers,
1482
+ runWhisperCpp: () => runWhisperCpp,
1483
+ scaffoldRecipeYaml: () => scaffoldRecipeYaml,
1438
1484
  stillCommand: () => stillCommand,
1485
+ transcribeCommand: () => transcribeCommand,
1486
+ transcribeProject: () => transcribeProject,
1487
+ transcriptCommand: () => transcriptCommand,
1488
+ trimCommand: () => trimCommand,
1489
+ trimProject: () => trimProject,
1439
1490
  validateCommand: () => validateCommand,
1440
1491
  validateFile: () => validateFile,
1441
- variablesCommand: () => variablesCommand
1492
+ variablesCommand: () => variablesCommand,
1493
+ writeComposition: () => writeComposition,
1494
+ writeCutList: () => writeCutList,
1495
+ writeTranscript: () => writeTranscript
1442
1496
  });
1443
1497
  module.exports = __toCommonJS(index_exports);
1444
1498
 
@@ -1461,6 +1515,7 @@ var import_zod11 = require("zod");
1461
1515
  var import_zod12 = require("zod");
1462
1516
  var import_zod13 = require("zod");
1463
1517
  var import_zod14 = require("zod");
1518
+ var import_zod15 = require("zod");
1464
1519
  var import_yaml = require("yaml");
1465
1520
  var PixelSchema = import_zod.z.number();
1466
1521
  var PercentageSchema = import_zod.z.string().regex(/^-?\d+(\.\d+)?%$/, {
@@ -1884,6 +1939,121 @@ var AtelierDocumentSchema = import_zod14.z.object({
1884
1939
  layers: import_zod14.z.array(LayerSchema),
1885
1940
  states: import_zod14.z.record(import_zod14.z.string(), StateSchema)
1886
1941
  });
1942
+ var SilencePolicySchema = import_zod15.z.object({
1943
+ noise: import_zod15.z.string().optional(),
1944
+ min_silence: import_zod15.z.number().nonnegative().optional(),
1945
+ default_padding_pre: import_zod15.z.number().nonnegative().optional(),
1946
+ default_padding_post: import_zod15.z.number().nonnegative().optional(),
1947
+ match_tolerance: import_zod15.z.number().nonnegative().optional()
1948
+ }).strict();
1949
+ var CaptionStyleSchema = import_zod15.z.object({
1950
+ font_family: import_zod15.z.string().optional(),
1951
+ font_size: import_zod15.z.number().positive().optional(),
1952
+ font_weight: import_zod15.z.union([import_zod15.z.literal("normal"), import_zod15.z.literal("bold"), import_zod15.z.number()]).optional(),
1953
+ text_align: import_zod15.z.enum(["left", "center", "right"]).optional(),
1954
+ color: import_zod15.z.string().optional(),
1955
+ y_ratio: import_zod15.z.number().min(0).max(1).optional(),
1956
+ width_ratio: import_zod15.z.number().min(0).max(1).optional(),
1957
+ fade_seconds: import_zod15.z.number().nonnegative().optional()
1958
+ }).strict();
1959
+ var CaptionGroupingSchema = import_zod15.z.object({
1960
+ max_words: import_zod15.z.number().int().positive().optional(),
1961
+ pause_gap: import_zod15.z.number().nonnegative().optional()
1962
+ }).strict();
1963
+ var OverlayAnchorSchema = import_zod15.z.enum([
1964
+ "top-left",
1965
+ "top-right",
1966
+ "bottom-left",
1967
+ "bottom-right"
1968
+ ]);
1969
+ var OverlayTextStyleSchema = import_zod15.z.object({
1970
+ font_family: import_zod15.z.string().optional(),
1971
+ font_size: import_zod15.z.number().positive().optional(),
1972
+ font_weight: import_zod15.z.union([import_zod15.z.literal("normal"), import_zod15.z.literal("bold"), import_zod15.z.number()]).optional(),
1973
+ color: import_zod15.z.string().optional()
1974
+ }).strict();
1975
+ function validatePageNumberFormat(format, ctx) {
1976
+ if (format.length === 0) {
1977
+ ctx.addIssue({
1978
+ code: import_zod15.z.ZodIssueCode.custom,
1979
+ message: "format must be a non-empty string"
1980
+ });
1981
+ return;
1982
+ }
1983
+ const open = (format.match(/\{/g) ?? []).length;
1984
+ const close = (format.match(/\}/g) ?? []).length;
1985
+ if (open !== close) {
1986
+ ctx.addIssue({
1987
+ code: import_zod15.z.ZodIssueCode.custom,
1988
+ message: `format has unbalanced braces (${open} '{' vs ${close} '}')`
1989
+ });
1990
+ return;
1991
+ }
1992
+ const groupRe = /\{([^{}]*)\}/g;
1993
+ const groupRule = /^(current|total)(:0\d+d)?$/;
1994
+ let m;
1995
+ let sawCurrent = false;
1996
+ let sawTotal = false;
1997
+ while ((m = groupRe.exec(format)) !== null) {
1998
+ const inner = m[1];
1999
+ if (!groupRule.test(inner)) {
2000
+ ctx.addIssue({
2001
+ code: import_zod15.z.ZodIssueCode.custom,
2002
+ message: `format placeholder "{${inner}}" is not recognized \u2014 expected {current}, {total}, {current:0Nd}, or {total:0Nd}`
2003
+ });
2004
+ return;
2005
+ }
2006
+ if (inner.startsWith("current")) sawCurrent = true;
2007
+ if (inner.startsWith("total")) sawTotal = true;
2008
+ }
2009
+ if (!sawCurrent && !sawTotal) {
2010
+ ctx.addIssue({
2011
+ code: import_zod15.z.ZodIssueCode.custom,
2012
+ message: "format must contain at least one {current} or {total} placeholder"
2013
+ });
2014
+ }
2015
+ }
2016
+ var OverlayHandleRuleSchema = import_zod15.z.object({
2017
+ text: import_zod15.z.string().min(1),
2018
+ anchor: OverlayAnchorSchema,
2019
+ margin: import_zod15.z.number().nonnegative().optional(),
2020
+ style: OverlayTextStyleSchema.optional()
2021
+ }).strict();
2022
+ var OverlayPageNumberRuleSchema = import_zod15.z.object({
2023
+ format: import_zod15.z.string().superRefine(validatePageNumberFormat),
2024
+ anchor: OverlayAnchorSchema,
2025
+ margin: import_zod15.z.number().nonnegative().optional(),
2026
+ style: OverlayTextStyleSchema.optional()
2027
+ }).strict();
2028
+ var OverlayRulesSchema = import_zod15.z.object({
2029
+ handle: OverlayHandleRuleSchema.optional(),
2030
+ page_number: OverlayPageNumberRuleSchema.optional()
2031
+ }).strict();
2032
+ var StudioRecipeSchema = import_zod15.z.object({
2033
+ version: import_zod15.z.string(),
2034
+ name: import_zod15.z.string(),
2035
+ description: import_zod15.z.string().optional(),
2036
+ author: import_zod15.z.string().optional(),
2037
+ tags: import_zod15.z.array(import_zod15.z.string()).optional(),
2038
+ silence_policy: SilencePolicySchema.optional(),
2039
+ caption_style: CaptionStyleSchema.optional(),
2040
+ caption_grouping: CaptionGroupingSchema.optional(),
2041
+ // Phase 1.5 — first-class overlay rules
2042
+ overlay_rules: OverlayRulesSchema.optional(),
2043
+ // Reserved — Phase 3 (parse-opaque)
2044
+ caption_highlight: import_zod15.z.unknown().optional(),
2045
+ transition_kit: import_zod15.z.unknown().optional(),
2046
+ palette: import_zod15.z.unknown().optional(),
2047
+ audio_policy: import_zod15.z.unknown().optional(),
2048
+ aspect_targets: import_zod15.z.array(import_zod15.z.unknown()).optional()
2049
+ }).strict();
2050
+ var RESERVED_RECIPE_FIELDS = [
2051
+ "caption_highlight",
2052
+ "transition_kit",
2053
+ "palette",
2054
+ "audio_policy",
2055
+ "aspect_targets"
2056
+ ];
1887
2057
  function formatErrors(error) {
1888
2058
  return error.issues.map((issue) => ({
1889
2059
  path: issue.path.join(".") || "(root)",
@@ -1897,6 +2067,22 @@ function validateDocument(input) {
1897
2067
  }
1898
2068
  return { success: false, errors: formatErrors(result.error) };
1899
2069
  }
2070
+ function validateRecipe(recipe) {
2071
+ const parsed = StudioRecipeSchema.safeParse(recipe);
2072
+ if (!parsed.success) {
2073
+ return { success: false, errors: formatErrors(parsed.error) };
2074
+ }
2075
+ const warnings = [];
2076
+ const data = parsed.data;
2077
+ for (const field of RESERVED_RECIPE_FIELDS) {
2078
+ if (data[field] !== void 0) {
2079
+ warnings.push(
2080
+ `${field} is reserved for Phase 3 and currently has no effect.`
2081
+ );
2082
+ }
2083
+ }
2084
+ return { success: true, data, ...warnings.length > 0 && { warnings } };
2085
+ }
1900
2086
  function parseAtelier(yamlString) {
1901
2087
  let parsed;
1902
2088
  try {
@@ -2119,17 +2305,9 @@ function stillCommand(program) {
2119
2305
  console.log(json);
2120
2306
  }
2121
2307
  } else {
2122
- let canvasMod;
2123
- try {
2124
- canvasMod = await import("canvas");
2125
- } catch {
2126
- console.error("PNG output requires the 'canvas' package. Install it: npm i canvas");
2127
- process.exit(1);
2128
- return;
2129
- }
2308
+ const { createCanvas: createCanvas2 } = await import("@napi-rs/canvas");
2130
2309
  const { renderFrame: renderFrame2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
2131
- const { createCanvas } = canvasMod;
2132
- const cvs = createCanvas(doc.canvas.width, doc.canvas.height);
2310
+ const cvs = createCanvas2(doc.canvas.width, doc.canvas.height);
2133
2311
  const ctx = cvs.getContext("2d");
2134
2312
  renderFrame2(ctx, resolved, doc);
2135
2313
  const buffer = cvs.toBuffer("image/png");
@@ -2158,10 +2336,10 @@ var import_node_child_process = require("child_process");
2158
2336
  init_dist2();
2159
2337
  init_dist3();
2160
2338
  async function checkFfmpeg() {
2161
- return new Promise((resolve9) => {
2339
+ return new Promise((resolve14) => {
2162
2340
  const proc = (0, import_node_child_process.spawn)("ffmpeg", ["-version"], { stdio: "pipe" });
2163
- proc.on("error", () => resolve9(false));
2164
- proc.on("close", (code) => resolve9(code === 0));
2341
+ proc.on("error", () => resolve14(false));
2342
+ proc.on("close", (code) => resolve14(code === 0));
2165
2343
  });
2166
2344
  }
2167
2345
  function buildFfmpegArgs(width, height, fps, format, output) {
@@ -2203,7 +2381,7 @@ function buildFfmpegArgs(width, height, fps, format, output) {
2203
2381
  output
2204
2382
  ];
2205
2383
  }
2206
- async function preloadImages(doc, loadImage) {
2384
+ async function preloadImages(doc, loadImage2) {
2207
2385
  const sources = /* @__PURE__ */ new Set();
2208
2386
  for (const layer of doc.layers) {
2209
2387
  if (layer.visual.type === "image") {
@@ -2222,7 +2400,7 @@ async function preloadImages(doc, loadImage) {
2222
2400
  await Promise.all(
2223
2401
  [...sources].map(async (src) => {
2224
2402
  try {
2225
- preloaded.set(src, await loadImage(src));
2403
+ preloaded.set(src, await loadImage2(src));
2226
2404
  } catch {
2227
2405
  }
2228
2406
  })
@@ -2241,20 +2419,20 @@ async function preloadImages(doc, loadImage) {
2241
2419
  for (const src of preloaded.keys()) {
2242
2420
  imageCache.load(src);
2243
2421
  }
2244
- await new Promise((resolve9) => process.nextTick(resolve9));
2422
+ await new Promise((resolve14) => process.nextTick(resolve14));
2245
2423
  return imageCache;
2246
2424
  }
2247
2425
  async function renderDocument(doc, opts) {
2248
2426
  const canvasModuleName = "canvas";
2249
- let createCanvas;
2250
- let loadImage;
2427
+ let createCanvas2;
2428
+ let loadImage2;
2251
2429
  try {
2252
2430
  const canvasModule = await import(
2253
2431
  /* webpackIgnore: true */
2254
2432
  canvasModuleName
2255
2433
  );
2256
- createCanvas = canvasModule.createCanvas;
2257
- loadImage = canvasModule.loadImage;
2434
+ createCanvas2 = canvasModule.createCanvas;
2435
+ loadImage2 = canvasModule.loadImage;
2258
2436
  } catch {
2259
2437
  throw new Error(
2260
2438
  "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"
@@ -2283,8 +2461,8 @@ async function renderDocument(doc, opts) {
2283
2461
  if (totalFrames === 0) {
2284
2462
  throw new Error("Nothing to render \u2014 all states have duration 0");
2285
2463
  }
2286
- const imageCache = await preloadImages(doc, loadImage);
2287
- const canvas = createCanvas(width, height);
2464
+ const imageCache = await preloadImages(doc, loadImage2);
2465
+ const canvas = createCanvas2(width, height);
2288
2466
  const ctx = canvas.getContext("2d");
2289
2467
  const ffmpegArgs = buildFfmpegArgs(width, height, fps, format, output);
2290
2468
  const ffmpeg = (0, import_node_child_process.spawn)("ffmpeg", ffmpegArgs, {
@@ -2305,7 +2483,7 @@ async function renderDocument(doc, opts) {
2305
2483
  const canWrite = ffmpeg.stdin.write(raw);
2306
2484
  if (!canWrite) {
2307
2485
  await new Promise(
2308
- (resolve9) => ffmpeg.stdin.once("drain", resolve9)
2486
+ (resolve14) => ffmpeg.stdin.once("drain", resolve14)
2309
2487
  );
2310
2488
  }
2311
2489
  frameIndex++;
@@ -2318,8 +2496,8 @@ async function renderDocument(doc, opts) {
2318
2496
  }
2319
2497
  }
2320
2498
  ffmpeg.stdin.end();
2321
- const exitCode = await new Promise((resolve9) => {
2322
- ffmpeg.on("close", resolve9);
2499
+ const exitCode = await new Promise((resolve14) => {
2500
+ ffmpeg.on("close", resolve14);
2323
2501
  });
2324
2502
  if (exitCode !== 0) {
2325
2503
  throw new Error(
@@ -3560,9 +3738,685 @@ function exportLottieCommand(program) {
3560
3738
  );
3561
3739
  }
3562
3740
 
3563
- // src/commands/assets.ts
3741
+ // src/commands/export-image.ts
3564
3742
  var import_node_fs7 = require("fs");
3565
3743
  var import_node_path7 = require("path");
3744
+
3745
+ // src/lib/render-image.ts
3746
+ init_dist2();
3747
+ var import_canvas = require("@napi-rs/canvas");
3748
+ function fitImageToCanvas(canvas, natural) {
3749
+ const cw = canvas.width;
3750
+ const ch = canvas.height;
3751
+ const iw = natural.width;
3752
+ const ih = natural.height;
3753
+ if (!iw || !ih) {
3754
+ return { bounds: { width: cw, height: ch }, frame: { x: cw / 2, y: ch / 2 } };
3755
+ }
3756
+ const imageAspect = iw / ih;
3757
+ const canvasAspect = cw / ch;
3758
+ let width;
3759
+ let height;
3760
+ if (imageAspect > canvasAspect) {
3761
+ width = cw;
3762
+ height = cw / imageAspect;
3763
+ } else {
3764
+ height = ch;
3765
+ width = ch * imageAspect;
3766
+ }
3767
+ width = Math.min(width, cw);
3768
+ height = Math.min(height, ch);
3769
+ return { bounds: { width, height }, frame: { x: cw / 2, y: ch / 2 } };
3770
+ }
3771
+ var CanvasUnavailableError = class extends Error {
3772
+ constructor() {
3773
+ super(
3774
+ "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."
3775
+ );
3776
+ this.name = "CanvasUnavailableError";
3777
+ }
3778
+ };
3779
+ async function loadCanvasModule() {
3780
+ if (typeof import_canvas.createCanvas !== "function" || typeof import_canvas.loadImage !== "function") {
3781
+ throw new CanvasUnavailableError();
3782
+ }
3783
+ return {
3784
+ createCanvas: import_canvas.createCanvas,
3785
+ loadImage: import_canvas.loadImage
3786
+ };
3787
+ }
3788
+ function resolveExportDimensions(docWidth, docHeight, width, height) {
3789
+ if (width !== void 0 && height !== void 0) {
3790
+ return { width, height };
3791
+ }
3792
+ if (width !== void 0) {
3793
+ const h = Math.max(1, Math.round(width * docHeight / docWidth));
3794
+ return { width, height: h };
3795
+ }
3796
+ if (height !== void 0) {
3797
+ const w = Math.max(1, Math.round(height * docWidth / docHeight));
3798
+ return { width: w, height };
3799
+ }
3800
+ return { width: docWidth, height: docHeight };
3801
+ }
3802
+ async function preloadImages2(doc, loadImage2) {
3803
+ const { ImageCache: ImageCache2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
3804
+ const sources = /* @__PURE__ */ new Set();
3805
+ for (const layer of doc.layers) {
3806
+ if (layer.visual.type === "image") {
3807
+ const iv = layer.visual;
3808
+ if (iv.src) sources.add(iv.src);
3809
+ else if (iv.assetId && doc.assets?.[iv.assetId]) sources.add(doc.assets[iv.assetId].src);
3810
+ }
3811
+ }
3812
+ if (sources.size === 0) {
3813
+ return { imageCache: new ImageCache2(), loaded: /* @__PURE__ */ new Map() };
3814
+ }
3815
+ const preloaded = /* @__PURE__ */ new Map();
3816
+ await Promise.all(
3817
+ [...sources].map(async (src) => {
3818
+ try {
3819
+ preloaded.set(src, await loadImage2(src));
3820
+ } catch {
3821
+ }
3822
+ })
3823
+ );
3824
+ const imageCache = new ImageCache2({
3825
+ createImage: (src, onLoad, onError) => {
3826
+ const img = preloaded.get(src);
3827
+ if (img) {
3828
+ process.nextTick(onLoad);
3829
+ return img;
3830
+ }
3831
+ process.nextTick(onError);
3832
+ return {};
3833
+ }
3834
+ });
3835
+ for (const src of preloaded.keys()) imageCache.load(src);
3836
+ await new Promise((resolve14) => process.nextTick(resolve14));
3837
+ return { imageCache, loaded: preloaded };
3838
+ }
3839
+ async function renderDocumentToPng(doc, opts = {}) {
3840
+ const stateNames = Object.keys(doc.states);
3841
+ if (stateNames.length === 0) {
3842
+ throw new Error("Document has no states");
3843
+ }
3844
+ const stateName = opts.state ?? stateNames[0];
3845
+ if (!doc.states[stateName]) {
3846
+ throw new Error(`State "${stateName}" not found. Available: ${stateNames.join(", ")}`);
3847
+ }
3848
+ const frameNumber = opts.frame ?? 0;
3849
+ if (!Number.isInteger(frameNumber) || frameNumber < 0) {
3850
+ throw new Error(`Invalid frame number: ${frameNumber}`);
3851
+ }
3852
+ const duration = doc.states[stateName].duration;
3853
+ if (frameNumber >= duration) {
3854
+ throw new Error(
3855
+ `Frame ${frameNumber} is out of range for state "${stateName}" (duration ${duration})`
3856
+ );
3857
+ }
3858
+ const { createCanvas: createCanvas2, loadImage: loadImage2 } = await loadCanvasModule();
3859
+ const { renderFrame: renderFrame2 } = await Promise.resolve().then(() => (init_dist3(), dist_exports));
3860
+ const { imageCache, loaded } = await preloadImages2(doc, loadImage2);
3861
+ let renderDoc = doc;
3862
+ if (opts.refitImageBounds && loaded.size > 0) {
3863
+ renderDoc = {
3864
+ ...doc,
3865
+ layers: doc.layers.map((layer) => {
3866
+ if (layer.visual.type !== "image") return layer;
3867
+ const iv = layer.visual;
3868
+ const src = iv.src ?? (iv.assetId ? doc.assets?.[iv.assetId]?.src : void 0);
3869
+ const natural = src ? loaded.get(src) : void 0;
3870
+ if (!natural || !natural.width || !natural.height) return layer;
3871
+ const fit = opts.refitImageBounds({ canvas: doc.canvas, natural });
3872
+ return { ...layer, bounds: fit.bounds, frame: fit.frame };
3873
+ })
3874
+ };
3875
+ }
3876
+ const { width: outW, height: outH } = resolveExportDimensions(
3877
+ renderDoc.canvas.width,
3878
+ renderDoc.canvas.height,
3879
+ opts.width,
3880
+ opts.height
3881
+ );
3882
+ const resolved = resolveFrame(renderDoc, stateName, frameNumber);
3883
+ const cvs = createCanvas2(outW, outH);
3884
+ const ctx = cvs.getContext("2d");
3885
+ const sx = outW / renderDoc.canvas.width;
3886
+ const sy = outH / renderDoc.canvas.height;
3887
+ if (sx !== 1 || sy !== 1) ctx.scale(sx, sy);
3888
+ renderFrame2(ctx, resolved, renderDoc, imageCache);
3889
+ return cvs.toBuffer("image/png");
3890
+ }
3891
+
3892
+ // src/commands/export-image.ts
3893
+ function readAndParse6(file) {
3894
+ const absPath = (0, import_node_path7.resolve)(file);
3895
+ let content;
3896
+ try {
3897
+ content = (0, import_node_fs7.readFileSync)(absPath, "utf-8");
3898
+ } catch {
3899
+ console.error(`Cannot read file: ${absPath}`);
3900
+ return process.exit(1);
3901
+ }
3902
+ const result = parseAtelier(content);
3903
+ if (!result.success) {
3904
+ console.error("Parse errors:");
3905
+ for (const error of result.errors) {
3906
+ console.error(` - ${error.path}: ${error.message}`);
3907
+ }
3908
+ return process.exit(1);
3909
+ }
3910
+ return result.data;
3911
+ }
3912
+ function parseDim(raw, name) {
3913
+ if (raw === void 0) return void 0;
3914
+ const n = parseInt(raw, 10);
3915
+ if (isNaN(n) || n <= 0) {
3916
+ console.error(`Invalid --${name}: ${raw}`);
3917
+ process.exit(1);
3918
+ }
3919
+ return n;
3920
+ }
3921
+ function exportImageCommand(program) {
3922
+ program.command("export-image <file>").description(
3923
+ "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)."
3924
+ ).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) => {
3925
+ const doc = readAndParse6(file);
3926
+ const frameNumber = parseInt(options.frame, 10);
3927
+ if (isNaN(frameNumber) || frameNumber < 0) {
3928
+ console.error(`Invalid frame number: ${options.frame}`);
3929
+ process.exit(1);
3930
+ return;
3931
+ }
3932
+ const width = parseDim(options.width, "width");
3933
+ const height = parseDim(options.height, "height");
3934
+ try {
3935
+ const buffer = await renderDocumentToPng(doc, {
3936
+ state: options.state,
3937
+ frame: frameNumber,
3938
+ width,
3939
+ height
3940
+ });
3941
+ (0, import_node_fs7.writeFileSync)((0, import_node_path7.resolve)(options.out), buffer);
3942
+ } catch (err) {
3943
+ if (err instanceof CanvasUnavailableError) {
3944
+ console.error(err.message);
3945
+ process.exit(1);
3946
+ return;
3947
+ }
3948
+ console.error(err.message);
3949
+ process.exit(1);
3950
+ }
3951
+ });
3952
+ }
3953
+
3954
+ // src/commands/carousel.ts
3955
+ var import_node_fs9 = require("fs");
3956
+ var import_node_path9 = require("path");
3957
+
3958
+ // src/lib/recipe.ts
3959
+ var import_node_fs8 = require("fs");
3960
+ var import_node_path8 = require("path");
3961
+ var import_node_os = require("os");
3962
+ var import_yaml2 = require("yaml");
3963
+ var RECIPE_VERSION = "1.0";
3964
+ function resolveRecipePath(pathOrName, projectDir) {
3965
+ if ((0, import_node_path8.isAbsolute)(pathOrName) || pathOrName.includes("/") || pathOrName.includes("\\")) {
3966
+ return (0, import_node_path8.resolve)(pathOrName);
3967
+ }
3968
+ const candidates = [];
3969
+ const exts = [".recipe.yaml", ".recipe.json", ".yaml", ".yml", ".json"];
3970
+ if (projectDir) {
3971
+ const projectRecipesDir = (0, import_node_path8.join)((0, import_node_path8.resolve)(projectDir), ".atelier", "recipes");
3972
+ for (const ext of exts) candidates.push((0, import_node_path8.join)(projectRecipesDir, `${pathOrName}${ext}`));
3973
+ }
3974
+ const userRecipesDir = (0, import_node_path8.join)((0, import_node_os.homedir)(), ".atelier", "recipes");
3975
+ for (const ext of exts) candidates.push((0, import_node_path8.join)(userRecipesDir, `${pathOrName}${ext}`));
3976
+ for (const candidate of candidates) {
3977
+ if ((0, import_node_fs8.existsSync)(candidate)) return candidate;
3978
+ }
3979
+ throw new Error(
3980
+ `Recipe "${pathOrName}" not found. Looked in:
3981
+ ${candidates.map((c) => ` ${c}`).join("\n")}`
3982
+ );
3983
+ }
3984
+ function loadRecipe(pathOrName, projectDir) {
3985
+ const path = resolveRecipePath(pathOrName, projectDir);
3986
+ const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
3987
+ let parsed;
3988
+ if (path.endsWith(".json")) {
3989
+ parsed = JSON.parse(raw);
3990
+ } else {
3991
+ parsed = (0, import_yaml2.parse)(raw);
3992
+ }
3993
+ const result = validateRecipe(parsed);
3994
+ if (!result.success) {
3995
+ const msg = result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
3996
+ throw new Error(`Invalid recipe at ${path}:
3997
+ ${msg}`);
3998
+ }
3999
+ return {
4000
+ recipe: result.data,
4001
+ path,
4002
+ warnings: result.warnings ?? []
4003
+ };
4004
+ }
4005
+ function scaffoldRecipeYaml(name) {
4006
+ return `# Studio Recipe \u2014 ${name}
4007
+ # Phase 1 \u2014 manual authoring + apply
4008
+ # https://github.com/ascend42/a-atelier/blob/main/.paradigm/specs/studio-recipe.md
4009
+
4010
+ version: "${RECIPE_VERSION}"
4011
+ name: "${name}"
4012
+ description: ""
4013
+ author: ""
4014
+ tags: []
4015
+
4016
+ # \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
4017
+ silence_policy:
4018
+ # silencedetect noise threshold (default: -30dB)
4019
+ noise: "-30dB"
4020
+ # Minimum silence duration to register, in seconds (default: 0.35)
4021
+ min_silence: 0.35
4022
+ # Default leading padding for new cuts, in seconds (default: 0.08)
4023
+ default_padding_pre: 0.08
4024
+ # Default trailing padding for new cuts, in seconds (default: 0.12)
4025
+ default_padding_post: 0.12
4026
+ # Re-detect match tolerance for preserving user padding, in seconds (default: 0.5)
4027
+ match_tolerance: 0.5
4028
+
4029
+ # \u2500\u2500 Caption visual style \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4030
+ caption_style:
4031
+ font_family: "Inter"
4032
+ font_size: 84
4033
+ font_weight: "bold" # normal | bold | numeric (100..900)
4034
+ text_align: "center" # left | center | right
4035
+ color: "#FFFFFF"
4036
+ y_ratio: 0.85 # 0=top, 1=bottom
4037
+ width_ratio: 0.9
4038
+ fade_seconds: 0.05
4039
+
4040
+ # \u2500\u2500 Caption phrase grouping \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4041
+ caption_grouping:
4042
+ max_words: 5
4043
+ pause_gap: 0.4
4044
+
4045
+ # \u2500\u2500 Overlay rules (Phase 1.5) \u2014 anchored handle + page-number overlays \u2500
4046
+ # overlay_rules:
4047
+ # handle:
4048
+ # text: "@username"
4049
+ # anchor: "bottom-left" # top-left | top-right | bottom-left | bottom-right
4050
+ # margin: 24 # px from anchored edges
4051
+ # style:
4052
+ # font_family: "Inter"
4053
+ # font_size: 36
4054
+ # font_weight: "bold"
4055
+ # color: "#FFFFFF"
4056
+ # page_number:
4057
+ # format: "{current}/{total}" # supports {current:02d} / {total:02d} zero-pad
4058
+ # anchor: "top-right"
4059
+ # margin: 24
4060
+ # style:
4061
+ # font_family: "Inter"
4062
+ # font_size: 36
4063
+ # font_weight: "normal"
4064
+ # color: "#FFFFFF"
4065
+
4066
+ # \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
4067
+ # caption_highlight: {}
4068
+ # transition_kit: {}
4069
+ # palette: {}
4070
+ # audio_policy: {}
4071
+ # aspect_targets: []
4072
+ `;
4073
+ }
4074
+ function applyRecipeToTrimOptions(recipe, cliOptions) {
4075
+ const policy = recipe?.silence_policy;
4076
+ if (!policy) return cliOptions;
4077
+ return {
4078
+ ...cliOptions,
4079
+ noise: cliOptions.noise ?? policy.noise,
4080
+ minSilence: cliOptions.minSilence ?? policy.min_silence,
4081
+ padPre: cliOptions.padPre ?? policy.default_padding_pre,
4082
+ padPost: cliOptions.padPost ?? policy.default_padding_post,
4083
+ // matchTolerance is recipe-only at this layer (no CLI flag yet)
4084
+ matchTolerance: cliOptions.matchTolerance ?? policy.match_tolerance
4085
+ };
4086
+ }
4087
+ function applyRecipeToCaptionOptions(recipe) {
4088
+ if (!recipe) return {};
4089
+ const style = recipe.caption_style;
4090
+ const grouping = recipe.caption_grouping;
4091
+ const runtimeStyle = {};
4092
+ if (style) {
4093
+ if (style.font_family !== void 0) runtimeStyle.fontFamily = style.font_family;
4094
+ if (style.font_size !== void 0) runtimeStyle.fontSize = style.font_size;
4095
+ if (style.font_weight !== void 0) runtimeStyle.fontWeight = style.font_weight;
4096
+ if (style.text_align !== void 0) runtimeStyle.textAlign = style.text_align;
4097
+ if (style.color !== void 0) runtimeStyle.color = style.color;
4098
+ if (style.y_ratio !== void 0) runtimeStyle.yRatio = style.y_ratio;
4099
+ if (style.width_ratio !== void 0) runtimeStyle.widthRatio = style.width_ratio;
4100
+ if (style.fade_seconds !== void 0) runtimeStyle.fadeSeconds = style.fade_seconds;
4101
+ }
4102
+ return {
4103
+ ...Object.keys(runtimeStyle).length > 0 && { style: runtimeStyle },
4104
+ ...grouping?.max_words !== void 0 && { maxWords: grouping.max_words },
4105
+ ...grouping?.pause_gap !== void 0 && { pauseGap: grouping.pause_gap }
4106
+ };
4107
+ }
4108
+ function applyRecipeToTranscribeOptions(recipe, cliOptions) {
4109
+ if (!recipe) return cliOptions;
4110
+ const captionOptions = applyRecipeToCaptionOptions(recipe);
4111
+ return {
4112
+ ...cliOptions,
4113
+ captionOptions
4114
+ };
4115
+ }
4116
+ function renderRecipeWithDefaults(recipe) {
4117
+ const defaults = {
4118
+ silence_policy: {
4119
+ noise: "-30dB",
4120
+ min_silence: 0.35,
4121
+ default_padding_pre: 0.08,
4122
+ default_padding_post: 0.12,
4123
+ match_tolerance: 0.5
4124
+ },
4125
+ caption_style: {
4126
+ font_family: "Inter",
4127
+ font_size: 84,
4128
+ font_weight: "bold",
4129
+ text_align: "center",
4130
+ color: "#FFFFFF",
4131
+ y_ratio: 0.85,
4132
+ width_ratio: 0.9,
4133
+ fade_seconds: 0.05
4134
+ },
4135
+ caption_grouping: {
4136
+ max_words: 5,
4137
+ pause_gap: 0.4
4138
+ }
4139
+ };
4140
+ return {
4141
+ ...recipe,
4142
+ silence_policy: { ...defaults.silence_policy, ...recipe.silence_policy },
4143
+ caption_style: { ...defaults.caption_style, ...recipe.caption_style },
4144
+ caption_grouping: { ...defaults.caption_grouping, ...recipe.caption_grouping }
4145
+ };
4146
+ }
4147
+ function recipeToYaml(recipe) {
4148
+ return (0, import_yaml2.stringify)(recipe);
4149
+ }
4150
+ var DEFAULT_OVERLAY_MARGIN = 24;
4151
+ var DEFAULT_OVERLAY_TEXT_STYLE = {
4152
+ fontFamily: "Inter",
4153
+ fontSize: 24,
4154
+ fontWeight: 600,
4155
+ color: "#F5F5F7"
4156
+ };
4157
+ var HANDLE_LAYER_ID = "overlay-handle";
4158
+ var PAGE_NUMBER_LAYER_ID = "overlay-page-number";
4159
+ function applyRecipeToOverlay(doc, recipe, ctx) {
4160
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("overlay"));
4161
+ const overlayLayers = [];
4162
+ const rules = recipe.overlay_rules;
4163
+ if (!rules) {
4164
+ return { ...doc, layers: preserved };
4165
+ }
4166
+ if (rules.handle) {
4167
+ overlayLayers.push(buildHandleLayer(rules.handle, doc.canvas));
4168
+ }
4169
+ if (rules.page_number) {
4170
+ if (ctx?.currentIndex != null && ctx?.totalCount != null) {
4171
+ overlayLayers.push(
4172
+ buildPageNumberLayer(rules.page_number, doc.canvas, ctx.currentIndex, ctx.totalCount)
4173
+ );
4174
+ } else {
4175
+ console.warn(
4176
+ `applyRecipeToOverlay: recipe.overlay_rules.page_number present but currentIndex/totalCount not provided \u2014 skipping page_number layer.`
4177
+ );
4178
+ }
4179
+ }
4180
+ return {
4181
+ ...doc,
4182
+ layers: [...preserved, ...overlayLayers]
4183
+ };
4184
+ }
4185
+ function buildHandleLayer(rule, canvas) {
4186
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
4187
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
4188
+ return {
4189
+ id: HANDLE_LAYER_ID,
4190
+ tags: ["overlay"],
4191
+ visual: {
4192
+ type: "text",
4193
+ content: rule.text,
4194
+ style: mergeOverlayStyle(rule.style)
4195
+ },
4196
+ frame,
4197
+ bounds: { width: 600, height: 80 },
4198
+ anchorPoint
4199
+ };
4200
+ }
4201
+ function buildPageNumberLayer(rule, canvas, currentIndex, totalCount) {
4202
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
4203
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
4204
+ return {
4205
+ id: PAGE_NUMBER_LAYER_ID,
4206
+ tags: ["overlay"],
4207
+ visual: {
4208
+ type: "text",
4209
+ content: renderPageNumberFormat(rule.format, currentIndex, totalCount),
4210
+ style: mergeOverlayStyle(rule.style)
4211
+ },
4212
+ frame,
4213
+ bounds: { width: 200, height: 80 },
4214
+ anchorPoint
4215
+ };
4216
+ }
4217
+ function anchorToFrame(anchor, canvas, margin) {
4218
+ switch (anchor) {
4219
+ case "top-left":
4220
+ return { frame: { x: margin, y: margin }, anchorPoint: { x: 0, y: 0 } };
4221
+ case "top-right":
4222
+ return { frame: { x: canvas.width - margin, y: margin }, anchorPoint: { x: 1, y: 0 } };
4223
+ case "bottom-left":
4224
+ return { frame: { x: margin, y: canvas.height - margin }, anchorPoint: { x: 0, y: 1 } };
4225
+ case "bottom-right":
4226
+ return {
4227
+ frame: { x: canvas.width - margin, y: canvas.height - margin },
4228
+ anchorPoint: { x: 1, y: 1 }
4229
+ };
4230
+ }
4231
+ }
4232
+ function mergeOverlayStyle(style) {
4233
+ return {
4234
+ fontFamily: style?.font_family ?? DEFAULT_OVERLAY_TEXT_STYLE.fontFamily,
4235
+ fontSize: style?.font_size ?? DEFAULT_OVERLAY_TEXT_STYLE.fontSize,
4236
+ fontWeight: style?.font_weight ?? DEFAULT_OVERLAY_TEXT_STYLE.fontWeight,
4237
+ color: style?.color ?? DEFAULT_OVERLAY_TEXT_STYLE.color
4238
+ };
4239
+ }
4240
+ function renderPageNumberFormat(format, currentIndex, totalCount) {
4241
+ return format.replace(
4242
+ /\{(current|total)(?::0(\d+)d)?\}/g,
4243
+ (_, name, padWidth) => {
4244
+ const value = name === "current" ? currentIndex : totalCount;
4245
+ const str = String(value);
4246
+ if (padWidth) {
4247
+ const width = parseInt(padWidth, 10);
4248
+ return str.padStart(width, "0");
4249
+ }
4250
+ return str;
4251
+ }
4252
+ );
4253
+ }
4254
+
4255
+ // src/commands/carousel.ts
4256
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
4257
+ var DEFAULT_CANVAS = 1080;
4258
+ function expandInputs(pattern) {
4259
+ const abs = (0, import_node_path9.resolve)(pattern);
4260
+ let isDir = false;
4261
+ try {
4262
+ isDir = (0, import_node_fs9.statSync)(abs).isDirectory();
4263
+ } catch {
4264
+ isDir = false;
4265
+ }
4266
+ if (isDir) {
4267
+ return listImages(abs);
4268
+ }
4269
+ const dir = (0, import_node_path9.dirname)(abs);
4270
+ const base = (0, import_node_path9.basename)(abs);
4271
+ if (base.includes("*")) {
4272
+ const matcher = globToRegExp(base);
4273
+ let entries;
4274
+ try {
4275
+ entries = (0, import_node_fs9.readdirSync)(dir);
4276
+ } catch {
4277
+ return [];
4278
+ }
4279
+ return entries.filter((name) => matcher.test(name) && isImageFile(name)).map((name) => (0, import_node_path9.join)(dir, name)).sort();
4280
+ }
4281
+ return isImageFile(base) ? [abs] : [];
4282
+ }
4283
+ function listImages(dir) {
4284
+ let entries;
4285
+ try {
4286
+ entries = (0, import_node_fs9.readdirSync)(dir);
4287
+ } catch {
4288
+ return [];
4289
+ }
4290
+ return entries.filter(isImageFile).map((name) => (0, import_node_path9.join)(dir, name)).sort();
4291
+ }
4292
+ function isImageFile(name) {
4293
+ return IMAGE_EXTS.has((0, import_node_path9.extname)(name).toLowerCase());
4294
+ }
4295
+ function globToRegExp(glob) {
4296
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4297
+ const pattern = escaped.replace(/\*/g, `[^${import_node_path9.sep === "\\" ? "\\\\" : import_node_path9.sep}]*`).replace(/\?/g, ".");
4298
+ return new RegExp(`^${pattern}$`);
4299
+ }
4300
+ function composeCarouselFrameDoc(args) {
4301
+ const { imagePath, index, total, width, height, recipe } = args;
4302
+ const canvas = { width, height };
4303
+ const fit = fitImageToCanvas(canvas, { width: 0, height: 0 });
4304
+ const assetId = "carousel-image-asset";
4305
+ const baseDoc = {
4306
+ version: "1.0",
4307
+ name: `carousel-${index}`,
4308
+ canvas: { width, height, fps: 30, background: args.background ?? "#000000" },
4309
+ assets: { [assetId]: { type: "image", src: imagePath } },
4310
+ layers: [
4311
+ {
4312
+ id: "carousel-image",
4313
+ visual: { type: "image", assetId, src: imagePath },
4314
+ frame: fit.frame,
4315
+ bounds: fit.bounds,
4316
+ anchorPoint: { x: 0.5, y: 0.5 },
4317
+ opacity: 1
4318
+ }
4319
+ ],
4320
+ states: { default: { duration: 1, deltas: [] } }
4321
+ };
4322
+ return applyRecipeToOverlay(baseDoc, recipe, { currentIndex: index, totalCount: total });
4323
+ }
4324
+ function carouselFileName(index, total, imagePath) {
4325
+ const padWidth = Math.max(2, String(total).length);
4326
+ const prefix = String(index).padStart(padWidth, "0");
4327
+ const ext = (0, import_node_path9.extname)(imagePath);
4328
+ const stem = (0, import_node_path9.basename)(imagePath, ext);
4329
+ return `${prefix}-${stem}.png`;
4330
+ }
4331
+ function parseDim2(raw, name) {
4332
+ if (raw === void 0) return void 0;
4333
+ const n = parseInt(raw, 10);
4334
+ if (isNaN(n) || n <= 0) {
4335
+ console.error(`Invalid --${name}: ${raw}`);
4336
+ process.exit(1);
4337
+ }
4338
+ return n;
4339
+ }
4340
+ function carouselCommand(program) {
4341
+ program.command("carousel <recipe>").description(
4342
+ "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."
4343
+ ).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) => {
4344
+ let recipe;
4345
+ try {
4346
+ const loaded = loadRecipe(recipeRef);
4347
+ recipe = loaded.recipe;
4348
+ console.log(`Loaded recipe ${loaded.path}`);
4349
+ for (const w of loaded.warnings) console.log(` \u26A0 ${w}`);
4350
+ } catch (err) {
4351
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
4352
+ process.exit(1);
4353
+ return;
4354
+ }
4355
+ const inputs = expandInputs(options.inputs);
4356
+ if (inputs.length === 0) {
4357
+ console.error(
4358
+ `atelier carousel: no image files matched --inputs "${options.inputs}" (accepted: ${[...IMAGE_EXTS].join(", ")})`
4359
+ );
4360
+ process.exit(1);
4361
+ return;
4362
+ }
4363
+ const width = parseDim2(options.width, "width") ?? DEFAULT_CANVAS;
4364
+ const height = parseDim2(options.height, "height") ?? DEFAULT_CANVAS;
4365
+ const frame = parseInt(options.frame, 10);
4366
+ if (isNaN(frame) || frame < 0) {
4367
+ console.error(`Invalid frame number: ${options.frame}`);
4368
+ process.exit(1);
4369
+ return;
4370
+ }
4371
+ const outDir = (0, import_node_path9.resolve)(options.outDir);
4372
+ try {
4373
+ (0, import_node_fs9.mkdirSync)(outDir, { recursive: true });
4374
+ } catch (err) {
4375
+ console.error(`atelier carousel: cannot create out-dir ${outDir}: ${err.message}`);
4376
+ process.exit(1);
4377
+ return;
4378
+ }
4379
+ const total = inputs.length;
4380
+ console.log(`Composing ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
4381
+ try {
4382
+ for (let n = 0; n < total; n++) {
4383
+ const index = n + 1;
4384
+ const imagePath = inputs[n];
4385
+ const doc = composeCarouselFrameDoc({
4386
+ imagePath,
4387
+ index,
4388
+ total,
4389
+ width,
4390
+ height,
4391
+ recipe
4392
+ });
4393
+ const buffer = await renderDocumentToPng(doc, {
4394
+ frame,
4395
+ // Re-fit the image to the canvas using real natural dims once decoded.
4396
+ refitImageBounds: ({ canvas, natural }) => fitImageToCanvas(canvas, natural)
4397
+ });
4398
+ const outName = carouselFileName(index, total, imagePath);
4399
+ (0, import_node_fs9.writeFileSync)((0, import_node_path9.join)(outDir, outName), buffer);
4400
+ console.log(` [${index}/${total}] ${(0, import_node_path9.basename)(imagePath)} \u2192 ${outName}`);
4401
+ }
4402
+ } catch (err) {
4403
+ if (err instanceof CanvasUnavailableError) {
4404
+ console.error(err.message);
4405
+ process.exit(1);
4406
+ return;
4407
+ }
4408
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
4409
+ process.exit(1);
4410
+ return;
4411
+ }
4412
+ console.log(`
4413
+ Done. ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
4414
+ });
4415
+ }
4416
+
4417
+ // src/commands/assets.ts
4418
+ var import_node_fs10 = require("fs");
4419
+ var import_node_path10 = require("path");
3566
4420
  function getAssets(doc) {
3567
4421
  const assets = doc.assets ?? {};
3568
4422
  return Object.entries(assets).map(([assetId, asset]) => {
@@ -3593,11 +4447,11 @@ function formatAssets(assets) {
3593
4447
  }
3594
4448
  return lines.join("\n");
3595
4449
  }
3596
- function readAndParse6(file) {
3597
- const absPath = (0, import_node_path7.resolve)(file);
4450
+ function readAndParse7(file) {
4451
+ const absPath = (0, import_node_path10.resolve)(file);
3598
4452
  let content;
3599
4453
  try {
3600
- content = (0, import_node_fs7.readFileSync)(absPath, "utf-8");
4454
+ content = (0, import_node_fs10.readFileSync)(absPath, "utf-8");
3601
4455
  } catch {
3602
4456
  console.error(`Cannot read file: ${absPath}`);
3603
4457
  return process.exit(1);
@@ -3614,15 +4468,15 @@ function readAndParse6(file) {
3614
4468
  }
3615
4469
  function assetsCommand(program) {
3616
4470
  program.command("assets <file>").description("List all assets in an .atelier file with usage info").action((file) => {
3617
- const doc = readAndParse6(file);
4471
+ const doc = readAndParse7(file);
3618
4472
  const assets = getAssets(doc);
3619
4473
  console.log(formatAssets(assets));
3620
4474
  });
3621
4475
  }
3622
4476
 
3623
4477
  // src/commands/variables.ts
3624
- var import_node_fs8 = require("fs");
3625
- var import_node_path8 = require("path");
4478
+ var import_node_fs11 = require("fs");
4479
+ var import_node_path11 = require("path");
3626
4480
  init_dist2();
3627
4481
  function getVariables(doc) {
3628
4482
  const variables = doc.variables ?? {};
@@ -3657,11 +4511,11 @@ function formatVariables(info) {
3657
4511
  }
3658
4512
  return lines.join("\n");
3659
4513
  }
3660
- function readAndParse7(file) {
3661
- const absPath = (0, import_node_path8.resolve)(file);
4514
+ function readAndParse8(file) {
4515
+ const absPath = (0, import_node_path11.resolve)(file);
3662
4516
  let content;
3663
4517
  try {
3664
- content = (0, import_node_fs8.readFileSync)(absPath, "utf-8");
4518
+ content = (0, import_node_fs11.readFileSync)(absPath, "utf-8");
3665
4519
  } catch {
3666
4520
  console.error(`Cannot read file: ${absPath}`);
3667
4521
  return process.exit(1);
@@ -3678,28 +4532,1369 @@ function readAndParse7(file) {
3678
4532
  }
3679
4533
  function variablesCommand(program) {
3680
4534
  program.command("variables <file>").description("List all variables in an .atelier file with usage info").action((file) => {
3681
- const doc = readAndParse7(file);
4535
+ const doc = readAndParse8(file);
3682
4536
  const info = getVariables(doc);
3683
4537
  console.log(formatVariables(info));
3684
4538
  });
3685
4539
  }
4540
+
4541
+ // src/lib/video-project.ts
4542
+ var import_node_fs12 = require("fs");
4543
+ var import_node_path12 = require("path");
4544
+ var VIDEO_PROJECT_VERSION = "1.0";
4545
+ var VIDEO_CUTLIST_VERSION = "1.1";
4546
+ var VIDEO_TRANSCRIPT_VERSION = "1.1";
4547
+ function effectiveSpan(cut, duration) {
4548
+ return {
4549
+ start: Math.max(0, cut.rawStart - cut.paddingPre),
4550
+ end: Math.min(duration, cut.rawEnd + cut.paddingPost)
4551
+ };
4552
+ }
4553
+ async function createVideoProject(srcPath, destDir) {
4554
+ const absSrc = (0, import_node_path12.resolve)(srcPath);
4555
+ const ext = (0, import_node_path12.extname)(absSrc);
4556
+ const stem = (0, import_node_path12.basename)(absSrc, ext);
4557
+ const projectDir = destDir ? (0, import_node_path12.resolve)(destDir) : (0, import_node_path12.join)((0, import_node_path12.resolve)(absSrc, ".."), stem);
4558
+ if (!(0, import_node_fs12.existsSync)(projectDir)) {
4559
+ (0, import_node_fs12.mkdirSync)(projectDir, { recursive: true });
4560
+ }
4561
+ const sourceFilename = `source${ext}`;
4562
+ const sourcePath = (0, import_node_path12.join)(projectDir, sourceFilename);
4563
+ if (!(0, import_node_fs12.existsSync)(sourcePath)) {
4564
+ (0, import_node_fs12.copyFileSync)(absSrc, sourcePath);
4565
+ }
4566
+ const compositionPath = (0, import_node_path12.join)(projectDir, "project.atelier");
4567
+ const transcriptPath = (0, import_node_path12.join)(projectDir, "transcript.json");
4568
+ const cutsPath = (0, import_node_path12.join)(projectDir, "cuts.json");
4569
+ const exportDir = (0, import_node_path12.join)(projectDir, "export");
4570
+ if (!(0, import_node_fs12.existsSync)(exportDir)) {
4571
+ (0, import_node_fs12.mkdirSync)(exportDir, { recursive: true });
4572
+ }
4573
+ const manifest = {
4574
+ source: sourceFilename,
4575
+ composition: "project.atelier",
4576
+ transcript: "transcript.json",
4577
+ cuts: "cuts.json",
4578
+ exportDir: "export/",
4579
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4580
+ version: VIDEO_PROJECT_VERSION
4581
+ };
4582
+ const draft = {
4583
+ version: "1.0",
4584
+ name: stem,
4585
+ canvas: { width: 1920, height: 1080, fps: 30 },
4586
+ assets: {
4587
+ src: {
4588
+ type: "video",
4589
+ src: sourceFilename,
4590
+ description: `Source: ${(0, import_node_path12.basename)(absSrc)}`
4591
+ }
4592
+ },
4593
+ layers: [
4594
+ {
4595
+ id: "clip-0",
4596
+ visual: {
4597
+ type: "video",
4598
+ assetId: "src",
4599
+ src: sourceFilename,
4600
+ startFrame: 0,
4601
+ sourceOffset: 0,
4602
+ playbackRate: 1,
4603
+ objectFit: "contain"
4604
+ },
4605
+ frame: { x: 0, y: 0 },
4606
+ bounds: { width: 1920, height: 1080 }
4607
+ }
4608
+ ],
4609
+ states: {
4610
+ default: {
4611
+ duration: 0,
4612
+ deltas: []
4613
+ }
4614
+ }
4615
+ };
4616
+ if (!(0, import_node_fs12.existsSync)(compositionPath)) {
4617
+ (0, import_node_fs12.writeFileSync)(compositionPath, JSON.stringify(draft, null, 2), "utf-8");
4618
+ }
4619
+ const initialCuts = {
4620
+ version: VIDEO_CUTLIST_VERSION,
4621
+ source: sourceFilename,
4622
+ cuts: []
4623
+ };
4624
+ if (!(0, import_node_fs12.existsSync)(cutsPath)) {
4625
+ (0, import_node_fs12.writeFileSync)(cutsPath, JSON.stringify(initialCuts, null, 2), "utf-8");
4626
+ }
4627
+ return {
4628
+ dir: projectDir,
4629
+ sourcePath,
4630
+ compositionPath,
4631
+ transcriptPath,
4632
+ cutsPath,
4633
+ exportDir,
4634
+ manifest
4635
+ };
4636
+ }
4637
+ function loadVideoProject(dir) {
4638
+ const projectDir = (0, import_node_path12.resolve)(dir);
4639
+ const possibleExts = [".mp4", ".mov", ".webm", ".mkv", ".avi"];
4640
+ let sourceFilename = "source.mp4";
4641
+ for (const ext of possibleExts) {
4642
+ if ((0, import_node_fs12.existsSync)((0, import_node_path12.join)(projectDir, `source${ext}`))) {
4643
+ sourceFilename = `source${ext}`;
4644
+ break;
4645
+ }
4646
+ }
4647
+ const manifest = {
4648
+ source: sourceFilename,
4649
+ composition: "project.atelier",
4650
+ transcript: "transcript.json",
4651
+ cuts: "cuts.json",
4652
+ exportDir: "export/",
4653
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4654
+ version: VIDEO_PROJECT_VERSION
4655
+ };
4656
+ return {
4657
+ dir: projectDir,
4658
+ sourcePath: (0, import_node_path12.join)(projectDir, sourceFilename),
4659
+ compositionPath: (0, import_node_path12.join)(projectDir, "project.atelier"),
4660
+ transcriptPath: (0, import_node_path12.join)(projectDir, "transcript.json"),
4661
+ cutsPath: (0, import_node_path12.join)(projectDir, "cuts.json"),
4662
+ exportDir: (0, import_node_path12.join)(projectDir, "export"),
4663
+ manifest
4664
+ };
4665
+ }
4666
+ function readCutList(project) {
4667
+ if (!(0, import_node_fs12.existsSync)(project.cutsPath)) {
4668
+ return { version: VIDEO_CUTLIST_VERSION, source: project.manifest.source, cuts: [] };
4669
+ }
4670
+ const raw = JSON.parse((0, import_node_fs12.readFileSync)(project.cutsPath, "utf-8"));
4671
+ const cuts = raw.cuts.map((entry) => {
4672
+ if ("rawStart" in entry) return entry;
4673
+ return {
4674
+ rawStart: entry.start,
4675
+ rawEnd: entry.end,
4676
+ paddingPre: 0,
4677
+ paddingPost: 0,
4678
+ ...entry.label !== void 0 && { label: entry.label }
4679
+ };
4680
+ });
4681
+ return {
4682
+ version: VIDEO_CUTLIST_VERSION,
4683
+ source: raw.source,
4684
+ cuts
4685
+ };
4686
+ }
4687
+ function writeCutList(project, cuts) {
4688
+ const payload = { ...cuts, version: VIDEO_CUTLIST_VERSION };
4689
+ (0, import_node_fs12.writeFileSync)(project.cutsPath, JSON.stringify(payload, null, 2), "utf-8");
4690
+ }
4691
+ function readTranscript(project) {
4692
+ if (!(0, import_node_fs12.existsSync)(project.transcriptPath)) return null;
4693
+ const raw = JSON.parse((0, import_node_fs12.readFileSync)(project.transcriptPath, "utf-8"));
4694
+ const segments = raw.segments.map((seg) => ({
4695
+ text: seg.text,
4696
+ start: seg.start,
4697
+ end: seg.end,
4698
+ words: seg.words.map((w) => {
4699
+ if ("detected" in w) return w;
4700
+ return {
4701
+ detected: w.word,
4702
+ text: w.word,
4703
+ start: w.start,
4704
+ end: w.end,
4705
+ ...w.confidence !== void 0 && { confidence: w.confidence }
4706
+ };
4707
+ })
4708
+ }));
4709
+ return {
4710
+ version: VIDEO_TRANSCRIPT_VERSION,
4711
+ ...raw.language !== void 0 && { language: raw.language },
4712
+ segments
4713
+ };
4714
+ }
4715
+ function writeTranscript(project, transcript) {
4716
+ const payload = { ...transcript, version: VIDEO_TRANSCRIPT_VERSION };
4717
+ (0, import_node_fs12.writeFileSync)(project.transcriptPath, JSON.stringify(payload, null, 2), "utf-8");
4718
+ }
4719
+ function readComposition(project) {
4720
+ return JSON.parse((0, import_node_fs12.readFileSync)(project.compositionPath, "utf-8"));
4721
+ }
4722
+ function writeComposition(project, doc) {
4723
+ (0, import_node_fs12.writeFileSync)(project.compositionPath, JSON.stringify(doc, null, 2), "utf-8");
4724
+ }
4725
+ function rewriteCutLayers(doc, cuts, sourceFilename, sourceDuration, assetId = "src") {
4726
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("silence-trim"));
4727
+ const fps = doc.canvas.fps;
4728
+ let cumulativeFrame = 0;
4729
+ const trimLayers = cuts.map((cut, idx) => {
4730
+ const span = effectiveSpan(cut, sourceDuration);
4731
+ const sourceOffsetFrames = Math.floor(span.start * fps) / fps;
4732
+ const sourceEndFrames = Math.ceil(span.end * fps) / fps;
4733
+ const durationFrames = Math.max(1, Math.round((sourceEndFrames - sourceOffsetFrames) * fps));
4734
+ const layer = {
4735
+ id: `clip-trim-${idx}`,
4736
+ tags: ["silence-trim"],
4737
+ visual: {
4738
+ type: "video",
4739
+ assetId,
4740
+ src: sourceFilename,
4741
+ startFrame: cumulativeFrame,
4742
+ sourceOffset: sourceOffsetFrames,
4743
+ sourceEnd: sourceEndFrames,
4744
+ playbackRate: 1,
4745
+ objectFit: "contain"
4746
+ },
4747
+ frame: { x: 0, y: 0 },
4748
+ bounds: { width: doc.canvas.width, height: doc.canvas.height }
4749
+ };
4750
+ cumulativeFrame += durationFrames;
4751
+ return layer;
4752
+ });
4753
+ return { ...doc, layers: [...preserved, ...trimLayers] };
4754
+ }
4755
+
4756
+ // src/lib/silence-detect.ts
4757
+ var import_node_child_process2 = require("child_process");
4758
+ async function probeSilencedetect() {
4759
+ const stdout = await runCapture("ffmpeg", ["-hide_banner", "-filters"]);
4760
+ if (!/\bsilencedetect\b/.test(stdout)) {
4761
+ throw new Error(
4762
+ "Your ffmpeg build lacks the silencedetect filter. Install a standard ffmpeg \u2265 4.0 (brew install ffmpeg / apt install ffmpeg)."
4763
+ );
4764
+ }
4765
+ }
4766
+ async function probeDuration(sourcePath) {
4767
+ const stdout = await runCapture("ffprobe", [
4768
+ "-v",
4769
+ "error",
4770
+ "-show_entries",
4771
+ "format=duration",
4772
+ "-of",
4773
+ "csv=p=0",
4774
+ sourcePath
4775
+ ]);
4776
+ const n = parseFloat(stdout.trim());
4777
+ if (!Number.isFinite(n) || n <= 0) {
4778
+ throw new Error(`ffprobe returned invalid duration for ${sourcePath}: "${stdout}"`);
4779
+ }
4780
+ return n;
4781
+ }
4782
+ async function runSilenceDetect(sourcePath, options = {}) {
4783
+ const noise = options.noise ?? "-30dB";
4784
+ const minSilence = options.minSilence ?? 0.35;
4785
+ const filter = `silencedetect=noise=${noise}:d=${minSilence}`;
4786
+ const stderr = await runCaptureStderr("ffmpeg", [
4787
+ "-hide_banner",
4788
+ "-nostats",
4789
+ "-i",
4790
+ sourcePath,
4791
+ "-af",
4792
+ filter,
4793
+ "-f",
4794
+ "null",
4795
+ "-"
4796
+ ]);
4797
+ return parseSilenceDetectStderr(stderr);
4798
+ }
4799
+ function parseSilenceDetectStderr(stderr) {
4800
+ const intervals = [];
4801
+ const startRe = /silence_start:\s*(-?[\d.]+)/;
4802
+ const endRe = /silence_end:\s*(-?[\d.]+)/;
4803
+ let pendingStart = null;
4804
+ for (const line of stderr.split(/\r?\n/)) {
4805
+ const sm = line.match(startRe);
4806
+ if (sm) {
4807
+ pendingStart = parseFloat(sm[1]);
4808
+ continue;
4809
+ }
4810
+ const em = line.match(endRe);
4811
+ if (em && pendingStart !== null) {
4812
+ const end = parseFloat(em[1]);
4813
+ intervals.push({ start: Math.max(0, pendingStart), end });
4814
+ pendingStart = null;
4815
+ }
4816
+ }
4817
+ return intervals;
4818
+ }
4819
+ function runCapture(cmd, args) {
4820
+ return new Promise((resolve14, reject) => {
4821
+ const proc = (0, import_node_child_process2.spawn)(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
4822
+ let stdout = "";
4823
+ proc.stdout.on("data", (b) => stdout += b.toString());
4824
+ proc.on("error", (err) => {
4825
+ const e = err;
4826
+ if (e.code === "ENOENT") {
4827
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg/ffprobe (brew install ffmpeg).`));
4828
+ } else {
4829
+ reject(err);
4830
+ }
4831
+ });
4832
+ proc.on("close", (code) => {
4833
+ if (code !== 0) reject(new Error(`${cmd} exited ${code}`));
4834
+ else resolve14(stdout);
4835
+ });
4836
+ });
4837
+ }
4838
+ function runCaptureStderr(cmd, args) {
4839
+ return new Promise((resolve14, reject) => {
4840
+ const proc = (0, import_node_child_process2.spawn)(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
4841
+ let stderr = "";
4842
+ proc.stderr.on("data", (b) => stderr += b.toString());
4843
+ proc.on("error", (err) => {
4844
+ const e = err;
4845
+ if (e.code === "ENOENT") {
4846
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg (brew install ffmpeg).`));
4847
+ } else {
4848
+ reject(err);
4849
+ }
4850
+ });
4851
+ proc.on("close", () => resolve14(stderr));
4852
+ });
4853
+ }
4854
+
4855
+ // src/lib/cut-model.ts
4856
+ var DEFAULT_PADDING_PRE = 0.08;
4857
+ var DEFAULT_PADDING_POST = 0.12;
4858
+ var DEFAULT_MATCH_TOLERANCE = 0.5;
4859
+ function invertToSpeechIntervals(silences, duration) {
4860
+ if (silences.length === 0) {
4861
+ return duration > 0 ? [{ start: 0, end: duration }] : [];
4862
+ }
4863
+ const sorted = [...silences].sort((a, b) => a.start - b.start);
4864
+ const speech = [];
4865
+ if (sorted[0].start > 0) {
4866
+ speech.push({ start: 0, end: sorted[0].start });
4867
+ }
4868
+ for (let i = 0; i < sorted.length - 1; i++) {
4869
+ const gapStart = sorted[i].end;
4870
+ const gapEnd = sorted[i + 1].start;
4871
+ if (gapEnd > gapStart) {
4872
+ speech.push({ start: gapStart, end: gapEnd });
4873
+ }
4874
+ }
4875
+ const last = sorted[sorted.length - 1];
4876
+ if (last.end < duration) {
4877
+ speech.push({ start: last.end, end: duration });
4878
+ }
4879
+ return speech;
4880
+ }
4881
+ function buildInitialCuts(speech, paddingPre = DEFAULT_PADDING_PRE, paddingPost = DEFAULT_PADDING_POST) {
4882
+ return speech.map((s) => ({
4883
+ rawStart: s.start,
4884
+ rawEnd: s.end,
4885
+ paddingPre,
4886
+ paddingPost
4887
+ }));
4888
+ }
4889
+ function resolveOverlaps(cuts) {
4890
+ for (let i = 0; i < cuts.length - 1; i++) {
4891
+ const a = cuts[i];
4892
+ const b = cuts[i + 1];
4893
+ const aEnd = a.rawEnd + a.paddingPost;
4894
+ const bStart = b.rawStart - b.paddingPre;
4895
+ if (aEnd > bStart) {
4896
+ const mid = (a.rawEnd + b.rawStart) / 2;
4897
+ a.paddingPost = Math.max(0, mid - a.rawEnd);
4898
+ b.paddingPre = Math.max(0, b.rawStart - mid);
4899
+ }
4900
+ }
4901
+ }
4902
+ function clampBoundaries(cuts, duration) {
4903
+ if (cuts.length === 0) return;
4904
+ const first = cuts[0];
4905
+ if (first.rawStart - first.paddingPre < 0) {
4906
+ first.paddingPre = first.rawStart;
4907
+ }
4908
+ const last = cuts[cuts.length - 1];
4909
+ if (last.rawEnd + last.paddingPost > duration) {
4910
+ last.paddingPost = Math.max(0, duration - last.rawEnd);
4911
+ }
4912
+ }
4913
+ function mergeWithExisting(fresh, existing, tolerance = DEFAULT_MATCH_TOLERANCE) {
4914
+ return fresh.map((f) => {
4915
+ const match = existing.find(
4916
+ (e) => Math.abs(e.rawStart - f.rawStart) < tolerance && Math.abs(e.rawEnd - f.rawEnd) < tolerance
4917
+ );
4918
+ if (!match) return f;
4919
+ return {
4920
+ rawStart: f.rawStart,
4921
+ rawEnd: f.rawEnd,
4922
+ paddingPre: match.paddingPre,
4923
+ paddingPost: match.paddingPost,
4924
+ ...match.label !== void 0 && { label: match.label }
4925
+ };
4926
+ });
4927
+ }
4928
+ function applyGlobalPadding(cuts, deltaSeconds) {
4929
+ for (const cut of cuts) {
4930
+ cut.paddingPre = Math.max(0, cut.paddingPre + deltaSeconds);
4931
+ cut.paddingPost = Math.max(0, cut.paddingPost + deltaSeconds);
4932
+ }
4933
+ }
4934
+
4935
+ // src/commands/trim.ts
4936
+ async function trimProject(projectDir, options = {}) {
4937
+ const project = loadVideoProject(projectDir);
4938
+ await probeSilencedetect();
4939
+ const duration = await probeDuration(project.sourcePath);
4940
+ const silences = await runSilenceDetect(project.sourcePath, {
4941
+ noise: options.noise,
4942
+ minSilence: options.minSilence
4943
+ });
4944
+ const speech = invertToSpeechIntervals(silences, duration);
4945
+ const padPre = options.padPre ?? DEFAULT_PADDING_PRE;
4946
+ const padPost = options.padPost ?? DEFAULT_PADDING_POST;
4947
+ let cuts = buildInitialCuts(speech, padPre, padPost);
4948
+ if (!options.reset) {
4949
+ const existing = readCutList(project);
4950
+ cuts = mergeWithExisting(cuts, existing.cuts, options.matchTolerance);
4951
+ }
4952
+ if (typeof options.tightenMs === "number") {
4953
+ applyGlobalPadding(cuts, -options.tightenMs / 1e3);
4954
+ }
4955
+ if (typeof options.loosenMs === "number") {
4956
+ applyGlobalPadding(cuts, options.loosenMs / 1e3);
4957
+ }
4958
+ if (typeof options.cutIndex === "number") {
4959
+ if (options.cutIndex < 0 || options.cutIndex >= cuts.length) {
4960
+ throw new Error(
4961
+ `--cut ${options.cutIndex} out of range (have ${cuts.length} cuts)`
4962
+ );
4963
+ }
4964
+ if (options.padPre !== void 0) cuts[options.cutIndex].paddingPre = options.padPre;
4965
+ if (options.padPost !== void 0) cuts[options.cutIndex].paddingPost = options.padPost;
4966
+ }
4967
+ resolveOverlaps(cuts);
4968
+ clampBoundaries(cuts, duration);
4969
+ const result = {
4970
+ projectDir: project.dir,
4971
+ duration,
4972
+ cuts,
4973
+ layerCount: cuts.length
4974
+ };
4975
+ if (options.dryRun) return result;
4976
+ writeCutList(project, {
4977
+ version: "1.1",
4978
+ source: project.manifest.source,
4979
+ cuts
4980
+ });
4981
+ const doc = readComposition(project);
4982
+ const updated = rewriteCutLayers(doc, cuts, project.manifest.source, duration);
4983
+ writeComposition(project, updated);
4984
+ return result;
4985
+ }
4986
+ function formatResult(result) {
4987
+ const lines = [];
4988
+ lines.push(`Trimmed ${result.projectDir}`);
4989
+ lines.push(` source duration: ${result.duration.toFixed(2)}s`);
4990
+ lines.push(` cuts: ${result.cuts.length}`);
4991
+ for (let i = 0; i < result.cuts.length; i++) {
4992
+ const c = result.cuts[i];
4993
+ const effStart = Math.max(0, c.rawStart - c.paddingPre);
4994
+ const effEnd = Math.min(result.duration, c.rawEnd + c.paddingPost);
4995
+ const dur = effEnd - effStart;
4996
+ lines.push(
4997
+ ` [${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}"` : "")
4998
+ );
4999
+ }
5000
+ return lines.join("\n");
5001
+ }
5002
+ function trimCommand(program) {
5003
+ program.command("trim <project>").description(
5004
+ "Detect silence and rewrite project.atelier with parametric cuts (silence-trim tagged layers). Preserves user padding overrides on re-run."
5005
+ ).option("--noise <dB>", "Silence threshold (default: -30dB)", "-30dB").option(
5006
+ "--min-silence <seconds>",
5007
+ "Minimum silence duration to register (default: 0.35)",
5008
+ (v) => parseFloat(v),
5009
+ 0.35
5010
+ ).option(
5011
+ "--pad-pre <seconds>",
5012
+ `Default leading padding (default: ${DEFAULT_PADDING_PRE})`,
5013
+ (v) => parseFloat(v)
5014
+ ).option(
5015
+ "--pad-post <seconds>",
5016
+ `Default trailing padding (default: ${DEFAULT_PADDING_POST})`,
5017
+ (v) => parseFloat(v)
5018
+ ).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(
5019
+ "--cut <index>",
5020
+ "Apply --pad-pre / --pad-post to one specific cut only",
5021
+ (v) => parseInt(v, 10)
5022
+ ).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) => {
5023
+ try {
5024
+ let trimOpts = {
5025
+ noise: opts.noise,
5026
+ minSilence: opts.minSilence,
5027
+ padPre: opts.padPre,
5028
+ padPost: opts.padPost,
5029
+ tightenMs: opts.tighten,
5030
+ loosenMs: opts.loosen,
5031
+ cutIndex: opts.cut,
5032
+ reset: opts.reset,
5033
+ dryRun: opts.dryRun
5034
+ };
5035
+ if (opts.recipe) {
5036
+ const { recipe } = loadRecipe(opts.recipe, project);
5037
+ trimOpts = applyRecipeToTrimOptions(recipe, trimOpts);
5038
+ }
5039
+ const result = await trimProject(project, trimOpts);
5040
+ if (opts.json) {
5041
+ console.log(JSON.stringify(result, null, 2));
5042
+ } else {
5043
+ console.log(formatResult(result));
5044
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
5045
+ }
5046
+ } catch (err) {
5047
+ const msg = err instanceof Error ? err.message : String(err);
5048
+ console.error(`atelier trim: ${msg}`);
5049
+ process.exit(1);
5050
+ }
5051
+ });
5052
+ }
5053
+
5054
+ // src/lib/whisper.ts
5055
+ var import_node_child_process3 = require("child_process");
5056
+ var import_node_fs13 = require("fs");
5057
+ async function probeWhisper() {
5058
+ if (await commandExists("whisper-cli")) return "whisper-cpp";
5059
+ if (process.env.OPENAI_API_KEY) return "openai-api";
5060
+ return "none";
5061
+ }
5062
+ async function runWhisperCpp(sourcePath, options = {}) {
5063
+ const model = options.modelPath ?? options.model ?? "base.en";
5064
+ const args = [
5065
+ sourcePath,
5066
+ "--model",
5067
+ model,
5068
+ "--output-json",
5069
+ "--word-thold",
5070
+ "0.01",
5071
+ // emit word-level timestamps
5072
+ "--print-progress",
5073
+ "false"
5074
+ ];
5075
+ if (options.language) {
5076
+ args.push("--language", options.language);
5077
+ }
5078
+ return runCaptureStdout("whisper-cli", args);
5079
+ }
5080
+ function parseWhisperCppJson(jsonStr) {
5081
+ const raw = JSON.parse(jsonStr);
5082
+ const segments = (raw.transcription ?? []).map((seg) => {
5083
+ const segStart = (seg.offsets?.from ?? 0) / 1e3;
5084
+ const segEnd = (seg.offsets?.to ?? 0) / 1e3;
5085
+ const segText = seg.text.trim();
5086
+ let words;
5087
+ if (seg.tokens && seg.tokens.length > 0) {
5088
+ words = seg.tokens.filter((t) => t.text.trim().length > 0 && !t.text.startsWith("[_")).map((t) => ({
5089
+ detected: t.text.trim(),
5090
+ text: t.text.trim(),
5091
+ start: (t.offsets?.from ?? segStart * 1e3) / 1e3,
5092
+ end: (t.offsets?.to ?? segEnd * 1e3) / 1e3,
5093
+ ...t.p !== void 0 && { confidence: t.p }
5094
+ }));
5095
+ } else {
5096
+ const tokens = segText.split(/\s+/).filter((t) => t.length > 0);
5097
+ const span = segEnd - segStart;
5098
+ const per = tokens.length > 0 ? span / tokens.length : 0;
5099
+ words = tokens.map((tok, i) => ({
5100
+ detected: tok,
5101
+ text: tok,
5102
+ start: segStart + i * per,
5103
+ end: segStart + (i + 1) * per
5104
+ }));
5105
+ }
5106
+ return {
5107
+ text: segText,
5108
+ start: segStart,
5109
+ end: segEnd,
5110
+ words
5111
+ };
5112
+ });
5113
+ return {
5114
+ version: "1.1",
5115
+ ...raw.result?.language !== void 0 && { language: raw.result.language },
5116
+ segments
5117
+ };
5118
+ }
5119
+ async function commandExists(name) {
5120
+ if (name.startsWith("/") || name.match(/^[A-Z]:\\/)) {
5121
+ return (0, import_node_fs13.existsSync)(name);
5122
+ }
5123
+ return new Promise((resolve14) => {
5124
+ const probe = (0, import_node_child_process3.spawn)(process.platform === "win32" ? "where" : "which", [name], {
5125
+ stdio: ["ignore", "ignore", "ignore"]
5126
+ });
5127
+ probe.on("error", () => resolve14(false));
5128
+ probe.on("close", (code) => resolve14(code === 0));
5129
+ });
5130
+ }
5131
+ function runCaptureStdout(cmd, args) {
5132
+ return new Promise((resolve14, reject) => {
5133
+ const proc = (0, import_node_child_process3.spawn)(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5134
+ let stdout = "";
5135
+ let stderr = "";
5136
+ proc.stdout.on("data", (b) => stdout += b.toString());
5137
+ proc.stderr.on("data", (b) => stderr += b.toString());
5138
+ proc.on("error", (err) => {
5139
+ const e = err;
5140
+ if (e.code === "ENOENT") {
5141
+ reject(new Error(
5142
+ `${cmd} not found on PATH. Install whisper.cpp (brew install whisper-cpp) or run \`pnpm add @xenova/transformers\` for the ONNX fallback.`
5143
+ ));
5144
+ } else {
5145
+ reject(err);
5146
+ }
5147
+ });
5148
+ proc.on("close", (code) => {
5149
+ if (code !== 0) {
5150
+ reject(new Error(`${cmd} exited ${code}
5151
+ ${stderr}`));
5152
+ } else {
5153
+ resolve14(stdout);
5154
+ }
5155
+ });
5156
+ });
5157
+ }
5158
+
5159
+ // src/lib/transcript-model.ts
5160
+ var DEFAULT_TRANSCRIPT_MATCH_TOLERANCE = 0.3;
5161
+ var DEFAULT_PHRASE_MAX_WORDS = 5;
5162
+ var DEFAULT_PHRASE_PAUSE_GAP_SECONDS = 0.4;
5163
+ var END_PUNCT = /[.!?,;:]\s*$/;
5164
+ function flattenWords(transcript) {
5165
+ return transcript.segments.flatMap((s) => s.words);
5166
+ }
5167
+ function groupIntoPhrases(transcript, options = {}) {
5168
+ const maxWords = options.maxWords ?? DEFAULT_PHRASE_MAX_WORDS;
5169
+ const pauseGap = options.pauseGap ?? DEFAULT_PHRASE_PAUSE_GAP_SECONDS;
5170
+ const phrases = [];
5171
+ const visibleWords = flattenWords(transcript).filter((w) => !w.hidden);
5172
+ let current = [];
5173
+ for (let i = 0; i < visibleWords.length; i++) {
5174
+ const w = visibleWords[i];
5175
+ current.push(w);
5176
+ const next = visibleWords[i + 1];
5177
+ const gap = next ? next.start - w.end : 0;
5178
+ const endsOnPunct = END_PUNCT.test(w.text);
5179
+ const atMax = current.length >= maxWords;
5180
+ if (!next || atMax || endsOnPunct || gap > pauseGap) {
5181
+ phrases.push({
5182
+ start: current[0].start,
5183
+ end: current[current.length - 1].end,
5184
+ text: current.map((c) => c.text).join(" "),
5185
+ words: current
5186
+ });
5187
+ current = [];
5188
+ }
5189
+ }
5190
+ return phrases;
5191
+ }
5192
+ function mergeTranscriptWithExisting(fresh, existing, tolerance = DEFAULT_TRANSCRIPT_MATCH_TOLERANCE) {
5193
+ const existingWords = flattenWords(existing);
5194
+ const merged = {
5195
+ version: "1.1",
5196
+ ...fresh.language !== void 0 && { language: fresh.language },
5197
+ segments: fresh.segments.map((seg) => ({
5198
+ text: seg.text,
5199
+ start: seg.start,
5200
+ end: seg.end,
5201
+ words: seg.words.map((freshWord) => {
5202
+ const match = existingWords.find(
5203
+ (e) => !e.userAdded && Math.abs(e.start - freshWord.start) < tolerance && e.detected === freshWord.detected
5204
+ );
5205
+ if (!match) return freshWord;
5206
+ return {
5207
+ detected: freshWord.detected,
5208
+ text: match.text,
5209
+ start: freshWord.start,
5210
+ end: freshWord.end,
5211
+ ...freshWord.confidence !== void 0 && { confidence: freshWord.confidence },
5212
+ ...match.userEdited && { userEdited: true },
5213
+ ...match.hidden && { hidden: true }
5214
+ };
5215
+ })
5216
+ }))
5217
+ };
5218
+ const orphans = existingWords.filter((w) => w.userAdded);
5219
+ for (const orphan of orphans) {
5220
+ const segIdx = merged.segments.findIndex(
5221
+ (s) => orphan.start >= s.start && orphan.start <= s.end
5222
+ );
5223
+ const targetSeg = segIdx >= 0 ? merged.segments[segIdx] : merged.segments[merged.segments.length - 1];
5224
+ if (!targetSeg) continue;
5225
+ const insertIdx = targetSeg.words.findIndex((w) => w.start > orphan.start);
5226
+ if (insertIdx === -1) targetSeg.words.push(orphan);
5227
+ else targetSeg.words.splice(insertIdx, 0, orphan);
5228
+ }
5229
+ return merged;
5230
+ }
5231
+ function applyTextEdit(transcript, wordIndex, newText) {
5232
+ return mapWord(transcript, wordIndex, (w) => ({
5233
+ ...w,
5234
+ text: newText,
5235
+ userEdited: true
5236
+ }));
5237
+ }
5238
+ function applyBatchReplace(transcript, find, replace) {
5239
+ return {
5240
+ ...transcript,
5241
+ segments: transcript.segments.map((seg) => ({
5242
+ ...seg,
5243
+ words: seg.words.map(
5244
+ (w) => w.detected === find ? { ...w, text: replace, userEdited: true } : w
5245
+ )
5246
+ }))
5247
+ };
5248
+ }
5249
+ function applyHide(transcript, wordIndex) {
5250
+ return mapWord(transcript, wordIndex, (w) => ({ ...w, hidden: true }));
5251
+ }
5252
+ function applyAdd(transcript, afterIndex, text, duration = 0.15) {
5253
+ const flat = flattenWords(transcript);
5254
+ if (afterIndex < 0 || afterIndex >= flat.length) {
5255
+ throw new Error(`afterIndex ${afterIndex} out of range (have ${flat.length} words)`);
5256
+ }
5257
+ const anchor = flat[afterIndex];
5258
+ const newWord = {
5259
+ detected: text,
5260
+ text,
5261
+ start: anchor.end,
5262
+ end: anchor.end + duration,
5263
+ userAdded: true
5264
+ };
5265
+ let cursor = 0;
5266
+ return {
5267
+ ...transcript,
5268
+ segments: transcript.segments.map((seg) => {
5269
+ const segStart = cursor;
5270
+ cursor += seg.words.length;
5271
+ if (afterIndex < segStart || afterIndex >= cursor) return seg;
5272
+ const localIdx = afterIndex - segStart;
5273
+ return {
5274
+ ...seg,
5275
+ words: [...seg.words.slice(0, localIdx + 1), newWord, ...seg.words.slice(localIdx + 1)]
5276
+ };
5277
+ })
5278
+ };
5279
+ }
5280
+ function applyMerge(transcript, firstIndex) {
5281
+ const flat = flattenWords(transcript);
5282
+ if (firstIndex < 0 || firstIndex >= flat.length - 1) {
5283
+ throw new Error(`firstIndex ${firstIndex} out of range for merge`);
5284
+ }
5285
+ const a = flat[firstIndex];
5286
+ const b = flat[firstIndex + 1];
5287
+ const mergedWord = {
5288
+ detected: a.detected + b.detected,
5289
+ text: a.text + b.text,
5290
+ start: a.start,
5291
+ end: b.end,
5292
+ userEdited: true
5293
+ };
5294
+ let cursor = 0;
5295
+ return {
5296
+ ...transcript,
5297
+ segments: transcript.segments.map((seg) => {
5298
+ const segStart = cursor;
5299
+ cursor += seg.words.length;
5300
+ if (firstIndex < segStart || firstIndex >= cursor - 1) return seg;
5301
+ const localIdx = firstIndex - segStart;
5302
+ return {
5303
+ ...seg,
5304
+ words: [...seg.words.slice(0, localIdx), mergedWord, ...seg.words.slice(localIdx + 2)]
5305
+ };
5306
+ })
5307
+ };
5308
+ }
5309
+ function applySplit(transcript, wordIndex, fraction, firstText, secondText) {
5310
+ if (fraction <= 0 || fraction >= 1) {
5311
+ throw new Error(`split fraction must be in (0, 1), got ${fraction}`);
5312
+ }
5313
+ const flat = flattenWords(transcript);
5314
+ if (wordIndex < 0 || wordIndex >= flat.length) {
5315
+ throw new Error(`wordIndex ${wordIndex} out of range`);
5316
+ }
5317
+ const w = flat[wordIndex];
5318
+ const splitTime = w.start + (w.end - w.start) * fraction;
5319
+ const cutChar = Math.max(1, Math.floor(w.text.length * fraction));
5320
+ const first = {
5321
+ detected: w.detected,
5322
+ text: firstText ?? w.text.slice(0, cutChar),
5323
+ start: w.start,
5324
+ end: splitTime,
5325
+ userEdited: true
5326
+ };
5327
+ const second = {
5328
+ detected: w.detected,
5329
+ text: secondText ?? w.text.slice(cutChar),
5330
+ start: splitTime,
5331
+ end: w.end,
5332
+ userEdited: true
5333
+ };
5334
+ let cursor = 0;
5335
+ return {
5336
+ ...transcript,
5337
+ segments: transcript.segments.map((seg) => {
5338
+ const segStart = cursor;
5339
+ cursor += seg.words.length;
5340
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
5341
+ const localIdx = wordIndex - segStart;
5342
+ return {
5343
+ ...seg,
5344
+ words: [...seg.words.slice(0, localIdx), first, second, ...seg.words.slice(localIdx + 1)]
5345
+ };
5346
+ })
5347
+ };
5348
+ }
5349
+ function mapWord(transcript, wordIndex, fn) {
5350
+ const flat = flattenWords(transcript);
5351
+ if (wordIndex < 0 || wordIndex >= flat.length) {
5352
+ throw new Error(`wordIndex ${wordIndex} out of range (have ${flat.length} words)`);
5353
+ }
5354
+ let cursor = 0;
5355
+ return {
5356
+ ...transcript,
5357
+ segments: transcript.segments.map((seg) => {
5358
+ const segStart = cursor;
5359
+ cursor += seg.words.length;
5360
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
5361
+ const localIdx = wordIndex - segStart;
5362
+ return {
5363
+ ...seg,
5364
+ words: seg.words.map((w, i) => i === localIdx ? fn(w) : w)
5365
+ };
5366
+ })
5367
+ };
5368
+ }
5369
+
5370
+ // src/lib/caption-builder.ts
5371
+ var DEFAULT_STYLE = {
5372
+ fontFamily: "Inter",
5373
+ fontSize: 84,
5374
+ fontWeight: "bold",
5375
+ textAlign: "center",
5376
+ color: "#FFFFFF",
5377
+ yRatio: 0.85,
5378
+ widthRatio: 0.9,
5379
+ fadeSeconds: 0.05
5380
+ };
5381
+ function buildCaptionLayers(transcript, canvas, options = {}) {
5382
+ const style = { ...DEFAULT_STYLE, ...options.style };
5383
+ const phrases = groupIntoPhrases(transcript, {
5384
+ maxWords: options.maxWords,
5385
+ pauseGap: options.pauseGap
5386
+ });
5387
+ const layers = [];
5388
+ const deltas = [];
5389
+ const fps = canvas.fps;
5390
+ phrases.forEach((phrase, idx) => {
5391
+ const layerId = `caption-${idx}`;
5392
+ layers.push(buildPhraseLayer(layerId, phrase, canvas, style));
5393
+ deltas.push(...buildPhraseDeltas(layerId, phrase, style.fadeSeconds, fps));
5394
+ });
5395
+ return { layers, deltas };
5396
+ }
5397
+ function rewriteCaptionLayers(doc, transcript, options = {}) {
5398
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("caption"));
5399
+ const { layers: captionLayers, deltas: captionDeltas } = buildCaptionLayers(
5400
+ transcript,
5401
+ doc.canvas,
5402
+ options
5403
+ );
5404
+ const captionLayerIds = new Set(captionLayers.map((l) => l.id));
5405
+ const stateNames = Object.keys(doc.states);
5406
+ const targetStateName = stateNames.includes("default") ? "default" : stateNames[0];
5407
+ const states = { ...doc.states };
5408
+ if (targetStateName && states[targetStateName]) {
5409
+ const existing = states[targetStateName];
5410
+ const preservedDeltas = existing.deltas.filter(
5411
+ (d) => !captionLayerIds.has(d.layer)
5412
+ );
5413
+ states[targetStateName] = {
5414
+ ...existing,
5415
+ deltas: [...preservedDeltas, ...captionDeltas]
5416
+ };
5417
+ }
5418
+ return {
5419
+ ...doc,
5420
+ layers: [...preserved, ...captionLayers],
5421
+ states
5422
+ };
5423
+ }
5424
+ function buildPhraseLayer(id, phrase, canvas, style) {
5425
+ return {
5426
+ id,
5427
+ tags: ["caption"],
5428
+ visual: {
5429
+ type: "text",
5430
+ content: phrase.text,
5431
+ style: {
5432
+ fontFamily: style.fontFamily,
5433
+ fontSize: style.fontSize,
5434
+ fontWeight: style.fontWeight,
5435
+ textAlign: style.textAlign,
5436
+ color: style.color
5437
+ }
5438
+ },
5439
+ frame: {
5440
+ x: canvas.width / 2,
5441
+ y: canvas.height * style.yRatio
5442
+ },
5443
+ bounds: {
5444
+ width: canvas.width * style.widthRatio,
5445
+ height: Math.max(120, style.fontSize * 1.6)
5446
+ },
5447
+ anchorPoint: { x: 0.5, y: 0.5 },
5448
+ opacity: 0
5449
+ };
5450
+ }
5451
+ function buildPhraseDeltas(layerId, phrase, fadeSeconds, fps) {
5452
+ const fadeFrames = Math.max(1, Math.round(fadeSeconds * fps));
5453
+ const startFrame = Math.floor(phrase.start * fps);
5454
+ const endFrame = Math.ceil(phrase.end * fps);
5455
+ const fadeOutStart = Math.max(startFrame + 1, endFrame - fadeFrames);
5456
+ return [
5457
+ // Fade in to visible at phrase start
5458
+ {
5459
+ layer: layerId,
5460
+ property: "opacity",
5461
+ range: [Math.max(0, startFrame - fadeFrames), startFrame],
5462
+ from: 0,
5463
+ to: 1,
5464
+ easing: "ease-out"
5465
+ },
5466
+ // Hold visible through the phrase
5467
+ // (no explicit delta needed — value persists between deltas)
5468
+ // Fade out at phrase end
5469
+ {
5470
+ layer: layerId,
5471
+ property: "opacity",
5472
+ range: [fadeOutStart, endFrame],
5473
+ from: 1,
5474
+ to: 0,
5475
+ easing: "ease-in"
5476
+ }
5477
+ ];
5478
+ }
5479
+
5480
+ // src/commands/transcribe.ts
5481
+ async function transcribeProject(projectDir, options = {}) {
5482
+ const project = loadVideoProject(projectDir);
5483
+ const backend = await probeWhisper();
5484
+ if (backend === "none") {
5485
+ throw new Error(
5486
+ "No Whisper backend available. Install whisper.cpp (brew install whisper-cpp) or set OPENAI_API_KEY and pass --use-api."
5487
+ );
5488
+ }
5489
+ if (backend === "openai-api") {
5490
+ throw new Error(
5491
+ "OpenAI API backend is not yet implemented. Install whisper.cpp for local transcription."
5492
+ );
5493
+ }
5494
+ const rawJson = await runWhisperCpp(project.sourcePath, {
5495
+ model: options.model,
5496
+ language: options.language
5497
+ });
5498
+ let transcript = parseWhisperCppJson(rawJson);
5499
+ if (!options.reset) {
5500
+ const existing = readTranscript(project);
5501
+ if (existing) {
5502
+ transcript = mergeTranscriptWithExisting(transcript, existing);
5503
+ }
5504
+ }
5505
+ const wordCount = transcript.segments.reduce((n, s) => n + s.words.length, 0);
5506
+ const result = {
5507
+ projectDir: project.dir,
5508
+ backend,
5509
+ transcript,
5510
+ wordCount,
5511
+ captionsGenerated: false
5512
+ };
5513
+ if (options.dryRun) return result;
5514
+ writeTranscript(project, transcript);
5515
+ if (!options.noCaptions) {
5516
+ const doc = readComposition(project);
5517
+ const updated = rewriteCaptionLayers(doc, transcript, options.captionOptions);
5518
+ writeComposition(project, updated);
5519
+ result.captionsGenerated = true;
5520
+ }
5521
+ return result;
5522
+ }
5523
+ function formatResult2(result) {
5524
+ const lines = [];
5525
+ lines.push(`Transcribed ${result.projectDir} via ${result.backend}`);
5526
+ if (result.transcript.language) {
5527
+ lines.push(` language: ${result.transcript.language}`);
5528
+ }
5529
+ lines.push(` segments: ${result.transcript.segments.length}`);
5530
+ lines.push(` words: ${result.wordCount}`);
5531
+ if (result.captionsGenerated) {
5532
+ lines.push(` captions: written to project.atelier`);
5533
+ } else {
5534
+ lines.push(` captions: skipped`);
5535
+ }
5536
+ return lines.join("\n");
5537
+ }
5538
+ function transcribeCommand(program) {
5539
+ program.command("transcribe <project>").description(
5540
+ "Transcribe source video via Whisper, write transcript.json, and rewrite caption-tagged TextVisual layers in project.atelier. Preserves user transcript edits on re-run."
5541
+ ).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) => {
5542
+ try {
5543
+ let transcribeOpts = {
5544
+ model: opts.model,
5545
+ language: opts.language,
5546
+ reset: opts.reset,
5547
+ noCaptions: !opts.captions,
5548
+ dryRun: opts.dryRun
5549
+ };
5550
+ if (opts.recipe) {
5551
+ const { recipe } = loadRecipe(opts.recipe, project);
5552
+ transcribeOpts = applyRecipeToTranscribeOptions(recipe, transcribeOpts);
5553
+ }
5554
+ const result = await transcribeProject(project, transcribeOpts);
5555
+ if (opts.json) {
5556
+ console.log(JSON.stringify(result, null, 2));
5557
+ } else {
5558
+ console.log(formatResult2(result));
5559
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
5560
+ }
5561
+ } catch (err) {
5562
+ const msg = err instanceof Error ? err.message : String(err);
5563
+ console.error(`atelier transcribe: ${msg}`);
5564
+ process.exit(1);
5565
+ }
5566
+ });
5567
+ }
5568
+
5569
+ // src/commands/transcript.ts
5570
+ function loadOrThrow(projectDir) {
5571
+ const project = loadVideoProject(projectDir);
5572
+ const transcript = readTranscript(project);
5573
+ if (!transcript) {
5574
+ throw new Error(
5575
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
5576
+ );
5577
+ }
5578
+ return { project, transcript };
5579
+ }
5580
+ function save(project, transcript, noRegenerate) {
5581
+ writeTranscript(project, transcript);
5582
+ if (!noRegenerate) {
5583
+ const doc = readComposition(project);
5584
+ const updated = rewriteCaptionLayers(doc, transcript);
5585
+ writeComposition(project, updated);
5586
+ }
5587
+ }
5588
+ function transcriptCommand(program) {
5589
+ const transcript = program.command("transcript").description("Edit transcript.json \u2014 fix, add, delete, merge, split, list");
5590
+ 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) => {
5591
+ try {
5592
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
5593
+ let updated = transcript2;
5594
+ if (opts.replace?.length) {
5595
+ for (const pair of opts.replace) {
5596
+ const [find, repl] = pair.split("=");
5597
+ if (!find || repl === void 0) {
5598
+ throw new Error(`Invalid --replace pair: "${pair}". Use 'detected=replacement'.`);
5599
+ }
5600
+ updated = applyBatchReplace(updated, find, repl);
5601
+ }
5602
+ }
5603
+ if (typeof opts.word === "number") {
5604
+ if (opts.text === void 0) {
5605
+ throw new Error("--word requires --text <correction>");
5606
+ }
5607
+ updated = applyTextEdit(updated, opts.word, opts.text);
5608
+ }
5609
+ if (!opts.replace?.length && opts.word === void 0) {
5610
+ throw new Error("Provide --replace 'wrong=right' or --word <idx> --text '...'");
5611
+ }
5612
+ save(project, updated, !opts.regenerate);
5613
+ console.log(`Updated transcript in ${project.dir}`);
5614
+ } catch (err) {
5615
+ console.error(`atelier transcript fix: ${err instanceof Error ? err.message : err}`);
5616
+ process.exit(1);
5617
+ }
5618
+ });
5619
+ 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) => {
5620
+ try {
5621
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
5622
+ const updated = applyAdd(transcript2, opts.afterWord, opts.text, opts.duration);
5623
+ save(project, updated, !opts.regenerate);
5624
+ console.log(`Inserted "${opts.text}" after word ${opts.afterWord} in ${project.dir}`);
5625
+ } catch (err) {
5626
+ console.error(`atelier transcript add: ${err instanceof Error ? err.message : err}`);
5627
+ process.exit(1);
5628
+ }
5629
+ });
5630
+ 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) => {
5631
+ try {
5632
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
5633
+ const updated = applyHide(transcript2, opts.word);
5634
+ save(project, updated, !opts.regenerate);
5635
+ console.log(`Hidden word ${opts.word} in ${project.dir}`);
5636
+ } catch (err) {
5637
+ console.error(`atelier transcript delete: ${err instanceof Error ? err.message : err}`);
5638
+ process.exit(1);
5639
+ }
5640
+ });
5641
+ 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) => {
5642
+ try {
5643
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
5644
+ const updated = applyMerge(transcript2, opts.word);
5645
+ save(project, updated, !opts.regenerate);
5646
+ console.log(`Merged words ${opts.word} and ${opts.word + 1} in ${project.dir}`);
5647
+ } catch (err) {
5648
+ console.error(`atelier transcript merge: ${err instanceof Error ? err.message : err}`);
5649
+ process.exit(1);
5650
+ }
5651
+ });
5652
+ 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) => {
5653
+ try {
5654
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
5655
+ const updated = applySplit(transcript2, opts.word, opts.at, opts.first, opts.second);
5656
+ save(project, updated, !opts.regenerate);
5657
+ console.log(`Split word ${opts.word} at ${opts.at} in ${project.dir}`);
5658
+ } catch (err) {
5659
+ console.error(`atelier transcript split: ${err instanceof Error ? err.message : err}`);
5660
+ process.exit(1);
5661
+ }
5662
+ });
5663
+ transcript.command("list <project>").description("Print all words with their indices for reference.").option("--json", "Output as JSON").action(async (projectDir, opts) => {
5664
+ try {
5665
+ const { transcript: transcript2 } = loadOrThrow(projectDir);
5666
+ const words = flattenWords(transcript2);
5667
+ if (opts.json) {
5668
+ console.log(JSON.stringify(words.map((w, i) => ({ index: i, ...w })), null, 2));
5669
+ } else {
5670
+ for (let i = 0; i < words.length; i++) {
5671
+ const w = words[i];
5672
+ const flags = [];
5673
+ if (w.userEdited) flags.push("edited");
5674
+ if (w.userAdded) flags.push("added");
5675
+ if (w.hidden) flags.push("hidden");
5676
+ const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
5677
+ const editedDisplay = w.text !== w.detected ? ` \u2190 "${w.detected}"` : "";
5678
+ console.log(
5679
+ ` [${i.toString().padStart(4)}] ${w.start.toFixed(2).padStart(7)}s "${w.text}"${editedDisplay}${flagStr}`
5680
+ );
5681
+ }
5682
+ }
5683
+ } catch (err) {
5684
+ console.error(`atelier transcript list: ${err instanceof Error ? err.message : err}`);
5685
+ process.exit(1);
5686
+ }
5687
+ });
5688
+ }
5689
+
5690
+ // src/commands/captions.ts
5691
+ function captionsCommand(program) {
5692
+ const captions = program.command("captions").description("Manage caption layers in a VideoProject");
5693
+ captions.command("regenerate <project>").description(
5694
+ "Re-derive caption-tagged TextVisual layers from the current transcript.json. Used when caption styling changes without re-running Whisper."
5695
+ ).option("--recipe <name>", "Apply a Studio Recipe's caption_style + caption_grouping").action(async (projectDir, opts) => {
5696
+ try {
5697
+ const project = loadVideoProject(projectDir);
5698
+ const transcript = readTranscript(project);
5699
+ if (!transcript) {
5700
+ throw new Error(
5701
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
5702
+ );
5703
+ }
5704
+ const captionOptions = opts.recipe ? applyRecipeToCaptionOptions(loadRecipe(opts.recipe, projectDir).recipe) : {};
5705
+ const doc = readComposition(project);
5706
+ const updated = rewriteCaptionLayers(doc, transcript, captionOptions);
5707
+ writeComposition(project, updated);
5708
+ const captionLayers = updated.layers.filter(
5709
+ (l) => (l.tags ?? []).includes("caption")
5710
+ );
5711
+ console.log(
5712
+ `Regenerated ${captionLayers.length} caption layer${captionLayers.length === 1 ? "" : "s"} in ${project.dir}`
5713
+ );
5714
+ } catch (err) {
5715
+ console.error(`atelier captions regenerate: ${err instanceof Error ? err.message : err}`);
5716
+ process.exit(1);
5717
+ }
5718
+ });
5719
+ }
5720
+
5721
+ // src/commands/recipe.ts
5722
+ var import_node_fs14 = require("fs");
5723
+ var import_node_path13 = require("path");
5724
+ function recipeCommand(program) {
5725
+ const recipe = program.command("recipe").description("Manage Studio Recipes \u2014 reusable style presets");
5726
+ 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) => {
5727
+ try {
5728
+ const baseDir = opts.dir ?? (0, import_node_path13.join)((0, import_node_path13.resolve)(process.cwd()), ".atelier", "recipes");
5729
+ if (!(0, import_node_fs14.existsSync)(baseDir)) {
5730
+ (0, import_node_fs14.mkdirSync)(baseDir, { recursive: true });
5731
+ }
5732
+ const hasKnownExt = /\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i.test(name);
5733
+ const fileName = hasKnownExt ? name : `${name}.recipe.yaml`;
5734
+ const outPath = (0, import_node_path13.join)(baseDir, fileName);
5735
+ if ((0, import_node_fs14.existsSync)(outPath)) {
5736
+ throw new Error(`Recipe already exists at ${outPath} \u2014 refusing to overwrite.`);
5737
+ }
5738
+ const recipeName = name.replace(/\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i, "");
5739
+ const yaml = scaffoldRecipeYaml(recipeName);
5740
+ (0, import_node_fs14.writeFileSync)(outPath, yaml, "utf-8");
5741
+ console.log(`Created ${outPath}`);
5742
+ } catch (err) {
5743
+ console.error(`atelier recipe new: ${err instanceof Error ? err.message : err}`);
5744
+ process.exit(1);
5745
+ }
5746
+ });
5747
+ 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) => {
5748
+ try {
5749
+ const loaded = loadRecipe(path, process.cwd());
5750
+ if (opts.json) {
5751
+ console.log(JSON.stringify({
5752
+ valid: true,
5753
+ path: loaded.path,
5754
+ warnings: loaded.warnings
5755
+ }, null, 2));
5756
+ } else {
5757
+ console.log(`PASS ${loaded.path}`);
5758
+ for (const w of loaded.warnings) {
5759
+ console.log(` \u26A0 ${w}`);
5760
+ }
5761
+ }
5762
+ } catch (err) {
5763
+ const msg = err instanceof Error ? err.message : String(err);
5764
+ if (opts.json) {
5765
+ console.log(JSON.stringify({ valid: false, error: msg }, null, 2));
5766
+ } else {
5767
+ console.error(`FAIL ${path}`);
5768
+ console.error(` ${msg}`);
5769
+ }
5770
+ process.exit(1);
5771
+ }
5772
+ });
5773
+ 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) => {
5774
+ try {
5775
+ const loaded = loadRecipe(path, process.cwd());
5776
+ const out = opts.withDefaults ? renderRecipeWithDefaults(loaded.recipe) : loaded.recipe;
5777
+ console.log(recipeToYaml(out));
5778
+ for (const w of loaded.warnings) {
5779
+ console.error(`# \u26A0 ${w}`);
5780
+ }
5781
+ } catch (err) {
5782
+ console.error(`atelier recipe show: ${err instanceof Error ? err.message : err}`);
5783
+ process.exit(1);
5784
+ }
5785
+ });
5786
+ }
5787
+
5788
+ // src/commands/apply-recipe.ts
5789
+ function applyRecipeCommand(program) {
5790
+ program.command("apply-recipe <project> <recipe>").description(
5791
+ "Apply a Studio Recipe by running atelier trim + atelier transcribe with the same recipe, in that order."
5792
+ ).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) => {
5793
+ try {
5794
+ const { recipe, path, warnings } = loadRecipe(recipeRef, project);
5795
+ console.log(`Loaded recipe ${path}`);
5796
+ for (const w of warnings) console.log(` \u26A0 ${w}`);
5797
+ if (opts.trim) {
5798
+ const trimOpts = applyRecipeToTrimOptions(recipe, { reset: opts.reset });
5799
+ console.log(`
5800
+ Running atelier trim...`);
5801
+ const r = await trimProject(project, trimOpts);
5802
+ console.log(` ${r.cuts.length} cut${r.cuts.length === 1 ? "" : "s"} written`);
5803
+ }
5804
+ if (opts.transcribe) {
5805
+ const transcribeOpts = applyRecipeToTranscribeOptions(recipe, { reset: opts.reset });
5806
+ console.log(`
5807
+ Running atelier transcribe...`);
5808
+ const r = await transcribeProject(project, transcribeOpts);
5809
+ console.log(` ${r.wordCount} words, ${r.captionsGenerated ? "captions written" : "captions skipped"}`);
5810
+ }
5811
+ if (recipe.overlay_rules) {
5812
+ const vp = loadVideoProject(project);
5813
+ const doc = readComposition(vp);
5814
+ const updated = applyRecipeToOverlay(doc, recipe);
5815
+ writeComposition(vp, updated);
5816
+ console.log(`
5817
+ Applied overlay rules to ${vp.compositionPath}`);
5818
+ }
5819
+ console.log(`
5820
+ Done.`);
5821
+ } catch (err) {
5822
+ console.error(`atelier apply-recipe: ${err instanceof Error ? err.message : err}`);
5823
+ process.exit(1);
5824
+ }
5825
+ });
5826
+ }
3686
5827
  // Annotate the CommonJS export names for ESM import in node:
3687
5828
  0 && (module.exports = {
5829
+ CanvasUnavailableError,
5830
+ RECIPE_VERSION,
5831
+ VIDEO_CUTLIST_VERSION,
5832
+ VIDEO_PROJECT_VERSION,
5833
+ VIDEO_TRANSCRIPT_VERSION,
5834
+ applyAdd,
5835
+ applyBatchReplace,
5836
+ applyHide,
5837
+ applyMerge,
5838
+ applyRecipeCommand,
5839
+ applyRecipeToCaptionOptions,
5840
+ applyRecipeToTranscribeOptions,
5841
+ applyRecipeToTrimOptions,
5842
+ applySplit,
5843
+ applyTextEdit,
3688
5844
  assetsCommand,
5845
+ buildCaptionLayers,
3689
5846
  buildFfmpegArgs,
5847
+ captionsCommand,
5848
+ carouselCommand,
5849
+ carouselFileName,
3690
5850
  checkFfmpeg,
5851
+ composeCarouselFrameDoc,
5852
+ createVideoProject,
5853
+ effectiveSpan,
5854
+ expandInputs,
5855
+ exportImageCommand,
3691
5856
  exportLottieCommand,
3692
5857
  exportSvgCommand,
5858
+ fitImageToCanvas,
5859
+ flattenWords,
3693
5860
  getAssets,
3694
5861
  getInfo,
3695
5862
  getVariables,
5863
+ groupIntoPhrases,
3696
5864
  infoCommand,
5865
+ loadCanvasModule,
5866
+ loadRecipe,
5867
+ loadVideoProject,
5868
+ mergeTranscriptWithExisting,
5869
+ parseWhisperCppJson,
5870
+ probeWhisper,
5871
+ readComposition,
5872
+ readCutList,
5873
+ readTranscript,
5874
+ recipeCommand,
5875
+ recipeToYaml,
3697
5876
  renderCommand,
3698
5877
  renderDocument,
5878
+ renderDocumentToPng,
5879
+ renderRecipeWithDefaults,
5880
+ resolveExportDimensions,
5881
+ resolveRecipePath,
3699
5882
  resolveStill,
5883
+ rewriteCaptionLayers,
5884
+ rewriteCutLayers,
5885
+ runWhisperCpp,
5886
+ scaffoldRecipeYaml,
3700
5887
  stillCommand,
5888
+ transcribeCommand,
5889
+ transcribeProject,
5890
+ transcriptCommand,
5891
+ trimCommand,
5892
+ trimProject,
3701
5893
  validateCommand,
3702
5894
  validateFile,
3703
- variablesCommand
5895
+ variablesCommand,
5896
+ writeComposition,
5897
+ writeCutList,
5898
+ writeTranscript
3704
5899
  });
3705
5900
  //# sourceMappingURL=index.cjs.map