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