@a-company/atelier 0.29.0 → 0.37.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.
Files changed (76) hide show
  1. package/dist/chunk-5QQESXI6.js +4432 -0
  2. package/dist/chunk-5QQESXI6.js.map +1 -0
  3. package/dist/cli.cjs +2391 -530
  4. package/dist/cli.cjs.map +1 -1
  5. package/dist/cli.js +301 -429
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +2233 -38
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +584 -2
  10. package/dist/index.d.ts +584 -2
  11. package/dist/index.js +111 -3
  12. package/dist/mcp.cjs +1215 -365
  13. package/dist/mcp.cjs.map +1 -1
  14. package/dist/mcp.js +1209 -365
  15. package/dist/mcp.js.map +1 -1
  16. package/package.json +20 -9
  17. package/src/web/inline-app.ts +867 -0
  18. package/src/web/tsconfig.json +9 -0
  19. package/templates/welcome.atelier +67 -0
  20. package/university/content/notes/N-atel-001-first-render.md +114 -0
  21. package/university/content/notes/N-atel-001-install-and-launch.md +84 -0
  22. package/university/content/notes/N-atel-001-what-is-atelier.md +51 -0
  23. package/university/content/notes/N-atel-101-easings.md +97 -0
  24. package/university/content/notes/N-atel-101-layers.md +106 -0
  25. package/university/content/notes/N-atel-101-states-and-deltas.md +94 -0
  26. package/university/content/notes/N-atel-101-the-atelier-format.md +72 -0
  27. package/university/content/notes/N-atel-201-authoring-tools.md +141 -0
  28. package/university/content/notes/N-atel-201-mcp-overview.md +86 -0
  29. package/university/content/notes/N-atel-201-patterns.md +108 -0
  30. package/university/content/notes/N-atel-201-visual-and-effects.md +125 -0
  31. package/university/content/notes/N-atel-301-composition-and-overlays.md +141 -0
  32. package/university/content/notes/N-atel-301-effects.md +136 -0
  33. package/university/content/notes/N-atel-301-images-and-video.md +126 -0
  34. package/university/content/notes/N-atel-301-shapes-and-text.md +118 -0
  35. package/university/content/notes/N-atel-401-hierarchical-states.md +71 -0
  36. package/university/content/notes/N-atel-401-motion-deep-dive.md +106 -0
  37. package/university/content/notes/N-atel-401-presets-and-templates.md +98 -0
  38. package/university/content/notes/N-atel-401-transitions.md +94 -0
  39. package/university/content/notes/N-atel-501-detected-vs-user-edited.md +76 -0
  40. package/university/content/notes/N-atel-501-layer-tag-isolation.md +62 -0
  41. package/university/content/notes/N-atel-501-silence-trim.md +98 -0
  42. package/university/content/notes/N-atel-501-transcribe-and-captions.md +98 -0
  43. package/university/content/notes/N-atel-601-carousel.md +71 -0
  44. package/university/content/notes/N-atel-601-overlay-rules.md +96 -0
  45. package/university/content/notes/N-atel-601-recipe-tools-and-apply.md +84 -0
  46. package/university/content/notes/N-atel-601-studio-recipe.md +103 -0
  47. package/university/content/notes/N-atel-701-choosing-output.md +68 -0
  48. package/university/content/notes/N-atel-701-png-and-frames.md +84 -0
  49. package/university/content/notes/N-atel-701-vector.md +85 -0
  50. package/university/content/notes/N-atel-701-video.md +88 -0
  51. package/university/content/notes/N-atel-801-editing-surface.md +69 -0
  52. package/university/content/notes/N-atel-801-live-bridge.md +84 -0
  53. package/university/content/notes/N-atel-801-studio-app.md +72 -0
  54. package/university/content/notes/N-atel-801-symbiotic-loop.md +56 -0
  55. package/university/content/paths/LP-atel-001.yaml +21 -0
  56. package/university/content/paths/LP-atel-101.yaml +22 -0
  57. package/university/content/paths/LP-atel-201.yaml +23 -0
  58. package/university/content/paths/LP-atel-301.yaml +22 -0
  59. package/university/content/paths/LP-atel-401.yaml +22 -0
  60. package/university/content/paths/LP-atel-501.yaml +22 -0
  61. package/university/content/paths/LP-atel-601.yaml +22 -0
  62. package/university/content/paths/LP-atel-701.yaml +22 -0
  63. package/university/content/paths/LP-atel-801.yaml +22 -0
  64. package/university/content/quizzes/Q-atel-001-orientation.yaml +66 -0
  65. package/university/content/quizzes/Q-atel-101-document-model.yaml +66 -0
  66. package/university/content/quizzes/Q-atel-201-mcp-authoring.yaml +66 -0
  67. package/university/content/quizzes/Q-atel-301-visual-system.yaml +66 -0
  68. package/university/content/quizzes/Q-atel-401-state-machines.yaml +66 -0
  69. package/university/content/quizzes/Q-atel-501-video-pipeline.yaml +66 -0
  70. package/university/content/quizzes/Q-atel-601-recipes.yaml +66 -0
  71. package/university/content/quizzes/Q-atel-701-export.yaml +66 -0
  72. package/university/content/quizzes/Q-atel-801-studio-loop.yaml +66 -0
  73. package/university/index.yaml +720 -0
  74. package/university/pack.yaml +21 -0
  75. package/dist/chunk-JV7RGETS.js +0 -2292
  76. package/dist/chunk-JV7RGETS.js.map +0 -1
@@ -0,0 +1,4432 @@
1
+ import {
2
+ ImageCache,
3
+ buildEffectiveLayer,
4
+ findTemplateVariables,
5
+ renderFrame,
6
+ resolveFrame,
7
+ validateAllDeltas
8
+ } from "./chunk-JPZ4F4PW.js";
9
+
10
+ // src/commands/validate.ts
11
+ import { readFileSync } from "fs";
12
+ import { resolve } from "path";
13
+
14
+ // ../schema/dist/index.js
15
+ import { z } from "zod";
16
+ import { z as z2 } from "zod";
17
+ import { z as z3 } from "zod";
18
+ import { z as z4 } from "zod";
19
+ import { z as z5 } from "zod";
20
+ import { z as z6 } from "zod";
21
+ import { z as z8 } from "zod";
22
+ import { z as z7 } from "zod";
23
+ import { z as z9 } from "zod";
24
+ import { z as z10 } from "zod";
25
+ import { z as z11 } from "zod";
26
+ import { z as z12 } from "zod";
27
+ import { z as z13 } from "zod";
28
+ import { z as z14 } from "zod";
29
+ import { z as z15 } from "zod";
30
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
31
+ var PixelSchema = z.number();
32
+ var PercentageSchema = z.string().regex(/^-?\d+(\.\d+)?%$/, {
33
+ message: 'Percentage must be a number followed by %, e.g. "50%"'
34
+ });
35
+ var UnitValueSchema = z.union([PixelSchema, PercentageSchema]);
36
+ var FrameSchema = z2.object({
37
+ x: UnitValueSchema,
38
+ y: UnitValueSchema
39
+ });
40
+ var BoundsSchema = z2.object({
41
+ width: UnitValueSchema,
42
+ height: UnitValueSchema
43
+ });
44
+ var AnchorPointSchema = z2.object({
45
+ x: z2.number().min(0).max(1),
46
+ y: z2.number().min(0).max(1)
47
+ });
48
+ var RGBAColorSchema = z3.object({
49
+ r: z3.number().min(0).max(255),
50
+ g: z3.number().min(0).max(255),
51
+ b: z3.number().min(0).max(255),
52
+ a: z3.number().min(0).max(1)
53
+ });
54
+ var HSLAColorSchema = z3.object({
55
+ h: z3.number().min(0).max(360),
56
+ s: z3.number().min(0).max(100),
57
+ l: z3.number().min(0).max(100),
58
+ a: z3.number().min(0).max(1)
59
+ });
60
+ var HexColorSchema = z3.string().regex(/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
61
+ message: "Color must be a hex string: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA"
62
+ });
63
+ var ColorSchema = z3.union([RGBAColorSchema, HSLAColorSchema, HexColorSchema]);
64
+ var PathPointSchema = z4.object({
65
+ x: z4.number(),
66
+ y: z4.number(),
67
+ in: z4.object({ x: z4.number(), y: z4.number() }).optional(),
68
+ out: z4.object({ x: z4.number(), y: z4.number() }).optional()
69
+ });
70
+ var RectShapeSchema = z4.object({
71
+ type: z4.literal("rect"),
72
+ cornerRadius: z4.union([
73
+ z4.number().min(0),
74
+ z4.tuple([z4.number().min(0), z4.number().min(0), z4.number().min(0), z4.number().min(0)])
75
+ ]).optional()
76
+ });
77
+ var EllipseShapeSchema = z4.object({
78
+ type: z4.literal("ellipse")
79
+ });
80
+ var PathShapeSchema = z4.object({
81
+ type: z4.literal("path"),
82
+ points: z4.array(PathPointSchema).min(2, "Path must have at least 2 points"),
83
+ closed: z4.boolean().optional()
84
+ });
85
+ var ShapeSchema = z4.discriminatedUnion("type", [
86
+ RectShapeSchema,
87
+ EllipseShapeSchema,
88
+ PathShapeSchema
89
+ ]);
90
+ var GradientStopSchema = z4.object({
91
+ offset: z4.number().min(0).max(1),
92
+ color: ColorSchema
93
+ });
94
+ var SolidFillSchema = z4.object({
95
+ type: z4.literal("solid"),
96
+ color: ColorSchema
97
+ });
98
+ var LinearGradientFillSchema = z4.object({
99
+ type: z4.literal("linear-gradient"),
100
+ angle: z4.number(),
101
+ stops: z4.array(GradientStopSchema).min(2, "Gradient needs at least 2 stops")
102
+ });
103
+ var RadialGradientFillSchema = z4.object({
104
+ type: z4.literal("radial-gradient"),
105
+ center: z4.object({ x: UnitValueSchema, y: UnitValueSchema }),
106
+ radius: UnitValueSchema,
107
+ stops: z4.array(GradientStopSchema).min(2, "Gradient needs at least 2 stops")
108
+ });
109
+ var FillSchema = z4.discriminatedUnion("type", [
110
+ SolidFillSchema,
111
+ LinearGradientFillSchema,
112
+ RadialGradientFillSchema
113
+ ]);
114
+ var StrokeSchema = z4.object({
115
+ color: ColorSchema,
116
+ width: z4.number().min(0),
117
+ dash: z4.array(z4.number().min(0)).optional(),
118
+ lineCap: z4.enum(["butt", "round", "square"]).optional(),
119
+ lineJoin: z4.enum(["miter", "round", "bevel"]).optional(),
120
+ strokeStart: z4.number().min(0).max(1).optional(),
121
+ strokeEnd: z4.number().min(0).max(1).optional()
122
+ });
123
+ var TextStyleSchema = z4.object({
124
+ fontFamily: z4.string().min(1, "fontFamily is required"),
125
+ fontSize: z4.number().positive("fontSize must be positive"),
126
+ fontWeight: z4.union([z4.number(), z4.enum(["normal", "bold"])]).optional(),
127
+ fontStyle: z4.enum(["normal", "italic"]).optional(),
128
+ textAlign: z4.enum(["left", "center", "right"]).optional(),
129
+ lineHeight: z4.number().positive().optional(),
130
+ letterSpacing: z4.number().optional(),
131
+ color: ColorSchema
132
+ });
133
+ var LinearEasingSchema = z5.object({ type: z5.literal("linear") });
134
+ var CubicBezierEasingSchema = z5.object({
135
+ type: z5.literal("cubic-bezier"),
136
+ x1: z5.number().min(0).max(1),
137
+ y1: z5.number(),
138
+ x2: z5.number().min(0).max(1),
139
+ y2: z5.number()
140
+ });
141
+ var SpringEasingSchema = z5.object({
142
+ type: z5.literal("spring"),
143
+ mass: z5.number().positive().optional(),
144
+ stiffness: z5.number().positive().optional(),
145
+ damping: z5.number().positive().optional(),
146
+ velocity: z5.number().optional()
147
+ });
148
+ var StepEasingSchema = z5.object({
149
+ type: z5.literal("step"),
150
+ steps: z5.number().int().positive(),
151
+ position: z5.enum(["start", "end"]).optional()
152
+ });
153
+ var EasingPresetSchema = z5.enum(["linear", "ease-in", "ease-out", "ease-in-out"]);
154
+ var EasingSchema = z5.union([
155
+ LinearEasingSchema,
156
+ CubicBezierEasingSchema,
157
+ SpringEasingSchema,
158
+ StepEasingSchema,
159
+ EasingPresetSchema
160
+ ]);
161
+ var ShadowSchema = z6.object({
162
+ color: ColorSchema,
163
+ blur: z6.number().min(0),
164
+ offsetX: z6.number().optional(),
165
+ offsetY: z6.number().optional()
166
+ });
167
+ var TriggerTypeSchema = z7.enum([
168
+ "click",
169
+ "hover",
170
+ "pointerdown",
171
+ "pointerup",
172
+ "timer",
173
+ "signal"
174
+ ]);
175
+ var TriggerSchema = z7.object({
176
+ type: TriggerTypeSchema,
177
+ delay: z7.number().nonnegative("Timer delay must be non-negative").optional(),
178
+ signal: z7.string().optional()
179
+ }).refine(
180
+ (t) => t.type !== "timer" || t.delay !== void 0,
181
+ { message: "Timer trigger requires a delay" }
182
+ ).refine(
183
+ (t) => t.type !== "signal" || t.signal !== void 0,
184
+ { message: "Signal trigger requires a signal name" }
185
+ );
186
+ var ActionTypeSchema = z7.enum([
187
+ "go-to-state",
188
+ "emit-signal",
189
+ "set-variable",
190
+ "toggle-visibility"
191
+ ]);
192
+ var ActionSchema = z7.object({
193
+ type: ActionTypeSchema,
194
+ state: z7.string().optional(),
195
+ signal: z7.string().optional(),
196
+ variable: z7.string().optional(),
197
+ value: z7.unknown().optional(),
198
+ targetLayer: z7.string().optional()
199
+ }).refine(
200
+ (a) => a.type !== "go-to-state" || a.state !== void 0,
201
+ { message: "go-to-state action requires a state name" }
202
+ ).refine(
203
+ (a) => a.type !== "emit-signal" || a.signal !== void 0,
204
+ { message: "emit-signal action requires a signal name" }
205
+ ).refine(
206
+ (a) => a.type !== "set-variable" || a.variable !== void 0 && a.value !== void 0,
207
+ { message: "set-variable action requires variable and value" }
208
+ );
209
+ var InteractionSchema = z7.object({
210
+ id: z7.string().min(1, "Interaction id is required"),
211
+ trigger: TriggerSchema,
212
+ action: ActionSchema,
213
+ description: z7.string().optional()
214
+ });
215
+ var BlendModeSchema = z8.enum([
216
+ "normal",
217
+ "multiply",
218
+ "screen",
219
+ "overlay",
220
+ "darken",
221
+ "lighten",
222
+ "color-dodge",
223
+ "color-burn",
224
+ "hard-light",
225
+ "soft-light",
226
+ "difference",
227
+ "exclusion",
228
+ "hue",
229
+ "saturation",
230
+ "color",
231
+ "luminosity"
232
+ ]);
233
+ var MotionPathSchema = z8.object({
234
+ points: z8.array(PathPointSchema).min(2, "Motion path must have at least 2 points"),
235
+ closed: z8.boolean().optional(),
236
+ autoRotate: z8.boolean().optional(),
237
+ autoRotateOffset: z8.number().optional()
238
+ });
239
+ var ShapeVisualSchema = z8.object({
240
+ type: z8.literal("shape"),
241
+ shape: ShapeSchema,
242
+ fill: FillSchema.optional(),
243
+ stroke: StrokeSchema.optional()
244
+ });
245
+ var TextVisualSchema = z8.object({
246
+ type: z8.literal("text"),
247
+ content: z8.string(),
248
+ style: TextStyleSchema
249
+ });
250
+ var SpritesheetConfigSchema = z8.object({
251
+ columns: z8.number().int().positive(),
252
+ rows: z8.number().int().positive(),
253
+ frameCount: z8.number().int().positive().optional(),
254
+ frameWidth: z8.number().positive(),
255
+ frameHeight: z8.number().positive()
256
+ });
257
+ var SourceRectSchema = z8.object({
258
+ x: z8.number(),
259
+ y: z8.number(),
260
+ width: z8.number().positive(),
261
+ height: z8.number().positive()
262
+ });
263
+ var ImageVisualSchema = z8.object({
264
+ type: z8.literal("image"),
265
+ assetId: z8.string().min(1, "assetId is required"),
266
+ src: z8.string().optional(),
267
+ sourceRect: SourceRectSchema.optional(),
268
+ spritesheet: SpritesheetConfigSchema.optional(),
269
+ frameIndex: z8.number().int().min(0).optional()
270
+ });
271
+ var VideoVisualSchema = z8.object({
272
+ type: z8.literal("video"),
273
+ assetId: z8.string().min(1, "assetId is required"),
274
+ src: z8.string().optional(),
275
+ startFrame: z8.number().int().min(0).optional(),
276
+ sourceOffset: z8.number().min(0).optional(),
277
+ sourceEnd: z8.number().positive().optional(),
278
+ playbackRate: z8.number().positive().optional(),
279
+ volume: z8.number().min(0).max(1).optional(),
280
+ muted: z8.boolean().optional(),
281
+ objectFit: z8.enum(["contain", "cover", "fill"]).optional()
282
+ });
283
+ var GroupVisualSchema = z8.object({
284
+ type: z8.literal("group")
285
+ });
286
+ var RefVisualSchema = z8.object({
287
+ type: z8.literal("ref"),
288
+ src: z8.string().min(1, "src is required"),
289
+ state: z8.string().optional(),
290
+ frame: z8.number().int().min(0).optional()
291
+ });
292
+ var VisualSchema = z8.discriminatedUnion("type", [
293
+ ShapeVisualSchema,
294
+ TextVisualSchema,
295
+ ImageVisualSchema,
296
+ VideoVisualSchema,
297
+ GroupVisualSchema,
298
+ RefVisualSchema
299
+ ]);
300
+ var LayerSchema = z8.object({
301
+ id: z8.string().min(1, "Layer id is required"),
302
+ description: z8.string().optional(),
303
+ tags: z8.array(z8.string()).optional(),
304
+ visual: VisualSchema,
305
+ frame: FrameSchema,
306
+ bounds: BoundsSchema,
307
+ anchorPoint: AnchorPointSchema.optional(),
308
+ parentId: z8.string().optional(),
309
+ opacity: z8.number().min(0).max(1).optional(),
310
+ rotation: z8.number().optional(),
311
+ scale: z8.object({ x: z8.number(), y: z8.number() }).optional(),
312
+ visible: z8.boolean().optional(),
313
+ shadow: ShadowSchema.optional(),
314
+ blendMode: BlendModeSchema.optional(),
315
+ motionPath: MotionPathSchema.optional(),
316
+ clipPath: ShapeSchema.optional(),
317
+ tint: z8.object({
318
+ color: z8.string(),
319
+ amount: z8.number().min(0).max(1)
320
+ }).optional(),
321
+ interactions: z8.array(InteractionSchema).optional()
322
+ });
323
+ var AnimatablePropertySchema = z9.enum([
324
+ "frame.x",
325
+ "frame.y",
326
+ "bounds.width",
327
+ "bounds.height",
328
+ "opacity",
329
+ "rotation",
330
+ "scale.x",
331
+ "scale.y",
332
+ "anchorPoint.x",
333
+ "anchorPoint.y",
334
+ "visual.shape.cornerRadius",
335
+ "visual.fill.color",
336
+ "visual.stroke.color",
337
+ "visual.stroke.width",
338
+ "visual.stroke.start",
339
+ "visual.stroke.end",
340
+ "visual.style.fontSize",
341
+ "visual.style.color",
342
+ "shadow.color",
343
+ "shadow.blur",
344
+ "shadow.offsetX",
345
+ "shadow.offsetY",
346
+ "motionPath.progress",
347
+ "visual.fill.angle",
348
+ "visual.fill.center.x",
349
+ "visual.fill.center.y",
350
+ "visual.fill.radius",
351
+ "visual.image.sourceRect.x",
352
+ "visual.image.sourceRect.y",
353
+ "visual.image.sourceRect.width",
354
+ "visual.image.sourceRect.height",
355
+ "visual.image.frameIndex",
356
+ "visible",
357
+ "tint.color",
358
+ "tint.amount"
359
+ ]);
360
+ var FrameRangeSchema = z9.tuple([
361
+ z9.number().int().min(0, "Frame start must be >= 0"),
362
+ z9.number().int().min(0, "Frame end must be >= 0")
363
+ ]).refine(([start, end]) => end >= start, {
364
+ message: "Frame range end must be >= start"
365
+ });
366
+ var DeltaSchema = z9.object({
367
+ id: z9.string().optional(),
368
+ name: z9.string().optional(),
369
+ layer: z9.string().min(1, "Delta must reference a layer id"),
370
+ property: AnimatablePropertySchema,
371
+ range: FrameRangeSchema,
372
+ from: z9.unknown(),
373
+ to: z9.unknown(),
374
+ easing: EasingSchema.optional(),
375
+ description: z9.string().optional(),
376
+ tags: z9.array(z9.string()).optional()
377
+ });
378
+ var AudioSchema = z10.object({
379
+ src: z10.string().min(1, "Audio src is required"),
380
+ offset: z10.number().min(0, "Audio offset must be non-negative").optional(),
381
+ volume: z10.number().min(0).max(1, "Audio volume must be 0\u20131").optional(),
382
+ loop: z10.boolean().optional(),
383
+ startFrame: z10.number().int().min(0, "Audio startFrame must be a non-negative integer").optional()
384
+ });
385
+ var StateTransitionConfigSchema = z10.object({
386
+ duration: z10.number().int().positive("Transition duration must be a positive integer (frames)"),
387
+ easing: EasingSchema.optional()
388
+ });
389
+ var StateSchema = z10.object({
390
+ description: z10.string().optional(),
391
+ tags: z10.array(z10.string()).optional(),
392
+ parent: z10.string().optional(),
393
+ duration: z10.number().int().positive("State duration must be a positive integer (frames)"),
394
+ deltas: z10.array(DeltaSchema),
395
+ audio: AudioSchema.optional(),
396
+ transitions: z10.record(z10.string(), StateTransitionConfigSchema).optional()
397
+ });
398
+ var PresetDeltaSchema = z11.object({
399
+ property: AnimatablePropertySchema,
400
+ offset: z11.tuple([z11.number().int().min(0), z11.number().int().min(0)]).optional(),
401
+ from: z11.unknown(),
402
+ to: z11.unknown(),
403
+ easing: EasingSchema.optional()
404
+ });
405
+ var PresetSchema = z11.object({
406
+ description: z11.string().optional(),
407
+ tags: z11.array(z11.string()).optional(),
408
+ deltas: z11.array(PresetDeltaSchema).min(1, "Preset must have at least one delta")
409
+ });
410
+ var VariableTypeSchema = z12.enum(["string", "number", "color", "asset", "boolean"]);
411
+ var VariableSchema = z12.object({
412
+ type: VariableTypeSchema,
413
+ default: z12.unknown().optional(),
414
+ description: z12.string().optional()
415
+ });
416
+ var AssetTypeSchema = z13.enum(["image", "svg", "font", "animation", "audio", "video"]);
417
+ var AssetSchema = z13.object({
418
+ type: AssetTypeSchema,
419
+ src: z13.string().min(1, "Asset src is required"),
420
+ description: z13.string().optional(),
421
+ spritesheet: z13.object({
422
+ columns: z13.number().int().positive(),
423
+ rows: z13.number().int().positive(),
424
+ frameCount: z13.number().int().positive().optional(),
425
+ frameWidth: z13.number().positive(),
426
+ frameHeight: z13.number().positive()
427
+ }).optional(),
428
+ videoMeta: z13.object({
429
+ duration: z13.number().positive("videoMeta.duration must be positive"),
430
+ fps: z13.number().positive("videoMeta.fps must be positive"),
431
+ width: z13.number().int().positive(),
432
+ height: z13.number().int().positive()
433
+ }).optional()
434
+ });
435
+ var CanvasSchema = z14.object({
436
+ width: z14.number().int().positive("Canvas width must be a positive integer"),
437
+ height: z14.number().int().positive("Canvas height must be a positive integer"),
438
+ fps: z14.number().int().positive("FPS must be a positive integer"),
439
+ background: z14.string().optional()
440
+ });
441
+ var AtelierDocumentSchema = z14.object({
442
+ version: z14.string().min(1, "Version is required"),
443
+ name: z14.string().min(1, "Animation name is required"),
444
+ description: z14.string().optional(),
445
+ tags: z14.array(z14.string()).optional(),
446
+ canvas: CanvasSchema,
447
+ variables: z14.record(z14.string(), VariableSchema).optional(),
448
+ assets: z14.record(z14.string(), AssetSchema).optional(),
449
+ presets: z14.record(z14.string(), PresetSchema).optional(),
450
+ layers: z14.array(LayerSchema),
451
+ states: z14.record(z14.string(), StateSchema)
452
+ });
453
+ var SilencePolicySchema = z15.object({
454
+ noise: z15.string().optional(),
455
+ min_silence: z15.number().nonnegative().optional(),
456
+ default_padding_pre: z15.number().nonnegative().optional(),
457
+ default_padding_post: z15.number().nonnegative().optional(),
458
+ match_tolerance: z15.number().nonnegative().optional()
459
+ }).strict();
460
+ var CaptionStyleSchema = z15.object({
461
+ font_family: z15.string().optional(),
462
+ font_size: z15.number().positive().optional(),
463
+ font_weight: z15.union([z15.literal("normal"), z15.literal("bold"), z15.number()]).optional(),
464
+ text_align: z15.enum(["left", "center", "right"]).optional(),
465
+ color: z15.string().optional(),
466
+ y_ratio: z15.number().min(0).max(1).optional(),
467
+ width_ratio: z15.number().min(0).max(1).optional(),
468
+ fade_seconds: z15.number().nonnegative().optional()
469
+ }).strict();
470
+ var CaptionGroupingSchema = z15.object({
471
+ max_words: z15.number().int().positive().optional(),
472
+ pause_gap: z15.number().nonnegative().optional()
473
+ }).strict();
474
+ var OverlayAnchorSchema = z15.enum([
475
+ "top-left",
476
+ "top-right",
477
+ "bottom-left",
478
+ "bottom-right"
479
+ ]);
480
+ var OverlayTextStyleSchema = z15.object({
481
+ font_family: z15.string().optional(),
482
+ font_size: z15.number().positive().optional(),
483
+ font_weight: z15.union([z15.literal("normal"), z15.literal("bold"), z15.number()]).optional(),
484
+ color: z15.string().optional()
485
+ }).strict();
486
+ function validatePageNumberFormat(format, ctx) {
487
+ if (format.length === 0) {
488
+ ctx.addIssue({
489
+ code: z15.ZodIssueCode.custom,
490
+ message: "format must be a non-empty string"
491
+ });
492
+ return;
493
+ }
494
+ const open = (format.match(/\{/g) ?? []).length;
495
+ const close = (format.match(/\}/g) ?? []).length;
496
+ if (open !== close) {
497
+ ctx.addIssue({
498
+ code: z15.ZodIssueCode.custom,
499
+ message: `format has unbalanced braces (${open} '{' vs ${close} '}')`
500
+ });
501
+ return;
502
+ }
503
+ const groupRe = /\{([^{}]*)\}/g;
504
+ const groupRule = /^(current|total)(:0\d+d)?$/;
505
+ let m;
506
+ let sawCurrent = false;
507
+ let sawTotal = false;
508
+ while ((m = groupRe.exec(format)) !== null) {
509
+ const inner = m[1];
510
+ if (!groupRule.test(inner)) {
511
+ ctx.addIssue({
512
+ code: z15.ZodIssueCode.custom,
513
+ message: `format placeholder "{${inner}}" is not recognized \u2014 expected {current}, {total}, {current:0Nd}, or {total:0Nd}`
514
+ });
515
+ return;
516
+ }
517
+ if (inner.startsWith("current")) sawCurrent = true;
518
+ if (inner.startsWith("total")) sawTotal = true;
519
+ }
520
+ if (!sawCurrent && !sawTotal) {
521
+ ctx.addIssue({
522
+ code: z15.ZodIssueCode.custom,
523
+ message: "format must contain at least one {current} or {total} placeholder"
524
+ });
525
+ }
526
+ }
527
+ var OverlayHandleRuleSchema = z15.object({
528
+ text: z15.string().min(1),
529
+ anchor: OverlayAnchorSchema,
530
+ margin: z15.number().nonnegative().optional(),
531
+ style: OverlayTextStyleSchema.optional()
532
+ }).strict();
533
+ var OverlayPageNumberRuleSchema = z15.object({
534
+ format: z15.string().superRefine(validatePageNumberFormat),
535
+ anchor: OverlayAnchorSchema,
536
+ margin: z15.number().nonnegative().optional(),
537
+ style: OverlayTextStyleSchema.optional()
538
+ }).strict();
539
+ var OverlayRulesSchema = z15.object({
540
+ handle: OverlayHandleRuleSchema.optional(),
541
+ page_number: OverlayPageNumberRuleSchema.optional()
542
+ }).strict();
543
+ var StudioRecipeSchema = z15.object({
544
+ version: z15.string(),
545
+ name: z15.string(),
546
+ description: z15.string().optional(),
547
+ author: z15.string().optional(),
548
+ tags: z15.array(z15.string()).optional(),
549
+ silence_policy: SilencePolicySchema.optional(),
550
+ caption_style: CaptionStyleSchema.optional(),
551
+ caption_grouping: CaptionGroupingSchema.optional(),
552
+ // Phase 1.5 — first-class overlay rules
553
+ overlay_rules: OverlayRulesSchema.optional(),
554
+ // Reserved — Phase 3 (parse-opaque)
555
+ caption_highlight: z15.unknown().optional(),
556
+ transition_kit: z15.unknown().optional(),
557
+ palette: z15.unknown().optional(),
558
+ audio_policy: z15.unknown().optional(),
559
+ aspect_targets: z15.array(z15.unknown()).optional()
560
+ }).strict();
561
+ var RESERVED_RECIPE_FIELDS = [
562
+ "caption_highlight",
563
+ "transition_kit",
564
+ "palette",
565
+ "audio_policy",
566
+ "aspect_targets"
567
+ ];
568
+ function formatErrors(error) {
569
+ return error.issues.map((issue) => ({
570
+ path: issue.path.join(".") || "(root)",
571
+ message: issue.message
572
+ }));
573
+ }
574
+ function validateDocument(input) {
575
+ const result = AtelierDocumentSchema.safeParse(input);
576
+ if (result.success) {
577
+ return { success: true, data: result.data };
578
+ }
579
+ return { success: false, errors: formatErrors(result.error) };
580
+ }
581
+ function validateVideoLayer(visual, videoMetaDuration) {
582
+ const errors = [];
583
+ if (!visual.assetId) {
584
+ errors.push({ path: "assetId", message: "assetId is required" });
585
+ }
586
+ const sourceOffset = visual.sourceOffset ?? 0;
587
+ if (visual.sourceEnd !== void 0) {
588
+ if (visual.sourceEnd <= sourceOffset) {
589
+ errors.push({
590
+ path: "sourceEnd",
591
+ message: `sourceEnd (${visual.sourceEnd}) must be greater than sourceOffset (${sourceOffset})`
592
+ });
593
+ }
594
+ if (videoMetaDuration !== void 0 && visual.sourceEnd > videoMetaDuration) {
595
+ errors.push({
596
+ path: "sourceEnd",
597
+ message: `sourceEnd (${visual.sourceEnd}) exceeds asset duration (${videoMetaDuration})`
598
+ });
599
+ }
600
+ }
601
+ if (videoMetaDuration !== void 0 && sourceOffset >= videoMetaDuration) {
602
+ errors.push({
603
+ path: "sourceOffset",
604
+ message: `sourceOffset (${sourceOffset}) is at or beyond asset duration (${videoMetaDuration})`
605
+ });
606
+ }
607
+ if (errors.length > 0) return { success: false, errors };
608
+ return { success: true, data: visual };
609
+ }
610
+ function validateRecipe(recipe) {
611
+ const parsed = StudioRecipeSchema.safeParse(recipe);
612
+ if (!parsed.success) {
613
+ return { success: false, errors: formatErrors(parsed.error) };
614
+ }
615
+ const warnings = [];
616
+ const data = parsed.data;
617
+ for (const field of RESERVED_RECIPE_FIELDS) {
618
+ if (data[field] !== void 0) {
619
+ warnings.push(
620
+ `${field} is reserved for Phase 3 and currently has no effect.`
621
+ );
622
+ }
623
+ }
624
+ return { success: true, data, ...warnings.length > 0 && { warnings } };
625
+ }
626
+ function parseAtelier(yamlString) {
627
+ let parsed;
628
+ try {
629
+ parsed = yamlParse(yamlString);
630
+ } catch (err) {
631
+ return {
632
+ success: false,
633
+ errors: [{ path: "(yaml)", message: `YAML parse error: ${err.message}` }]
634
+ };
635
+ }
636
+ return validateDocument(parsed);
637
+ }
638
+ function serializeAtelier(doc) {
639
+ return yamlStringify(doc, { indent: 2 });
640
+ }
641
+
642
+ // src/commands/validate.ts
643
+ function validateFile(filePath) {
644
+ const absPath = resolve(filePath);
645
+ let content;
646
+ try {
647
+ content = readFileSync(absPath, "utf-8");
648
+ } catch {
649
+ return { valid: false, errors: [`Cannot read file: ${absPath}`] };
650
+ }
651
+ const result = parseAtelier(content);
652
+ if (!result.success) {
653
+ return {
654
+ valid: false,
655
+ errors: result.errors.map(
656
+ (e) => `${e.path}: ${e.message}`
657
+ )
658
+ };
659
+ }
660
+ const overlapErrors = [];
661
+ for (const [stateName, state] of Object.entries(result.data.states)) {
662
+ const overlaps = validateAllDeltas(state.deltas);
663
+ for (const overlap of overlaps) {
664
+ overlapErrors.push(`State "${stateName}": ${overlap.message}`);
665
+ }
666
+ }
667
+ if (overlapErrors.length > 0) {
668
+ return { valid: false, errors: overlapErrors };
669
+ }
670
+ return { valid: true, errors: [] };
671
+ }
672
+ function validateCommand(program) {
673
+ program.command("validate <file>").description("Validate an .atelier YAML file").action((file) => {
674
+ const { valid, errors } = validateFile(file);
675
+ if (valid) {
676
+ console.log("Valid");
677
+ } else {
678
+ console.error("Validation errors:");
679
+ for (const error of errors) {
680
+ console.error(` - ${error}`);
681
+ }
682
+ process.exit(1);
683
+ }
684
+ });
685
+ }
686
+
687
+ // src/lib/video-project.ts
688
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync, copyFileSync } from "fs";
689
+ import { join, basename, extname, resolve as resolve2 } from "path";
690
+ var VIDEO_PROJECT_VERSION = "1.0";
691
+ var VIDEO_CUTLIST_VERSION = "1.1";
692
+ var VIDEO_TRANSCRIPT_VERSION = "1.1";
693
+ function effectiveSpan(cut, duration) {
694
+ return {
695
+ start: Math.max(0, cut.rawStart - cut.paddingPre),
696
+ end: Math.min(duration, cut.rawEnd + cut.paddingPost)
697
+ };
698
+ }
699
+ async function createVideoProject(srcPath, destDir) {
700
+ const absSrc = resolve2(srcPath);
701
+ const ext = extname(absSrc);
702
+ const stem = basename(absSrc, ext);
703
+ const projectDir = destDir ? resolve2(destDir) : join(resolve2(absSrc, ".."), stem);
704
+ if (!existsSync(projectDir)) {
705
+ mkdirSync(projectDir, { recursive: true });
706
+ }
707
+ const sourceFilename = `source${ext}`;
708
+ const sourcePath = join(projectDir, sourceFilename);
709
+ if (!existsSync(sourcePath)) {
710
+ copyFileSync(absSrc, sourcePath);
711
+ }
712
+ const compositionPath = join(projectDir, "project.atelier");
713
+ const transcriptPath = join(projectDir, "transcript.json");
714
+ const cutsPath = join(projectDir, "cuts.json");
715
+ const exportDir = join(projectDir, "export");
716
+ if (!existsSync(exportDir)) {
717
+ mkdirSync(exportDir, { recursive: true });
718
+ }
719
+ const manifest = {
720
+ source: sourceFilename,
721
+ composition: "project.atelier",
722
+ transcript: "transcript.json",
723
+ cuts: "cuts.json",
724
+ exportDir: "export/",
725
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
726
+ version: VIDEO_PROJECT_VERSION
727
+ };
728
+ const draft = {
729
+ version: "1.0",
730
+ name: stem,
731
+ canvas: { width: 1920, height: 1080, fps: 30 },
732
+ assets: {
733
+ src: {
734
+ type: "video",
735
+ src: sourceFilename,
736
+ description: `Source: ${basename(absSrc)}`
737
+ }
738
+ },
739
+ layers: [
740
+ {
741
+ id: "clip-0",
742
+ visual: {
743
+ type: "video",
744
+ assetId: "src",
745
+ src: sourceFilename,
746
+ startFrame: 0,
747
+ sourceOffset: 0,
748
+ playbackRate: 1,
749
+ objectFit: "contain"
750
+ },
751
+ frame: { x: 0, y: 0 },
752
+ bounds: { width: 1920, height: 1080 }
753
+ }
754
+ ],
755
+ states: {
756
+ default: {
757
+ duration: 0,
758
+ deltas: []
759
+ }
760
+ }
761
+ };
762
+ if (!existsSync(compositionPath)) {
763
+ writeFileSync(compositionPath, JSON.stringify(draft, null, 2), "utf-8");
764
+ }
765
+ const initialCuts = {
766
+ version: VIDEO_CUTLIST_VERSION,
767
+ source: sourceFilename,
768
+ cuts: []
769
+ };
770
+ if (!existsSync(cutsPath)) {
771
+ writeFileSync(cutsPath, JSON.stringify(initialCuts, null, 2), "utf-8");
772
+ }
773
+ return {
774
+ dir: projectDir,
775
+ sourcePath,
776
+ compositionPath,
777
+ transcriptPath,
778
+ cutsPath,
779
+ exportDir,
780
+ manifest
781
+ };
782
+ }
783
+ function loadVideoProject(dir) {
784
+ const projectDir = resolve2(dir);
785
+ const possibleExts = [".mp4", ".mov", ".webm", ".mkv", ".avi"];
786
+ let sourceFilename = "source.mp4";
787
+ for (const ext of possibleExts) {
788
+ if (existsSync(join(projectDir, `source${ext}`))) {
789
+ sourceFilename = `source${ext}`;
790
+ break;
791
+ }
792
+ }
793
+ const manifest = {
794
+ source: sourceFilename,
795
+ composition: "project.atelier",
796
+ transcript: "transcript.json",
797
+ cuts: "cuts.json",
798
+ exportDir: "export/",
799
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
800
+ version: VIDEO_PROJECT_VERSION
801
+ };
802
+ return {
803
+ dir: projectDir,
804
+ sourcePath: join(projectDir, sourceFilename),
805
+ compositionPath: join(projectDir, "project.atelier"),
806
+ transcriptPath: join(projectDir, "transcript.json"),
807
+ cutsPath: join(projectDir, "cuts.json"),
808
+ exportDir: join(projectDir, "export"),
809
+ manifest
810
+ };
811
+ }
812
+ function readCutList(project) {
813
+ if (!existsSync(project.cutsPath)) {
814
+ return { version: VIDEO_CUTLIST_VERSION, source: project.manifest.source, cuts: [] };
815
+ }
816
+ const raw = JSON.parse(readFileSync2(project.cutsPath, "utf-8"));
817
+ const cuts = raw.cuts.map((entry) => {
818
+ if ("rawStart" in entry) return entry;
819
+ return {
820
+ rawStart: entry.start,
821
+ rawEnd: entry.end,
822
+ paddingPre: 0,
823
+ paddingPost: 0,
824
+ ...entry.label !== void 0 && { label: entry.label }
825
+ };
826
+ });
827
+ return {
828
+ version: VIDEO_CUTLIST_VERSION,
829
+ source: raw.source,
830
+ cuts
831
+ };
832
+ }
833
+ function writeCutList(project, cuts) {
834
+ const payload = { ...cuts, version: VIDEO_CUTLIST_VERSION };
835
+ writeFileSync(project.cutsPath, JSON.stringify(payload, null, 2), "utf-8");
836
+ }
837
+ function readTranscript(project) {
838
+ if (!existsSync(project.transcriptPath)) return null;
839
+ const raw = JSON.parse(readFileSync2(project.transcriptPath, "utf-8"));
840
+ const segments = raw.segments.map((seg) => ({
841
+ text: seg.text,
842
+ start: seg.start,
843
+ end: seg.end,
844
+ words: seg.words.map((w) => {
845
+ if ("detected" in w) return w;
846
+ return {
847
+ detected: w.word,
848
+ text: w.word,
849
+ start: w.start,
850
+ end: w.end,
851
+ ...w.confidence !== void 0 && { confidence: w.confidence }
852
+ };
853
+ })
854
+ }));
855
+ return {
856
+ version: VIDEO_TRANSCRIPT_VERSION,
857
+ ...raw.language !== void 0 && { language: raw.language },
858
+ segments
859
+ };
860
+ }
861
+ function writeTranscript(project, transcript) {
862
+ const payload = { ...transcript, version: VIDEO_TRANSCRIPT_VERSION };
863
+ writeFileSync(project.transcriptPath, JSON.stringify(payload, null, 2), "utf-8");
864
+ }
865
+ function readComposition(project) {
866
+ return JSON.parse(readFileSync2(project.compositionPath, "utf-8"));
867
+ }
868
+ function writeComposition(project, doc) {
869
+ writeFileSync(project.compositionPath, JSON.stringify(doc, null, 2), "utf-8");
870
+ }
871
+ function rewriteCutLayers(doc, cuts, sourceFilename, sourceDuration, assetId = "src") {
872
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("silence-trim"));
873
+ const fps = doc.canvas.fps;
874
+ let cumulativeFrame = 0;
875
+ const trimLayers = cuts.map((cut, idx) => {
876
+ const span = effectiveSpan(cut, sourceDuration);
877
+ const sourceOffsetFrames = Math.floor(span.start * fps) / fps;
878
+ const sourceEndFrames = Math.ceil(span.end * fps) / fps;
879
+ const durationFrames = Math.max(1, Math.round((sourceEndFrames - sourceOffsetFrames) * fps));
880
+ const layer = {
881
+ id: `clip-trim-${idx}`,
882
+ tags: ["silence-trim"],
883
+ visual: {
884
+ type: "video",
885
+ assetId,
886
+ src: sourceFilename,
887
+ startFrame: cumulativeFrame,
888
+ sourceOffset: sourceOffsetFrames,
889
+ sourceEnd: sourceEndFrames,
890
+ playbackRate: 1,
891
+ objectFit: "contain"
892
+ },
893
+ frame: { x: 0, y: 0 },
894
+ bounds: { width: doc.canvas.width, height: doc.canvas.height }
895
+ };
896
+ cumulativeFrame += durationFrames;
897
+ return layer;
898
+ });
899
+ return { ...doc, layers: [...preserved, ...trimLayers] };
900
+ }
901
+
902
+ // src/lib/recipe.ts
903
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
904
+ import { join as join2, resolve as resolve3, isAbsolute } from "path";
905
+ import { homedir } from "os";
906
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
907
+ var RECIPE_VERSION = "1.0";
908
+ function resolveRecipePath(pathOrName, projectDir) {
909
+ if (isAbsolute(pathOrName) || pathOrName.includes("/") || pathOrName.includes("\\")) {
910
+ return resolve3(pathOrName);
911
+ }
912
+ const candidates = [];
913
+ const exts = [".recipe.yaml", ".recipe.json", ".yaml", ".yml", ".json"];
914
+ if (projectDir) {
915
+ const projectRecipesDir = join2(resolve3(projectDir), ".atelier", "recipes");
916
+ for (const ext of exts) candidates.push(join2(projectRecipesDir, `${pathOrName}${ext}`));
917
+ }
918
+ const userRecipesDir = join2(homedir(), ".atelier", "recipes");
919
+ for (const ext of exts) candidates.push(join2(userRecipesDir, `${pathOrName}${ext}`));
920
+ for (const candidate of candidates) {
921
+ if (existsSync2(candidate)) return candidate;
922
+ }
923
+ throw new Error(
924
+ `Recipe "${pathOrName}" not found. Looked in:
925
+ ${candidates.map((c) => ` ${c}`).join("\n")}`
926
+ );
927
+ }
928
+ function loadRecipe(pathOrName, projectDir) {
929
+ const path = resolveRecipePath(pathOrName, projectDir);
930
+ const raw = readFileSync3(path, "utf-8");
931
+ let parsed;
932
+ if (path.endsWith(".json")) {
933
+ parsed = JSON.parse(raw);
934
+ } else {
935
+ parsed = parseYaml(raw);
936
+ }
937
+ const result = validateRecipe(parsed);
938
+ if (!result.success) {
939
+ const msg = result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
940
+ throw new Error(`Invalid recipe at ${path}:
941
+ ${msg}`);
942
+ }
943
+ return {
944
+ recipe: result.data,
945
+ path,
946
+ warnings: result.warnings ?? []
947
+ };
948
+ }
949
+ function scaffoldRecipeYaml(name) {
950
+ return `# Studio Recipe \u2014 ${name}
951
+ # Phase 1 \u2014 manual authoring + apply
952
+ # https://github.com/ascend42/a-atelier/blob/main/.paradigm/specs/studio-recipe.md
953
+
954
+ version: "${RECIPE_VERSION}"
955
+ name: "${name}"
956
+ description: ""
957
+ author: ""
958
+ tags: []
959
+
960
+ # \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
961
+ silence_policy:
962
+ # silencedetect noise threshold (default: -30dB)
963
+ noise: "-30dB"
964
+ # Minimum silence duration to register, in seconds (default: 0.35)
965
+ min_silence: 0.35
966
+ # Default leading padding for new cuts, in seconds (default: 0.08)
967
+ default_padding_pre: 0.08
968
+ # Default trailing padding for new cuts, in seconds (default: 0.12)
969
+ default_padding_post: 0.12
970
+ # Re-detect match tolerance for preserving user padding, in seconds (default: 0.5)
971
+ match_tolerance: 0.5
972
+
973
+ # \u2500\u2500 Caption visual style \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
974
+ caption_style:
975
+ font_family: "Inter"
976
+ font_size: 84
977
+ font_weight: "bold" # normal | bold | numeric (100..900)
978
+ text_align: "center" # left | center | right
979
+ color: "#FFFFFF"
980
+ y_ratio: 0.85 # 0=top, 1=bottom
981
+ width_ratio: 0.9
982
+ fade_seconds: 0.05
983
+
984
+ # \u2500\u2500 Caption phrase grouping \u2014 consumed by \`atelier transcribe\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
985
+ caption_grouping:
986
+ max_words: 5
987
+ pause_gap: 0.4
988
+
989
+ # \u2500\u2500 Overlay rules (Phase 1.5) \u2014 anchored handle + page-number overlays \u2500
990
+ # overlay_rules:
991
+ # handle:
992
+ # text: "@username"
993
+ # anchor: "bottom-left" # top-left | top-right | bottom-left | bottom-right
994
+ # margin: 24 # px from anchored edges
995
+ # style:
996
+ # font_family: "Inter"
997
+ # font_size: 36
998
+ # font_weight: "bold"
999
+ # color: "#FFFFFF"
1000
+ # page_number:
1001
+ # format: "{current}/{total}" # supports {current:02d} / {total:02d} zero-pad
1002
+ # anchor: "top-right"
1003
+ # margin: 24
1004
+ # style:
1005
+ # font_family: "Inter"
1006
+ # font_size: 36
1007
+ # font_weight: "normal"
1008
+ # color: "#FFFFFF"
1009
+
1010
+ # \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
1011
+ # caption_highlight: {}
1012
+ # transition_kit: {}
1013
+ # palette: {}
1014
+ # audio_policy: {}
1015
+ # aspect_targets: []
1016
+ `;
1017
+ }
1018
+ function applyRecipeToTrimOptions(recipe, cliOptions) {
1019
+ const policy = recipe?.silence_policy;
1020
+ if (!policy) return cliOptions;
1021
+ return {
1022
+ ...cliOptions,
1023
+ noise: cliOptions.noise ?? policy.noise,
1024
+ minSilence: cliOptions.minSilence ?? policy.min_silence,
1025
+ padPre: cliOptions.padPre ?? policy.default_padding_pre,
1026
+ padPost: cliOptions.padPost ?? policy.default_padding_post,
1027
+ // matchTolerance is recipe-only at this layer (no CLI flag yet)
1028
+ matchTolerance: cliOptions.matchTolerance ?? policy.match_tolerance
1029
+ };
1030
+ }
1031
+ function applyRecipeToCaptionOptions(recipe) {
1032
+ if (!recipe) return {};
1033
+ const style = recipe.caption_style;
1034
+ const grouping = recipe.caption_grouping;
1035
+ const runtimeStyle = {};
1036
+ if (style) {
1037
+ if (style.font_family !== void 0) runtimeStyle.fontFamily = style.font_family;
1038
+ if (style.font_size !== void 0) runtimeStyle.fontSize = style.font_size;
1039
+ if (style.font_weight !== void 0) runtimeStyle.fontWeight = style.font_weight;
1040
+ if (style.text_align !== void 0) runtimeStyle.textAlign = style.text_align;
1041
+ if (style.color !== void 0) runtimeStyle.color = style.color;
1042
+ if (style.y_ratio !== void 0) runtimeStyle.yRatio = style.y_ratio;
1043
+ if (style.width_ratio !== void 0) runtimeStyle.widthRatio = style.width_ratio;
1044
+ if (style.fade_seconds !== void 0) runtimeStyle.fadeSeconds = style.fade_seconds;
1045
+ }
1046
+ return {
1047
+ ...Object.keys(runtimeStyle).length > 0 && { style: runtimeStyle },
1048
+ ...grouping?.max_words !== void 0 && { maxWords: grouping.max_words },
1049
+ ...grouping?.pause_gap !== void 0 && { pauseGap: grouping.pause_gap }
1050
+ };
1051
+ }
1052
+ function applyRecipeToTranscribeOptions(recipe, cliOptions) {
1053
+ if (!recipe) return cliOptions;
1054
+ const captionOptions = applyRecipeToCaptionOptions(recipe);
1055
+ return {
1056
+ ...cliOptions,
1057
+ captionOptions
1058
+ };
1059
+ }
1060
+ function renderRecipeWithDefaults(recipe) {
1061
+ const defaults = {
1062
+ silence_policy: {
1063
+ noise: "-30dB",
1064
+ min_silence: 0.35,
1065
+ default_padding_pre: 0.08,
1066
+ default_padding_post: 0.12,
1067
+ match_tolerance: 0.5
1068
+ },
1069
+ caption_style: {
1070
+ font_family: "Inter",
1071
+ font_size: 84,
1072
+ font_weight: "bold",
1073
+ text_align: "center",
1074
+ color: "#FFFFFF",
1075
+ y_ratio: 0.85,
1076
+ width_ratio: 0.9,
1077
+ fade_seconds: 0.05
1078
+ },
1079
+ caption_grouping: {
1080
+ max_words: 5,
1081
+ pause_gap: 0.4
1082
+ }
1083
+ };
1084
+ return {
1085
+ ...recipe,
1086
+ silence_policy: { ...defaults.silence_policy, ...recipe.silence_policy },
1087
+ caption_style: { ...defaults.caption_style, ...recipe.caption_style },
1088
+ caption_grouping: { ...defaults.caption_grouping, ...recipe.caption_grouping }
1089
+ };
1090
+ }
1091
+ function recipeToYaml(recipe) {
1092
+ return stringifyYaml(recipe);
1093
+ }
1094
+ var DEFAULT_OVERLAY_MARGIN = 24;
1095
+ var DEFAULT_OVERLAY_TEXT_STYLE = {
1096
+ fontFamily: "Inter",
1097
+ fontSize: 24,
1098
+ fontWeight: 600,
1099
+ color: "#F5F5F7"
1100
+ };
1101
+ var HANDLE_LAYER_ID = "overlay-handle";
1102
+ var PAGE_NUMBER_LAYER_ID = "overlay-page-number";
1103
+ function applyRecipeToOverlay(doc, recipe, ctx) {
1104
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("overlay"));
1105
+ const overlayLayers = [];
1106
+ const rules = recipe.overlay_rules;
1107
+ if (!rules) {
1108
+ return { ...doc, layers: preserved };
1109
+ }
1110
+ if (rules.handle) {
1111
+ overlayLayers.push(buildHandleLayer(rules.handle, doc.canvas));
1112
+ }
1113
+ if (rules.page_number) {
1114
+ if (ctx?.currentIndex != null && ctx?.totalCount != null) {
1115
+ overlayLayers.push(
1116
+ buildPageNumberLayer(rules.page_number, doc.canvas, ctx.currentIndex, ctx.totalCount)
1117
+ );
1118
+ } else {
1119
+ console.warn(
1120
+ `applyRecipeToOverlay: recipe.overlay_rules.page_number present but currentIndex/totalCount not provided \u2014 skipping page_number layer.`
1121
+ );
1122
+ }
1123
+ }
1124
+ return {
1125
+ ...doc,
1126
+ layers: [...preserved, ...overlayLayers]
1127
+ };
1128
+ }
1129
+ function buildHandleLayer(rule, canvas) {
1130
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
1131
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
1132
+ return {
1133
+ id: HANDLE_LAYER_ID,
1134
+ tags: ["overlay"],
1135
+ visual: {
1136
+ type: "text",
1137
+ content: rule.text,
1138
+ style: mergeOverlayStyle(rule.style)
1139
+ },
1140
+ frame,
1141
+ bounds: { width: 600, height: 80 },
1142
+ anchorPoint
1143
+ };
1144
+ }
1145
+ function buildPageNumberLayer(rule, canvas, currentIndex, totalCount) {
1146
+ const margin = rule.margin ?? DEFAULT_OVERLAY_MARGIN;
1147
+ const { frame, anchorPoint } = anchorToFrame(rule.anchor, canvas, margin);
1148
+ return {
1149
+ id: PAGE_NUMBER_LAYER_ID,
1150
+ tags: ["overlay"],
1151
+ visual: {
1152
+ type: "text",
1153
+ content: renderPageNumberFormat(rule.format, currentIndex, totalCount),
1154
+ style: mergeOverlayStyle(rule.style)
1155
+ },
1156
+ frame,
1157
+ bounds: { width: 200, height: 80 },
1158
+ anchorPoint
1159
+ };
1160
+ }
1161
+ function anchorToFrame(anchor, canvas, margin) {
1162
+ switch (anchor) {
1163
+ case "top-left":
1164
+ return { frame: { x: margin, y: margin }, anchorPoint: { x: 0, y: 0 } };
1165
+ case "top-right":
1166
+ return { frame: { x: canvas.width - margin, y: margin }, anchorPoint: { x: 1, y: 0 } };
1167
+ case "bottom-left":
1168
+ return { frame: { x: margin, y: canvas.height - margin }, anchorPoint: { x: 0, y: 1 } };
1169
+ case "bottom-right":
1170
+ return {
1171
+ frame: { x: canvas.width - margin, y: canvas.height - margin },
1172
+ anchorPoint: { x: 1, y: 1 }
1173
+ };
1174
+ }
1175
+ }
1176
+ function mergeOverlayStyle(style) {
1177
+ return {
1178
+ fontFamily: style?.font_family ?? DEFAULT_OVERLAY_TEXT_STYLE.fontFamily,
1179
+ fontSize: style?.font_size ?? DEFAULT_OVERLAY_TEXT_STYLE.fontSize,
1180
+ fontWeight: style?.font_weight ?? DEFAULT_OVERLAY_TEXT_STYLE.fontWeight,
1181
+ color: style?.color ?? DEFAULT_OVERLAY_TEXT_STYLE.color
1182
+ };
1183
+ }
1184
+ function renderPageNumberFormat(format, currentIndex, totalCount) {
1185
+ return format.replace(
1186
+ /\{(current|total)(?::0(\d+)d)?\}/g,
1187
+ (_, name, padWidth) => {
1188
+ const value = name === "current" ? currentIndex : totalCount;
1189
+ const str = String(value);
1190
+ if (padWidth) {
1191
+ const width = parseInt(padWidth, 10);
1192
+ return str.padStart(width, "0");
1193
+ }
1194
+ return str;
1195
+ }
1196
+ );
1197
+ }
1198
+
1199
+ // src/lib/silence-detect.ts
1200
+ import { spawn } from "child_process";
1201
+ async function probeSilencedetect() {
1202
+ const stdout = await runCapture("ffmpeg", ["-hide_banner", "-filters"]);
1203
+ if (!/\bsilencedetect\b/.test(stdout)) {
1204
+ throw new Error(
1205
+ "Your ffmpeg build lacks the silencedetect filter. Install a standard ffmpeg \u2265 4.0 (brew install ffmpeg / apt install ffmpeg)."
1206
+ );
1207
+ }
1208
+ }
1209
+ async function probeDuration(sourcePath) {
1210
+ const stdout = await runCapture("ffprobe", [
1211
+ "-v",
1212
+ "error",
1213
+ "-show_entries",
1214
+ "format=duration",
1215
+ "-of",
1216
+ "csv=p=0",
1217
+ sourcePath
1218
+ ]);
1219
+ const n = parseFloat(stdout.trim());
1220
+ if (!Number.isFinite(n) || n <= 0) {
1221
+ throw new Error(`ffprobe returned invalid duration for ${sourcePath}: "${stdout}"`);
1222
+ }
1223
+ return n;
1224
+ }
1225
+ async function runSilenceDetect(sourcePath, options = {}) {
1226
+ const noise = options.noise ?? "-30dB";
1227
+ const minSilence = options.minSilence ?? 0.35;
1228
+ const filter = `silencedetect=noise=${noise}:d=${minSilence}`;
1229
+ const stderr = await runCaptureStderr("ffmpeg", [
1230
+ "-hide_banner",
1231
+ "-nostats",
1232
+ "-i",
1233
+ sourcePath,
1234
+ "-af",
1235
+ filter,
1236
+ "-f",
1237
+ "null",
1238
+ "-"
1239
+ ]);
1240
+ return parseSilenceDetectStderr(stderr);
1241
+ }
1242
+ function parseSilenceDetectStderr(stderr) {
1243
+ const intervals = [];
1244
+ const startRe = /silence_start:\s*(-?[\d.]+)/;
1245
+ const endRe = /silence_end:\s*(-?[\d.]+)/;
1246
+ let pendingStart = null;
1247
+ for (const line of stderr.split(/\r?\n/)) {
1248
+ const sm = line.match(startRe);
1249
+ if (sm) {
1250
+ pendingStart = parseFloat(sm[1]);
1251
+ continue;
1252
+ }
1253
+ const em = line.match(endRe);
1254
+ if (em && pendingStart !== null) {
1255
+ const end = parseFloat(em[1]);
1256
+ intervals.push({ start: Math.max(0, pendingStart), end });
1257
+ pendingStart = null;
1258
+ }
1259
+ }
1260
+ return intervals;
1261
+ }
1262
+ function runCapture(cmd, args) {
1263
+ return new Promise((resolve14, reject) => {
1264
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1265
+ let stdout = "";
1266
+ proc.stdout.on("data", (b) => stdout += b.toString());
1267
+ proc.on("error", (err) => {
1268
+ const e = err;
1269
+ if (e.code === "ENOENT") {
1270
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg/ffprobe (brew install ffmpeg).`));
1271
+ } else {
1272
+ reject(err);
1273
+ }
1274
+ });
1275
+ proc.on("close", (code) => {
1276
+ if (code !== 0) reject(new Error(`${cmd} exited ${code}`));
1277
+ else resolve14(stdout);
1278
+ });
1279
+ });
1280
+ }
1281
+ function runCaptureStderr(cmd, args) {
1282
+ return new Promise((resolve14, reject) => {
1283
+ const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
1284
+ let stderr = "";
1285
+ proc.stderr.on("data", (b) => stderr += b.toString());
1286
+ proc.on("error", (err) => {
1287
+ const e = err;
1288
+ if (e.code === "ENOENT") {
1289
+ reject(new Error(`${cmd} not found on PATH. Install ffmpeg (brew install ffmpeg).`));
1290
+ } else {
1291
+ reject(err);
1292
+ }
1293
+ });
1294
+ proc.on("close", () => resolve14(stderr));
1295
+ });
1296
+ }
1297
+
1298
+ // src/lib/cut-model.ts
1299
+ var DEFAULT_PADDING_PRE = 0.08;
1300
+ var DEFAULT_PADDING_POST = 0.12;
1301
+ var DEFAULT_MATCH_TOLERANCE = 0.5;
1302
+ function invertToSpeechIntervals(silences, duration) {
1303
+ if (silences.length === 0) {
1304
+ return duration > 0 ? [{ start: 0, end: duration }] : [];
1305
+ }
1306
+ const sorted = [...silences].sort((a, b) => a.start - b.start);
1307
+ const speech = [];
1308
+ if (sorted[0].start > 0) {
1309
+ speech.push({ start: 0, end: sorted[0].start });
1310
+ }
1311
+ for (let i = 0; i < sorted.length - 1; i++) {
1312
+ const gapStart = sorted[i].end;
1313
+ const gapEnd = sorted[i + 1].start;
1314
+ if (gapEnd > gapStart) {
1315
+ speech.push({ start: gapStart, end: gapEnd });
1316
+ }
1317
+ }
1318
+ const last = sorted[sorted.length - 1];
1319
+ if (last.end < duration) {
1320
+ speech.push({ start: last.end, end: duration });
1321
+ }
1322
+ return speech;
1323
+ }
1324
+ function buildInitialCuts(speech, paddingPre = DEFAULT_PADDING_PRE, paddingPost = DEFAULT_PADDING_POST) {
1325
+ return speech.map((s) => ({
1326
+ rawStart: s.start,
1327
+ rawEnd: s.end,
1328
+ paddingPre,
1329
+ paddingPost
1330
+ }));
1331
+ }
1332
+ function resolveOverlaps(cuts) {
1333
+ for (let i = 0; i < cuts.length - 1; i++) {
1334
+ const a = cuts[i];
1335
+ const b = cuts[i + 1];
1336
+ const aEnd = a.rawEnd + a.paddingPost;
1337
+ const bStart = b.rawStart - b.paddingPre;
1338
+ if (aEnd > bStart) {
1339
+ const mid = (a.rawEnd + b.rawStart) / 2;
1340
+ a.paddingPost = Math.max(0, mid - a.rawEnd);
1341
+ b.paddingPre = Math.max(0, b.rawStart - mid);
1342
+ }
1343
+ }
1344
+ }
1345
+ function clampBoundaries(cuts, duration) {
1346
+ if (cuts.length === 0) return;
1347
+ const first = cuts[0];
1348
+ if (first.rawStart - first.paddingPre < 0) {
1349
+ first.paddingPre = first.rawStart;
1350
+ }
1351
+ const last = cuts[cuts.length - 1];
1352
+ if (last.rawEnd + last.paddingPost > duration) {
1353
+ last.paddingPost = Math.max(0, duration - last.rawEnd);
1354
+ }
1355
+ }
1356
+ function mergeWithExisting(fresh, existing, tolerance = DEFAULT_MATCH_TOLERANCE) {
1357
+ return fresh.map((f) => {
1358
+ const match = existing.find(
1359
+ (e) => Math.abs(e.rawStart - f.rawStart) < tolerance && Math.abs(e.rawEnd - f.rawEnd) < tolerance
1360
+ );
1361
+ if (!match) return f;
1362
+ return {
1363
+ rawStart: f.rawStart,
1364
+ rawEnd: f.rawEnd,
1365
+ paddingPre: match.paddingPre,
1366
+ paddingPost: match.paddingPost,
1367
+ ...match.label !== void 0 && { label: match.label }
1368
+ };
1369
+ });
1370
+ }
1371
+ function applyGlobalPadding(cuts, deltaSeconds) {
1372
+ for (const cut of cuts) {
1373
+ cut.paddingPre = Math.max(0, cut.paddingPre + deltaSeconds);
1374
+ cut.paddingPost = Math.max(0, cut.paddingPost + deltaSeconds);
1375
+ }
1376
+ }
1377
+
1378
+ // src/commands/trim.ts
1379
+ async function trimProject(projectDir, options = {}) {
1380
+ const project = loadVideoProject(projectDir);
1381
+ await probeSilencedetect();
1382
+ const duration = await probeDuration(project.sourcePath);
1383
+ const silences = await runSilenceDetect(project.sourcePath, {
1384
+ noise: options.noise,
1385
+ minSilence: options.minSilence
1386
+ });
1387
+ const speech = invertToSpeechIntervals(silences, duration);
1388
+ const padPre = options.padPre ?? DEFAULT_PADDING_PRE;
1389
+ const padPost = options.padPost ?? DEFAULT_PADDING_POST;
1390
+ let cuts = buildInitialCuts(speech, padPre, padPost);
1391
+ if (!options.reset) {
1392
+ const existing = readCutList(project);
1393
+ cuts = mergeWithExisting(cuts, existing.cuts, options.matchTolerance);
1394
+ }
1395
+ if (typeof options.tightenMs === "number") {
1396
+ applyGlobalPadding(cuts, -options.tightenMs / 1e3);
1397
+ }
1398
+ if (typeof options.loosenMs === "number") {
1399
+ applyGlobalPadding(cuts, options.loosenMs / 1e3);
1400
+ }
1401
+ if (typeof options.cutIndex === "number") {
1402
+ if (options.cutIndex < 0 || options.cutIndex >= cuts.length) {
1403
+ throw new Error(
1404
+ `--cut ${options.cutIndex} out of range (have ${cuts.length} cuts)`
1405
+ );
1406
+ }
1407
+ if (options.padPre !== void 0) cuts[options.cutIndex].paddingPre = options.padPre;
1408
+ if (options.padPost !== void 0) cuts[options.cutIndex].paddingPost = options.padPost;
1409
+ }
1410
+ resolveOverlaps(cuts);
1411
+ clampBoundaries(cuts, duration);
1412
+ const result = {
1413
+ projectDir: project.dir,
1414
+ duration,
1415
+ cuts,
1416
+ layerCount: cuts.length
1417
+ };
1418
+ if (options.dryRun) return result;
1419
+ writeCutList(project, {
1420
+ version: "1.1",
1421
+ source: project.manifest.source,
1422
+ cuts
1423
+ });
1424
+ const doc = readComposition(project);
1425
+ const updated = rewriteCutLayers(doc, cuts, project.manifest.source, duration);
1426
+ writeComposition(project, updated);
1427
+ return result;
1428
+ }
1429
+ function formatResult(result) {
1430
+ const lines = [];
1431
+ lines.push(`Trimmed ${result.projectDir}`);
1432
+ lines.push(` source duration: ${result.duration.toFixed(2)}s`);
1433
+ lines.push(` cuts: ${result.cuts.length}`);
1434
+ for (let i = 0; i < result.cuts.length; i++) {
1435
+ const c = result.cuts[i];
1436
+ const effStart = Math.max(0, c.rawStart - c.paddingPre);
1437
+ const effEnd = Math.min(result.duration, c.rawEnd + c.paddingPost);
1438
+ const dur = effEnd - effStart;
1439
+ lines.push(
1440
+ ` [${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}"` : "")
1441
+ );
1442
+ }
1443
+ return lines.join("\n");
1444
+ }
1445
+ function trimCommand(program) {
1446
+ program.command("trim <project>").description(
1447
+ "Detect silence and rewrite project.atelier with parametric cuts (silence-trim tagged layers). Preserves user padding overrides on re-run."
1448
+ ).option("--noise <dB>", "Silence threshold (default: -30dB)", "-30dB").option(
1449
+ "--min-silence <seconds>",
1450
+ "Minimum silence duration to register (default: 0.35)",
1451
+ (v) => parseFloat(v),
1452
+ 0.35
1453
+ ).option(
1454
+ "--pad-pre <seconds>",
1455
+ `Default leading padding (default: ${DEFAULT_PADDING_PRE})`,
1456
+ (v) => parseFloat(v)
1457
+ ).option(
1458
+ "--pad-post <seconds>",
1459
+ `Default trailing padding (default: ${DEFAULT_PADDING_POST})`,
1460
+ (v) => parseFloat(v)
1461
+ ).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(
1462
+ "--cut <index>",
1463
+ "Apply --pad-pre / --pad-post to one specific cut only",
1464
+ (v) => parseInt(v, 10)
1465
+ ).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) => {
1466
+ try {
1467
+ let trimOpts = {
1468
+ noise: opts.noise,
1469
+ minSilence: opts.minSilence,
1470
+ padPre: opts.padPre,
1471
+ padPost: opts.padPost,
1472
+ tightenMs: opts.tighten,
1473
+ loosenMs: opts.loosen,
1474
+ cutIndex: opts.cut,
1475
+ reset: opts.reset,
1476
+ dryRun: opts.dryRun
1477
+ };
1478
+ if (opts.recipe) {
1479
+ const { recipe } = loadRecipe(opts.recipe, project);
1480
+ trimOpts = applyRecipeToTrimOptions(recipe, trimOpts);
1481
+ }
1482
+ const result = await trimProject(project, trimOpts);
1483
+ if (opts.json) {
1484
+ console.log(JSON.stringify(result, null, 2));
1485
+ } else {
1486
+ console.log(formatResult(result));
1487
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
1488
+ }
1489
+ } catch (err) {
1490
+ const msg = err instanceof Error ? err.message : String(err);
1491
+ console.error(`atelier trim: ${msg}`);
1492
+ process.exit(1);
1493
+ }
1494
+ });
1495
+ }
1496
+
1497
+ // src/lib/whisper.ts
1498
+ import { spawn as spawn2 } from "child_process";
1499
+ import { existsSync as existsSync3 } from "fs";
1500
+ async function probeWhisper() {
1501
+ if (await commandExists("whisper-cli")) return "whisper-cpp";
1502
+ if (process.env.OPENAI_API_KEY) return "openai-api";
1503
+ return "none";
1504
+ }
1505
+ async function runWhisperCpp(sourcePath, options = {}) {
1506
+ const model = options.modelPath ?? options.model ?? "base.en";
1507
+ const args = [
1508
+ sourcePath,
1509
+ "--model",
1510
+ model,
1511
+ "--output-json",
1512
+ "--word-thold",
1513
+ "0.01",
1514
+ // emit word-level timestamps
1515
+ "--print-progress",
1516
+ "false"
1517
+ ];
1518
+ if (options.language) {
1519
+ args.push("--language", options.language);
1520
+ }
1521
+ return runCaptureStdout("whisper-cli", args);
1522
+ }
1523
+ function parseWhisperCppJson(jsonStr) {
1524
+ const raw = JSON.parse(jsonStr);
1525
+ const segments = (raw.transcription ?? []).map((seg) => {
1526
+ const segStart = (seg.offsets?.from ?? 0) / 1e3;
1527
+ const segEnd = (seg.offsets?.to ?? 0) / 1e3;
1528
+ const segText = seg.text.trim();
1529
+ let words;
1530
+ if (seg.tokens && seg.tokens.length > 0) {
1531
+ words = seg.tokens.filter((t) => t.text.trim().length > 0 && !t.text.startsWith("[_")).map((t) => ({
1532
+ detected: t.text.trim(),
1533
+ text: t.text.trim(),
1534
+ start: (t.offsets?.from ?? segStart * 1e3) / 1e3,
1535
+ end: (t.offsets?.to ?? segEnd * 1e3) / 1e3,
1536
+ ...t.p !== void 0 && { confidence: t.p }
1537
+ }));
1538
+ } else {
1539
+ const tokens = segText.split(/\s+/).filter((t) => t.length > 0);
1540
+ const span = segEnd - segStart;
1541
+ const per = tokens.length > 0 ? span / tokens.length : 0;
1542
+ words = tokens.map((tok, i) => ({
1543
+ detected: tok,
1544
+ text: tok,
1545
+ start: segStart + i * per,
1546
+ end: segStart + (i + 1) * per
1547
+ }));
1548
+ }
1549
+ return {
1550
+ text: segText,
1551
+ start: segStart,
1552
+ end: segEnd,
1553
+ words
1554
+ };
1555
+ });
1556
+ return {
1557
+ version: "1.1",
1558
+ ...raw.result?.language !== void 0 && { language: raw.result.language },
1559
+ segments
1560
+ };
1561
+ }
1562
+ async function commandExists(name) {
1563
+ if (name.startsWith("/") || name.match(/^[A-Z]:\\/)) {
1564
+ return existsSync3(name);
1565
+ }
1566
+ return new Promise((resolve14) => {
1567
+ const probe = spawn2(process.platform === "win32" ? "where" : "which", [name], {
1568
+ stdio: ["ignore", "ignore", "ignore"]
1569
+ });
1570
+ probe.on("error", () => resolve14(false));
1571
+ probe.on("close", (code) => resolve14(code === 0));
1572
+ });
1573
+ }
1574
+ function runCaptureStdout(cmd, args) {
1575
+ return new Promise((resolve14, reject) => {
1576
+ const proc = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1577
+ let stdout = "";
1578
+ let stderr = "";
1579
+ proc.stdout.on("data", (b) => stdout += b.toString());
1580
+ proc.stderr.on("data", (b) => stderr += b.toString());
1581
+ proc.on("error", (err) => {
1582
+ const e = err;
1583
+ if (e.code === "ENOENT") {
1584
+ reject(new Error(
1585
+ `${cmd} not found on PATH. Install whisper.cpp (brew install whisper-cpp) or run \`pnpm add @xenova/transformers\` for the ONNX fallback.`
1586
+ ));
1587
+ } else {
1588
+ reject(err);
1589
+ }
1590
+ });
1591
+ proc.on("close", (code) => {
1592
+ if (code !== 0) {
1593
+ reject(new Error(`${cmd} exited ${code}
1594
+ ${stderr}`));
1595
+ } else {
1596
+ resolve14(stdout);
1597
+ }
1598
+ });
1599
+ });
1600
+ }
1601
+
1602
+ // src/lib/transcript-model.ts
1603
+ var DEFAULT_TRANSCRIPT_MATCH_TOLERANCE = 0.3;
1604
+ var DEFAULT_PHRASE_MAX_WORDS = 5;
1605
+ var DEFAULT_PHRASE_PAUSE_GAP_SECONDS = 0.4;
1606
+ var END_PUNCT = /[.!?,;:]\s*$/;
1607
+ function flattenWords(transcript) {
1608
+ return transcript.segments.flatMap((s) => s.words);
1609
+ }
1610
+ function groupIntoPhrases(transcript, options = {}) {
1611
+ const maxWords = options.maxWords ?? DEFAULT_PHRASE_MAX_WORDS;
1612
+ const pauseGap = options.pauseGap ?? DEFAULT_PHRASE_PAUSE_GAP_SECONDS;
1613
+ const phrases = [];
1614
+ const visibleWords = flattenWords(transcript).filter((w) => !w.hidden);
1615
+ let current = [];
1616
+ for (let i = 0; i < visibleWords.length; i++) {
1617
+ const w = visibleWords[i];
1618
+ current.push(w);
1619
+ const next = visibleWords[i + 1];
1620
+ const gap = next ? next.start - w.end : 0;
1621
+ const endsOnPunct = END_PUNCT.test(w.text);
1622
+ const atMax = current.length >= maxWords;
1623
+ if (!next || atMax || endsOnPunct || gap > pauseGap) {
1624
+ phrases.push({
1625
+ start: current[0].start,
1626
+ end: current[current.length - 1].end,
1627
+ text: current.map((c) => c.text).join(" "),
1628
+ words: current
1629
+ });
1630
+ current = [];
1631
+ }
1632
+ }
1633
+ return phrases;
1634
+ }
1635
+ function mergeTranscriptWithExisting(fresh, existing, tolerance = DEFAULT_TRANSCRIPT_MATCH_TOLERANCE) {
1636
+ const existingWords = flattenWords(existing);
1637
+ const merged = {
1638
+ version: "1.1",
1639
+ ...fresh.language !== void 0 && { language: fresh.language },
1640
+ segments: fresh.segments.map((seg) => ({
1641
+ text: seg.text,
1642
+ start: seg.start,
1643
+ end: seg.end,
1644
+ words: seg.words.map((freshWord) => {
1645
+ const match = existingWords.find(
1646
+ (e) => !e.userAdded && Math.abs(e.start - freshWord.start) < tolerance && e.detected === freshWord.detected
1647
+ );
1648
+ if (!match) return freshWord;
1649
+ return {
1650
+ detected: freshWord.detected,
1651
+ text: match.text,
1652
+ start: freshWord.start,
1653
+ end: freshWord.end,
1654
+ ...freshWord.confidence !== void 0 && { confidence: freshWord.confidence },
1655
+ ...match.userEdited && { userEdited: true },
1656
+ ...match.hidden && { hidden: true }
1657
+ };
1658
+ })
1659
+ }))
1660
+ };
1661
+ const orphans = existingWords.filter((w) => w.userAdded);
1662
+ for (const orphan of orphans) {
1663
+ const segIdx = merged.segments.findIndex(
1664
+ (s) => orphan.start >= s.start && orphan.start <= s.end
1665
+ );
1666
+ const targetSeg = segIdx >= 0 ? merged.segments[segIdx] : merged.segments[merged.segments.length - 1];
1667
+ if (!targetSeg) continue;
1668
+ const insertIdx = targetSeg.words.findIndex((w) => w.start > orphan.start);
1669
+ if (insertIdx === -1) targetSeg.words.push(orphan);
1670
+ else targetSeg.words.splice(insertIdx, 0, orphan);
1671
+ }
1672
+ return merged;
1673
+ }
1674
+ function applyTextEdit(transcript, wordIndex, newText) {
1675
+ return mapWord(transcript, wordIndex, (w) => ({
1676
+ ...w,
1677
+ text: newText,
1678
+ userEdited: true
1679
+ }));
1680
+ }
1681
+ function applyBatchReplace(transcript, find, replace) {
1682
+ return {
1683
+ ...transcript,
1684
+ segments: transcript.segments.map((seg) => ({
1685
+ ...seg,
1686
+ words: seg.words.map(
1687
+ (w) => w.detected === find ? { ...w, text: replace, userEdited: true } : w
1688
+ )
1689
+ }))
1690
+ };
1691
+ }
1692
+ function applyHide(transcript, wordIndex) {
1693
+ return mapWord(transcript, wordIndex, (w) => ({ ...w, hidden: true }));
1694
+ }
1695
+ function applyAdd(transcript, afterIndex, text, duration = 0.15) {
1696
+ const flat = flattenWords(transcript);
1697
+ if (afterIndex < 0 || afterIndex >= flat.length) {
1698
+ throw new Error(`afterIndex ${afterIndex} out of range (have ${flat.length} words)`);
1699
+ }
1700
+ const anchor = flat[afterIndex];
1701
+ const newWord = {
1702
+ detected: text,
1703
+ text,
1704
+ start: anchor.end,
1705
+ end: anchor.end + duration,
1706
+ userAdded: true
1707
+ };
1708
+ let cursor = 0;
1709
+ return {
1710
+ ...transcript,
1711
+ segments: transcript.segments.map((seg) => {
1712
+ const segStart = cursor;
1713
+ cursor += seg.words.length;
1714
+ if (afterIndex < segStart || afterIndex >= cursor) return seg;
1715
+ const localIdx = afterIndex - segStart;
1716
+ return {
1717
+ ...seg,
1718
+ words: [...seg.words.slice(0, localIdx + 1), newWord, ...seg.words.slice(localIdx + 1)]
1719
+ };
1720
+ })
1721
+ };
1722
+ }
1723
+ function applyMerge(transcript, firstIndex) {
1724
+ const flat = flattenWords(transcript);
1725
+ if (firstIndex < 0 || firstIndex >= flat.length - 1) {
1726
+ throw new Error(`firstIndex ${firstIndex} out of range for merge`);
1727
+ }
1728
+ const a = flat[firstIndex];
1729
+ const b = flat[firstIndex + 1];
1730
+ const mergedWord = {
1731
+ detected: a.detected + b.detected,
1732
+ text: a.text + b.text,
1733
+ start: a.start,
1734
+ end: b.end,
1735
+ userEdited: true
1736
+ };
1737
+ let cursor = 0;
1738
+ return {
1739
+ ...transcript,
1740
+ segments: transcript.segments.map((seg) => {
1741
+ const segStart = cursor;
1742
+ cursor += seg.words.length;
1743
+ if (firstIndex < segStart || firstIndex >= cursor - 1) return seg;
1744
+ const localIdx = firstIndex - segStart;
1745
+ return {
1746
+ ...seg,
1747
+ words: [...seg.words.slice(0, localIdx), mergedWord, ...seg.words.slice(localIdx + 2)]
1748
+ };
1749
+ })
1750
+ };
1751
+ }
1752
+ function applySplit(transcript, wordIndex, fraction, firstText, secondText) {
1753
+ if (fraction <= 0 || fraction >= 1) {
1754
+ throw new Error(`split fraction must be in (0, 1), got ${fraction}`);
1755
+ }
1756
+ const flat = flattenWords(transcript);
1757
+ if (wordIndex < 0 || wordIndex >= flat.length) {
1758
+ throw new Error(`wordIndex ${wordIndex} out of range`);
1759
+ }
1760
+ const w = flat[wordIndex];
1761
+ const splitTime = w.start + (w.end - w.start) * fraction;
1762
+ const cutChar = Math.max(1, Math.floor(w.text.length * fraction));
1763
+ const first = {
1764
+ detected: w.detected,
1765
+ text: firstText ?? w.text.slice(0, cutChar),
1766
+ start: w.start,
1767
+ end: splitTime,
1768
+ userEdited: true
1769
+ };
1770
+ const second = {
1771
+ detected: w.detected,
1772
+ text: secondText ?? w.text.slice(cutChar),
1773
+ start: splitTime,
1774
+ end: w.end,
1775
+ userEdited: true
1776
+ };
1777
+ let cursor = 0;
1778
+ return {
1779
+ ...transcript,
1780
+ segments: transcript.segments.map((seg) => {
1781
+ const segStart = cursor;
1782
+ cursor += seg.words.length;
1783
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
1784
+ const localIdx = wordIndex - segStart;
1785
+ return {
1786
+ ...seg,
1787
+ words: [...seg.words.slice(0, localIdx), first, second, ...seg.words.slice(localIdx + 1)]
1788
+ };
1789
+ })
1790
+ };
1791
+ }
1792
+ function mapWord(transcript, wordIndex, fn) {
1793
+ const flat = flattenWords(transcript);
1794
+ if (wordIndex < 0 || wordIndex >= flat.length) {
1795
+ throw new Error(`wordIndex ${wordIndex} out of range (have ${flat.length} words)`);
1796
+ }
1797
+ let cursor = 0;
1798
+ return {
1799
+ ...transcript,
1800
+ segments: transcript.segments.map((seg) => {
1801
+ const segStart = cursor;
1802
+ cursor += seg.words.length;
1803
+ if (wordIndex < segStart || wordIndex >= cursor) return seg;
1804
+ const localIdx = wordIndex - segStart;
1805
+ return {
1806
+ ...seg,
1807
+ words: seg.words.map((w, i) => i === localIdx ? fn(w) : w)
1808
+ };
1809
+ })
1810
+ };
1811
+ }
1812
+
1813
+ // src/lib/caption-builder.ts
1814
+ var DEFAULT_STYLE = {
1815
+ fontFamily: "Inter",
1816
+ fontSize: 84,
1817
+ fontWeight: "bold",
1818
+ textAlign: "center",
1819
+ color: "#FFFFFF",
1820
+ yRatio: 0.85,
1821
+ widthRatio: 0.9,
1822
+ fadeSeconds: 0.05
1823
+ };
1824
+ function buildCaptionLayers(transcript, canvas, options = {}) {
1825
+ const style = { ...DEFAULT_STYLE, ...options.style };
1826
+ const phrases = groupIntoPhrases(transcript, {
1827
+ maxWords: options.maxWords,
1828
+ pauseGap: options.pauseGap
1829
+ });
1830
+ const layers = [];
1831
+ const deltas = [];
1832
+ const fps = canvas.fps;
1833
+ phrases.forEach((phrase, idx) => {
1834
+ const layerId = `caption-${idx}`;
1835
+ layers.push(buildPhraseLayer(layerId, phrase, canvas, style));
1836
+ deltas.push(...buildPhraseDeltas(layerId, phrase, style.fadeSeconds, fps));
1837
+ });
1838
+ return { layers, deltas };
1839
+ }
1840
+ function rewriteCaptionLayers(doc, transcript, options = {}) {
1841
+ const preserved = doc.layers.filter((l) => !(l.tags ?? []).includes("caption"));
1842
+ const { layers: captionLayers, deltas: captionDeltas } = buildCaptionLayers(
1843
+ transcript,
1844
+ doc.canvas,
1845
+ options
1846
+ );
1847
+ const captionLayerIds = new Set(captionLayers.map((l) => l.id));
1848
+ const stateNames = Object.keys(doc.states);
1849
+ const targetStateName = stateNames.includes("default") ? "default" : stateNames[0];
1850
+ const states = { ...doc.states };
1851
+ if (targetStateName && states[targetStateName]) {
1852
+ const existing = states[targetStateName];
1853
+ const preservedDeltas = existing.deltas.filter(
1854
+ (d) => !captionLayerIds.has(d.layer)
1855
+ );
1856
+ states[targetStateName] = {
1857
+ ...existing,
1858
+ deltas: [...preservedDeltas, ...captionDeltas]
1859
+ };
1860
+ }
1861
+ return {
1862
+ ...doc,
1863
+ layers: [...preserved, ...captionLayers],
1864
+ states
1865
+ };
1866
+ }
1867
+ function buildPhraseLayer(id, phrase, canvas, style) {
1868
+ return {
1869
+ id,
1870
+ tags: ["caption"],
1871
+ visual: {
1872
+ type: "text",
1873
+ content: phrase.text,
1874
+ style: {
1875
+ fontFamily: style.fontFamily,
1876
+ fontSize: style.fontSize,
1877
+ fontWeight: style.fontWeight,
1878
+ textAlign: style.textAlign,
1879
+ color: style.color
1880
+ }
1881
+ },
1882
+ frame: {
1883
+ x: canvas.width / 2,
1884
+ y: canvas.height * style.yRatio
1885
+ },
1886
+ bounds: {
1887
+ width: canvas.width * style.widthRatio,
1888
+ height: Math.max(120, style.fontSize * 1.6)
1889
+ },
1890
+ anchorPoint: { x: 0.5, y: 0.5 },
1891
+ opacity: 0
1892
+ };
1893
+ }
1894
+ function buildPhraseDeltas(layerId, phrase, fadeSeconds, fps) {
1895
+ const fadeFrames = Math.max(1, Math.round(fadeSeconds * fps));
1896
+ const startFrame = Math.floor(phrase.start * fps);
1897
+ const endFrame = Math.ceil(phrase.end * fps);
1898
+ const fadeOutStart = Math.max(startFrame + 1, endFrame - fadeFrames);
1899
+ return [
1900
+ // Fade in to visible at phrase start
1901
+ {
1902
+ layer: layerId,
1903
+ property: "opacity",
1904
+ range: [Math.max(0, startFrame - fadeFrames), startFrame],
1905
+ from: 0,
1906
+ to: 1,
1907
+ easing: "ease-out"
1908
+ },
1909
+ // Hold visible through the phrase
1910
+ // (no explicit delta needed — value persists between deltas)
1911
+ // Fade out at phrase end
1912
+ {
1913
+ layer: layerId,
1914
+ property: "opacity",
1915
+ range: [fadeOutStart, endFrame],
1916
+ from: 1,
1917
+ to: 0,
1918
+ easing: "ease-in"
1919
+ }
1920
+ ];
1921
+ }
1922
+
1923
+ // src/commands/transcribe.ts
1924
+ async function transcribeProject(projectDir, options = {}) {
1925
+ const project = loadVideoProject(projectDir);
1926
+ const backend = await probeWhisper();
1927
+ if (backend === "none") {
1928
+ throw new Error(
1929
+ "No Whisper backend available. Install whisper.cpp (brew install whisper-cpp) or set OPENAI_API_KEY and pass --use-api."
1930
+ );
1931
+ }
1932
+ if (backend === "openai-api") {
1933
+ throw new Error(
1934
+ "OpenAI API backend is not yet implemented. Install whisper.cpp for local transcription."
1935
+ );
1936
+ }
1937
+ const rawJson = await runWhisperCpp(project.sourcePath, {
1938
+ model: options.model,
1939
+ language: options.language
1940
+ });
1941
+ let transcript = parseWhisperCppJson(rawJson);
1942
+ if (!options.reset) {
1943
+ const existing = readTranscript(project);
1944
+ if (existing) {
1945
+ transcript = mergeTranscriptWithExisting(transcript, existing);
1946
+ }
1947
+ }
1948
+ const wordCount = transcript.segments.reduce((n, s) => n + s.words.length, 0);
1949
+ const result = {
1950
+ projectDir: project.dir,
1951
+ backend,
1952
+ transcript,
1953
+ wordCount,
1954
+ captionsGenerated: false
1955
+ };
1956
+ if (options.dryRun) return result;
1957
+ writeTranscript(project, transcript);
1958
+ if (!options.noCaptions) {
1959
+ const doc = readComposition(project);
1960
+ const updated = rewriteCaptionLayers(doc, transcript, options.captionOptions);
1961
+ writeComposition(project, updated);
1962
+ result.captionsGenerated = true;
1963
+ }
1964
+ return result;
1965
+ }
1966
+ function formatResult2(result) {
1967
+ const lines = [];
1968
+ lines.push(`Transcribed ${result.projectDir} via ${result.backend}`);
1969
+ if (result.transcript.language) {
1970
+ lines.push(` language: ${result.transcript.language}`);
1971
+ }
1972
+ lines.push(` segments: ${result.transcript.segments.length}`);
1973
+ lines.push(` words: ${result.wordCount}`);
1974
+ if (result.captionsGenerated) {
1975
+ lines.push(` captions: written to project.atelier`);
1976
+ } else {
1977
+ lines.push(` captions: skipped`);
1978
+ }
1979
+ return lines.join("\n");
1980
+ }
1981
+ function transcribeCommand(program) {
1982
+ program.command("transcribe <project>").description(
1983
+ "Transcribe source video via Whisper, write transcript.json, and rewrite caption-tagged TextVisual layers in project.atelier. Preserves user transcript edits on re-run."
1984
+ ).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) => {
1985
+ try {
1986
+ let transcribeOpts = {
1987
+ model: opts.model,
1988
+ language: opts.language,
1989
+ reset: opts.reset,
1990
+ noCaptions: !opts.captions,
1991
+ dryRun: opts.dryRun
1992
+ };
1993
+ if (opts.recipe) {
1994
+ const { recipe } = loadRecipe(opts.recipe, project);
1995
+ transcribeOpts = applyRecipeToTranscribeOptions(recipe, transcribeOpts);
1996
+ }
1997
+ const result = await transcribeProject(project, transcribeOpts);
1998
+ if (opts.json) {
1999
+ console.log(JSON.stringify(result, null, 2));
2000
+ } else {
2001
+ console.log(formatResult2(result));
2002
+ if (opts.dryRun) console.log("(dry-run \u2014 no files written)");
2003
+ }
2004
+ } catch (err) {
2005
+ const msg = err instanceof Error ? err.message : String(err);
2006
+ console.error(`atelier transcribe: ${msg}`);
2007
+ process.exit(1);
2008
+ }
2009
+ });
2010
+ }
2011
+
2012
+ // src/commands/transcript.ts
2013
+ function loadOrThrow(projectDir) {
2014
+ const project = loadVideoProject(projectDir);
2015
+ const transcript = readTranscript(project);
2016
+ if (!transcript) {
2017
+ throw new Error(
2018
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
2019
+ );
2020
+ }
2021
+ return { project, transcript };
2022
+ }
2023
+ function save(project, transcript, noRegenerate) {
2024
+ writeTranscript(project, transcript);
2025
+ if (!noRegenerate) {
2026
+ const doc = readComposition(project);
2027
+ const updated = rewriteCaptionLayers(doc, transcript);
2028
+ writeComposition(project, updated);
2029
+ }
2030
+ }
2031
+ function transcriptCommand(program) {
2032
+ const transcript = program.command("transcript").description("Edit transcript.json \u2014 fix, add, delete, merge, split, list");
2033
+ 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) => {
2034
+ try {
2035
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
2036
+ let updated = transcript2;
2037
+ if (opts.replace?.length) {
2038
+ for (const pair of opts.replace) {
2039
+ const [find, repl] = pair.split("=");
2040
+ if (!find || repl === void 0) {
2041
+ throw new Error(`Invalid --replace pair: "${pair}". Use 'detected=replacement'.`);
2042
+ }
2043
+ updated = applyBatchReplace(updated, find, repl);
2044
+ }
2045
+ }
2046
+ if (typeof opts.word === "number") {
2047
+ if (opts.text === void 0) {
2048
+ throw new Error("--word requires --text <correction>");
2049
+ }
2050
+ updated = applyTextEdit(updated, opts.word, opts.text);
2051
+ }
2052
+ if (!opts.replace?.length && opts.word === void 0) {
2053
+ throw new Error("Provide --replace 'wrong=right' or --word <idx> --text '...'");
2054
+ }
2055
+ save(project, updated, !opts.regenerate);
2056
+ console.log(`Updated transcript in ${project.dir}`);
2057
+ } catch (err) {
2058
+ console.error(`atelier transcript fix: ${err instanceof Error ? err.message : err}`);
2059
+ process.exit(1);
2060
+ }
2061
+ });
2062
+ 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) => {
2063
+ try {
2064
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
2065
+ const updated = applyAdd(transcript2, opts.afterWord, opts.text, opts.duration);
2066
+ save(project, updated, !opts.regenerate);
2067
+ console.log(`Inserted "${opts.text}" after word ${opts.afterWord} in ${project.dir}`);
2068
+ } catch (err) {
2069
+ console.error(`atelier transcript add: ${err instanceof Error ? err.message : err}`);
2070
+ process.exit(1);
2071
+ }
2072
+ });
2073
+ 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) => {
2074
+ try {
2075
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
2076
+ const updated = applyHide(transcript2, opts.word);
2077
+ save(project, updated, !opts.regenerate);
2078
+ console.log(`Hidden word ${opts.word} in ${project.dir}`);
2079
+ } catch (err) {
2080
+ console.error(`atelier transcript delete: ${err instanceof Error ? err.message : err}`);
2081
+ process.exit(1);
2082
+ }
2083
+ });
2084
+ 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) => {
2085
+ try {
2086
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
2087
+ const updated = applyMerge(transcript2, opts.word);
2088
+ save(project, updated, !opts.regenerate);
2089
+ console.log(`Merged words ${opts.word} and ${opts.word + 1} in ${project.dir}`);
2090
+ } catch (err) {
2091
+ console.error(`atelier transcript merge: ${err instanceof Error ? err.message : err}`);
2092
+ process.exit(1);
2093
+ }
2094
+ });
2095
+ 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) => {
2096
+ try {
2097
+ const { project, transcript: transcript2 } = loadOrThrow(projectDir);
2098
+ const updated = applySplit(transcript2, opts.word, opts.at, opts.first, opts.second);
2099
+ save(project, updated, !opts.regenerate);
2100
+ console.log(`Split word ${opts.word} at ${opts.at} in ${project.dir}`);
2101
+ } catch (err) {
2102
+ console.error(`atelier transcript split: ${err instanceof Error ? err.message : err}`);
2103
+ process.exit(1);
2104
+ }
2105
+ });
2106
+ transcript.command("list <project>").description("Print all words with their indices for reference.").option("--json", "Output as JSON").action(async (projectDir, opts) => {
2107
+ try {
2108
+ const { transcript: transcript2 } = loadOrThrow(projectDir);
2109
+ const words = flattenWords(transcript2);
2110
+ if (opts.json) {
2111
+ console.log(JSON.stringify(words.map((w, i) => ({ index: i, ...w })), null, 2));
2112
+ } else {
2113
+ for (let i = 0; i < words.length; i++) {
2114
+ const w = words[i];
2115
+ const flags = [];
2116
+ if (w.userEdited) flags.push("edited");
2117
+ if (w.userAdded) flags.push("added");
2118
+ if (w.hidden) flags.push("hidden");
2119
+ const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
2120
+ const editedDisplay = w.text !== w.detected ? ` \u2190 "${w.detected}"` : "";
2121
+ console.log(
2122
+ ` [${i.toString().padStart(4)}] ${w.start.toFixed(2).padStart(7)}s "${w.text}"${editedDisplay}${flagStr}`
2123
+ );
2124
+ }
2125
+ }
2126
+ } catch (err) {
2127
+ console.error(`atelier transcript list: ${err instanceof Error ? err.message : err}`);
2128
+ process.exit(1);
2129
+ }
2130
+ });
2131
+ }
2132
+
2133
+ // src/commands/captions.ts
2134
+ function captionsCommand(program) {
2135
+ const captions = program.command("captions").description("Manage caption layers in a VideoProject");
2136
+ captions.command("regenerate <project>").description(
2137
+ "Re-derive caption-tagged TextVisual layers from the current transcript.json. Used when caption styling changes without re-running Whisper."
2138
+ ).option("--recipe <name>", "Apply a Studio Recipe's caption_style + caption_grouping").action(async (projectDir, opts) => {
2139
+ try {
2140
+ const project = loadVideoProject(projectDir);
2141
+ const transcript = readTranscript(project);
2142
+ if (!transcript) {
2143
+ throw new Error(
2144
+ `No transcript.json in ${projectDir}. Run \`atelier transcribe ${projectDir}\` first.`
2145
+ );
2146
+ }
2147
+ const captionOptions = opts.recipe ? applyRecipeToCaptionOptions(loadRecipe(opts.recipe, projectDir).recipe) : {};
2148
+ const doc = readComposition(project);
2149
+ const updated = rewriteCaptionLayers(doc, transcript, captionOptions);
2150
+ writeComposition(project, updated);
2151
+ const captionLayers = updated.layers.filter(
2152
+ (l) => (l.tags ?? []).includes("caption")
2153
+ );
2154
+ console.log(
2155
+ `Regenerated ${captionLayers.length} caption layer${captionLayers.length === 1 ? "" : "s"} in ${project.dir}`
2156
+ );
2157
+ } catch (err) {
2158
+ console.error(`atelier captions regenerate: ${err instanceof Error ? err.message : err}`);
2159
+ process.exit(1);
2160
+ }
2161
+ });
2162
+ }
2163
+
2164
+ // src/commands/recipe.ts
2165
+ import { writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
2166
+ import { join as join3, resolve as resolve4 } from "path";
2167
+ function recipeCommand(program) {
2168
+ const recipe = program.command("recipe").description("Manage Studio Recipes \u2014 reusable style presets");
2169
+ 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) => {
2170
+ try {
2171
+ const baseDir = opts.dir ?? join3(resolve4(process.cwd()), ".atelier", "recipes");
2172
+ if (!existsSync4(baseDir)) {
2173
+ mkdirSync2(baseDir, { recursive: true });
2174
+ }
2175
+ const hasKnownExt = /\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i.test(name);
2176
+ const fileName = hasKnownExt ? name : `${name}.recipe.yaml`;
2177
+ const outPath = join3(baseDir, fileName);
2178
+ if (existsSync4(outPath)) {
2179
+ throw new Error(`Recipe already exists at ${outPath} \u2014 refusing to overwrite.`);
2180
+ }
2181
+ const recipeName = name.replace(/\.(recipe\.yaml|recipe\.json|yaml|yml|json)$/i, "");
2182
+ const yaml = scaffoldRecipeYaml(recipeName);
2183
+ writeFileSync2(outPath, yaml, "utf-8");
2184
+ console.log(`Created ${outPath}`);
2185
+ } catch (err) {
2186
+ console.error(`atelier recipe new: ${err instanceof Error ? err.message : err}`);
2187
+ process.exit(1);
2188
+ }
2189
+ });
2190
+ 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) => {
2191
+ try {
2192
+ const loaded = loadRecipe(path, process.cwd());
2193
+ if (opts.json) {
2194
+ console.log(JSON.stringify({
2195
+ valid: true,
2196
+ path: loaded.path,
2197
+ warnings: loaded.warnings
2198
+ }, null, 2));
2199
+ } else {
2200
+ console.log(`PASS ${loaded.path}`);
2201
+ for (const w of loaded.warnings) {
2202
+ console.log(` \u26A0 ${w}`);
2203
+ }
2204
+ }
2205
+ } catch (err) {
2206
+ const msg = err instanceof Error ? err.message : String(err);
2207
+ if (opts.json) {
2208
+ console.log(JSON.stringify({ valid: false, error: msg }, null, 2));
2209
+ } else {
2210
+ console.error(`FAIL ${path}`);
2211
+ console.error(` ${msg}`);
2212
+ }
2213
+ process.exit(1);
2214
+ }
2215
+ });
2216
+ 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) => {
2217
+ try {
2218
+ const loaded = loadRecipe(path, process.cwd());
2219
+ const out = opts.withDefaults ? renderRecipeWithDefaults(loaded.recipe) : loaded.recipe;
2220
+ console.log(recipeToYaml(out));
2221
+ for (const w of loaded.warnings) {
2222
+ console.error(`# \u26A0 ${w}`);
2223
+ }
2224
+ } catch (err) {
2225
+ console.error(`atelier recipe show: ${err instanceof Error ? err.message : err}`);
2226
+ process.exit(1);
2227
+ }
2228
+ });
2229
+ }
2230
+
2231
+ // src/commands/apply-recipe.ts
2232
+ function applyRecipeCommand(program) {
2233
+ program.command("apply-recipe <project> <recipe>").description(
2234
+ "Apply a Studio Recipe by running atelier trim + atelier transcribe with the same recipe, in that order."
2235
+ ).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) => {
2236
+ try {
2237
+ const { recipe, path, warnings } = loadRecipe(recipeRef, project);
2238
+ console.log(`Loaded recipe ${path}`);
2239
+ for (const w of warnings) console.log(` \u26A0 ${w}`);
2240
+ if (opts.trim) {
2241
+ const trimOpts = applyRecipeToTrimOptions(recipe, { reset: opts.reset });
2242
+ console.log(`
2243
+ Running atelier trim...`);
2244
+ const r = await trimProject(project, trimOpts);
2245
+ console.log(` ${r.cuts.length} cut${r.cuts.length === 1 ? "" : "s"} written`);
2246
+ }
2247
+ if (opts.transcribe) {
2248
+ const transcribeOpts = applyRecipeToTranscribeOptions(recipe, { reset: opts.reset });
2249
+ console.log(`
2250
+ Running atelier transcribe...`);
2251
+ const r = await transcribeProject(project, transcribeOpts);
2252
+ console.log(` ${r.wordCount} words, ${r.captionsGenerated ? "captions written" : "captions skipped"}`);
2253
+ }
2254
+ if (recipe.overlay_rules) {
2255
+ const vp = loadVideoProject(project);
2256
+ const doc = readComposition(vp);
2257
+ const updated = applyRecipeToOverlay(doc, recipe);
2258
+ writeComposition(vp, updated);
2259
+ console.log(`
2260
+ Applied overlay rules to ${vp.compositionPath}`);
2261
+ }
2262
+ console.log(`
2263
+ Done.`);
2264
+ } catch (err) {
2265
+ console.error(`atelier apply-recipe: ${err instanceof Error ? err.message : err}`);
2266
+ process.exit(1);
2267
+ }
2268
+ });
2269
+ }
2270
+
2271
+ // src/lib/render-image.ts
2272
+ import { createCanvas, loadImage } from "@napi-rs/canvas";
2273
+ function fitImageToCanvas(canvas, natural) {
2274
+ const cw = canvas.width;
2275
+ const ch = canvas.height;
2276
+ const iw = natural.width;
2277
+ const ih = natural.height;
2278
+ if (!iw || !ih) {
2279
+ return { bounds: { width: cw, height: ch }, frame: { x: cw / 2, y: ch / 2 } };
2280
+ }
2281
+ const imageAspect = iw / ih;
2282
+ const canvasAspect = cw / ch;
2283
+ let width;
2284
+ let height;
2285
+ if (imageAspect > canvasAspect) {
2286
+ width = cw;
2287
+ height = cw / imageAspect;
2288
+ } else {
2289
+ height = ch;
2290
+ width = ch * imageAspect;
2291
+ }
2292
+ width = Math.min(width, cw);
2293
+ height = Math.min(height, ch);
2294
+ return { bounds: { width, height }, frame: { x: cw / 2, y: ch / 2 } };
2295
+ }
2296
+ var CanvasUnavailableError = class extends Error {
2297
+ constructor() {
2298
+ super(
2299
+ "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."
2300
+ );
2301
+ this.name = "CanvasUnavailableError";
2302
+ }
2303
+ };
2304
+ async function loadCanvasModule() {
2305
+ if (typeof createCanvas !== "function" || typeof loadImage !== "function") {
2306
+ throw new CanvasUnavailableError();
2307
+ }
2308
+ return {
2309
+ createCanvas,
2310
+ loadImage
2311
+ };
2312
+ }
2313
+ function resolveExportDimensions(docWidth, docHeight, width, height) {
2314
+ if (width !== void 0 && height !== void 0) {
2315
+ return { width, height };
2316
+ }
2317
+ if (width !== void 0) {
2318
+ const h = Math.max(1, Math.round(width * docHeight / docWidth));
2319
+ return { width, height: h };
2320
+ }
2321
+ if (height !== void 0) {
2322
+ const w = Math.max(1, Math.round(height * docWidth / docHeight));
2323
+ return { width: w, height };
2324
+ }
2325
+ return { width: docWidth, height: docHeight };
2326
+ }
2327
+ async function preloadImages(doc, loadImage2) {
2328
+ const { ImageCache: ImageCache2 } = await import("./dist-M67UZGFQ.js");
2329
+ const sources = /* @__PURE__ */ new Set();
2330
+ for (const layer of doc.layers) {
2331
+ if (layer.visual.type === "image") {
2332
+ const iv = layer.visual;
2333
+ if (iv.src) sources.add(iv.src);
2334
+ else if (iv.assetId && doc.assets?.[iv.assetId]) sources.add(doc.assets[iv.assetId].src);
2335
+ }
2336
+ }
2337
+ if (sources.size === 0) {
2338
+ return { imageCache: new ImageCache2(), loaded: /* @__PURE__ */ new Map() };
2339
+ }
2340
+ const preloaded = /* @__PURE__ */ new Map();
2341
+ await Promise.all(
2342
+ [...sources].map(async (src) => {
2343
+ try {
2344
+ preloaded.set(src, await loadImage2(src));
2345
+ } catch {
2346
+ }
2347
+ })
2348
+ );
2349
+ const imageCache = new ImageCache2({
2350
+ createImage: (src, onLoad, onError) => {
2351
+ const img = preloaded.get(src);
2352
+ if (img) {
2353
+ process.nextTick(onLoad);
2354
+ return img;
2355
+ }
2356
+ process.nextTick(onError);
2357
+ return {};
2358
+ }
2359
+ });
2360
+ for (const src of preloaded.keys()) imageCache.load(src);
2361
+ await new Promise((resolve14) => process.nextTick(resolve14));
2362
+ return { imageCache, loaded: preloaded };
2363
+ }
2364
+ async function renderDocumentToPng(doc, opts = {}) {
2365
+ const stateNames = Object.keys(doc.states);
2366
+ if (stateNames.length === 0) {
2367
+ throw new Error("Document has no states");
2368
+ }
2369
+ const stateName = opts.state ?? stateNames[0];
2370
+ if (!doc.states[stateName]) {
2371
+ throw new Error(`State "${stateName}" not found. Available: ${stateNames.join(", ")}`);
2372
+ }
2373
+ const frameNumber = opts.frame ?? 0;
2374
+ if (!Number.isInteger(frameNumber) || frameNumber < 0) {
2375
+ throw new Error(`Invalid frame number: ${frameNumber}`);
2376
+ }
2377
+ const duration = doc.states[stateName].duration;
2378
+ if (frameNumber >= duration) {
2379
+ throw new Error(
2380
+ `Frame ${frameNumber} is out of range for state "${stateName}" (duration ${duration})`
2381
+ );
2382
+ }
2383
+ const { createCanvas: createCanvas2, loadImage: loadImage2 } = await loadCanvasModule();
2384
+ const { renderFrame: renderFrame2 } = await import("./dist-M67UZGFQ.js");
2385
+ const { imageCache, loaded } = await preloadImages(doc, loadImage2);
2386
+ let renderDoc = doc;
2387
+ if (opts.refitImageBounds && loaded.size > 0) {
2388
+ renderDoc = {
2389
+ ...doc,
2390
+ layers: doc.layers.map((layer) => {
2391
+ if (layer.visual.type !== "image") return layer;
2392
+ const iv = layer.visual;
2393
+ const src = iv.src ?? (iv.assetId ? doc.assets?.[iv.assetId]?.src : void 0);
2394
+ const natural = src ? loaded.get(src) : void 0;
2395
+ if (!natural || !natural.width || !natural.height) return layer;
2396
+ const fit = opts.refitImageBounds({ canvas: doc.canvas, natural });
2397
+ return { ...layer, bounds: fit.bounds, frame: fit.frame };
2398
+ })
2399
+ };
2400
+ }
2401
+ const { width: outW, height: outH } = resolveExportDimensions(
2402
+ renderDoc.canvas.width,
2403
+ renderDoc.canvas.height,
2404
+ opts.width,
2405
+ opts.height
2406
+ );
2407
+ const resolved = resolveFrame(renderDoc, stateName, frameNumber);
2408
+ const cvs = createCanvas2(outW, outH);
2409
+ const ctx = cvs.getContext("2d");
2410
+ const sx = outW / renderDoc.canvas.width;
2411
+ const sy = outH / renderDoc.canvas.height;
2412
+ if (sx !== 1 || sy !== 1) ctx.scale(sx, sy);
2413
+ renderFrame2(ctx, resolved, renderDoc, imageCache);
2414
+ return cvs.toBuffer("image/png");
2415
+ }
2416
+
2417
+ // src/commands/carousel.ts
2418
+ import { readdirSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, statSync } from "fs";
2419
+ import { resolve as resolve5, join as join4, basename as basename2, extname as extname2, dirname, sep } from "path";
2420
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
2421
+ var DEFAULT_CANVAS = 1080;
2422
+ function expandInputs(pattern) {
2423
+ const abs = resolve5(pattern);
2424
+ let isDir = false;
2425
+ try {
2426
+ isDir = statSync(abs).isDirectory();
2427
+ } catch {
2428
+ isDir = false;
2429
+ }
2430
+ if (isDir) {
2431
+ return listImages(abs);
2432
+ }
2433
+ const dir = dirname(abs);
2434
+ const base = basename2(abs);
2435
+ if (base.includes("*")) {
2436
+ const matcher = globToRegExp(base);
2437
+ let entries;
2438
+ try {
2439
+ entries = readdirSync(dir);
2440
+ } catch {
2441
+ return [];
2442
+ }
2443
+ return entries.filter((name) => matcher.test(name) && isImageFile(name)).map((name) => join4(dir, name)).sort();
2444
+ }
2445
+ return isImageFile(base) ? [abs] : [];
2446
+ }
2447
+ function listImages(dir) {
2448
+ let entries;
2449
+ try {
2450
+ entries = readdirSync(dir);
2451
+ } catch {
2452
+ return [];
2453
+ }
2454
+ return entries.filter(isImageFile).map((name) => join4(dir, name)).sort();
2455
+ }
2456
+ function isImageFile(name) {
2457
+ return IMAGE_EXTS.has(extname2(name).toLowerCase());
2458
+ }
2459
+ function globToRegExp(glob) {
2460
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
2461
+ const pattern = escaped.replace(/\*/g, `[^${sep === "\\" ? "\\\\" : sep}]*`).replace(/\?/g, ".");
2462
+ return new RegExp(`^${pattern}$`);
2463
+ }
2464
+ function composeCarouselFrameDoc(args) {
2465
+ const { imagePath, index, total, width, height, recipe } = args;
2466
+ const canvas = { width, height };
2467
+ const fit = fitImageToCanvas(canvas, { width: 0, height: 0 });
2468
+ const assetId = "carousel-image-asset";
2469
+ const baseDoc = {
2470
+ version: "1.0",
2471
+ name: `carousel-${index}`,
2472
+ canvas: { width, height, fps: 30, background: args.background ?? "#000000" },
2473
+ assets: { [assetId]: { type: "image", src: imagePath } },
2474
+ layers: [
2475
+ {
2476
+ id: "carousel-image",
2477
+ visual: { type: "image", assetId, src: imagePath },
2478
+ frame: fit.frame,
2479
+ bounds: fit.bounds,
2480
+ anchorPoint: { x: 0.5, y: 0.5 },
2481
+ opacity: 1
2482
+ }
2483
+ ],
2484
+ states: { default: { duration: 1, deltas: [] } }
2485
+ };
2486
+ return applyRecipeToOverlay(baseDoc, recipe, { currentIndex: index, totalCount: total });
2487
+ }
2488
+ function carouselFileName(index, total, imagePath) {
2489
+ const padWidth = Math.max(2, String(total).length);
2490
+ const prefix = String(index).padStart(padWidth, "0");
2491
+ const ext = extname2(imagePath);
2492
+ const stem = basename2(imagePath, ext);
2493
+ return `${prefix}-${stem}.png`;
2494
+ }
2495
+ function parseDim(raw, name) {
2496
+ if (raw === void 0) return void 0;
2497
+ const n = parseInt(raw, 10);
2498
+ if (isNaN(n) || n <= 0) {
2499
+ console.error(`Invalid --${name}: ${raw}`);
2500
+ process.exit(1);
2501
+ }
2502
+ return n;
2503
+ }
2504
+ function carouselCommand(program) {
2505
+ program.command("carousel <recipe>").description(
2506
+ "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."
2507
+ ).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) => {
2508
+ let recipe;
2509
+ try {
2510
+ const loaded = loadRecipe(recipeRef);
2511
+ recipe = loaded.recipe;
2512
+ console.log(`Loaded recipe ${loaded.path}`);
2513
+ for (const w of loaded.warnings) console.log(` \u26A0 ${w}`);
2514
+ } catch (err) {
2515
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
2516
+ process.exit(1);
2517
+ return;
2518
+ }
2519
+ const inputs = expandInputs(options.inputs);
2520
+ if (inputs.length === 0) {
2521
+ console.error(
2522
+ `atelier carousel: no image files matched --inputs "${options.inputs}" (accepted: ${[...IMAGE_EXTS].join(", ")})`
2523
+ );
2524
+ process.exit(1);
2525
+ return;
2526
+ }
2527
+ const width = parseDim(options.width, "width") ?? DEFAULT_CANVAS;
2528
+ const height = parseDim(options.height, "height") ?? DEFAULT_CANVAS;
2529
+ const frame = parseInt(options.frame, 10);
2530
+ if (isNaN(frame) || frame < 0) {
2531
+ console.error(`Invalid frame number: ${options.frame}`);
2532
+ process.exit(1);
2533
+ return;
2534
+ }
2535
+ const outDir = resolve5(options.outDir);
2536
+ try {
2537
+ mkdirSync3(outDir, { recursive: true });
2538
+ } catch (err) {
2539
+ console.error(`atelier carousel: cannot create out-dir ${outDir}: ${err.message}`);
2540
+ process.exit(1);
2541
+ return;
2542
+ }
2543
+ const total = inputs.length;
2544
+ console.log(`Composing ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
2545
+ try {
2546
+ for (let n = 0; n < total; n++) {
2547
+ const index = n + 1;
2548
+ const imagePath = inputs[n];
2549
+ const doc = composeCarouselFrameDoc({
2550
+ imagePath,
2551
+ index,
2552
+ total,
2553
+ width,
2554
+ height,
2555
+ recipe
2556
+ });
2557
+ const buffer = await renderDocumentToPng(doc, {
2558
+ frame,
2559
+ // Re-fit the image to the canvas using real natural dims once decoded.
2560
+ refitImageBounds: ({ canvas, natural }) => fitImageToCanvas(canvas, natural)
2561
+ });
2562
+ const outName = carouselFileName(index, total, imagePath);
2563
+ writeFileSync3(join4(outDir, outName), buffer);
2564
+ console.log(` [${index}/${total}] ${basename2(imagePath)} \u2192 ${outName}`);
2565
+ }
2566
+ } catch (err) {
2567
+ if (err instanceof CanvasUnavailableError) {
2568
+ console.error(err.message);
2569
+ process.exit(1);
2570
+ return;
2571
+ }
2572
+ console.error(`atelier carousel: ${err instanceof Error ? err.message : err}`);
2573
+ process.exit(1);
2574
+ return;
2575
+ }
2576
+ console.log(`
2577
+ Done. ${total} image${total === 1 ? "" : "s"} \u2192 ${outDir}`);
2578
+ });
2579
+ }
2580
+
2581
+ // src/commands/info.ts
2582
+ import { readFileSync as readFileSync4 } from "fs";
2583
+ import { resolve as resolve6 } from "path";
2584
+ function getInfo(doc) {
2585
+ return {
2586
+ name: doc.name,
2587
+ description: doc.description,
2588
+ canvas: {
2589
+ width: doc.canvas.width,
2590
+ height: doc.canvas.height,
2591
+ fps: doc.canvas.fps,
2592
+ background: doc.canvas.background
2593
+ },
2594
+ layers: {
2595
+ count: doc.layers.length,
2596
+ items: doc.layers.map((layer) => ({
2597
+ id: layer.id,
2598
+ type: layer.visual.type
2599
+ }))
2600
+ },
2601
+ states: {
2602
+ count: Object.keys(doc.states).length,
2603
+ items: Object.entries(doc.states).map(([name, state]) => ({
2604
+ name,
2605
+ duration: state.duration,
2606
+ deltaCount: state.deltas.length
2607
+ }))
2608
+ },
2609
+ presets: {
2610
+ count: doc.presets ? Object.keys(doc.presets).length : 0
2611
+ }
2612
+ };
2613
+ }
2614
+ function formatInfo(info) {
2615
+ const lines = [];
2616
+ lines.push(`Name: ${info.name}`);
2617
+ if (info.description) {
2618
+ lines.push(`Description: ${info.description}`);
2619
+ }
2620
+ const bg = info.canvas.background ? `, background: ${info.canvas.background}` : "";
2621
+ lines.push(
2622
+ `Canvas: ${info.canvas.width}x${info.canvas.height} @ ${info.canvas.fps}fps${bg}`
2623
+ );
2624
+ lines.push(`Layers: ${info.layers.count}`);
2625
+ for (const layer of info.layers.items) {
2626
+ lines.push(` - ${layer.id} (${layer.type})`);
2627
+ }
2628
+ lines.push(`States: ${info.states.count}`);
2629
+ for (const state of info.states.items) {
2630
+ lines.push(
2631
+ ` - ${state.name}: ${state.duration} frames, ${state.deltaCount} deltas`
2632
+ );
2633
+ }
2634
+ if (info.presets.count > 0) {
2635
+ lines.push(`Presets: ${info.presets.count}`);
2636
+ }
2637
+ return lines.join("\n");
2638
+ }
2639
+ function readAndParse(file) {
2640
+ const absPath = resolve6(file);
2641
+ let content;
2642
+ try {
2643
+ content = readFileSync4(absPath, "utf-8");
2644
+ } catch {
2645
+ console.error(`Cannot read file: ${absPath}`);
2646
+ return process.exit(1);
2647
+ }
2648
+ const result = parseAtelier(content);
2649
+ if (!result.success) {
2650
+ console.error("Parse errors:");
2651
+ for (const error of result.errors) {
2652
+ console.error(` - ${error.path}: ${error.message}`);
2653
+ }
2654
+ return process.exit(1);
2655
+ }
2656
+ return result.data;
2657
+ }
2658
+ function infoCommand(program) {
2659
+ program.command("info <file>").description("Display summary info for an .atelier file").action((file) => {
2660
+ const doc = readAndParse(file);
2661
+ const info = getInfo(doc);
2662
+ console.log(formatInfo(info));
2663
+ });
2664
+ }
2665
+
2666
+ // src/commands/still.ts
2667
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2668
+ import { resolve as resolve7 } from "path";
2669
+ function resolveStill(doc, stateName, frame) {
2670
+ const stateNames = Object.keys(doc.states);
2671
+ if (stateNames.length === 0) {
2672
+ throw new Error("Document has no states");
2673
+ }
2674
+ const resolvedStateName = stateName ?? stateNames[0];
2675
+ if (!(resolvedStateName in doc.states)) {
2676
+ throw new Error(
2677
+ `State "${resolvedStateName}" not found. Available: ${stateNames.join(", ")}`
2678
+ );
2679
+ }
2680
+ const resolvedFrame = frame ?? 0;
2681
+ return resolveFrame(doc, resolvedStateName, resolvedFrame);
2682
+ }
2683
+ function readAndParse2(file) {
2684
+ const absPath = resolve7(file);
2685
+ let content;
2686
+ try {
2687
+ content = readFileSync5(absPath, "utf-8");
2688
+ } catch {
2689
+ console.error(`Cannot read file: ${absPath}`);
2690
+ return process.exit(1);
2691
+ }
2692
+ const result = parseAtelier(content);
2693
+ if (!result.success) {
2694
+ console.error("Parse errors:");
2695
+ for (const error of result.errors) {
2696
+ console.error(` - ${error.path}: ${error.message}`);
2697
+ }
2698
+ return process.exit(1);
2699
+ }
2700
+ return result.data;
2701
+ }
2702
+ function stillCommand(program) {
2703
+ program.command("still <file>").description(
2704
+ "Resolve a single frame and output as JSON or render as PNG"
2705
+ ).option("-s, --state <name>", "State name (defaults to first state)").option(
2706
+ "-f, --frame <number>",
2707
+ "Frame number (defaults to 0)",
2708
+ "0"
2709
+ ).option(
2710
+ "--format <type>",
2711
+ "Output format: json | png (default: json)",
2712
+ "json"
2713
+ ).option("-o, --output <path>", "Output file path (default: stdout)").action(
2714
+ async (file, options) => {
2715
+ const doc = readAndParse2(file);
2716
+ const frameNumber = parseInt(options.frame, 10);
2717
+ if (isNaN(frameNumber) || frameNumber < 0) {
2718
+ console.error(
2719
+ `Invalid frame number: ${options.frame}`
2720
+ );
2721
+ process.exit(1);
2722
+ return;
2723
+ }
2724
+ if (options.format !== "json" && options.format !== "png") {
2725
+ console.error(`Unknown format: "${options.format}". Use json or png.`);
2726
+ process.exit(1);
2727
+ return;
2728
+ }
2729
+ try {
2730
+ const resolved = resolveStill(
2731
+ doc,
2732
+ options.state,
2733
+ frameNumber
2734
+ );
2735
+ if (options.format === "json") {
2736
+ const json = JSON.stringify(resolved, null, 2);
2737
+ if (options.output) {
2738
+ writeFileSync4(resolve7(options.output), json, "utf-8");
2739
+ } else {
2740
+ console.log(json);
2741
+ }
2742
+ } else {
2743
+ const { createCanvas: createCanvas2 } = await import("@napi-rs/canvas");
2744
+ const { renderFrame: renderFrame2 } = await import("./dist-M67UZGFQ.js");
2745
+ const cvs = createCanvas2(doc.canvas.width, doc.canvas.height);
2746
+ const ctx = cvs.getContext("2d");
2747
+ renderFrame2(ctx, resolved, doc);
2748
+ const buffer = cvs.toBuffer("image/png");
2749
+ if (options.output) {
2750
+ writeFileSync4(resolve7(options.output), buffer);
2751
+ } else {
2752
+ process.stdout.write(buffer);
2753
+ }
2754
+ }
2755
+ } catch (err) {
2756
+ console.error(
2757
+ err.message
2758
+ );
2759
+ process.exit(1);
2760
+ }
2761
+ }
2762
+ );
2763
+ }
2764
+
2765
+ // src/commands/render-pipeline.ts
2766
+ import { spawn as spawn3 } from "child_process";
2767
+ async function checkFfmpeg() {
2768
+ return new Promise((resolve14) => {
2769
+ const proc = spawn3("ffmpeg", ["-version"], { stdio: "pipe" });
2770
+ proc.on("error", () => resolve14(false));
2771
+ proc.on("close", (code) => resolve14(code === 0));
2772
+ });
2773
+ }
2774
+ function buildFfmpegArgs(width, height, fps, format, output) {
2775
+ const input = [
2776
+ "-y",
2777
+ "-f",
2778
+ "rawvideo",
2779
+ "-pix_fmt",
2780
+ "bgra",
2781
+ "-s",
2782
+ `${width}x${height}`,
2783
+ "-r",
2784
+ String(fps),
2785
+ "-i",
2786
+ "pipe:0"
2787
+ ];
2788
+ if (format === "mp4") {
2789
+ return [
2790
+ ...input,
2791
+ "-c:v",
2792
+ "libx264",
2793
+ "-pix_fmt",
2794
+ "yuv420p",
2795
+ "-preset",
2796
+ "medium",
2797
+ "-crf",
2798
+ "18",
2799
+ "-movflags",
2800
+ "+faststart",
2801
+ output
2802
+ ];
2803
+ }
2804
+ return [
2805
+ ...input,
2806
+ "-vf",
2807
+ "split[s0][s1];[s0]palettegen=stats_mode=single[p];[s1][p]paletteuse=dither=sierra2_4a",
2808
+ "-loop",
2809
+ "0",
2810
+ output
2811
+ ];
2812
+ }
2813
+ async function preloadImages2(doc, loadImage2) {
2814
+ const sources = /* @__PURE__ */ new Set();
2815
+ for (const layer of doc.layers) {
2816
+ if (layer.visual.type === "image") {
2817
+ const iv = layer.visual;
2818
+ if (iv.src) {
2819
+ sources.add(iv.src);
2820
+ } else if (iv.assetId && doc.assets?.[iv.assetId]) {
2821
+ sources.add(doc.assets[iv.assetId].src);
2822
+ }
2823
+ }
2824
+ }
2825
+ if (sources.size === 0) {
2826
+ return new ImageCache();
2827
+ }
2828
+ const preloaded = /* @__PURE__ */ new Map();
2829
+ await Promise.all(
2830
+ [...sources].map(async (src) => {
2831
+ try {
2832
+ preloaded.set(src, await loadImage2(src));
2833
+ } catch {
2834
+ }
2835
+ })
2836
+ );
2837
+ const imageCache = new ImageCache({
2838
+ createImage: (src, onLoad, onError) => {
2839
+ const img = preloaded.get(src);
2840
+ if (img) {
2841
+ process.nextTick(onLoad);
2842
+ return img;
2843
+ }
2844
+ process.nextTick(onError);
2845
+ return {};
2846
+ }
2847
+ });
2848
+ for (const src of preloaded.keys()) {
2849
+ imageCache.load(src);
2850
+ }
2851
+ await new Promise((resolve14) => process.nextTick(resolve14));
2852
+ return imageCache;
2853
+ }
2854
+ async function renderDocument(doc, opts) {
2855
+ const canvasModuleName = "canvas";
2856
+ let createCanvas2;
2857
+ let loadImage2;
2858
+ try {
2859
+ const canvasModule = await import(
2860
+ /* webpackIgnore: true */
2861
+ canvasModuleName
2862
+ );
2863
+ createCanvas2 = canvasModule.createCanvas;
2864
+ loadImage2 = canvasModule.loadImage;
2865
+ } catch {
2866
+ throw new Error(
2867
+ "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"
2868
+ );
2869
+ }
2870
+ const { width, height, fps } = doc.canvas;
2871
+ const { output, format, states, onProgress } = opts;
2872
+ if (format === "mp4" && (width % 2 !== 0 || height % 2 !== 0)) {
2873
+ throw new Error(
2874
+ `H.264 requires even dimensions. Canvas is ${width}\xD7${height}. Try ${width + width % 2}\xD7${height + height % 2}.`
2875
+ );
2876
+ }
2877
+ const allStates = Object.keys(doc.states);
2878
+ const renderStates = states ?? allStates;
2879
+ for (const s of renderStates) {
2880
+ if (!(s in doc.states)) {
2881
+ throw new Error(
2882
+ `State "${s}" not found. Available: ${allStates.join(", ")}`
2883
+ );
2884
+ }
2885
+ }
2886
+ let totalFrames = 0;
2887
+ for (const s of renderStates) {
2888
+ totalFrames += doc.states[s].duration;
2889
+ }
2890
+ if (totalFrames === 0) {
2891
+ throw new Error("Nothing to render \u2014 all states have duration 0");
2892
+ }
2893
+ const imageCache = await preloadImages2(doc, loadImage2);
2894
+ const canvas = createCanvas2(width, height);
2895
+ const ctx = canvas.getContext("2d");
2896
+ const ffmpegArgs = buildFfmpegArgs(width, height, fps, format, output);
2897
+ const ffmpeg = spawn3("ffmpeg", ffmpegArgs, {
2898
+ stdio: ["pipe", "pipe", "pipe"]
2899
+ });
2900
+ let stderrOutput = "";
2901
+ ffmpeg.stderr?.on("data", (chunk) => {
2902
+ stderrOutput += chunk.toString();
2903
+ });
2904
+ const startTime = Date.now();
2905
+ let frameIndex = 0;
2906
+ for (const stateName of renderStates) {
2907
+ const duration = doc.states[stateName].duration;
2908
+ for (let f = 0; f < duration; f++) {
2909
+ const resolved = resolveFrame(doc, stateName, f);
2910
+ renderFrame(ctx, resolved, doc, imageCache);
2911
+ const raw = canvas.toBuffer("raw");
2912
+ const canWrite = ffmpeg.stdin.write(raw);
2913
+ if (!canWrite) {
2914
+ await new Promise(
2915
+ (resolve14) => ffmpeg.stdin.once("drain", resolve14)
2916
+ );
2917
+ }
2918
+ frameIndex++;
2919
+ onProgress?.({
2920
+ frame: frameIndex,
2921
+ totalFrames,
2922
+ state: stateName,
2923
+ percent: Math.round(frameIndex / totalFrames * 100)
2924
+ });
2925
+ }
2926
+ }
2927
+ ffmpeg.stdin.end();
2928
+ const exitCode = await new Promise((resolve14) => {
2929
+ ffmpeg.on("close", resolve14);
2930
+ });
2931
+ if (exitCode !== 0) {
2932
+ throw new Error(
2933
+ `FFmpeg exited with code ${exitCode}.
2934
+ ${stderrOutput.slice(-500)}`
2935
+ );
2936
+ }
2937
+ return {
2938
+ output,
2939
+ format,
2940
+ totalFrames,
2941
+ states: renderStates,
2942
+ durationMs: Date.now() - startTime
2943
+ };
2944
+ }
2945
+
2946
+ // src/commands/render.ts
2947
+ import { readFileSync as readFileSync6 } from "fs";
2948
+ import { resolve as resolve8, basename as basename3, extname as extname3 } from "path";
2949
+ function readAndParse3(file) {
2950
+ const absPath = resolve8(file);
2951
+ let content;
2952
+ try {
2953
+ content = readFileSync6(absPath, "utf-8");
2954
+ } catch {
2955
+ console.error(`Cannot read file: ${absPath}`);
2956
+ return process.exit(1);
2957
+ }
2958
+ const result = parseAtelier(content);
2959
+ if (!result.success) {
2960
+ console.error("Parse errors:");
2961
+ for (const error of result.errors) {
2962
+ console.error(` - ${error.path}: ${error.message}`);
2963
+ }
2964
+ return process.exit(1);
2965
+ }
2966
+ return result.data;
2967
+ }
2968
+ function inferFormat(output) {
2969
+ if (!output) return "mp4";
2970
+ const ext = extname3(output).toLowerCase();
2971
+ if (ext === ".gif") return "gif";
2972
+ return "mp4";
2973
+ }
2974
+ function renderCommand(program) {
2975
+ program.command("render <file>").description("Render animation to MP4 or GIF via FFmpeg").option("-o, --output <path>", "Output file path").option("-f, --format <type>", "Output format: mp4 | gif").option(
2976
+ "-s, --state <names...>",
2977
+ "State(s) to render (default: all in order)"
2978
+ ).action(
2979
+ async (file, options) => {
2980
+ const hasFfmpeg = await checkFfmpeg();
2981
+ if (!hasFfmpeg) {
2982
+ console.error("FFmpeg is not installed or not in PATH.");
2983
+ console.error("Install it:");
2984
+ console.error(" macOS: brew install ffmpeg");
2985
+ console.error(" Ubuntu: sudo apt install ffmpeg");
2986
+ console.error(" Windows: https://ffmpeg.org/download.html");
2987
+ process.exit(1);
2988
+ return;
2989
+ }
2990
+ const doc = readAndParse3(file);
2991
+ let format;
2992
+ if (options.format) {
2993
+ if (options.format !== "mp4" && options.format !== "gif") {
2994
+ console.error(
2995
+ `Unknown format: "${options.format}". Use mp4 or gif.`
2996
+ );
2997
+ process.exit(1);
2998
+ return;
2999
+ }
3000
+ format = options.format;
3001
+ } else {
3002
+ format = inferFormat(options.output);
3003
+ }
3004
+ const inputName = basename3(file, extname3(file));
3005
+ const output = options.output ?? `${inputName}.${format}`;
3006
+ const startTime = Date.now();
3007
+ try {
3008
+ const result = await renderDocument(doc, {
3009
+ output: resolve8(output),
3010
+ format,
3011
+ states: options.state,
3012
+ onProgress: ({ frame, totalFrames, state, percent }) => {
3013
+ const elapsed = (Date.now() - startTime) / 1e3;
3014
+ const rate = elapsed > 0 ? frame / elapsed : 0;
3015
+ const remaining = rate > 0 ? (totalFrames - frame) / rate : 0;
3016
+ process.stderr.write(
3017
+ `\rRendering: frame ${frame}/${totalFrames} (${percent}%) - state "${state}" - ETA ${remaining.toFixed(1)}s`
3018
+ );
3019
+ }
3020
+ });
3021
+ process.stderr.write("\n");
3022
+ console.log(
3023
+ `Done: ${result.totalFrames} frames \u2192 ${result.output} (${(result.durationMs / 1e3).toFixed(1)}s)`
3024
+ );
3025
+ } catch (err) {
3026
+ process.stderr.write("\n");
3027
+ console.error(err.message);
3028
+ process.exit(1);
3029
+ }
3030
+ }
3031
+ );
3032
+ }
3033
+
3034
+ // src/commands/export-svg.ts
3035
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
3036
+ import { resolve as resolve9 } from "path";
3037
+
3038
+ // ../svg/dist/index.js
3039
+ function buildTransform(eff) {
3040
+ const parts = [];
3041
+ if (eff.x !== 0 || eff.y !== 0) {
3042
+ parts.push(`translate(${eff.x}, ${eff.y})`);
3043
+ }
3044
+ const totalRotation = eff.rotation + eff.motionPathAngle;
3045
+ if (totalRotation !== 0 || eff.scaleX !== 1 || eff.scaleY !== 1) {
3046
+ const ax = eff.anchorX * eff.width;
3047
+ const ay = eff.anchorY * eff.height;
3048
+ if (ax !== 0 || ay !== 0) {
3049
+ parts.push(`translate(${ax}, ${ay})`);
3050
+ }
3051
+ if (totalRotation !== 0) {
3052
+ parts.push(`rotate(${totalRotation})`);
3053
+ }
3054
+ if (eff.scaleX !== 1 || eff.scaleY !== 1) {
3055
+ parts.push(`scale(${eff.scaleX}, ${eff.scaleY})`);
3056
+ }
3057
+ if (ax !== 0 || ay !== 0) {
3058
+ parts.push(`translate(${-ax}, ${-ay})`);
3059
+ }
3060
+ }
3061
+ return parts.length > 0 ? parts.join(" ") : "";
3062
+ }
3063
+ function buildStyleAttrs(eff) {
3064
+ const attrs = [];
3065
+ if (eff.opacity < 1) {
3066
+ attrs.push(`opacity="${eff.opacity}"`);
3067
+ }
3068
+ if (eff.blendMode !== "normal") {
3069
+ attrs.push(`style="mix-blend-mode: ${eff.blendMode}"`);
3070
+ }
3071
+ return attrs.join(" ");
3072
+ }
3073
+ function escapeXml(str) {
3074
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3075
+ }
3076
+ var gradientIdCounter = 0;
3077
+ function resetGradientCounter() {
3078
+ gradientIdCounter = 0;
3079
+ }
3080
+ function colorToCSS(color) {
3081
+ if (typeof color === "string") return color;
3082
+ if ("r" in color) {
3083
+ const c = color;
3084
+ return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${c.a})`;
3085
+ }
3086
+ if ("h" in color) {
3087
+ const c = color;
3088
+ return `hsla(${c.h}, ${c.s}%, ${c.l}%, ${c.a})`;
3089
+ }
3090
+ return "#000000";
3091
+ }
3092
+ function buildGradientDef(fill, width, height) {
3093
+ if (fill.type === "solid") {
3094
+ return { defs: "", fillRef: colorToCSS(fill.color) };
3095
+ }
3096
+ if (fill.type === "linear-gradient") {
3097
+ return buildLinearGradient(fill, width, height);
3098
+ }
3099
+ if (fill.type === "radial-gradient") {
3100
+ return buildRadialGradient(fill, width, height);
3101
+ }
3102
+ return { defs: "", fillRef: "none" };
3103
+ }
3104
+ function buildLinearGradient(fill, _width, _height) {
3105
+ const id = `grad-${++gradientIdCounter}`;
3106
+ const rad = fill.angle * Math.PI / 180;
3107
+ const cos = Math.cos(rad);
3108
+ const sin = Math.sin(rad);
3109
+ const x1 = 0.5 - cos * 0.5;
3110
+ const y1 = 0.5 - sin * 0.5;
3111
+ const x2 = 0.5 + cos * 0.5;
3112
+ const y2 = 0.5 + sin * 0.5;
3113
+ const stops = fill.stops.map(
3114
+ (s) => `<stop offset="${s.offset}" stop-color="${colorToCSS(s.color)}" />`
3115
+ ).join("");
3116
+ const def = `<linearGradient id="${id}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stops}</linearGradient>`;
3117
+ return { defs: def, fillRef: `url(#${id})` };
3118
+ }
3119
+ function buildRadialGradient(fill, width, height) {
3120
+ const id = `grad-${++gradientIdCounter}`;
3121
+ const cx = typeof fill.center.x === "number" ? fill.center.x : parseFloat(fill.center.x) / 100 * width;
3122
+ const cy = typeof fill.center.y === "number" ? fill.center.y : parseFloat(fill.center.y) / 100 * height;
3123
+ const r = typeof fill.radius === "number" ? fill.radius : parseFloat(fill.radius) / 100 * Math.max(width, height);
3124
+ const stops = fill.stops.map(
3125
+ (s) => `<stop offset="${s.offset}" stop-color="${colorToCSS(s.color)}" />`
3126
+ ).join("");
3127
+ const def = `<radialGradient id="${id}" cx="${cx}" cy="${cy}" r="${r}" gradientUnits="userSpaceOnUse">${stops}</radialGradient>`;
3128
+ return { defs: def, fillRef: `url(#${id})` };
3129
+ }
3130
+ function renderShapeSVG(eff, visual) {
3131
+ const { shape } = visual;
3132
+ const defs = [];
3133
+ let fillAttr = "none";
3134
+ if (visual.fill) {
3135
+ const gradResult = buildGradientDef(visual.fill, eff.width, eff.height);
3136
+ if (gradResult.defs) defs.push(gradResult.defs);
3137
+ fillAttr = gradResult.fillRef;
3138
+ }
3139
+ let strokeAttrs = "";
3140
+ if (visual.stroke) {
3141
+ strokeAttrs = buildStrokeAttrs(visual.stroke);
3142
+ }
3143
+ const element = buildShapeElement(shape, eff.width, eff.height, fillAttr, strokeAttrs);
3144
+ return { elements: element, defs: defs.join("") };
3145
+ }
3146
+ function buildShapeElement(shape, width, height, fill, strokeAttrs) {
3147
+ switch (shape.type) {
3148
+ case "rect":
3149
+ return buildRectElement(shape, width, height, fill, strokeAttrs);
3150
+ case "ellipse":
3151
+ return buildEllipseElement(width, height, fill, strokeAttrs);
3152
+ case "path":
3153
+ return buildPathElement(shape, fill, strokeAttrs);
3154
+ }
3155
+ }
3156
+ function buildRectElement(shape, width, height, fill, strokeAttrs) {
3157
+ let rx = "";
3158
+ if (shape.cornerRadius) {
3159
+ const r = typeof shape.cornerRadius === "number" ? shape.cornerRadius : shape.cornerRadius[0];
3160
+ rx = ` rx="${r}" ry="${r}"`;
3161
+ }
3162
+ return `<rect width="${width}" height="${height}" fill="${fill}"${rx}${strokeAttrs ? " " + strokeAttrs : ""} />`;
3163
+ }
3164
+ function buildEllipseElement(width, height, fill, strokeAttrs) {
3165
+ const cx = width / 2;
3166
+ const cy = height / 2;
3167
+ const rx = width / 2;
3168
+ const ry = height / 2;
3169
+ return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}"${strokeAttrs ? " " + strokeAttrs : ""} />`;
3170
+ }
3171
+ function buildPathElement(shape, fill, strokeAttrs) {
3172
+ if (shape.points.length < 2) return "";
3173
+ const d = [];
3174
+ d.push(`M ${shape.points[0].x} ${shape.points[0].y}`);
3175
+ for (let i = 1; i < shape.points.length; i++) {
3176
+ const prev = shape.points[i - 1];
3177
+ const curr = shape.points[i];
3178
+ if (prev.out && curr.in) {
3179
+ d.push(`C ${prev.x + prev.out.x} ${prev.y + prev.out.y} ${curr.x + curr.in.x} ${curr.y + curr.in.y} ${curr.x} ${curr.y}`);
3180
+ } else {
3181
+ d.push(`L ${curr.x} ${curr.y}`);
3182
+ }
3183
+ }
3184
+ if (shape.closed) d.push("Z");
3185
+ return `<path d="${d.join(" ")}" fill="${fill}"${strokeAttrs ? " " + strokeAttrs : ""} />`;
3186
+ }
3187
+ function buildStrokeAttrs(stroke) {
3188
+ const parts = [];
3189
+ parts.push(`stroke="${colorToCSS(stroke.color)}"`);
3190
+ parts.push(`stroke-width="${stroke.width}"`);
3191
+ if (stroke.lineCap) parts.push(`stroke-linecap="${stroke.lineCap}"`);
3192
+ if (stroke.lineJoin) parts.push(`stroke-linejoin="${stroke.lineJoin}"`);
3193
+ if (stroke.dash) parts.push(`stroke-dasharray="${stroke.dash.join(" ")}"`);
3194
+ return parts.join(" ");
3195
+ }
3196
+ function renderTextSVG(eff, visual) {
3197
+ const { style } = visual;
3198
+ const attrs = [];
3199
+ attrs.push(`font-family="${escapeXml(style.fontFamily)}"`);
3200
+ attrs.push(`font-size="${style.fontSize}"`);
3201
+ if (style.fontWeight && style.fontWeight !== "normal") {
3202
+ attrs.push(`font-weight="${style.fontWeight}"`);
3203
+ }
3204
+ if (style.fontStyle && style.fontStyle !== "normal") {
3205
+ attrs.push(`font-style="${style.fontStyle}"`);
3206
+ }
3207
+ attrs.push(`fill="${colorToCSS(style.color)}"`);
3208
+ const align = style.textAlign ?? "left";
3209
+ let textAnchor = "start";
3210
+ let x = 0;
3211
+ if (align === "center") {
3212
+ textAnchor = "middle";
3213
+ x = eff.width / 2;
3214
+ } else if (align === "right") {
3215
+ textAnchor = "end";
3216
+ x = eff.width;
3217
+ }
3218
+ attrs.push(`text-anchor="${textAnchor}"`);
3219
+ if (style.letterSpacing) {
3220
+ attrs.push(`letter-spacing="${style.letterSpacing}"`);
3221
+ }
3222
+ attrs.push(`dominant-baseline="hanging"`);
3223
+ return `<text x="${x}" y="0" ${attrs.join(" ")}>${escapeXml(visual.content)}</text>`;
3224
+ }
3225
+ var filterIdCounter = 0;
3226
+ function resetFilterCounter() {
3227
+ filterIdCounter = 0;
3228
+ }
3229
+ function buildShadowFilter(eff) {
3230
+ if (!eff.shadow) return null;
3231
+ const id = `filter-${++filterIdCounter}`;
3232
+ const { color, blur, offsetX, offsetY } = eff.shadow;
3233
+ const def = [
3234
+ `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">`,
3235
+ `<feDropShadow dx="${offsetX}" dy="${offsetY}" stdDeviation="${blur / 2}" flood-color="${color}" />`,
3236
+ `</filter>`
3237
+ ].join("");
3238
+ return { defs: def, filterRef: `url(#${id})` };
3239
+ }
3240
+ function buildTintFilter(eff) {
3241
+ if (!eff.tint || eff.tint.amount <= 0) return null;
3242
+ const id = `filter-${++filterIdCounter}`;
3243
+ const { color, amount } = eff.tint;
3244
+ const def = [
3245
+ `<filter id="${id}" x="0%" y="0%" width="100%" height="100%">`,
3246
+ `<feFlood flood-color="${color}" flood-opacity="${amount}" result="tint" />`,
3247
+ `<feBlend in="SourceGraphic" in2="tint" mode="multiply" />`,
3248
+ `</filter>`
3249
+ ].join("");
3250
+ return { defs: def, filterRef: `url(#${id})` };
3251
+ }
3252
+ var clipIdCounter = 0;
3253
+ function resetClipCounter() {
3254
+ clipIdCounter = 0;
3255
+ }
3256
+ function buildClipPathDef(shape, width, height) {
3257
+ const id = `clip-${++clipIdCounter}`;
3258
+ let pathContent = "";
3259
+ switch (shape.type) {
3260
+ case "rect":
3261
+ if (shape.cornerRadius) {
3262
+ const r = typeof shape.cornerRadius === "number" ? shape.cornerRadius : shape.cornerRadius[0];
3263
+ pathContent = `<rect width="${width}" height="${height}" rx="${r}" ry="${r}" />`;
3264
+ } else {
3265
+ pathContent = `<rect width="${width}" height="${height}" />`;
3266
+ }
3267
+ break;
3268
+ case "ellipse":
3269
+ pathContent = `<ellipse cx="${width / 2}" cy="${height / 2}" rx="${width / 2}" ry="${height / 2}" />`;
3270
+ break;
3271
+ case "path": {
3272
+ if (shape.points.length < 2) return { defs: "", clipRef: "" };
3273
+ const d = [];
3274
+ d.push(`M ${shape.points[0].x} ${shape.points[0].y}`);
3275
+ for (let i = 1; i < shape.points.length; i++) {
3276
+ const prev = shape.points[i - 1];
3277
+ const curr = shape.points[i];
3278
+ if (prev.out && curr.in) {
3279
+ d.push(`C ${prev.x + prev.out.x} ${prev.y + prev.out.y} ${curr.x + curr.in.x} ${curr.y + curr.in.y} ${curr.x} ${curr.y}`);
3280
+ } else {
3281
+ d.push(`L ${curr.x} ${curr.y}`);
3282
+ }
3283
+ }
3284
+ if (shape.closed) d.push("Z");
3285
+ pathContent = `<path d="${d.join(" ")}" />`;
3286
+ break;
3287
+ }
3288
+ }
3289
+ const def = `<clipPath id="${id}">${pathContent}</clipPath>`;
3290
+ return { defs: def, clipRef: `url(#${id})` };
3291
+ }
3292
+ function renderFrameSVG(doc, stateOrFrame, frame, opts) {
3293
+ resetGradientCounter();
3294
+ resetFilterCounter();
3295
+ resetClipCounter();
3296
+ let resolved;
3297
+ if (typeof stateOrFrame === "string") {
3298
+ resolved = resolveFrame(doc, stateOrFrame, frame ?? 0);
3299
+ } else {
3300
+ resolved = stateOrFrame;
3301
+ }
3302
+ const { width, height } = doc.canvas;
3303
+ const indent = opts?.indent ?? 2;
3304
+ const pad = " ".repeat(indent);
3305
+ const effLayers = resolved.layers.map(
3306
+ (rl) => buildEffectiveLayer(rl, width, height)
3307
+ );
3308
+ const allDefs = [];
3309
+ const layerElements = [];
3310
+ for (let i = 0; i < effLayers.length; i++) {
3311
+ const eff = effLayers[i];
3312
+ const layer = resolved.layers[i].layer;
3313
+ if (!eff.visible) continue;
3314
+ if (eff.opacity <= 0) continue;
3315
+ if (layer.visual.type === "image") {
3316
+ const iv = eff.visual;
3317
+ if (!iv.src && iv.assetId && doc.assets?.[iv.assetId]) {
3318
+ eff.visual.src = doc.assets[iv.assetId].src;
3319
+ }
3320
+ }
3321
+ const transform = buildTransform(eff);
3322
+ const styleAttrs = buildStyleAttrs(eff);
3323
+ const filterResult = buildShadowFilter(eff);
3324
+ if (filterResult) allDefs.push(filterResult.defs);
3325
+ const tintResult = buildTintFilter(eff);
3326
+ if (tintResult) allDefs.push(tintResult.defs);
3327
+ let clipAttr = "";
3328
+ if (layer.clipPath) {
3329
+ const clipResult = buildClipPathDef(layer.clipPath, eff.width, eff.height);
3330
+ if (clipResult.defs) {
3331
+ allDefs.push(clipResult.defs);
3332
+ clipAttr = ` clip-path="${clipResult.clipRef}"`;
3333
+ }
3334
+ }
3335
+ const gAttrs = [];
3336
+ if (transform) gAttrs.push(`transform="${transform}"`);
3337
+ if (styleAttrs) gAttrs.push(styleAttrs);
3338
+ if (filterResult && tintResult) {
3339
+ gAttrs.push(`filter="${filterResult.filterRef}"`);
3340
+ } else if (filterResult) {
3341
+ gAttrs.push(`filter="${filterResult.filterRef}"`);
3342
+ } else if (tintResult) {
3343
+ gAttrs.push(`filter="${tintResult.filterRef}"`);
3344
+ }
3345
+ if (clipAttr) gAttrs.push(clipAttr.trim());
3346
+ let content = "";
3347
+ let layerDefs = "";
3348
+ switch (layer.visual.type) {
3349
+ case "shape": {
3350
+ const result = renderShapeSVG(eff, eff.visual);
3351
+ content = result.elements;
3352
+ layerDefs = result.defs;
3353
+ break;
3354
+ }
3355
+ case "text":
3356
+ content = renderTextSVG(eff, eff.visual);
3357
+ break;
3358
+ case "image": {
3359
+ const iv = eff.visual;
3360
+ if (iv.src) {
3361
+ if (iv.spritesheet) {
3362
+ const { columns, rows, frameWidth, frameHeight, frameCount } = iv.spritesheet;
3363
+ const maxFrames = frameCount ?? columns * rows;
3364
+ const idx = Math.max(0, Math.min(Math.floor(iv.frameIndex ?? 0), maxFrames - 1));
3365
+ const col = idx % columns;
3366
+ const row = Math.floor(idx / columns);
3367
+ const sx = col * frameWidth;
3368
+ const sy = row * frameHeight;
3369
+ const imgW = columns * frameWidth;
3370
+ const imgH = rows * frameHeight;
3371
+ content = `<svg viewBox="${sx} ${sy} ${frameWidth} ${frameHeight}" width="${eff.width}" height="${eff.height}"><image href="${escapeXml(iv.src)}" width="${imgW}" height="${imgH}" /></svg>`;
3372
+ } else if (iv.sourceRect) {
3373
+ const sr = iv.sourceRect;
3374
+ content = `<svg viewBox="${sr.x} ${sr.y} ${sr.width} ${sr.height}" width="${eff.width}" height="${eff.height}"><image href="${escapeXml(iv.src)}" width="${eff.width}" height="${eff.height}" /></svg>`;
3375
+ } else {
3376
+ content = `<image href="${escapeXml(iv.src)}" width="${eff.width}" height="${eff.height}" />`;
3377
+ }
3378
+ }
3379
+ break;
3380
+ }
3381
+ case "group":
3382
+ break;
3383
+ case "ref": {
3384
+ const refVisual = eff.visual;
3385
+ const refContent = renderRefSVG(eff, refVisual, doc, opts);
3386
+ content = refContent;
3387
+ break;
3388
+ }
3389
+ }
3390
+ if (layerDefs) allDefs.push(layerDefs);
3391
+ if (content) {
3392
+ const gOpen = gAttrs.length > 0 ? `<g ${gAttrs.join(" ")}>` : "<g>";
3393
+ layerElements.push(`${pad}${gOpen}${content}</g>`);
3394
+ }
3395
+ }
3396
+ const lines = [];
3397
+ if (opts?.xmlDeclaration) {
3398
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
3399
+ }
3400
+ const viewBox = opts?.viewBox !== false ? ` viewBox="0 0 ${width} ${height}"` : "";
3401
+ const bg = doc.canvas.background;
3402
+ lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${viewBox}>`);
3403
+ if (allDefs.length > 0) {
3404
+ lines.push(`${pad}<defs>`);
3405
+ for (const def of allDefs) {
3406
+ lines.push(`${pad}${pad}${def}`);
3407
+ }
3408
+ lines.push(`${pad}</defs>`);
3409
+ }
3410
+ if (bg && bg !== "transparent") {
3411
+ lines.push(`${pad}<rect width="${width}" height="${height}" fill="${bg}" />`);
3412
+ }
3413
+ lines.push(...layerElements);
3414
+ lines.push("</svg>");
3415
+ return lines.join("\n");
3416
+ }
3417
+ function renderRefSVG(eff, visual, _parentDoc, opts, _depth, _visitedRefs) {
3418
+ const resolver = opts?.documentResolver;
3419
+ if (!resolver) {
3420
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#999" stroke-dasharray="4 2" />`;
3421
+ }
3422
+ const depth = _depth ?? 0;
3423
+ const maxDepth = opts?.maxRefDepth ?? 4;
3424
+ if (depth >= maxDepth) {
3425
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#c33" stroke-dasharray="4 2" /><text x="${eff.width / 2}" y="${eff.height / 2}" text-anchor="middle" dominant-baseline="middle" fill="#c33" font-size="12">MAX DEPTH</text>`;
3426
+ }
3427
+ const visitedRefs = _visitedRefs ?? /* @__PURE__ */ new Set();
3428
+ if (visitedRefs.has(visual.src)) {
3429
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#c33" stroke-dasharray="4 2" /><text x="${eff.width / 2}" y="${eff.height / 2}" text-anchor="middle" dominant-baseline="middle" fill="#c33" font-size="12">CYCLE</text>`;
3430
+ }
3431
+ const subDoc = resolver(visual.src);
3432
+ if (!subDoc) {
3433
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#999" stroke-dasharray="4 2" /><text x="${eff.width / 2}" y="${eff.height / 2}" text-anchor="middle" dominant-baseline="middle" fill="#999" font-size="12">NOT FOUND</text>`;
3434
+ }
3435
+ const stateNames = Object.keys(subDoc.states);
3436
+ if (stateNames.length === 0) {
3437
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#999" stroke-dasharray="4 2" />`;
3438
+ }
3439
+ const stateName = visual.state ?? stateNames[0];
3440
+ const stateObj = subDoc.states[stateName];
3441
+ if (!stateObj) {
3442
+ return `<rect width="${eff.width}" height="${eff.height}" fill="none" stroke="#999" stroke-dasharray="4 2" />`;
3443
+ }
3444
+ const maxFrame = Math.max(0, stateObj.duration - 1);
3445
+ const frame = Math.min(visual.frame ?? 0, maxFrame);
3446
+ visitedRefs.add(visual.src);
3447
+ const subW = subDoc.canvas.width;
3448
+ const subH = subDoc.canvas.height;
3449
+ const resolved = resolveFrame(subDoc, stateName, frame);
3450
+ const subParts = [];
3451
+ for (const rl of resolved.layers) {
3452
+ const subEff = buildEffectiveLayer(rl, subW, subH);
3453
+ if (!subEff.visible || subEff.opacity <= 0) continue;
3454
+ if (rl.layer.visual.type === "image") {
3455
+ const iv = subEff.visual;
3456
+ if (!iv.src && iv.assetId && subDoc.assets?.[iv.assetId]) {
3457
+ iv.src = subDoc.assets[iv.assetId].src;
3458
+ }
3459
+ }
3460
+ const transform = buildTransform(subEff);
3461
+ const styleAttrs = buildStyleAttrs(subEff);
3462
+ const gAttrs = [];
3463
+ if (transform) gAttrs.push(`transform="${transform}"`);
3464
+ if (styleAttrs) gAttrs.push(styleAttrs);
3465
+ let childContent = "";
3466
+ switch (rl.layer.visual.type) {
3467
+ case "shape": {
3468
+ const result = renderShapeSVG(subEff, subEff.visual);
3469
+ childContent = result.elements;
3470
+ break;
3471
+ }
3472
+ case "text":
3473
+ childContent = renderTextSVG(subEff, subEff.visual);
3474
+ break;
3475
+ case "image": {
3476
+ const iv = subEff.visual;
3477
+ if (iv.src) {
3478
+ childContent = `<image href="${escapeXml(iv.src)}" width="${subEff.width}" height="${subEff.height}" />`;
3479
+ }
3480
+ break;
3481
+ }
3482
+ case "ref": {
3483
+ const refV = subEff.visual;
3484
+ childContent = renderRefSVG(subEff, refV, subDoc, opts, depth + 1, visitedRefs);
3485
+ break;
3486
+ }
3487
+ }
3488
+ if (childContent) {
3489
+ const gOpen = gAttrs.length > 0 ? `<g ${gAttrs.join(" ")}>` : "<g>";
3490
+ subParts.push(`${gOpen}${childContent}</g>`);
3491
+ }
3492
+ }
3493
+ visitedRefs.delete(visual.src);
3494
+ return `<svg x="0" y="0" width="${eff.width}" height="${eff.height}" viewBox="0 0 ${subW} ${subH}">${subParts.join("")}</svg>`;
3495
+ }
3496
+
3497
+ // src/commands/export-svg.ts
3498
+ function readAndParse4(file) {
3499
+ const absPath = resolve9(file);
3500
+ let content;
3501
+ try {
3502
+ content = readFileSync7(absPath, "utf-8");
3503
+ } catch {
3504
+ console.error(`Cannot read file: ${absPath}`);
3505
+ return process.exit(1);
3506
+ }
3507
+ const result = parseAtelier(content);
3508
+ if (!result.success) {
3509
+ console.error("Parse errors:");
3510
+ for (const error of result.errors) {
3511
+ console.error(` - ${error.path}: ${error.message}`);
3512
+ }
3513
+ return process.exit(1);
3514
+ }
3515
+ return result.data;
3516
+ }
3517
+ function exportSvgCommand(program) {
3518
+ program.command("export-svg <file>").description("Export a frame as SVG").option("-s, --state <name>", "State name (defaults to first state)").option("-f, --frame <number>", "Frame number (defaults to 0)", "0").option("-o, --output <path>", "Output file path (default: stdout)").option("--xml-declaration", "Include XML declaration").action(
3519
+ (file, options) => {
3520
+ const doc = readAndParse4(file);
3521
+ const frameNumber = parseInt(options.frame, 10);
3522
+ if (isNaN(frameNumber) || frameNumber < 0) {
3523
+ console.error(`Invalid frame number: ${options.frame}`);
3524
+ process.exit(1);
3525
+ return;
3526
+ }
3527
+ const stateNames = Object.keys(doc.states);
3528
+ if (stateNames.length === 0) {
3529
+ console.error("Document has no states");
3530
+ process.exit(1);
3531
+ return;
3532
+ }
3533
+ const stateName = options.state ?? stateNames[0];
3534
+ if (!doc.states[stateName]) {
3535
+ console.error(`State "${stateName}" not found. Available: ${stateNames.join(", ")}`);
3536
+ process.exit(1);
3537
+ return;
3538
+ }
3539
+ try {
3540
+ const svg = renderFrameSVG(doc, stateName, frameNumber, {
3541
+ xmlDeclaration: options.xmlDeclaration
3542
+ });
3543
+ if (options.output) {
3544
+ writeFileSync5(resolve9(options.output), svg, "utf-8");
3545
+ } else {
3546
+ console.log(svg);
3547
+ }
3548
+ } catch (err) {
3549
+ console.error(err.message);
3550
+ process.exit(1);
3551
+ }
3552
+ }
3553
+ );
3554
+ }
3555
+
3556
+ // src/commands/export-lottie.ts
3557
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
3558
+ import { resolve as resolve10 } from "path";
3559
+
3560
+ // ../lottie/dist/index.js
3561
+ function colorToLottie(color) {
3562
+ if (typeof color === "string") {
3563
+ return hexToLottie(color);
3564
+ }
3565
+ if ("r" in color) {
3566
+ const c = color;
3567
+ return [c.r / 255, c.g / 255, c.b / 255, c.a];
3568
+ }
3569
+ if ("h" in color) {
3570
+ const c = color;
3571
+ const rgb = hslToRgb(c.h, c.s, c.l);
3572
+ return [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, c.a];
3573
+ }
3574
+ return [0, 0, 0, 1];
3575
+ }
3576
+ function hexToLottie(hex) {
3577
+ const clean = hex.replace("#", "");
3578
+ if (clean.length === 3 || clean.length === 4) {
3579
+ const r2 = parseInt(clean[0] + clean[0], 16) / 255;
3580
+ const g2 = parseInt(clean[1] + clean[1], 16) / 255;
3581
+ const b2 = parseInt(clean[2] + clean[2], 16) / 255;
3582
+ const a2 = clean.length === 4 ? parseInt(clean[3] + clean[3], 16) / 255 : 1;
3583
+ return [r2, g2, b2, a2];
3584
+ }
3585
+ const r = parseInt(clean.slice(0, 2), 16) / 255;
3586
+ const g = parseInt(clean.slice(2, 4), 16) / 255;
3587
+ const b = parseInt(clean.slice(4, 6), 16) / 255;
3588
+ const a = clean.length === 8 ? parseInt(clean.slice(6, 8), 16) / 255 : 1;
3589
+ return [r, g, b, a];
3590
+ }
3591
+ function hslToRgb(h, s, l) {
3592
+ s = s / 100;
3593
+ l = l / 100;
3594
+ const a = s * Math.min(l, 1 - l);
3595
+ const f = (n) => {
3596
+ const k = (n + h / 30) % 12;
3597
+ return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
3598
+ };
3599
+ return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
3600
+ }
3601
+ function mapShapeVisual(visual, width, height) {
3602
+ const items = [];
3603
+ switch (visual.shape.type) {
3604
+ case "rect":
3605
+ items.push({
3606
+ ty: "rc",
3607
+ nm: "Rectangle",
3608
+ d: 1,
3609
+ s: { a: 0, k: [width, height] },
3610
+ p: { a: 0, k: [width / 2, height / 2] },
3611
+ r: { a: 0, k: typeof visual.shape.cornerRadius === "number" ? visual.shape.cornerRadius : 0 }
3612
+ });
3613
+ break;
3614
+ case "ellipse":
3615
+ items.push({
3616
+ ty: "el",
3617
+ nm: "Ellipse",
3618
+ d: 1,
3619
+ s: { a: 0, k: [width, height] },
3620
+ p: { a: 0, k: [width / 2, height / 2] }
3621
+ });
3622
+ break;
3623
+ case "path": {
3624
+ const vertices = visual.shape.points.map((p) => [p.x, p.y]);
3625
+ const inTangents = visual.shape.points.map((p) => p.in ? [p.in.x, p.in.y] : [0, 0]);
3626
+ const outTangents = visual.shape.points.map((p) => p.out ? [p.out.x, p.out.y] : [0, 0]);
3627
+ items.push({
3628
+ ty: "sh",
3629
+ nm: "Path",
3630
+ ks: {
3631
+ a: 0,
3632
+ k: {
3633
+ c: visual.shape.closed ?? false,
3634
+ v: vertices,
3635
+ i: inTangents,
3636
+ o: outTangents
3637
+ }
3638
+ }
3639
+ });
3640
+ break;
3641
+ }
3642
+ }
3643
+ if (visual.fill) {
3644
+ items.push(mapFill(visual.fill, width, height));
3645
+ }
3646
+ if (visual.stroke) {
3647
+ items.push(mapStroke(visual.stroke));
3648
+ }
3649
+ return items;
3650
+ }
3651
+ function mapFill(fill, _width, _height) {
3652
+ if (fill.type === "solid") {
3653
+ const color = colorToLottie(fill.color);
3654
+ return {
3655
+ ty: "fl",
3656
+ nm: "Fill",
3657
+ c: { a: 0, k: color.slice(0, 3) },
3658
+ o: { a: 0, k: (color[3] ?? 1) * 100 },
3659
+ r: 1
3660
+ };
3661
+ }
3662
+ if (fill.type === "linear-gradient") {
3663
+ const stops = [];
3664
+ for (const stop of fill.stops) {
3665
+ const c = colorToLottie(stop.color);
3666
+ stops.push(stop.offset, c[0], c[1], c[2]);
3667
+ }
3668
+ return {
3669
+ ty: "gf",
3670
+ nm: "Gradient Fill",
3671
+ t: 1,
3672
+ // linear
3673
+ s: { a: 0, k: [0, 0] },
3674
+ e: { a: 0, k: [100, 0] },
3675
+ g: { p: fill.stops.length, k: { a: 0, k: stops } },
3676
+ r: 1,
3677
+ o: { a: 0, k: 100 }
3678
+ };
3679
+ }
3680
+ if (fill.type === "radial-gradient") {
3681
+ const stops = [];
3682
+ for (const stop of fill.stops) {
3683
+ const c = colorToLottie(stop.color);
3684
+ stops.push(stop.offset, c[0], c[1], c[2]);
3685
+ }
3686
+ return {
3687
+ ty: "gf",
3688
+ nm: "Gradient Fill",
3689
+ t: 2,
3690
+ // radial
3691
+ s: { a: 0, k: [50, 50] },
3692
+ e: { a: 0, k: [100, 50] },
3693
+ g: { p: fill.stops.length, k: { a: 0, k: stops } },
3694
+ r: 1,
3695
+ o: { a: 0, k: 100 }
3696
+ };
3697
+ }
3698
+ return { ty: "fl", nm: "Fill", c: { a: 0, k: [0, 0, 0] }, o: { a: 0, k: 100 }, r: 1 };
3699
+ }
3700
+ function mapStroke(stroke) {
3701
+ const color = colorToLottie(stroke.color);
3702
+ return {
3703
+ ty: "st",
3704
+ nm: "Stroke",
3705
+ c: { a: 0, k: color.slice(0, 3) },
3706
+ o: { a: 0, k: (color[3] ?? 1) * 100 },
3707
+ w: { a: 0, k: stroke.width },
3708
+ lc: stroke.lineCap === "round" ? 2 : stroke.lineCap === "square" ? 3 : 1,
3709
+ lj: stroke.lineJoin === "round" ? 2 : stroke.lineJoin === "bevel" ? 3 : 1
3710
+ };
3711
+ }
3712
+ function mapEasing(easing) {
3713
+ if (!easing) return linearEasing();
3714
+ if (typeof easing === "string") {
3715
+ switch (easing) {
3716
+ case "ease-in":
3717
+ return bezierEasing(0.42, 0, 1, 1);
3718
+ case "ease-out":
3719
+ return bezierEasing(0, 0, 0.58, 1);
3720
+ case "ease-in-out":
3721
+ return bezierEasing(0.42, 0, 0.58, 1);
3722
+ default:
3723
+ return linearEasing();
3724
+ }
3725
+ }
3726
+ switch (easing.type) {
3727
+ case "linear":
3728
+ return linearEasing();
3729
+ case "cubic-bezier":
3730
+ return bezierEasing(easing.x1, easing.y1, easing.x2, easing.y2);
3731
+ case "spring":
3732
+ return bezierEasing(0.25, 0.1, 0.25, 1);
3733
+ case "step":
3734
+ return { h: 1 };
3735
+ default:
3736
+ return linearEasing();
3737
+ }
3738
+ }
3739
+ function linearEasing() {
3740
+ return {
3741
+ i: { x: [0.833], y: [0.833] },
3742
+ o: { x: [0.167], y: [0.167] }
3743
+ };
3744
+ }
3745
+ function bezierEasing(x1, y1, x2, y2) {
3746
+ return {
3747
+ i: { x: [x2], y: [y2] },
3748
+ o: { x: [x1], y: [y1] }
3749
+ };
3750
+ }
3751
+ function isLossyEasing(easing) {
3752
+ if (!easing || typeof easing === "string") return null;
3753
+ if (easing.type === "spring") return "Spring easing approximated as cubic-bezier";
3754
+ if (easing.type === "step") return "Step easing mapped to hold keyframes";
3755
+ return null;
3756
+ }
3757
+ function mapDeltasToAnimated(deltas, property, warnings) {
3758
+ if (deltas.length === 0) {
3759
+ return { a: 0, k: 0 };
3760
+ }
3761
+ for (const d of deltas) {
3762
+ const lossyMsg = isLossyEasing(d.easing);
3763
+ if (lossyMsg) warnings.push(`${property}: ${lossyMsg}`);
3764
+ }
3765
+ for (const d of deltas) {
3766
+ if (isExpression(d.from) || isExpression(d.to)) {
3767
+ warnings.push(`${property}: Expression values not supported in Lottie, using static values`);
3768
+ return { a: 0, k: typeof d.from === "number" ? d.from : 0 };
3769
+ }
3770
+ }
3771
+ if (deltas.length === 1) {
3772
+ const d = deltas[0];
3773
+ const fromVal = resolveValue(d.from, property);
3774
+ const toVal = resolveValue(d.to, property);
3775
+ if (fromVal === toVal) {
3776
+ return { a: 0, k: fromVal };
3777
+ }
3778
+ const easing = mapEasing(d.easing);
3779
+ const kfs2 = [];
3780
+ if ("h" in easing) {
3781
+ kfs2.push({ t: d.range[0], s: [fromVal], h: 1 });
3782
+ kfs2.push({ t: d.range[1], s: [toVal] });
3783
+ } else {
3784
+ kfs2.push({ t: d.range[0], s: [fromVal], e: [toVal], i: easing.i, o: easing.o });
3785
+ kfs2.push({ t: d.range[1], s: [toVal] });
3786
+ }
3787
+ return { a: 1, k: kfs2 };
3788
+ }
3789
+ const sorted = [...deltas].sort((a, b) => a.range[0] - b.range[0]);
3790
+ const kfs = [];
3791
+ for (const d of sorted) {
3792
+ const fromVal = resolveValue(d.from, property);
3793
+ const toVal = resolveValue(d.to, property);
3794
+ const easing = mapEasing(d.easing);
3795
+ if ("h" in easing) {
3796
+ kfs.push({ t: d.range[0], s: [fromVal], h: 1 });
3797
+ } else {
3798
+ kfs.push({ t: d.range[0], s: [fromVal], e: [toVal], i: easing.i, o: easing.o });
3799
+ }
3800
+ }
3801
+ const last = sorted[sorted.length - 1];
3802
+ kfs.push({ t: last.range[1], s: [resolveValue(last.to, property)] });
3803
+ return { a: 1, k: kfs };
3804
+ }
3805
+ function mapPositionDeltas(xDeltas, yDeltas, baseX, baseY, warnings) {
3806
+ const hasXAnim = xDeltas.length > 0;
3807
+ const hasYAnim = yDeltas.length > 0;
3808
+ if (!hasXAnim && !hasYAnim) {
3809
+ return { a: 0, k: [baseX, baseY, 0] };
3810
+ }
3811
+ const frames = /* @__PURE__ */ new Set();
3812
+ for (const d of [...xDeltas, ...yDeltas]) {
3813
+ frames.add(d.range[0]);
3814
+ frames.add(d.range[1]);
3815
+ }
3816
+ const sortedFrames = [...frames].sort((a, b) => a - b);
3817
+ if (sortedFrames.length < 2) {
3818
+ return { a: 0, k: [baseX, baseY, 0] };
3819
+ }
3820
+ const kfs = [];
3821
+ for (let i = 0; i < sortedFrames.length; i++) {
3822
+ const f = sortedFrames[i];
3823
+ const x = resolveAtFrame(xDeltas, f, baseX);
3824
+ const y = resolveAtFrame(yDeltas, f, baseY);
3825
+ if (i < sortedFrames.length - 1) {
3826
+ const nextF = sortedFrames[i + 1];
3827
+ const nextX = resolveAtFrame(xDeltas, nextF, baseX);
3828
+ const nextY = resolveAtFrame(yDeltas, nextF, baseY);
3829
+ const activeX = xDeltas.find((d) => d.range[0] <= f && d.range[1] >= f);
3830
+ const activeY = yDeltas.find((d) => d.range[0] <= f && d.range[1] >= f);
3831
+ const easing = mapEasing(activeX?.easing ?? activeY?.easing);
3832
+ if ("h" in easing) {
3833
+ kfs.push({ t: f, s: [x, y, 0], h: 1 });
3834
+ } else {
3835
+ kfs.push({ t: f, s: [x, y, 0], e: [nextX, nextY, 0], i: easing.i, o: easing.o });
3836
+ }
3837
+ } else {
3838
+ kfs.push({ t: f, s: [x, y, 0] });
3839
+ }
3840
+ }
3841
+ for (const d of [...xDeltas, ...yDeltas]) {
3842
+ const msg = isLossyEasing(d.easing);
3843
+ if (msg) warnings.push(`position: ${msg}`);
3844
+ }
3845
+ return { a: 1, k: kfs };
3846
+ }
3847
+ function resolveAtFrame(deltas, frame, base) {
3848
+ for (const d of deltas) {
3849
+ if (frame >= d.range[0] && frame <= d.range[1]) {
3850
+ const from = typeof d.from === "number" ? d.from : base;
3851
+ const to = typeof d.to === "number" ? d.to : base;
3852
+ const progress = d.range[0] === d.range[1] ? 1 : (frame - d.range[0]) / (d.range[1] - d.range[0]);
3853
+ return from + (to - from) * progress;
3854
+ }
3855
+ }
3856
+ let lastCompleted;
3857
+ for (const d of deltas) {
3858
+ if (frame > d.range[1]) {
3859
+ if (!lastCompleted || d.range[1] > lastCompleted.range[1]) {
3860
+ lastCompleted = d;
3861
+ }
3862
+ }
3863
+ }
3864
+ if (lastCompleted) return typeof lastCompleted.to === "number" ? lastCompleted.to : base;
3865
+ return base;
3866
+ }
3867
+ function resolveValue(val, _property) {
3868
+ if (typeof val === "number") return val;
3869
+ if (typeof val === "string" && val.startsWith("#")) {
3870
+ return 0;
3871
+ }
3872
+ return 0;
3873
+ }
3874
+ function isExpression(val) {
3875
+ return typeof val === "object" && val !== null && "expr" in val;
3876
+ }
3877
+ function toNum(v) {
3878
+ return typeof v === "number" ? v : 0;
3879
+ }
3880
+ function mapLayers(doc, state, warnings) {
3881
+ const layerIndexMap = /* @__PURE__ */ new Map();
3882
+ doc.layers.forEach((l, i) => layerIndexMap.set(l.id, i));
3883
+ return doc.layers.map((layer, index) => {
3884
+ const deltas = state.deltas.filter((d) => d.layer === layer.id);
3885
+ return mapLayer(layer, index, deltas, layerIndexMap, doc, warnings);
3886
+ });
3887
+ }
3888
+ function mapLayer(layer, index, deltas, layerIndexMap, doc, warnings) {
3889
+ const duration = getMaxFrame(deltas, doc);
3890
+ const base = {
3891
+ ty: getLayerType(layer),
3892
+ nm: layer.id,
3893
+ ind: index,
3894
+ ip: 0,
3895
+ op: duration,
3896
+ st: 0,
3897
+ ks: buildTransform2(layer, deltas, warnings)
3898
+ };
3899
+ if (layer.parentId) {
3900
+ const parentIdx = layerIndexMap.get(layer.parentId);
3901
+ if (parentIdx !== void 0) {
3902
+ base.parent = parentIdx;
3903
+ }
3904
+ }
3905
+ if (layer.blendMode) {
3906
+ base.bm = mapBlendMode(layer.blendMode);
3907
+ }
3908
+ if (layer.visual.type === "shape") {
3909
+ base.shapes = mapShapeVisual(layer.visual, toNum(layer.bounds.width), toNum(layer.bounds.height));
3910
+ }
3911
+ if (layer.visual.type === "text") {
3912
+ const color = colorToLottie(layer.visual.style.color);
3913
+ base.t = {
3914
+ d: {
3915
+ k: [{
3916
+ s: {
3917
+ s: layer.visual.style.fontSize,
3918
+ f: layer.visual.style.fontFamily,
3919
+ t: layer.visual.content,
3920
+ fc: color.slice(0, 3),
3921
+ j: layer.visual.style.textAlign === "center" ? 1 : layer.visual.style.textAlign === "right" ? 2 : 0
3922
+ },
3923
+ t: 0
3924
+ }]
3925
+ }
3926
+ };
3927
+ }
3928
+ if (layer.visual.type === "image") {
3929
+ base.refId = layer.visual.assetId;
3930
+ base.w = toNum(layer.bounds.width);
3931
+ base.h = toNum(layer.bounds.height);
3932
+ if (layer.visual.spritesheet) {
3933
+ warnings.push(`Layer "${layer.id}": Spritesheet animation not supported in Lottie export`);
3934
+ }
3935
+ if (layer.visual.sourceRect) {
3936
+ warnings.push(`Layer "${layer.id}": sourceRect cropping not supported in Lottie export`);
3937
+ }
3938
+ }
3939
+ if (layer.tint && layer.tint.amount > 0) {
3940
+ warnings.push(`Layer "${layer.id}": Tint effect not supported in Lottie export`);
3941
+ }
3942
+ return base;
3943
+ }
3944
+ function getLayerType(layer) {
3945
+ switch (layer.visual.type) {
3946
+ case "shape":
3947
+ return 4;
3948
+ case "text":
3949
+ return 5;
3950
+ case "image":
3951
+ return 2;
3952
+ case "group":
3953
+ return 3;
3954
+ // null layer
3955
+ case "ref":
3956
+ return 0;
3957
+ // precomp
3958
+ default:
3959
+ return 4;
3960
+ }
3961
+ }
3962
+ function buildTransform2(layer, deltas, warnings) {
3963
+ const byProp = /* @__PURE__ */ new Map();
3964
+ for (const d of deltas) {
3965
+ if (!byProp.has(d.property)) byProp.set(d.property, []);
3966
+ byProp.get(d.property).push(d);
3967
+ }
3968
+ const xDeltas = byProp.get("frame.x") ?? [];
3969
+ const yDeltas = byProp.get("frame.y") ?? [];
3970
+ const opacityDeltas = byProp.get("opacity") ?? [];
3971
+ const rotationDeltas = byProp.get("rotation") ?? [];
3972
+ const scaleXDeltas = byProp.get("scale.x") ?? [];
3973
+ const scaleYDeltas = byProp.get("scale.y") ?? [];
3974
+ const position = mapPositionDeltas(
3975
+ xDeltas,
3976
+ yDeltas,
3977
+ typeof layer.frame.x === "number" ? layer.frame.x : 0,
3978
+ typeof layer.frame.y === "number" ? layer.frame.y : 0,
3979
+ warnings
3980
+ );
3981
+ let opacity;
3982
+ if (opacityDeltas.length > 0) {
3983
+ const raw = mapDeltasToAnimated(opacityDeltas, "opacity", warnings);
3984
+ if (raw.a === 0) {
3985
+ opacity = { a: 0, k: raw.k * 100 };
3986
+ } else {
3987
+ const kfs = raw.k.map((kf) => ({
3988
+ ...kf,
3989
+ s: [kf.s[0] * 100],
3990
+ e: kf.e ? [kf.e[0] * 100] : void 0
3991
+ }));
3992
+ opacity = { a: 1, k: kfs };
3993
+ }
3994
+ } else {
3995
+ opacity = { a: 0, k: (layer.opacity ?? 1) * 100 };
3996
+ }
3997
+ const rotation = rotationDeltas.length > 0 ? mapDeltasToAnimated(rotationDeltas, "rotation", warnings) : { a: 0, k: layer.rotation ?? 0 };
3998
+ const baseScaleX = (layer.scale?.x ?? 1) * 100;
3999
+ const baseScaleY = (layer.scale?.y ?? 1) * 100;
4000
+ let scale;
4001
+ if (scaleXDeltas.length > 0 || scaleYDeltas.length > 0) {
4002
+ scale = { a: 0, k: [baseScaleX, baseScaleY, 100] };
4003
+ if (scaleXDeltas.length > 0 || scaleYDeltas.length > 0) {
4004
+ warnings.push("Scale animation partially mapped to Lottie");
4005
+ }
4006
+ } else {
4007
+ scale = { a: 0, k: [baseScaleX, baseScaleY, 100] };
4008
+ }
4009
+ const ax = (layer.anchorPoint?.x ?? 0) * toNum(layer.bounds.width);
4010
+ const ay = (layer.anchorPoint?.y ?? 0) * toNum(layer.bounds.height);
4011
+ return {
4012
+ o: opacity,
4013
+ r: rotation,
4014
+ p: position,
4015
+ a: { a: 0, k: [ax, ay, 0] },
4016
+ s: scale
4017
+ };
4018
+ }
4019
+ function getMaxFrame(deltas, doc) {
4020
+ let max = 0;
4021
+ for (const state of Object.values(doc.states)) {
4022
+ if (state.duration > max) max = state.duration;
4023
+ }
4024
+ for (const d of deltas) {
4025
+ if (d.range[1] > max) max = d.range[1];
4026
+ }
4027
+ return max || 60;
4028
+ }
4029
+ function mapBlendMode(mode) {
4030
+ const map = {
4031
+ normal: 0,
4032
+ multiply: 1,
4033
+ screen: 2,
4034
+ overlay: 3,
4035
+ darken: 4,
4036
+ lighten: 5,
4037
+ "color-dodge": 6,
4038
+ "color-burn": 7,
4039
+ "hard-light": 8,
4040
+ "soft-light": 9,
4041
+ difference: 10,
4042
+ exclusion: 11,
4043
+ hue: 12,
4044
+ saturation: 13,
4045
+ color: 14,
4046
+ luminosity: 15
4047
+ };
4048
+ return map[mode] ?? 0;
4049
+ }
4050
+ function collectUnsupportedWarnings(doc, state) {
4051
+ const warnings = [];
4052
+ if (state.audio) {
4053
+ warnings.push("Audio tracks are not supported in Lottie format and will be dropped");
4054
+ }
4055
+ for (const delta of state.deltas) {
4056
+ if (isExpression2(delta.from) || isExpression2(delta.to)) {
4057
+ warnings.push(`Expression values on "${delta.layer}.${delta.property}" not supported in Lottie`);
4058
+ }
4059
+ }
4060
+ for (const layer of doc.layers) {
4061
+ if (layer.shadow) {
4062
+ warnings.push(`Shadow on layer "${layer.id}" is not supported in base Lottie format`);
4063
+ }
4064
+ }
4065
+ for (const layer of doc.layers) {
4066
+ if (layer.motionPath) {
4067
+ warnings.push(`Motion path on layer "${layer.id}" is not directly mappable to Lottie`);
4068
+ }
4069
+ }
4070
+ for (const layer of doc.layers) {
4071
+ if (layer.clipPath) {
4072
+ warnings.push(`Clip path on layer "${layer.id}" mapped as Lottie mask (partial support)`);
4073
+ }
4074
+ }
4075
+ if (Object.keys(doc.states).length > 1) {
4076
+ warnings.push("Multiple states flattened to single timeline in Lottie export");
4077
+ }
4078
+ return warnings;
4079
+ }
4080
+ function isExpression2(val) {
4081
+ return typeof val === "object" && val !== null && "expr" in val;
4082
+ }
4083
+ function exportToLottie(doc, opts) {
4084
+ const stateNames = Object.keys(doc.states);
4085
+ if (stateNames.length === 0) {
4086
+ throw new Error("Document has no states to export");
4087
+ }
4088
+ const stateName = opts?.state ?? stateNames[0];
4089
+ const state = doc.states[stateName];
4090
+ if (!state) {
4091
+ throw new Error(`State "${stateName}" not found`);
4092
+ }
4093
+ const warnings = [];
4094
+ warnings.push(...collectUnsupportedWarnings(doc, state));
4095
+ const layers = mapLayers(doc, state, warnings);
4096
+ const assets = [];
4097
+ if (doc.assets) {
4098
+ for (const [id, asset] of Object.entries(doc.assets)) {
4099
+ if (asset.type === "image") {
4100
+ assets.push({
4101
+ id,
4102
+ w: 100,
4103
+ h: 100,
4104
+ p: asset.src,
4105
+ e: 0
4106
+ });
4107
+ }
4108
+ }
4109
+ }
4110
+ const json = {
4111
+ v: "5.7.4",
4112
+ fr: doc.canvas.fps,
4113
+ ip: 0,
4114
+ op: state.duration,
4115
+ w: doc.canvas.width,
4116
+ h: doc.canvas.height,
4117
+ nm: doc.name,
4118
+ layers,
4119
+ ...assets.length > 0 ? { assets } : {}
4120
+ };
4121
+ const uniqueWarnings = [...new Set(warnings)];
4122
+ return { json, warnings: uniqueWarnings };
4123
+ }
4124
+
4125
+ // src/commands/export-lottie.ts
4126
+ function readAndParse5(file) {
4127
+ const absPath = resolve10(file);
4128
+ let content;
4129
+ try {
4130
+ content = readFileSync8(absPath, "utf-8");
4131
+ } catch {
4132
+ console.error(`Cannot read file: ${absPath}`);
4133
+ return process.exit(1);
4134
+ }
4135
+ const result = parseAtelier(content);
4136
+ if (!result.success) {
4137
+ console.error("Parse errors:");
4138
+ for (const error of result.errors) {
4139
+ console.error(` - ${error.path}: ${error.message}`);
4140
+ }
4141
+ return process.exit(1);
4142
+ }
4143
+ return result.data;
4144
+ }
4145
+ function exportLottieCommand(program) {
4146
+ program.command("export-lottie <file>").description("Export a document to Lottie JSON format").option("-s, --state <name>", "State name (defaults to first state)").option("-o, --output <path>", "Output file path (default: stdout)").action(
4147
+ (file, options) => {
4148
+ const doc = readAndParse5(file);
4149
+ try {
4150
+ const { json, warnings } = exportToLottie(doc, {
4151
+ state: options.state
4152
+ });
4153
+ for (const warning of warnings) {
4154
+ console.error(`Warning: ${warning}`);
4155
+ }
4156
+ const output = JSON.stringify(json, null, 2);
4157
+ if (options.output) {
4158
+ writeFileSync6(resolve10(options.output), output, "utf-8");
4159
+ } else {
4160
+ console.log(output);
4161
+ }
4162
+ } catch (err) {
4163
+ console.error(err.message);
4164
+ process.exit(1);
4165
+ }
4166
+ }
4167
+ );
4168
+ }
4169
+
4170
+ // src/commands/export-image.ts
4171
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
4172
+ import { resolve as resolve11 } from "path";
4173
+ function readAndParse6(file) {
4174
+ const absPath = resolve11(file);
4175
+ let content;
4176
+ try {
4177
+ content = readFileSync9(absPath, "utf-8");
4178
+ } catch {
4179
+ console.error(`Cannot read file: ${absPath}`);
4180
+ return process.exit(1);
4181
+ }
4182
+ const result = parseAtelier(content);
4183
+ if (!result.success) {
4184
+ console.error("Parse errors:");
4185
+ for (const error of result.errors) {
4186
+ console.error(` - ${error.path}: ${error.message}`);
4187
+ }
4188
+ return process.exit(1);
4189
+ }
4190
+ return result.data;
4191
+ }
4192
+ function parseDim2(raw, name) {
4193
+ if (raw === void 0) return void 0;
4194
+ const n = parseInt(raw, 10);
4195
+ if (isNaN(n) || n <= 0) {
4196
+ console.error(`Invalid --${name}: ${raw}`);
4197
+ process.exit(1);
4198
+ }
4199
+ return n;
4200
+ }
4201
+ function exportImageCommand(program) {
4202
+ program.command("export-image <file>").description(
4203
+ "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)."
4204
+ ).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) => {
4205
+ const doc = readAndParse6(file);
4206
+ const frameNumber = parseInt(options.frame, 10);
4207
+ if (isNaN(frameNumber) || frameNumber < 0) {
4208
+ console.error(`Invalid frame number: ${options.frame}`);
4209
+ process.exit(1);
4210
+ return;
4211
+ }
4212
+ const width = parseDim2(options.width, "width");
4213
+ const height = parseDim2(options.height, "height");
4214
+ try {
4215
+ const buffer = await renderDocumentToPng(doc, {
4216
+ state: options.state,
4217
+ frame: frameNumber,
4218
+ width,
4219
+ height
4220
+ });
4221
+ writeFileSync7(resolve11(options.out), buffer);
4222
+ } catch (err) {
4223
+ if (err instanceof CanvasUnavailableError) {
4224
+ console.error(err.message);
4225
+ process.exit(1);
4226
+ return;
4227
+ }
4228
+ console.error(err.message);
4229
+ process.exit(1);
4230
+ }
4231
+ });
4232
+ }
4233
+
4234
+ // src/commands/assets.ts
4235
+ import { readFileSync as readFileSync10 } from "fs";
4236
+ import { resolve as resolve12 } from "path";
4237
+ function getAssets(doc) {
4238
+ const assets = doc.assets ?? {};
4239
+ return Object.entries(assets).map(([assetId, asset]) => {
4240
+ const usedByLayers = doc.layers.filter((l) => l.visual.type === "image" && l.visual.assetId === assetId).map((l) => l.id);
4241
+ const usedByStates = Object.entries(doc.states).filter(([, state]) => state.audio?.src === assetId).map(([name]) => name);
4242
+ return {
4243
+ assetId,
4244
+ type: asset.type,
4245
+ src: asset.src,
4246
+ description: asset.description,
4247
+ usedByLayers,
4248
+ usedByStates
4249
+ };
4250
+ });
4251
+ }
4252
+ function formatAssets(assets) {
4253
+ if (assets.length === 0) return "No assets registered.";
4254
+ const lines = [`Assets: ${assets.length}`];
4255
+ for (const a of assets) {
4256
+ const desc = a.description ? ` \u2014 ${a.description}` : "";
4257
+ lines.push(` - ${a.assetId} (${a.type}): ${a.src}${desc}`);
4258
+ if (a.usedByLayers.length > 0) {
4259
+ lines.push(` Layers: ${a.usedByLayers.join(", ")}`);
4260
+ }
4261
+ if (a.usedByStates.length > 0) {
4262
+ lines.push(` States: ${a.usedByStates.join(", ")}`);
4263
+ }
4264
+ }
4265
+ return lines.join("\n");
4266
+ }
4267
+ function readAndParse7(file) {
4268
+ const absPath = resolve12(file);
4269
+ let content;
4270
+ try {
4271
+ content = readFileSync10(absPath, "utf-8");
4272
+ } catch {
4273
+ console.error(`Cannot read file: ${absPath}`);
4274
+ return process.exit(1);
4275
+ }
4276
+ const result = parseAtelier(content);
4277
+ if (!result.success) {
4278
+ console.error("Parse errors:");
4279
+ for (const error of result.errors) {
4280
+ console.error(` - ${error.path}: ${error.message}`);
4281
+ }
4282
+ return process.exit(1);
4283
+ }
4284
+ return result.data;
4285
+ }
4286
+ function assetsCommand(program) {
4287
+ program.command("assets <file>").description("List all assets in an .atelier file with usage info").action((file) => {
4288
+ const doc = readAndParse7(file);
4289
+ const assets = getAssets(doc);
4290
+ console.log(formatAssets(assets));
4291
+ });
4292
+ }
4293
+
4294
+ // src/commands/variables.ts
4295
+ import { readFileSync as readFileSync11 } from "fs";
4296
+ import { resolve as resolve13 } from "path";
4297
+ function getVariables(doc) {
4298
+ const variables = doc.variables ?? {};
4299
+ const referenced = findTemplateVariables(doc);
4300
+ const entries = Object.entries(variables).map(([name, variable]) => ({
4301
+ name,
4302
+ type: variable.type,
4303
+ description: variable.description,
4304
+ default: variable.default,
4305
+ referenced: referenced.includes(name)
4306
+ }));
4307
+ const undeclared = referenced.filter((r) => !variables[r]);
4308
+ return { variables: entries, undeclared };
4309
+ }
4310
+ function formatVariables(info) {
4311
+ if (info.variables.length === 0 && info.undeclared.length === 0) return "No variables declared or referenced.";
4312
+ const lines = [];
4313
+ if (info.variables.length > 0) {
4314
+ lines.push(`Variables: ${info.variables.length}`);
4315
+ for (const v of info.variables) {
4316
+ const desc = v.description ? ` \u2014 ${v.description}` : "";
4317
+ const def = v.default !== void 0 ? ` [default: ${JSON.stringify(v.default)}]` : "";
4318
+ const ref = v.referenced ? "" : " (unused)";
4319
+ lines.push(` - {{${v.name}}} (${v.type})${def}${desc}${ref}`);
4320
+ }
4321
+ }
4322
+ if (info.undeclared.length > 0) {
4323
+ lines.push(`Undeclared references: ${info.undeclared.length}`);
4324
+ for (const name of info.undeclared) {
4325
+ lines.push(` - {{${name}}} (not declared in variables)`);
4326
+ }
4327
+ }
4328
+ return lines.join("\n");
4329
+ }
4330
+ function readAndParse8(file) {
4331
+ const absPath = resolve13(file);
4332
+ let content;
4333
+ try {
4334
+ content = readFileSync11(absPath, "utf-8");
4335
+ } catch {
4336
+ console.error(`Cannot read file: ${absPath}`);
4337
+ return process.exit(1);
4338
+ }
4339
+ const result = parseAtelier(content);
4340
+ if (!result.success) {
4341
+ console.error("Parse errors:");
4342
+ for (const error of result.errors) {
4343
+ console.error(` - ${error.path}: ${error.message}`);
4344
+ }
4345
+ return process.exit(1);
4346
+ }
4347
+ return result.data;
4348
+ }
4349
+ function variablesCommand(program) {
4350
+ program.command("variables <file>").description("List all variables in an .atelier file with usage info").action((file) => {
4351
+ const doc = readAndParse8(file);
4352
+ const info = getVariables(doc);
4353
+ console.log(formatVariables(info));
4354
+ });
4355
+ }
4356
+
4357
+ export {
4358
+ validateVideoLayer,
4359
+ parseAtelier,
4360
+ serializeAtelier,
4361
+ validateFile,
4362
+ validateCommand,
4363
+ VIDEO_PROJECT_VERSION,
4364
+ VIDEO_CUTLIST_VERSION,
4365
+ VIDEO_TRANSCRIPT_VERSION,
4366
+ effectiveSpan,
4367
+ createVideoProject,
4368
+ loadVideoProject,
4369
+ readCutList,
4370
+ writeCutList,
4371
+ readTranscript,
4372
+ writeTranscript,
4373
+ readComposition,
4374
+ writeComposition,
4375
+ rewriteCutLayers,
4376
+ RECIPE_VERSION,
4377
+ resolveRecipePath,
4378
+ loadRecipe,
4379
+ scaffoldRecipeYaml,
4380
+ applyRecipeToTrimOptions,
4381
+ applyRecipeToCaptionOptions,
4382
+ applyRecipeToTranscribeOptions,
4383
+ renderRecipeWithDefaults,
4384
+ recipeToYaml,
4385
+ trimProject,
4386
+ trimCommand,
4387
+ probeWhisper,
4388
+ runWhisperCpp,
4389
+ parseWhisperCppJson,
4390
+ flattenWords,
4391
+ groupIntoPhrases,
4392
+ mergeTranscriptWithExisting,
4393
+ applyTextEdit,
4394
+ applyBatchReplace,
4395
+ applyHide,
4396
+ applyAdd,
4397
+ applyMerge,
4398
+ applySplit,
4399
+ buildCaptionLayers,
4400
+ rewriteCaptionLayers,
4401
+ transcribeProject,
4402
+ transcribeCommand,
4403
+ transcriptCommand,
4404
+ captionsCommand,
4405
+ recipeCommand,
4406
+ applyRecipeCommand,
4407
+ fitImageToCanvas,
4408
+ CanvasUnavailableError,
4409
+ loadCanvasModule,
4410
+ resolveExportDimensions,
4411
+ renderDocumentToPng,
4412
+ expandInputs,
4413
+ composeCarouselFrameDoc,
4414
+ carouselFileName,
4415
+ carouselCommand,
4416
+ getInfo,
4417
+ infoCommand,
4418
+ resolveStill,
4419
+ stillCommand,
4420
+ checkFfmpeg,
4421
+ buildFfmpegArgs,
4422
+ renderDocument,
4423
+ renderCommand,
4424
+ exportSvgCommand,
4425
+ exportLottieCommand,
4426
+ exportImageCommand,
4427
+ getAssets,
4428
+ assetsCommand,
4429
+ getVariables,
4430
+ variablesCommand
4431
+ };
4432
+ //# sourceMappingURL=chunk-5QQESXI6.js.map