@kitsra/kavio-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject to
14
+ the limitations and conditions below.
15
+
16
+ Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set of
20
+ the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software.
38
+
39
+ If you or your company make any written claim that the software infringes or
40
+ contributes to infringement of any patent, your patent license for the software
41
+ granted under these terms ends immediately. If your company makes such a claim,
42
+ your patent license ends immediately for work on behalf of your company.
43
+
44
+ Notices
45
+
46
+ You must ensure that anyone who gets a copy of any part of the software from
47
+ you also gets a copy of these terms.
48
+
49
+ If you modify the software, you must include in any modified copies of the
50
+ software prominent notices stating that you have modified the software.
51
+
52
+ No Other Rights
53
+
54
+ These terms do not imply any licenses other than those expressly granted in
55
+ these terms.
56
+
57
+ Termination
58
+
59
+ If you use the software in violation of these terms, such use is not licensed,
60
+ and your licenses will automatically terminate. If the licensor provides you
61
+ with a notice of your violation, and you cease all violation of this license no
62
+ later than 30 days after you receive that notice, your licenses will be
63
+ reinstated retroactively. However, if you violate these terms after such
64
+ reinstatement, any additional violation of these terms will cause your licenses
65
+ to terminate automatically and permanently.
66
+
67
+ No Liability
68
+
69
+ As far as the law allows, the software comes as is, without any warranty or
70
+ condition, and the licensor will not be liable to you for any damages arising
71
+ out of these terms or the use or nature of the software, under any kind of
72
+ legal claim.
73
+
74
+ Definitions
75
+
76
+ The licensor is the entity offering these terms, and the software is the
77
+ software the licensor makes available under these terms, including any portion
78
+ of it.
79
+
80
+ you refers to the individual or entity agreeing to these terms.
81
+
82
+ your company is any legal entity, sole proprietorship, or other kind of
83
+ organization that you work for, plus all organizations that have control over,
84
+ are under the control of, or are under common control with that organization.
85
+ control means ownership of substantially all the assets of an entity, or the
86
+ power to direct its management and policies by vote, contract, or otherwise.
87
+ Control can be direct or indirect.
88
+
89
+ your licenses are all the licenses granted to you for the software under these
90
+ terms.
91
+
92
+ use means anything you do with the software requiring one of your licenses.
93
+
94
+ trademark means trademarks, service marks, and similar rights.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,1037 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { resolve } from "node:path";
5
+ import { socialMediaPresets } from "@kitsra/kavio-builder";
6
+ import { compileTransitionOverlapWindows } from "@kitsra/kavio-core";
7
+ import { schemaVersion, validateComposition } from "@kitsra/kavio-schema";
8
+ import { renderBatch } from "@kitsra/kavio-render";
9
+ const commands = new Set(["validate", "inspect", "migrate", "preview", "render", "presets"]);
10
+ const VALUE_FLAGS = new Set(["--export", "--props", "--batch", "--out", "--concurrency"]);
11
+ async function main(argv) {
12
+ const parsed = parseArgs(argv);
13
+ if ("errors" in parsed) {
14
+ emitFailure(parsed, parsed.command === "unknown" ? argv.includes("--json") : false);
15
+ return 1;
16
+ }
17
+ if (parsed.command === "help") {
18
+ writeStdout(helpText());
19
+ return 0;
20
+ }
21
+ if (parsed.command === "presets") {
22
+ return runPresets(parsed.file, parsed.json);
23
+ }
24
+ if (parsed.file === undefined) {
25
+ emitFailure({
26
+ command: parsed.command,
27
+ ok: false,
28
+ errors: [
29
+ cliError("CLI_FILE_REQUIRED", "", `Missing file argument for kavio ${parsed.command}.`, `Usage: kavio ${parsed.command} <file>`)
30
+ ]
31
+ }, parsed.json);
32
+ return 1;
33
+ }
34
+ switch (parsed.command) {
35
+ case "validate":
36
+ return await runValidate(parsed.file, parsed.json);
37
+ case "inspect":
38
+ return await runInspect(parsed.file, parsed.json);
39
+ case "migrate":
40
+ return await runMigrate(parsed.file, parsed.json);
41
+ case "preview":
42
+ return await runPreview(parsed.file, parsed.json);
43
+ case "render":
44
+ return await runRender(parsed);
45
+ }
46
+ return 0;
47
+ }
48
+ function runPresets(name, json) {
49
+ if (name !== undefined) {
50
+ const preset = findSocialMediaPreset(name);
51
+ if (preset === undefined) {
52
+ emitFailure({
53
+ command: "presets",
54
+ ok: false,
55
+ errors: [
56
+ cliError("CLI_UNKNOWN_PRESET", "", `Unknown social media preset: ${name}`, `Run kavio presets to list supported preset ids.`)
57
+ ]
58
+ }, json);
59
+ return 1;
60
+ }
61
+ if (json) {
62
+ const output = { command: "presets", ok: true, preset };
63
+ writeJson(output);
64
+ }
65
+ else {
66
+ writeJson(preset.preset);
67
+ }
68
+ return 0;
69
+ }
70
+ if (json) {
71
+ const output = { command: "presets", ok: true, presets: socialMediaPresets };
72
+ writeJson(output);
73
+ }
74
+ else {
75
+ writeSocialMediaPresets(socialMediaPresets);
76
+ }
77
+ return 0;
78
+ }
79
+ function parseArgs(argv) {
80
+ let json = false;
81
+ let help = false;
82
+ let allExports = false;
83
+ let failFast = false;
84
+ let continueOnFrameError = false;
85
+ let exportName;
86
+ let propsFile;
87
+ let batchFile;
88
+ let outDir;
89
+ let concurrency;
90
+ const positional = [];
91
+ for (let index = 0; index < argv.length; index += 1) {
92
+ const arg = argv[index];
93
+ if (arg === undefined) {
94
+ continue;
95
+ }
96
+ if (arg === "--json") {
97
+ json = true;
98
+ continue;
99
+ }
100
+ if (arg === "--help" || arg === "-h") {
101
+ help = true;
102
+ continue;
103
+ }
104
+ if (arg === "--all-exports") {
105
+ allExports = true;
106
+ continue;
107
+ }
108
+ if (arg === "--fail-fast") {
109
+ failFast = true;
110
+ continue;
111
+ }
112
+ if (arg === "--continue-on-frame-error") {
113
+ continueOnFrameError = true;
114
+ continue;
115
+ }
116
+ if (VALUE_FLAGS.has(arg)) {
117
+ const value = argv[index + 1];
118
+ if (value === undefined || value.startsWith("-")) {
119
+ return flagFailure(`Option ${arg} requires a value.`);
120
+ }
121
+ index += 1;
122
+ if (arg === "--export") {
123
+ exportName = value;
124
+ }
125
+ else if (arg === "--props") {
126
+ propsFile = value;
127
+ }
128
+ else if (arg === "--batch") {
129
+ batchFile = value;
130
+ }
131
+ else if (arg === "--out") {
132
+ outDir = value;
133
+ }
134
+ else {
135
+ const parsed = Number(value);
136
+ if (!Number.isInteger(parsed) || parsed < 1) {
137
+ return flagFailure("--concurrency must be a positive integer.");
138
+ }
139
+ concurrency = parsed;
140
+ }
141
+ continue;
142
+ }
143
+ if (arg.startsWith("-")) {
144
+ return flagFailure(`Unknown option: ${arg}`);
145
+ }
146
+ positional.push(arg);
147
+ }
148
+ if (help || positional.length === 0) {
149
+ return { command: "help", json, allExports, failFast, continueOnFrameError };
150
+ }
151
+ const [command, file] = positional;
152
+ if (command === undefined || !commands.has(command)) {
153
+ return {
154
+ command: "unknown",
155
+ ok: false,
156
+ errors: [
157
+ cliError("CLI_UNKNOWN_COMMAND", "", command === undefined ? "Missing command." : `Unknown command: ${command}`, "Run kavio --help for supported commands.")
158
+ ]
159
+ };
160
+ }
161
+ if (positional.length > 2) {
162
+ return {
163
+ command: command,
164
+ ok: false,
165
+ errors: [
166
+ cliError("CLI_TOO_MANY_ARGUMENTS", "", `Too many arguments for kavio ${command}.`, `Usage: kavio ${command} <file>`)
167
+ ]
168
+ };
169
+ }
170
+ const parsed = { command: command, json, allExports, failFast, continueOnFrameError };
171
+ if (file !== undefined) {
172
+ parsed.file = file;
173
+ }
174
+ if (exportName !== undefined) {
175
+ parsed.exportName = exportName;
176
+ }
177
+ if (propsFile !== undefined) {
178
+ parsed.propsFile = propsFile;
179
+ }
180
+ if (batchFile !== undefined) {
181
+ parsed.batchFile = batchFile;
182
+ }
183
+ if (outDir !== undefined) {
184
+ parsed.outDir = outDir;
185
+ }
186
+ if (concurrency !== undefined) {
187
+ parsed.concurrency = concurrency;
188
+ }
189
+ return parsed;
190
+ }
191
+ function flagFailure(message) {
192
+ return {
193
+ command: "unknown",
194
+ ok: false,
195
+ errors: [cliError("CLI_UNKNOWN_FLAG", "", message, "Run kavio --help for supported options.")]
196
+ };
197
+ }
198
+ async function runValidate(file, json) {
199
+ const loaded = await loadJson(file);
200
+ if (!loaded.ok) {
201
+ emitFailure({ command: "validate", ok: false, errors: [loaded.error] }, json);
202
+ return 1;
203
+ }
204
+ const validation = validateComposition(loaded.document);
205
+ const output = {
206
+ command: "validate",
207
+ ok: validation.ok,
208
+ file: loaded.filePath,
209
+ errors: validation.errors
210
+ };
211
+ const version = readVersion(loaded.document);
212
+ if (version !== undefined) {
213
+ output.version = version;
214
+ }
215
+ if (json) {
216
+ writeJson(output);
217
+ }
218
+ else if (validation.ok) {
219
+ writeStdout(`Valid Kavio composition: ${loaded.filePath}\n`);
220
+ }
221
+ else {
222
+ writeValidationErrors("Invalid Kavio composition", loaded.filePath, validation);
223
+ }
224
+ return validation.ok ? 0 : 1;
225
+ }
226
+ async function runInspect(file, json) {
227
+ const loaded = await loadJson(file);
228
+ if (!loaded.ok) {
229
+ emitFailure({ command: "inspect", ok: false, errors: [loaded.error] }, json);
230
+ return 1;
231
+ }
232
+ const validation = validateComposition(loaded.document);
233
+ if (!validation.ok) {
234
+ if (json) {
235
+ writeJson({
236
+ command: "inspect",
237
+ ok: false,
238
+ file: loaded.filePath,
239
+ errors: validation.errors
240
+ });
241
+ }
242
+ else {
243
+ writeValidationErrors("Cannot inspect invalid Kavio composition", loaded.filePath, validation);
244
+ }
245
+ return 1;
246
+ }
247
+ const summary = inspectDocument(loaded.filePath, loaded.document);
248
+ const output = { command: "inspect", ok: true, summary };
249
+ if (json) {
250
+ writeJson(output);
251
+ }
252
+ else {
253
+ writeInspection(summary);
254
+ }
255
+ return 0;
256
+ }
257
+ async function runMigrate(file, json) {
258
+ const loaded = await loadJson(file);
259
+ if (!loaded.ok) {
260
+ emitFailure({ command: "migrate", ok: false, errors: [loaded.error] }, json);
261
+ return 1;
262
+ }
263
+ const version = readVersion(loaded.document);
264
+ if (version === undefined) {
265
+ emitFailure({
266
+ command: "migrate",
267
+ ok: false,
268
+ errors: [
269
+ cliError("CLI_MIGRATION_VERSION_REQUIRED", "version", "Cannot migrate a document without a string version.", `Current supported schema version is ${schemaVersion}.`)
270
+ ]
271
+ }, json);
272
+ return 1;
273
+ }
274
+ if (version !== schemaVersion) {
275
+ emitFailure({
276
+ command: "migrate",
277
+ ok: false,
278
+ errors: [
279
+ cliError("CLI_MIGRATION_UNSUPPORTED_VERSION", "version", `No migration path is available from ${version} to ${schemaVersion}.`, "This CLI currently supports the no-op 0.1 to 0.1 migration path.")
280
+ ]
281
+ }, json);
282
+ return 1;
283
+ }
284
+ const validation = validateComposition(loaded.document);
285
+ if (!validation.ok) {
286
+ if (json) {
287
+ writeJson({
288
+ command: "migrate",
289
+ ok: false,
290
+ file: loaded.filePath,
291
+ fromVersion: version,
292
+ toVersion: schemaVersion,
293
+ errors: validation.errors
294
+ });
295
+ }
296
+ else {
297
+ writeValidationErrors("Cannot migrate invalid Kavio composition", loaded.filePath, validation);
298
+ }
299
+ return 1;
300
+ }
301
+ const output = {
302
+ command: "migrate",
303
+ ok: true,
304
+ file: loaded.filePath,
305
+ changed: false,
306
+ fromVersion: version,
307
+ toVersion: schemaVersion,
308
+ document: loaded.document
309
+ };
310
+ if (json) {
311
+ writeJson(output);
312
+ }
313
+ else {
314
+ writeStderr(`No migration needed: ${loaded.filePath} is already schema ${schemaVersion}.\n`);
315
+ writeJson(loaded.document);
316
+ }
317
+ return 0;
318
+ }
319
+ async function runPreview(file, json) {
320
+ const loaded = await loadJson(file);
321
+ if (!loaded.ok) {
322
+ emitFailure({ command: "preview", ok: false, errors: [loaded.error] }, json);
323
+ return 1;
324
+ }
325
+ const validation = validateComposition(loaded.document);
326
+ if (!validation.ok) {
327
+ if (json) {
328
+ writeJson({
329
+ command: "preview",
330
+ ok: false,
331
+ file: loaded.filePath,
332
+ errors: validation.errors
333
+ });
334
+ }
335
+ else {
336
+ writeValidationErrors("Cannot preview invalid Kavio composition", loaded.filePath, validation);
337
+ }
338
+ return 1;
339
+ }
340
+ const document = loaded.document;
341
+ const summary = inspectDocument(loaded.filePath, document);
342
+ const assets = await loadPreviewAssets();
343
+ const server = createServer((request, response) => {
344
+ void servePreviewRequest(request, response, document, summary, assets).catch((error) => {
345
+ response.statusCode = 500;
346
+ response.setHeader("content-type", "text/plain; charset=utf-8");
347
+ response.end(`Preview server error: ${errorMessage(error)}\n`);
348
+ });
349
+ });
350
+ const port = await listenOnLocalhost(server);
351
+ const url = `http://127.0.0.1:${port}/`;
352
+ const output = {
353
+ command: "preview",
354
+ ok: true,
355
+ file: loaded.filePath,
356
+ url,
357
+ renderer: assets.renderer,
358
+ summary
359
+ };
360
+ if (json) {
361
+ writeJson(output);
362
+ }
363
+ else {
364
+ writeStdout(`Kavio preview: ${url}\n`);
365
+ writeStdout(`Composition: ${loaded.filePath}\n`);
366
+ writeStdout(`Renderer: ${formatPreviewRendererStatus(assets.renderer)}\n`);
367
+ writeStdout("Press Ctrl+C to stop the preview server.\n");
368
+ }
369
+ return 0;
370
+ }
371
+ async function runRender(parsed) {
372
+ const file = parsed.file;
373
+ if (file === undefined) {
374
+ emitFailure({
375
+ command: "render",
376
+ ok: false,
377
+ errors: [cliError("CLI_FILE_REQUIRED", "", "Missing file argument for kavio render.", "Usage: kavio render <file>")]
378
+ }, parsed.json);
379
+ return 1;
380
+ }
381
+ const loaded = await loadJson(file);
382
+ if (!loaded.ok) {
383
+ emitFailure({ command: "render", ok: false, errors: [loaded.error] }, parsed.json);
384
+ return 1;
385
+ }
386
+ const rows = await resolveRenderRows(parsed);
387
+ if (!rows.ok) {
388
+ emitFailure({ command: "render", ok: false, errors: [rows.error] }, parsed.json);
389
+ return 1;
390
+ }
391
+ const input = { template: loaded.document, rows: rows.rows };
392
+ if (parsed.exportName !== undefined) {
393
+ input.presets = [parsed.exportName];
394
+ }
395
+ const options = {
396
+ outDir: parsed.outDir ?? "renders",
397
+ failFast: parsed.failFast,
398
+ continueOnFrameError: parsed.continueOnFrameError
399
+ };
400
+ if (parsed.concurrency !== undefined) {
401
+ options.concurrency = parsed.concurrency;
402
+ }
403
+ let results;
404
+ try {
405
+ results = await renderBatch(input, options);
406
+ }
407
+ catch (error) {
408
+ emitFailure({
409
+ command: "render",
410
+ ok: false,
411
+ errors: [cliError("CLI_RENDER_FAILED", "", errorMessage(error), "Check export preset names and render inputs.")]
412
+ }, parsed.json);
413
+ return 1;
414
+ }
415
+ const succeeded = results.filter((item) => item.result.ok).length;
416
+ const ok = results.length > 0 && succeeded === results.length;
417
+ if (parsed.json) {
418
+ writeJson({
419
+ command: "render",
420
+ ok,
421
+ total: results.length,
422
+ succeeded,
423
+ outputs: results.map((item) => item.result.ok
424
+ ? { id: item.id, outputName: item.outputName, ok: true, outputPath: item.result.outputPath }
425
+ : { id: item.id, outputName: item.outputName, ok: false, errors: item.result.errors })
426
+ });
427
+ }
428
+ else {
429
+ for (const item of results) {
430
+ if (item.result.ok) {
431
+ writeStdout(`Rendered ${item.result.outputPath}\n`);
432
+ }
433
+ else {
434
+ writeStderr(`Failed ${item.outputName}:\n`);
435
+ for (const error of item.result.errors) {
436
+ const path = error.path.length > 0 ? ` at ${error.path}` : "";
437
+ writeStderr(` - [${error.code}]${path} ${error.message}\n`);
438
+ }
439
+ }
440
+ }
441
+ writeStdout(`${succeeded}/${results.length} renders succeeded.\n`);
442
+ }
443
+ return ok ? 0 : 1;
444
+ }
445
+ async function resolveRenderRows(parsed) {
446
+ if (parsed.batchFile !== undefined) {
447
+ const batch = await loadJson(parsed.batchFile);
448
+ if (!batch.ok) {
449
+ return { ok: false, error: batch.error };
450
+ }
451
+ if (!Array.isArray(batch.document)) {
452
+ return {
453
+ ok: false,
454
+ error: cliError("CLI_BATCH_INVALID", "", "Batch file must be a JSON array of prop rows.", 'Each row is { "id"?: string, "props"?: object }.')
455
+ };
456
+ }
457
+ return { ok: true, rows: batch.document };
458
+ }
459
+ if (parsed.propsFile !== undefined) {
460
+ const props = await loadJson(parsed.propsFile);
461
+ if (!props.ok) {
462
+ return { ok: false, error: props.error };
463
+ }
464
+ if (typeof props.document !== "object" || props.document === null || Array.isArray(props.document)) {
465
+ return {
466
+ ok: false,
467
+ error: cliError("CLI_PROPS_INVALID", "", "Props file must be a JSON object.", 'Provide { "propName": value, ... }.')
468
+ };
469
+ }
470
+ const propValues = props.document;
471
+ return { ok: true, rows: [{ props: propValues }] };
472
+ }
473
+ return { ok: true, rows: [{}] };
474
+ }
475
+ async function loadJson(file) {
476
+ const filePath = resolve(process.cwd(), file);
477
+ try {
478
+ const source = await readFile(filePath, "utf8");
479
+ try {
480
+ return { ok: true, filePath, document: JSON.parse(source) };
481
+ }
482
+ catch (error) {
483
+ return {
484
+ ok: false,
485
+ error: cliError("CLI_JSON_PARSE_FAILED", filePath, `Failed to parse JSON: ${errorMessage(error)}`, "Check the file for invalid JSON syntax.")
486
+ };
487
+ }
488
+ }
489
+ catch (error) {
490
+ return {
491
+ ok: false,
492
+ error: cliError("CLI_FILE_READ_FAILED", filePath, `Failed to read file: ${errorMessage(error)}`, "Check that the path exists and is readable.")
493
+ };
494
+ }
495
+ }
496
+ async function loadPreviewAssets() {
497
+ const browserRendererUrl = new URL("../../browser-renderer/dist/index.js", import.meta.url);
498
+ const coreUrl = new URL("../../core/dist/index.js", import.meta.url);
499
+ try {
500
+ const [browserRendererSource, coreSource] = await Promise.all([
501
+ readFile(browserRendererUrl, "utf8"),
502
+ readFile(coreUrl, "utf8")
503
+ ]);
504
+ return {
505
+ browserRendererSource,
506
+ coreSource,
507
+ renderer: {
508
+ available: true,
509
+ mode: "browser-renderer"
510
+ }
511
+ };
512
+ }
513
+ catch (error) {
514
+ return {
515
+ renderer: {
516
+ available: false,
517
+ mode: "placeholder",
518
+ reason: `Browser renderer package output is not available: ${errorMessage(error)}`
519
+ }
520
+ };
521
+ }
522
+ }
523
+ function listenOnLocalhost(server) {
524
+ return new Promise((resolveListen, rejectListen) => {
525
+ server.listen(0, "127.0.0.1", () => {
526
+ const address = server.address();
527
+ if (isListenAddress(address)) {
528
+ resolveListen(address.port);
529
+ return;
530
+ }
531
+ rejectListen(new Error("Preview server did not return a TCP address."));
532
+ });
533
+ });
534
+ }
535
+ function isListenAddress(value) {
536
+ return isRecord(value) && typeof value.port === "number";
537
+ }
538
+ async function servePreviewRequest(request, response, document, summary, assets) {
539
+ const path = normalizeRequestPath(request.url);
540
+ if (request.method !== undefined && request.method !== "GET" && request.method !== "HEAD") {
541
+ sendText(response, 405, "Method not allowed.\n");
542
+ return;
543
+ }
544
+ if (path === "/" || path === "/index.html") {
545
+ sendHtml(response, renderPreviewHtml(summary, assets.renderer));
546
+ return;
547
+ }
548
+ if (path === "/composition.json") {
549
+ sendJson(response, document);
550
+ return;
551
+ }
552
+ if (path === "/healthz") {
553
+ sendJson(response, {
554
+ ok: true,
555
+ command: "preview",
556
+ renderer: assets.renderer
557
+ });
558
+ return;
559
+ }
560
+ if (path === "/vendor/browser-renderer/index.js" && assets.browserRendererSource !== undefined) {
561
+ sendJavaScript(response, assets.browserRendererSource);
562
+ return;
563
+ }
564
+ if (path === "/vendor/core/index.js" && assets.coreSource !== undefined) {
565
+ sendJavaScript(response, assets.coreSource);
566
+ return;
567
+ }
568
+ sendText(response, 404, "Not found.\n");
569
+ }
570
+ function normalizeRequestPath(url) {
571
+ return (url ?? "/").split("?", 1)[0] ?? "/";
572
+ }
573
+ function inspectDocument(filePath, document) {
574
+ const fps = Number(document.composition.fps);
575
+ const durationSeconds = fps > 0 ? document.composition.durationFrames / fps : 0;
576
+ const composition = {
577
+ width: document.composition.width,
578
+ height: document.composition.height,
579
+ fps,
580
+ durationFrames: document.composition.durationFrames,
581
+ durationSeconds
582
+ };
583
+ if (document.composition.background !== undefined) {
584
+ composition.background = document.composition.background;
585
+ }
586
+ if (document.composition.colorSpace !== undefined) {
587
+ composition.colorSpace = document.composition.colorSpace;
588
+ }
589
+ return {
590
+ file: filePath,
591
+ version: document.version,
592
+ composition,
593
+ props: {
594
+ count: document.props === undefined ? 0 : Object.keys(document.props).length
595
+ },
596
+ assets: {
597
+ count: Object.keys(document.assets).length,
598
+ types: countTypes(Object.values(document.assets))
599
+ },
600
+ layers: {
601
+ count: document.layers.length,
602
+ types: countTypes(document.layers)
603
+ },
604
+ masks: inspectMasks(document),
605
+ tracks: {
606
+ count: document.tracks === undefined ? 0 : document.tracks.length,
607
+ clipCount: document.tracks === undefined ? 0 : document.tracks.reduce((total, track) => total + track.clips.length, 0),
608
+ transitionWindows: compileTransitionOverlapWindows(document.tracks).map((window) => ({
609
+ trackId: window.trackId,
610
+ previousClipId: window.previousClipId,
611
+ previousLayerId: window.previousLayerId,
612
+ nextClipId: window.nextClipId,
613
+ nextLayerId: window.nextLayerId,
614
+ startFrame: window.startFrame,
615
+ endFrame: window.endFrame,
616
+ durationFrames: window.durationFrames,
617
+ transitionType: window.transition.type
618
+ }))
619
+ },
620
+ audio: {
621
+ count: document.audio === undefined ? 0 : document.audio.length
622
+ },
623
+ exports: {
624
+ count: document.exports.length,
625
+ names: document.exports.map((entry, index) => readName(entry) ?? `export-${index + 1}`)
626
+ }
627
+ };
628
+ }
629
+ function inspectMasks(document) {
630
+ const summary = {
631
+ count: 0,
632
+ shapeCount: 0,
633
+ assetMasks: [],
634
+ proceduralMasks: [],
635
+ invertedCount: 0
636
+ };
637
+ for (const layer of document.layers) {
638
+ const mask = layer.mask;
639
+ if (!mask) {
640
+ continue;
641
+ }
642
+ summary.count += 1;
643
+ if (mask.invert === true) {
644
+ summary.invertedCount += 1;
645
+ }
646
+ switch (mask.source.kind) {
647
+ case "shape":
648
+ summary.shapeCount += 1;
649
+ break;
650
+ case "asset":
651
+ summary.assetMasks.push(withResolution({
652
+ layerId: layer.id,
653
+ asset: mask.source.asset,
654
+ mode: mask.source.mode ?? "alpha"
655
+ }, mask.source.resolution));
656
+ break;
657
+ case "procedural":
658
+ summary.proceduralMasks.push(withResolution({
659
+ layerId: layer.id,
660
+ type: mask.source.type,
661
+ seed: mask.source.seed,
662
+ ...(mask.source.direction === undefined ? {} : { direction: mask.source.direction })
663
+ }, mask.source.resolution));
664
+ break;
665
+ }
666
+ }
667
+ return summary;
668
+ }
669
+ function withResolution(summary, resolution) {
670
+ return (resolution === undefined
671
+ ? summary
672
+ : {
673
+ ...summary,
674
+ width: resolution.width,
675
+ height: resolution.height
676
+ });
677
+ }
678
+ function countTypes(items) {
679
+ const counts = {};
680
+ for (const item of items) {
681
+ const type = readType(item) ?? "unknown";
682
+ counts[type] = (counts[type] ?? 0) + 1;
683
+ }
684
+ return counts;
685
+ }
686
+ function readVersion(value) {
687
+ if (!isRecord(value) || typeof value.version !== "string") {
688
+ return undefined;
689
+ }
690
+ return value.version;
691
+ }
692
+ function readType(value) {
693
+ if (!isRecord(value) || typeof value.type !== "string") {
694
+ return undefined;
695
+ }
696
+ return value.type;
697
+ }
698
+ function readName(value) {
699
+ if (!isRecord(value) || typeof value.name !== "string") {
700
+ return undefined;
701
+ }
702
+ return value.name;
703
+ }
704
+ function isRecord(value) {
705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
706
+ }
707
+ function writeInspection(summary) {
708
+ writeStdout(`Kavio composition: ${summary.file}\n`);
709
+ writeStdout(`Version: ${summary.version}\n`);
710
+ writeStdout(`Size: ${summary.composition.width}x${summary.composition.height} @ ${summary.composition.fps}fps\n`);
711
+ writeStdout(`Duration: ${summary.composition.durationFrames} frames (${summary.composition.durationSeconds.toFixed(2)}s)\n`);
712
+ writeStdout(`Props: ${summary.props.count}\n`);
713
+ writeStdout(`Assets: ${summary.assets.count}${formatTypeCounts(summary.assets.types)}\n`);
714
+ writeStdout(`Layers: ${summary.layers.count}${formatTypeCounts(summary.layers.types)}\n`);
715
+ writeStdout(`Masks: ${summary.masks.count} (shape: ${summary.masks.shapeCount}, asset: ${summary.masks.assetMasks.length}, procedural: ${summary.masks.proceduralMasks.length}, inverted: ${summary.masks.invertedCount})\n`);
716
+ for (const mask of summary.masks.assetMasks) {
717
+ writeStdout(` - asset ${mask.asset} on ${mask.layerId}, ${mask.mode}${formatResolution(mask.width, mask.height)}\n`);
718
+ }
719
+ for (const mask of summary.masks.proceduralMasks) {
720
+ writeStdout(` - procedural ${mask.type} on ${mask.layerId}, seed ${mask.seed}${formatResolution(mask.width, mask.height)}\n`);
721
+ }
722
+ writeStdout(`Tracks: ${summary.tracks.count} (${summary.tracks.clipCount} clips, ${summary.tracks.transitionWindows.length} transition windows)\n`);
723
+ for (const window of summary.tracks.transitionWindows) {
724
+ writeStdout(` - ${window.trackId}: ${window.previousClipId} -> ${window.nextClipId}, ${window.transitionType}, frames ${window.startFrame}-${window.endFrame - 1}\n`);
725
+ }
726
+ writeStdout(`Audio: ${summary.audio.count}\n`);
727
+ writeStdout(`Exports: ${summary.exports.count}\n`);
728
+ for (const exportName of summary.exports.names) {
729
+ writeStdout(` - ${exportName}\n`);
730
+ }
731
+ }
732
+ function formatResolution(width, height) {
733
+ return width === undefined || height === undefined ? "" : `, ${width}x${height}`;
734
+ }
735
+ function writeSocialMediaPresets(presets) {
736
+ writeStdout("Kavio social media export presets:\n");
737
+ for (const preset of presets) {
738
+ writeStdout(` ${preset.id.padEnd(24)} ${preset.label.padEnd(24)} ${String(preset.width).padStart(4)}x${String(preset.height).padEnd(4)} ${preset.aspectRatio.padEnd(4)} export: ${preset.defaultName}\n`);
739
+ }
740
+ writeStdout("\nUse `kavio presets <id>` to print a copy/pasteable export preset JSON object.\n");
741
+ writeStdout("Use `kavio presets --json` to print the full machine-readable preset catalog.\n");
742
+ }
743
+ function findSocialMediaPreset(name) {
744
+ const normalized = normalizePresetName(name);
745
+ return socialMediaPresets.find((preset) => normalizePresetName(preset.id) === normalized || normalizePresetName(preset.defaultName) === normalized);
746
+ }
747
+ function normalizePresetName(value) {
748
+ return value.trim().toLowerCase().replaceAll(/[\s_]+/g, "-");
749
+ }
750
+ function renderPreviewHtml(summary, renderer) {
751
+ const frameCount = Math.max(1, summary.composition.durationFrames);
752
+ const initialScale = Math.min(1, 720 / summary.composition.height, 960 / summary.composition.width);
753
+ return `<!doctype html>
754
+ <html lang="en">
755
+ <head>
756
+ <meta charset="utf-8">
757
+ <meta name="viewport" content="width=device-width, initial-scale=1">
758
+ <title>Kavio Preview</title>
759
+ ${renderer.available ? `<script type="importmap">${escapeHtml(JSON.stringify({ imports: { "@kitsra/kavio-core": "/vendor/core/index.js" } }))}</script>` : ""}
760
+ <style>
761
+ :root {
762
+ color-scheme: dark;
763
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
764
+ background: #101820;
765
+ color: #f5f7fa;
766
+ }
767
+ body {
768
+ margin: 0;
769
+ min-height: 100vh;
770
+ display: grid;
771
+ grid-template-columns: minmax(260px, 320px) 1fr;
772
+ }
773
+ aside {
774
+ border-right: 1px solid #26313d;
775
+ padding: 20px;
776
+ background: #151f29;
777
+ }
778
+ main {
779
+ display: grid;
780
+ place-items: center;
781
+ min-width: 0;
782
+ padding: 24px;
783
+ }
784
+ h1 {
785
+ margin: 0 0 16px;
786
+ font-size: 20px;
787
+ font-weight: 650;
788
+ }
789
+ dl {
790
+ display: grid;
791
+ grid-template-columns: auto 1fr;
792
+ gap: 8px 12px;
793
+ margin: 0 0 18px;
794
+ font-size: 13px;
795
+ }
796
+ dt {
797
+ color: #a7b3c0;
798
+ }
799
+ dd {
800
+ margin: 0;
801
+ overflow-wrap: anywhere;
802
+ }
803
+ .status {
804
+ border: 1px solid #334150;
805
+ padding: 12px;
806
+ background: #101820;
807
+ font-size: 13px;
808
+ line-height: 1.45;
809
+ }
810
+ .viewport {
811
+ display: grid;
812
+ place-items: center;
813
+ max-width: 100%;
814
+ overflow: auto;
815
+ }
816
+ #stage {
817
+ width: ${summary.composition.width}px;
818
+ height: ${summary.composition.height}px;
819
+ transform: scale(${initialScale});
820
+ transform-origin: center;
821
+ background: #0b0f14;
822
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
823
+ }
824
+ #placeholder {
825
+ box-sizing: border-box;
826
+ width: 100%;
827
+ height: 100%;
828
+ display: grid;
829
+ align-content: center;
830
+ justify-items: center;
831
+ gap: 12px;
832
+ padding: 32px;
833
+ text-align: center;
834
+ color: #d9e2ec;
835
+ background:
836
+ linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px),
837
+ linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px),
838
+ #0b0f14;
839
+ background-size: 48px 48px;
840
+ }
841
+ code {
842
+ color: #b7f7d5;
843
+ }
844
+ @media (max-width: 760px) {
845
+ body {
846
+ grid-template-columns: 1fr;
847
+ }
848
+ aside {
849
+ border-right: 0;
850
+ border-bottom: 1px solid #26313d;
851
+ }
852
+ }
853
+ </style>
854
+ </head>
855
+ <body>
856
+ <aside>
857
+ <h1>Kavio Preview</h1>
858
+ <dl>
859
+ <dt>File</dt><dd>${escapeHtml(summary.file)}</dd>
860
+ <dt>Version</dt><dd>${escapeHtml(summary.version)}</dd>
861
+ <dt>Size</dt><dd>${summary.composition.width}x${summary.composition.height}</dd>
862
+ <dt>Frames</dt><dd>${summary.composition.durationFrames} @ ${summary.composition.fps}fps</dd>
863
+ <dt>Layers</dt><dd>${summary.layers.count}${escapeHtml(formatTypeCounts(summary.layers.types))}</dd>
864
+ <dt>Assets</dt><dd>${summary.assets.count}${escapeHtml(formatTypeCounts(summary.assets.types))}</dd>
865
+ </dl>
866
+ <div class="status" id="status">${escapeHtml(formatPreviewRendererStatus(renderer))}</div>
867
+ </aside>
868
+ <main>
869
+ <div class="viewport">
870
+ <div id="stage">
871
+ <div id="placeholder">
872
+ <strong>Preview shell ready</strong>
873
+ <span>Loaded ${frameCount} frame${frameCount === 1 ? "" : "s"} from <code>/composition.json</code>.</span>
874
+ </div>
875
+ </div>
876
+ </div>
877
+ </main>
878
+ <script type="application/json" id="kavio-summary">${escapeHtml(JSON.stringify(summary))}</script>
879
+ ${renderer.available
880
+ ? `<script type="module">
881
+ const status = document.getElementById("status");
882
+ const stage = document.getElementById("stage");
883
+ try {
884
+ const composition = await fetch("/composition.json").then((response) => response.json());
885
+ const { installBrowserRendererRuntime } = await import("/vendor/browser-renderer/index.js");
886
+ const runtime = installBrowserRendererRuntime({ root: stage });
887
+ await runtime.loadComposition(composition);
888
+ const rendered = await runtime.renderFrame(0);
889
+ status.textContent = "Browser renderer loaded. Showing frame 0 with " + rendered.layers.length + " active layer(s).";
890
+ } catch (error) {
891
+ status.textContent = "Preview placeholder active. Browser renderer package output was found, but could not run in this shell: " + String(error instanceof Error ? error.message : error);
892
+ }
893
+ </script>`
894
+ : ""}
895
+ </body>
896
+ </html>
897
+ `;
898
+ }
899
+ function formatPreviewRendererStatus(renderer) {
900
+ if (renderer.available) {
901
+ return "browser-renderer package output available";
902
+ }
903
+ return renderer.reason === undefined ? "placeholder preview shell" : `placeholder preview shell (${renderer.reason})`;
904
+ }
905
+ function sendHtml(response, value) {
906
+ send(response, 200, "text/html; charset=utf-8", value);
907
+ }
908
+ function sendJson(response, value) {
909
+ send(response, 200, "application/json; charset=utf-8", `${JSON.stringify(value, null, 2)}\n`);
910
+ }
911
+ function sendJavaScript(response, value) {
912
+ send(response, 200, "text/javascript; charset=utf-8", value);
913
+ }
914
+ function sendText(response, statusCode, value) {
915
+ send(response, statusCode, "text/plain; charset=utf-8", value);
916
+ }
917
+ function send(response, statusCode, contentType, value) {
918
+ response.statusCode = statusCode;
919
+ response.setHeader("content-type", contentType);
920
+ response.setHeader("cache-control", "no-store");
921
+ response.end(value);
922
+ }
923
+ function writeValidationErrors(title, filePath, validation) {
924
+ writeStderr(`${title}: ${filePath}\n`);
925
+ for (const error of validation.errors) {
926
+ const path = error.path.length > 0 ? ` at ${error.path}` : "";
927
+ const hint = error.hint === undefined ? "" : ` Hint: ${error.hint}`;
928
+ writeStderr(` - [${error.code}]${path} ${error.message}${hint}\n`);
929
+ }
930
+ }
931
+ function emitFailure(failure, json) {
932
+ if (json) {
933
+ writeJson(failure);
934
+ return;
935
+ }
936
+ for (const error of failure.errors) {
937
+ const hint = error.hint === undefined ? "" : `\n${error.hint}`;
938
+ writeStderr(`${error.message}${hint}\n`);
939
+ }
940
+ }
941
+ function cliError(code, path, message, hint) {
942
+ const error = {
943
+ code,
944
+ severity: "error",
945
+ message,
946
+ path,
947
+ stage: "io",
948
+ retryable: false
949
+ };
950
+ if (hint !== undefined) {
951
+ error.hint = hint;
952
+ }
953
+ return error;
954
+ }
955
+ function errorMessage(error) {
956
+ if (error instanceof Error) {
957
+ return error.message;
958
+ }
959
+ return String(error);
960
+ }
961
+ function formatTypeCounts(counts) {
962
+ const entries = Object.entries(counts);
963
+ if (entries.length === 0) {
964
+ return "";
965
+ }
966
+ return ` (${entries.map(([type, count]) => `${type}: ${count}`).join(", ")})`;
967
+ }
968
+ function escapeHtml(value) {
969
+ return value.replace(/[&<>"']/g, (character) => {
970
+ switch (character) {
971
+ case "&":
972
+ return "&amp;";
973
+ case "<":
974
+ return "&lt;";
975
+ case ">":
976
+ return "&gt;";
977
+ case '"':
978
+ return "&quot;";
979
+ case "'":
980
+ return "&#39;";
981
+ default:
982
+ return character;
983
+ }
984
+ });
985
+ }
986
+ function helpText() {
987
+ return `Kavio CLI
988
+
989
+ Usage:
990
+ kavio --help
991
+ kavio [--json] validate <file>
992
+ kavio [--json] inspect <file>
993
+ kavio [--json] migrate <file>
994
+ kavio [--json] preview <file>
995
+ kavio [--json] render <file> [render options]
996
+ kavio [--json] presets [preset-id]
997
+
998
+ Options:
999
+ --json Emit machine-readable JSON for CI and repair loops.
1000
+ -h, --help Show this help.
1001
+
1002
+ Render options:
1003
+ --export <name> Render one named export preset.
1004
+ --all-exports Render every export preset (default).
1005
+ --props <file.json> Prop values for a single render.
1006
+ --batch <file.json> Array of prop rows -> rows x presets.
1007
+ --out <dir> Output directory (default: renders).
1008
+ --concurrency <n> Parallel jobs (default: 1).
1009
+ --fail-fast Abort the batch on first job failure.
1010
+ --continue-on-frame-error Tolerate per-frame capture failures.
1011
+
1012
+ Commands:
1013
+ validate Validate a Kavio composition JSON file.
1014
+ inspect Print a composition summary.
1015
+ migrate Write the latest-schema JSON document to stdout.
1016
+ preview Start a local browser preview server.
1017
+ render Render a composition to MP4 (browser capture + FFmpeg).
1018
+ presets List social media export presets or print one export JSON object.
1019
+ `;
1020
+ }
1021
+ function writeJson(value) {
1022
+ writeStdout(`${JSON.stringify(value, null, 2)}\n`);
1023
+ }
1024
+ function writeStdout(value) {
1025
+ process.stdout.write(value);
1026
+ }
1027
+ function writeStderr(value) {
1028
+ process.stderr.write(value);
1029
+ }
1030
+ void main(process.argv.slice(2))
1031
+ .then((exitCode) => {
1032
+ process.exitCode = exitCode;
1033
+ })
1034
+ .catch((error) => {
1035
+ writeStderr(`Unexpected CLI failure: ${errorMessage(error)}\n`);
1036
+ process.exitCode = 1;
1037
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@kitsra/kavio-cli",
3
+ "version": "0.1.0",
4
+ "description": "Local CLI for Kavio validation, migration, preview, and rendering.",
5
+ "license": "Elastic-2.0",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "kavio": "./dist/index.js"
12
+ },
13
+ "dependencies": {
14
+ "@kitsra/kavio-builder": "0.1.0",
15
+ "@kitsra/kavio-core": "0.1.0",
16
+ "@kitsra/kavio-render": "0.1.0",
17
+ "@kitsra/kavio-schema": "0.1.0"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!dist/**/*.tsbuildinfo"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "check": "tsc -p tsconfig.json --noEmit",
32
+ "test": "tsc -b tsconfig.json && node --test test/*.test.mjs"
33
+ }
34
+ }