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