@engine-room/after-effects-mcp 0.3.1 → 0.4.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/README.md +7 -5
- package/bin/server.js +1379 -196
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/contactsheet.js +347 -0
- package/panel/client/framereader.js +248 -0
- package/panel/client/main.js +400 -95
- package/panel/client/pngcodec.js +122 -0
- package/panel/jsx/bundle.jsx +2444 -116
- package/panel/package.json +1 -1
package/panel/jsx/bundle.jsx
CHANGED
|
@@ -69,13 +69,33 @@ function __newJobId() {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// ---------- Undo wrapper ----------
|
|
72
|
-
// __UNDO_OPEN tracks
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// __UNDO_OPEN tracks whether a group *this bundle* opened is currently open.
|
|
73
|
+
// withUndo() is the only thing that may set it — dispatch and run_batch both
|
|
74
|
+
// group through there — because it is what lets withoutUndoGroup() reopen
|
|
75
|
+
// exactly what it closed, and leave undo state alone entirely when we never
|
|
76
|
+
// opened a group in the first place.
|
|
75
77
|
var __UNDO_OPEN = false;
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
// Every undo group this bundle opens is counted here, and __beginUndoGroup is
|
|
80
|
+
// the only place allowed to call app.beginUndoGroup. run_batch reports the
|
|
81
|
+
// number of undo steps it cost as the *delta* of this counter, so the number an
|
|
82
|
+
// agent repeats to the user as "press Cmd-Z N times" is measured rather than
|
|
83
|
+
// predicted — a chunk that threw still opened its group, and a batched op that
|
|
84
|
+
// called withoutUndoGroup() really did split itself into two steps.
|
|
85
|
+
var __UNDO_GROUPS = 0;
|
|
86
|
+
|
|
87
|
+
function __beginUndoGroup(name) {
|
|
88
|
+
__UNDO_GROUPS += 1;
|
|
78
89
|
app.beginUndoGroup(name || "AE MCP");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function __undoGroupsOpened() { return __UNDO_GROUPS; }
|
|
93
|
+
|
|
94
|
+
// An undo group must open and close inside ONE evalScript call. After Effects
|
|
95
|
+
// discards one that spans two — see the note in CLAUDE.md; measured, not
|
|
96
|
+
// inferred. Everything that groups goes through here for that reason.
|
|
97
|
+
function withUndo(name, fn) {
|
|
98
|
+
__beginUndoGroup(name || "AE MCP");
|
|
79
99
|
__UNDO_OPEN = true;
|
|
80
100
|
try { return fn(); }
|
|
81
101
|
finally { __UNDO_OPEN = false; app.endUndoGroup(); }
|
|
@@ -91,15 +111,28 @@ function withoutUndoGroup(fn) {
|
|
|
91
111
|
app.endUndoGroup();
|
|
92
112
|
__UNDO_OPEN = false;
|
|
93
113
|
try { return fn(); }
|
|
94
|
-
finally {
|
|
114
|
+
finally { __beginUndoGroup("AE MCP: continue"); __UNDO_OPEN = true; }
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
// ---------- Error helper ----------
|
|
118
|
+
// A handler that can say more about *where* it failed than "an error happened"
|
|
119
|
+
// attaches that on `aeDetail` and it is copied onto the result verbatim —
|
|
120
|
+
// run_jsx maps After Effects' line number back onto the script the caller
|
|
121
|
+
// actually submitted (issue #46). The bag is free-form here on purpose; the
|
|
122
|
+
// panel forwards a named list of fields, which is where the contract is kept.
|
|
98
123
|
function __mkError(e) {
|
|
99
124
|
var msg = e && e.message ? String(e.message) : String(e);
|
|
100
125
|
var stack = e && e.stack ? String(e.stack) : "";
|
|
101
126
|
var line = e && typeof e.line !== "undefined" ? e.line : null;
|
|
102
|
-
|
|
127
|
+
var out = { ok: false, error: msg, stack: stack, line: line };
|
|
128
|
+
var detail = null;
|
|
129
|
+
try { if (e && e.aeDetail) detail = e.aeDetail; } catch (ed) {}
|
|
130
|
+
if (detail) {
|
|
131
|
+
for (var k in detail) {
|
|
132
|
+
if (detail.hasOwnProperty(k)) out[k] = detail[k];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
103
136
|
}
|
|
104
137
|
|
|
105
138
|
// ---------- Main dispatch ----------
|
|
@@ -268,6 +301,155 @@ OPS.delete_comp = function (args) {
|
|
|
268
301
|
return { ok: true };
|
|
269
302
|
};
|
|
270
303
|
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// duplicate_comp
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// There was duplicate_layer, create_comp and delete_comp but no way to copy a
|
|
308
|
+
// comp, so every rig workflow detoured through run_jsx and CompItem.duplicate()
|
|
309
|
+
// (issue #54). Two things that detour never got right:
|
|
310
|
+
//
|
|
311
|
+
// * AE's own Duplicate is SHALLOW. The copy's precomp layers point at the
|
|
312
|
+
// same source comps as the original, so "make a variant of this rig" and
|
|
313
|
+
// then editing the variant edits the original too. `deep:true` duplicates
|
|
314
|
+
// the nested comps as well and re-points the copy's layers at them, which
|
|
315
|
+
// is the entire value of the flag.
|
|
316
|
+
// * The same nested comp usually appears on several layers. Duplicating per
|
|
317
|
+
// layer fans out one copy per reference; __dupNested keeps a map from
|
|
318
|
+
// original id to its copy and reuses it, and registers the copy *before*
|
|
319
|
+
// recursing so a cycle terminates instead of recursing for ever.
|
|
320
|
+
|
|
321
|
+
// AE happily allows two project items with the same name, which makes a
|
|
322
|
+
// deep-duplicated rig unreadable in the project panel. Appending a counter is
|
|
323
|
+
// the smaller evil, and the chosen name is reported either way.
|
|
324
|
+
function __dupNameTaken(name) {
|
|
325
|
+
for (var i = 1; i <= app.project.numItems; i++) {
|
|
326
|
+
if (app.project.item(i).name === name) return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function __dupUniqueName(base) {
|
|
332
|
+
if (!__dupNameTaken(base)) return base;
|
|
333
|
+
for (var n = 2; n < 1000; n++) {
|
|
334
|
+
var candidate = base + " " + n;
|
|
335
|
+
if (!__dupNameTaken(candidate)) return candidate;
|
|
336
|
+
}
|
|
337
|
+
return base;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Depth is a backstop, not the cycle guard — `seen` is. AE refuses to nest a
|
|
341
|
+
// comp inside itself, but nothing here should recurse for ever if a future
|
|
342
|
+
// build ever allows it.
|
|
343
|
+
var __DUP_MAX_DEPTH = 32;
|
|
344
|
+
|
|
345
|
+
function __dupNested(src, opts, depth) {
|
|
346
|
+
var key = "C" + src.id;
|
|
347
|
+
if (opts.seen.hasOwnProperty(key)) return opts.seen[key];
|
|
348
|
+
if (depth > __DUP_MAX_DEPTH) {
|
|
349
|
+
throw new Error("nested comps are more than " + __DUP_MAX_DEPTH + " deep below the comp being duplicated");
|
|
350
|
+
}
|
|
351
|
+
var srcId = src.id;
|
|
352
|
+
var srcName = src.name;
|
|
353
|
+
var dup = src.duplicate();
|
|
354
|
+
opts.seen[key] = dup;
|
|
355
|
+
if (opts.nameSuffix) dup.name = __dupUniqueName(srcName + opts.nameSuffix);
|
|
356
|
+
opts.created.push({ fromCompId: srcId, fromName: srcName, compId: dup.id, name: dup.name });
|
|
357
|
+
__dupRepoint(dup, opts, depth);
|
|
358
|
+
return dup;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Re-point every precomp layer of a freshly duplicated comp at the duplicate of
|
|
362
|
+
// its source rather than the original. Layers whose source is footage, and
|
|
363
|
+
// layers with no source at all, are left alone.
|
|
364
|
+
function __dupRepoint(comp, opts, depth) {
|
|
365
|
+
for (var i = 1; i <= comp.numLayers; i++) {
|
|
366
|
+
var l = comp.layer(i);
|
|
367
|
+
if (!(l instanceof AVLayer)) continue;
|
|
368
|
+
var srcItem = null;
|
|
369
|
+
try { srcItem = l.source; } catch (e) { continue; }
|
|
370
|
+
if (!srcItem || !(srcItem instanceof CompItem)) continue;
|
|
371
|
+
var replacement = __dupNested(srcItem, opts, depth + 1);
|
|
372
|
+
// fixExpressions:false — the layer keeps its name and its own properties,
|
|
373
|
+
// so there is nothing for AE to rewrite, and letting it rewrite expressions
|
|
374
|
+
// on a rig is a change nobody asked for.
|
|
375
|
+
l.replaceSource(replacement, false);
|
|
376
|
+
opts.repointed += 1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
OPS.duplicate_comp = function (args) {
|
|
381
|
+
var src = getCompById(args.compId);
|
|
382
|
+
var folder = null;
|
|
383
|
+
if (args.folderId !== undefined && args.folderId !== null) {
|
|
384
|
+
var f = app.project.itemByID(args.folderId);
|
|
385
|
+
if (!f) throw new Error("No project item with id " + args.folderId + " to use as folderId");
|
|
386
|
+
if (!(f instanceof FolderItem)) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
"folderId " + args.folderId + ' ("' + f.name + '") is a ' + __itemKind(f) +
|
|
389
|
+
", not a project folder. Pass the id of a folder from get_project_summary, or omit folderId."
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
folder = f;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
var opts = { seen: {}, created: [], repointed: 0, nameSuffix: null };
|
|
396
|
+
if (args.nameSuffix) opts.nameSuffix = args.nameSuffix;
|
|
397
|
+
|
|
398
|
+
// Captured as primitives before the duplicate. Some AE calls invalidate every
|
|
399
|
+
// handle held across them (exportAsMotionGraphicsTemplate is the measured
|
|
400
|
+
// one), so nothing below reads `src` again.
|
|
401
|
+
var srcId = src.id;
|
|
402
|
+
var srcName = src.name;
|
|
403
|
+
var dup = src.duplicate();
|
|
404
|
+
var newId = dup.id;
|
|
405
|
+
if (args.name) dup.name = args.name;
|
|
406
|
+
|
|
407
|
+
if (args.deep) {
|
|
408
|
+
opts.seen["C" + srcId] = dup;
|
|
409
|
+
try {
|
|
410
|
+
__dupRepoint(dup, opts, 1);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
// The copy and any nested copies made before the failure are real and
|
|
413
|
+
// nothing rolled them back. Reporting {ok:true} over a half-built rig, or
|
|
414
|
+
// an error that does not name what exists, are the same class of lie.
|
|
415
|
+
var madeIds = [];
|
|
416
|
+
madeIds.push(String(newId));
|
|
417
|
+
for (var m = 0; m < opts.created.length; m++) madeIds.push(String(opts.created[m].compId));
|
|
418
|
+
throw new Error(
|
|
419
|
+
"duplicate_comp deep failed part-way: " + e.message +
|
|
420
|
+
". These comps were created and still exist: ids " + madeIds.join(", ") +
|
|
421
|
+
". Undo once in After Effects to back the whole thing out, or delete_comp them."
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Re-fetch by id rather than trusting the handle held across the duplication.
|
|
427
|
+
var made = app.project.itemByID(newId);
|
|
428
|
+
if (!made) throw new Error("duplicate_comp created comp " + newId + " but it could not be read back");
|
|
429
|
+
if (folder) made.parentFolder = folder;
|
|
430
|
+
|
|
431
|
+
var out = __compSummary(made);
|
|
432
|
+
out.fromCompId = srcId;
|
|
433
|
+
out.fromName = srcName;
|
|
434
|
+
out.deep = !!args.deep;
|
|
435
|
+
if (folder) {
|
|
436
|
+
out.folderId = folder.id;
|
|
437
|
+
out.folderName = folder.name;
|
|
438
|
+
}
|
|
439
|
+
if (args.deep) {
|
|
440
|
+
out.nestedDuplicated = opts.created;
|
|
441
|
+
out.nestedCount = opts.created.length;
|
|
442
|
+
out.layersRepointed = opts.repointed;
|
|
443
|
+
if (opts.created.length === 0) {
|
|
444
|
+
out.note = "deep:true had nothing to do - this comp has no precomp layers.";
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
out.note = "Shallow copy: its precomp layers still point at the SAME nested comps as the original, " +
|
|
448
|
+
"so editing one of those edits both. Pass deep:true to duplicate the nested comps too.";
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
};
|
|
452
|
+
|
|
271
453
|
OPS.set_active_comp = function (args) {
|
|
272
454
|
var c = getCompById(args.compId);
|
|
273
455
|
c.openInViewer();
|
|
@@ -415,12 +597,59 @@ OPS.create_adjustment_layer = function (args) {
|
|
|
415
597
|
return __layerSummary(l);
|
|
416
598
|
};
|
|
417
599
|
|
|
600
|
+
// Where a new shape layer's origin goes (issue #51).
|
|
601
|
+
//
|
|
602
|
+
// AE's addShape() leaves Position at the comp centre with the Anchor Point at
|
|
603
|
+
// [0,0], so layer space is offset from comp space by half a frame. Every path
|
|
604
|
+
// this toolset can write — set_shape_path vertices, add_shape_content vertices,
|
|
605
|
+
// a rect/ellipse `position` — is expressed in *layer* space, and nothing in the
|
|
606
|
+
// response says where that space starts. An agent authoring in comp pixels gets
|
|
607
|
+
// the whole drawing shifted by (width/2, height/2), and the check that would
|
|
608
|
+
// catch it is a downsampled screenshot.
|
|
609
|
+
//
|
|
610
|
+
// Default [0,0] makes layer space and comp space the same space for a fresh
|
|
611
|
+
// shape layer, which is the only arrangement in which a vertex list means what
|
|
612
|
+
// it says. "center" is AE's own spawn point, kept as one word so the old
|
|
613
|
+
// behaviour is still one argument away.
|
|
614
|
+
function __shapeSpawnPosition(comp, position) {
|
|
615
|
+
if (position === undefined || position === null) return [0, 0];
|
|
616
|
+
if (position === "center") return [comp.width / 2, comp.height / 2];
|
|
617
|
+
if (position instanceof Array && position.length >= 2) {
|
|
618
|
+
if (position.length === 3) return [position[0], position[1], position[2]];
|
|
619
|
+
return [position[0], position[1]];
|
|
620
|
+
}
|
|
621
|
+
throw new Error(
|
|
622
|
+
"create_shape_layer: position must be [x,y], [x,y,z] or the string \"center\" (After Effects' own " +
|
|
623
|
+
"spawn point, the comp centre). Got: " + String(position)
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
418
627
|
OPS.create_shape_layer = function (args) {
|
|
419
628
|
var c = getCompById(args.compId);
|
|
629
|
+
var pos = __shapeSpawnPosition(c, args.position);
|
|
420
630
|
var l = c.layers.addShape();
|
|
421
631
|
if (args.name) l.name = args.name;
|
|
632
|
+
try {
|
|
633
|
+
l.property("Transform").property("Position").setValue(pos);
|
|
634
|
+
} catch (ePos) {
|
|
635
|
+
// The shape that can still get here is three components on a 2D layer, which
|
|
636
|
+
// AE refuses. Leaving an empty shape layer in the timeline behind a thrown
|
|
637
|
+
// error is the half-built failure add_shape_content refuses to produce.
|
|
638
|
+
try { l.remove(); } catch (eRm) {}
|
|
639
|
+
throw new Error(
|
|
640
|
+
"create_shape_layer could not set position to [" + pos.join(", ") + "]: " + ePos.message +
|
|
641
|
+
". (A three-component position needs a 3D layer; create it 2D and set threeDLayer with set_layer first.) " +
|
|
642
|
+
"The empty layer was removed, so nothing changed."
|
|
643
|
+
);
|
|
644
|
+
}
|
|
422
645
|
// shapes payload kept loose for v1 — the agent can use add_shape_content for detail
|
|
423
|
-
|
|
646
|
+
var out = __layerSummary(l);
|
|
647
|
+
// Read both back rather than echoing what we asked for: together they are the
|
|
648
|
+
// coordinate space every subsequent path on this layer is measured in, and a
|
|
649
|
+
// caller should never have to render a frame to find out what it is.
|
|
650
|
+
out.position = l.property("Transform").property("Position").value;
|
|
651
|
+
out.anchorPoint = l.property("Transform").property("Anchor Point").value;
|
|
652
|
+
return out;
|
|
424
653
|
};
|
|
425
654
|
|
|
426
655
|
OPS.create_precomp_layer = function (args) {
|
|
@@ -824,14 +1053,672 @@ OPS.parent_layer = function (args) {
|
|
|
824
1053
|
return out;
|
|
825
1054
|
};
|
|
826
1055
|
|
|
1056
|
+
// Layers do not reorder with moveTo. `moveTo` is a PropertyBase method for
|
|
1057
|
+
// re-ranking a property inside an indexed group, and calling it on a Layer
|
|
1058
|
+
// throws "parent is not an INDEXED_GROUP" — which is every call this op has
|
|
1059
|
+
// ever served (issue #70). AE's layer-level primitives are moveBefore,
|
|
1060
|
+
// moveAfter, moveToBeginning and moveToEnd, and all four take a *layer*, never
|
|
1061
|
+
// an index. (The one legitimate moveTo in this codebase is the shape-property
|
|
1062
|
+
// one in shapes.jsx; it is on a PropertyBase and stays.)
|
|
1063
|
+
//
|
|
1064
|
+
// Two ways in, because reorder is the op that invalidates indexes:
|
|
1065
|
+
//
|
|
1066
|
+
// * `beforeLayerId` / `afterLayerId` — place this layer relative to another
|
|
1067
|
+
// one by id. Nothing about the answer depends on the stack not having moved
|
|
1068
|
+
// since the caller last read it, which is why these are the preferred form.
|
|
1069
|
+
// * `toIndex` — the index the layer ends up at, counting from the front.
|
|
1070
|
+
// Stated as the *landing* index because "the index it displaces" and "the
|
|
1071
|
+
// index it lands on" differ by one when moving down the stack and the
|
|
1072
|
+
// caller cannot tell which one a tool meant.
|
|
1073
|
+
//
|
|
1074
|
+
// The two directions need different primitives, and swapping them is off by one
|
|
1075
|
+
// with no error. Moving up (toIndex < from), the layer currently at the target
|
|
1076
|
+
// is pushed down, so moveBefore lands exactly on it. Moving down, that layer
|
|
1077
|
+
// shifts up by one as this one leaves, so moveAfter is what lands on the target.
|
|
827
1078
|
OPS.reorder_layer = function (args) {
|
|
828
1079
|
var c = getCompById(args.compId);
|
|
829
1080
|
var l = getLayerById(c, args.layerId);
|
|
830
|
-
l.
|
|
831
|
-
|
|
1081
|
+
var from = l.index;
|
|
1082
|
+
var n = c.numLayers;
|
|
1083
|
+
var to;
|
|
1084
|
+
|
|
1085
|
+
if (args.beforeLayerId !== undefined && args.beforeLayerId !== null) {
|
|
1086
|
+
var b = getLayerById(c, args.beforeLayerId);
|
|
1087
|
+
if (b.id === l.id) throw new Error("reorder_layer: beforeLayerId is the layer being moved (" + l.id + ").");
|
|
1088
|
+
l.moveBefore(b);
|
|
1089
|
+
} else if (args.afterLayerId !== undefined && args.afterLayerId !== null) {
|
|
1090
|
+
var a = getLayerById(c, args.afterLayerId);
|
|
1091
|
+
if (a.id === l.id) throw new Error("reorder_layer: afterLayerId is the layer being moved (" + l.id + ").");
|
|
1092
|
+
l.moveAfter(a);
|
|
1093
|
+
} else if (args.toIndex !== undefined && args.toIndex !== null) {
|
|
1094
|
+
to = Math.round(args.toIndex);
|
|
1095
|
+
if (to < 1) to = 1;
|
|
1096
|
+
if (to > n) to = n;
|
|
1097
|
+
if (to === from) {
|
|
1098
|
+
// Nothing to do. Saying so beats an AE call that is a no-op anyway.
|
|
1099
|
+
} else if (to === 1) {
|
|
1100
|
+
l.moveToBeginning();
|
|
1101
|
+
} else if (to === n) {
|
|
1102
|
+
l.moveToEnd();
|
|
1103
|
+
} else if (to < from) {
|
|
1104
|
+
l.moveBefore(c.layer(to));
|
|
1105
|
+
} else {
|
|
1106
|
+
l.moveAfter(c.layer(to));
|
|
1107
|
+
}
|
|
1108
|
+
} else {
|
|
1109
|
+
throw new Error("reorder_layer: pass one of toIndex, beforeLayerId or afterLayerId.");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Report both ends of the move. The landing index is read back off the layer
|
|
1113
|
+
// rather than assumed, so a result can never claim a position AE did not
|
|
1114
|
+
// give it — and `movedFrom === index` is how a caller sees a no-op.
|
|
1115
|
+
var out = __layerSummary(l);
|
|
1116
|
+
out.movedFrom = from;
|
|
1117
|
+
return out;
|
|
832
1118
|
};
|
|
833
1119
|
|
|
834
1120
|
|
|
1121
|
+
// ===== snapshot.jsx =====
|
|
1122
|
+
|
|
1123
|
+
// snapshot.jsx — a cheap structural fingerprint of a comp, and the diff
|
|
1124
|
+
// between two of them.
|
|
1125
|
+
//
|
|
1126
|
+
// Verifying a write today means reading the comp back and comparing by eye,
|
|
1127
|
+
// and every one of those reads is re-sent on every later request for the rest
|
|
1128
|
+
// of the session. A fingerprint is the other end of that trade: it records
|
|
1129
|
+
// what an agent actually checks after a write — which layers exist, what they
|
|
1130
|
+
// are called, where they sit in time, what they are parented to, how many
|
|
1131
|
+
// keyframes/expressions/effects they carry — and nothing else. The diff of two
|
|
1132
|
+
// of them is a few dozen tokens for "3 layers added: ids 512-514; layer 498
|
|
1133
|
+
// Opacity keys 0 -> 4" (issue #52).
|
|
1134
|
+
//
|
|
1135
|
+
// Cheapness is the whole point, so the walk stops at the layer's own Transform
|
|
1136
|
+
// group plus four named properties. It never opens an effect's parameters or a
|
|
1137
|
+
// shape layer's Contents — those are what make get_layer_full expensive, and a
|
|
1138
|
+
// fingerprint that costs as much as the read it replaces is worth nothing.
|
|
1139
|
+
//
|
|
1140
|
+
// That has a cost of its own, and per the "a scoped read must say what it left
|
|
1141
|
+
// out" rule it is stated rather than left to be discovered: a diff can only
|
|
1142
|
+
// report a field it records. __DIFF_COVERS travels with every diff for exactly
|
|
1143
|
+
// that reason — "no differences" must never be read as "identical".
|
|
1144
|
+
|
|
1145
|
+
var __DIFF_COVERS = "Compares recorded fields only - not property values, expression text, " +
|
|
1146
|
+
"effect parameters, masks or shape contents. \"No differences\" means none of the recorded fields moved, " +
|
|
1147
|
+
"not that the two states render identically.";
|
|
1148
|
+
|
|
1149
|
+
var __FP_COVERS = "A snapshot records, per layer: id, name, index, type, inPoint, outPoint, startTime, " +
|
|
1150
|
+
"parentId, enabled, keyframe counts per Transform property (plus Marker, Time Remap and Source Text), " +
|
|
1151
|
+
"expression count and effect count. Per comp: name, size, duration, frame rate, work area and markers. " +
|
|
1152
|
+
"It does NOT record property values, expression text, effect parameters, mask shapes or shape contents, " +
|
|
1153
|
+
"because reading those costs as much as the read this replaces.";
|
|
1154
|
+
|
|
1155
|
+
// Times are floats out of AE and are compared, not displayed, so they are
|
|
1156
|
+
// rounded once here rather than at every comparison site.
|
|
1157
|
+
var __FP_PRECISION = 1000000;
|
|
1158
|
+
|
|
1159
|
+
function __fpRound(n) {
|
|
1160
|
+
if (typeof n !== "number") return n;
|
|
1161
|
+
if (!isFinite(n)) return null;
|
|
1162
|
+
return Math.round(n * __FP_PRECISION) / __FP_PRECISION;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Properties worth a fingerprint that do not live under Transform. Each is one
|
|
1166
|
+
// guarded lookup; a layer that has none of them pays four null checks.
|
|
1167
|
+
var __FP_EXTRA_PROPS = ["Marker", "Time Remap", "Source Text", "Audio Levels"];
|
|
1168
|
+
|
|
1169
|
+
function __fpCountProperty(p, out) {
|
|
1170
|
+
if (!p) return;
|
|
1171
|
+
var keys = 0;
|
|
1172
|
+
try { keys = p.numKeys; } catch (e) { return; }
|
|
1173
|
+
if (typeof keys !== "number") return;
|
|
1174
|
+
if (keys > 0) out.keyCounts[p.name] = keys;
|
|
1175
|
+
try {
|
|
1176
|
+
if (p.canSetExpression && p.expression) out.expressionCount += 1;
|
|
1177
|
+
} catch (e2) {}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function __fpLayer(l) {
|
|
1181
|
+
var out = {
|
|
1182
|
+
id: l.id,
|
|
1183
|
+
name: l.name,
|
|
1184
|
+
index: l.index,
|
|
1185
|
+
type: __layerKind(l),
|
|
1186
|
+
inPoint: __fpRound(l.inPoint),
|
|
1187
|
+
outPoint: __fpRound(l.outPoint),
|
|
1188
|
+
startTime: __fpRound(l.startTime),
|
|
1189
|
+
parentId: null,
|
|
1190
|
+
enabled: l.enabled,
|
|
1191
|
+
keyCounts: {},
|
|
1192
|
+
expressionCount: 0,
|
|
1193
|
+
effectCount: 0
|
|
1194
|
+
};
|
|
1195
|
+
try { if (l.parent) out.parentId = l.parent.id; } catch (e0) {}
|
|
1196
|
+
|
|
1197
|
+
var tr = null;
|
|
1198
|
+
try { tr = l.property("Transform"); } catch (e1) {}
|
|
1199
|
+
if (tr) {
|
|
1200
|
+
for (var i = 1; i <= tr.numProperties; i++) __fpCountProperty(tr.property(i), out);
|
|
1201
|
+
}
|
|
1202
|
+
for (var j = 0; j < __FP_EXTRA_PROPS.length; j++) {
|
|
1203
|
+
var extra = null;
|
|
1204
|
+
try { extra = l.property(__FP_EXTRA_PROPS[j]); } catch (e2) {}
|
|
1205
|
+
__fpCountProperty(extra, out);
|
|
1206
|
+
}
|
|
1207
|
+
// Effects are counted, never walked. The parameter tree is the expensive
|
|
1208
|
+
// part and an agent checking "did my effect land" only needs the count plus
|
|
1209
|
+
// list_effects when it did not.
|
|
1210
|
+
try {
|
|
1211
|
+
var fx = l.property("Effects");
|
|
1212
|
+
if (fx) out.effectCount = fx.numProperties;
|
|
1213
|
+
} catch (e3) {}
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function __fpCompMarkers(c) {
|
|
1218
|
+
var out = [];
|
|
1219
|
+
var mp = null;
|
|
1220
|
+
try { mp = c.markerProperty; } catch (e) { return out; }
|
|
1221
|
+
if (!mp) return out;
|
|
1222
|
+
var n = 0;
|
|
1223
|
+
try { n = mp.numKeys; } catch (e2) { return out; }
|
|
1224
|
+
for (var i = 1; i <= n; i++) {
|
|
1225
|
+
var mv = mp.keyValue(i);
|
|
1226
|
+
out.push(String(__fpRound(mp.keyTime(i))) + "|" + String(mv.comment) + "|" + String(__fpRound(mv.duration)));
|
|
1227
|
+
}
|
|
1228
|
+
return out;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function __compFingerprint(compId) {
|
|
1232
|
+
var c = getCompById(compId);
|
|
1233
|
+
var fp = {
|
|
1234
|
+
compId: c.id,
|
|
1235
|
+
name: c.name,
|
|
1236
|
+
width: c.width,
|
|
1237
|
+
height: c.height,
|
|
1238
|
+
duration: __fpRound(c.duration),
|
|
1239
|
+
frameRate: __fpRound(c.frameRate),
|
|
1240
|
+
workAreaStart: __fpRound(c.workAreaStart),
|
|
1241
|
+
workAreaDuration: __fpRound(c.workAreaDuration),
|
|
1242
|
+
numLayers: c.numLayers,
|
|
1243
|
+
markers: __fpCompMarkers(c),
|
|
1244
|
+
layers: []
|
|
1245
|
+
};
|
|
1246
|
+
for (var i = 1; i <= c.numLayers; i++) fp.layers.push(__fpLayer(c.layer(i)));
|
|
1247
|
+
return fp;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ---------------------------------------------------------------------------
|
|
1251
|
+
// The diff
|
|
1252
|
+
// ---------------------------------------------------------------------------
|
|
1253
|
+
// Pure: two fingerprints in, one object out, no AE. That is what lets it be
|
|
1254
|
+
// tested against synthetic input (tests/unit/comp-snapshot.mjs) — there is no
|
|
1255
|
+
// ExtendScript runtime on a runner and this is the part that has to be right.
|
|
1256
|
+
|
|
1257
|
+
// Below a frame at any sane frame rate, above the float noise a round-trip
|
|
1258
|
+
// through JSON leaves behind. Without it a comp re-read after a frame-rate
|
|
1259
|
+
// change reports every layer "retimed" by 1e-15.
|
|
1260
|
+
var __DIFF_EPS = 0.000001;
|
|
1261
|
+
|
|
1262
|
+
function __diffMoved(a, b) {
|
|
1263
|
+
if (typeof a !== "number") return a !== b;
|
|
1264
|
+
if (typeof b !== "number") return true;
|
|
1265
|
+
return Math.abs(a - b) > __DIFF_EPS;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ES3 has no Map. The "L" prefix keeps a numeric id away from anything already
|
|
1269
|
+
// on Object.prototype.
|
|
1270
|
+
function __fpById(fp) {
|
|
1271
|
+
var m = {};
|
|
1272
|
+
for (var i = 0; i < fp.layers.length; i++) m["L" + fp.layers[i].id] = fp.layers[i];
|
|
1273
|
+
return m;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function __diffKeyCounts(a, b) {
|
|
1277
|
+
var out = null;
|
|
1278
|
+
var seen = {};
|
|
1279
|
+
var k;
|
|
1280
|
+
for (k in a) {
|
|
1281
|
+
if (!a.hasOwnProperty(k)) continue;
|
|
1282
|
+
seen[k] = true;
|
|
1283
|
+
var bv = 0;
|
|
1284
|
+
if (b.hasOwnProperty(k)) bv = b[k];
|
|
1285
|
+
if (a[k] !== bv) {
|
|
1286
|
+
if (!out) out = {};
|
|
1287
|
+
out[k] = { from: a[k], to: bv };
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
for (k in b) {
|
|
1291
|
+
if (!b.hasOwnProperty(k)) continue;
|
|
1292
|
+
if (seen[k]) continue;
|
|
1293
|
+
if (!out) out = {};
|
|
1294
|
+
out[k] = { from: 0, to: b[k] };
|
|
1295
|
+
}
|
|
1296
|
+
return out;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
var __DIFF_EXACT_FIELDS = ["name", "type", "enabled"];
|
|
1300
|
+
var __DIFF_TIME_FIELDS = ["inPoint", "outPoint", "startTime"];
|
|
1301
|
+
var __DIFF_COUNT_FIELDS = ["expressionCount", "effectCount"];
|
|
1302
|
+
|
|
1303
|
+
// `index` is deliberately not compared here: inserting one layer shifts every
|
|
1304
|
+
// index below it, which would report twenty changed layers for one addition.
|
|
1305
|
+
// Relative order is compared separately, in __diffReordered.
|
|
1306
|
+
function __diffLayer(a, b) {
|
|
1307
|
+
var ch = {};
|
|
1308
|
+
var n = 0;
|
|
1309
|
+
var i, k;
|
|
1310
|
+
for (i = 0; i < __DIFF_EXACT_FIELDS.length; i++) {
|
|
1311
|
+
k = __DIFF_EXACT_FIELDS[i];
|
|
1312
|
+
if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
|
|
1313
|
+
}
|
|
1314
|
+
for (i = 0; i < __DIFF_TIME_FIELDS.length; i++) {
|
|
1315
|
+
k = __DIFF_TIME_FIELDS[i];
|
|
1316
|
+
if (__diffMoved(a[k], b[k])) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
|
|
1317
|
+
}
|
|
1318
|
+
for (i = 0; i < __DIFF_COUNT_FIELDS.length; i++) {
|
|
1319
|
+
k = __DIFF_COUNT_FIELDS[i];
|
|
1320
|
+
if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
|
|
1321
|
+
}
|
|
1322
|
+
if (a.parentId !== b.parentId) { ch.parentId = { from: a.parentId, to: b.parentId }; n += 1; }
|
|
1323
|
+
var kc = __diffKeyCounts(a.keyCounts || {}, b.keyCounts || {});
|
|
1324
|
+
if (kc) { ch.keyCounts = kc; n += 1; }
|
|
1325
|
+
if (n === 0) return null;
|
|
1326
|
+
return { id: b.id, name: b.name, changes: ch };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Only layers present on both sides, and only when their order relative to one
|
|
1330
|
+
// another actually changed. A layer added or removed elsewhere in the stack is
|
|
1331
|
+
// not a reorder of anything.
|
|
1332
|
+
function __diffReordered(before, after, aMap, bMap) {
|
|
1333
|
+
var seqA = [];
|
|
1334
|
+
var seqB = [];
|
|
1335
|
+
var i;
|
|
1336
|
+
for (i = 0; i < before.layers.length; i++) {
|
|
1337
|
+
var idA = before.layers[i].id;
|
|
1338
|
+
if (bMap.hasOwnProperty("L" + idA)) seqA.push(idA);
|
|
1339
|
+
}
|
|
1340
|
+
for (i = 0; i < after.layers.length; i++) {
|
|
1341
|
+
var idB = after.layers[i].id;
|
|
1342
|
+
if (aMap.hasOwnProperty("L" + idB)) seqB.push(idB);
|
|
1343
|
+
}
|
|
1344
|
+
if (seqA.length !== seqB.length) return null;
|
|
1345
|
+
var same = true;
|
|
1346
|
+
for (i = 0; i < seqA.length; i++) {
|
|
1347
|
+
if (seqA[i] !== seqB[i]) { same = false; break; }
|
|
1348
|
+
}
|
|
1349
|
+
if (same) return null;
|
|
1350
|
+
var posA = {};
|
|
1351
|
+
for (i = 0; i < seqA.length; i++) posA["L" + seqA[i]] = i;
|
|
1352
|
+
var moved = [];
|
|
1353
|
+
for (i = 0; i < seqB.length; i++) {
|
|
1354
|
+
var id = seqB[i];
|
|
1355
|
+
if (posA["L" + id] === i) continue;
|
|
1356
|
+
moved.push({
|
|
1357
|
+
id: id,
|
|
1358
|
+
name: bMap["L" + id].name,
|
|
1359
|
+
fromIndex: aMap["L" + id].index,
|
|
1360
|
+
toIndex: bMap["L" + id].index
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
if (moved.length === 0) return null;
|
|
1364
|
+
return moved;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function __diffMarkers(a, b) {
|
|
1368
|
+
if (a.length !== b.length) return { from: a.length, to: b.length };
|
|
1369
|
+
for (var i = 0; i < a.length; i++) {
|
|
1370
|
+
if (a[i] !== b[i]) return { count: a.length, edited: true };
|
|
1371
|
+
}
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function __diffCompFields(a, b) {
|
|
1376
|
+
var ch = {};
|
|
1377
|
+
var n = 0;
|
|
1378
|
+
if (a.name !== b.name) { ch.name = { from: a.name, to: b.name }; n += 1; }
|
|
1379
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
1380
|
+
ch.size = { from: [a.width, a.height], to: [b.width, b.height] };
|
|
1381
|
+
n += 1;
|
|
1382
|
+
}
|
|
1383
|
+
if (__diffMoved(a.duration, b.duration)) { ch.duration = { from: a.duration, to: b.duration }; n += 1; }
|
|
1384
|
+
if (__diffMoved(a.frameRate, b.frameRate)) { ch.frameRate = { from: a.frameRate, to: b.frameRate }; n += 1; }
|
|
1385
|
+
if (__diffMoved(a.workAreaStart, b.workAreaStart) || __diffMoved(a.workAreaDuration, b.workAreaDuration)) {
|
|
1386
|
+
ch.workArea = {
|
|
1387
|
+
from: [a.workAreaStart, a.workAreaDuration],
|
|
1388
|
+
to: [b.workAreaStart, b.workAreaDuration]
|
|
1389
|
+
};
|
|
1390
|
+
n += 1;
|
|
1391
|
+
}
|
|
1392
|
+
var mk = __diffMarkers(a.markers || [], b.markers || []);
|
|
1393
|
+
if (mk) { ch.markers = mk; n += 1; }
|
|
1394
|
+
if (n === 0) return null;
|
|
1395
|
+
return ch;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ---------- summary prose ----------
|
|
1399
|
+
|
|
1400
|
+
function __diffPlural(n) {
|
|
1401
|
+
if (n === 1) return "";
|
|
1402
|
+
return "s";
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// "512-514" for a run, "512, 517" otherwise. Ids come out of AE in creation
|
|
1406
|
+
// order, so a batch of new layers is nearly always consecutive and this is the
|
|
1407
|
+
// difference between a readable line and a wall of numbers.
|
|
1408
|
+
function __diffIdList(ids) {
|
|
1409
|
+
if (!ids.length) return "";
|
|
1410
|
+
var sorted = [];
|
|
1411
|
+
for (var i = 0; i < ids.length; i++) sorted.push(ids[i]);
|
|
1412
|
+
sorted.sort(function (x, y) { return x - y; });
|
|
1413
|
+
var parts = [];
|
|
1414
|
+
var start = sorted[0];
|
|
1415
|
+
var prev = sorted[0];
|
|
1416
|
+
for (var j = 1; j <= sorted.length; j++) {
|
|
1417
|
+
var cur = sorted[j];
|
|
1418
|
+
var breaks = true;
|
|
1419
|
+
if (j < sorted.length && cur === prev + 1) breaks = false;
|
|
1420
|
+
if (breaks) {
|
|
1421
|
+
if (start === prev) parts.push(String(start));
|
|
1422
|
+
else if (prev === start + 1) parts.push(String(start) + ", " + String(prev));
|
|
1423
|
+
else parts.push(String(start) + "-" + String(prev));
|
|
1424
|
+
start = cur;
|
|
1425
|
+
}
|
|
1426
|
+
prev = cur;
|
|
1427
|
+
}
|
|
1428
|
+
return parts.join(", ");
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function __diffParentLabel(v) {
|
|
1432
|
+
if (v === null || v === undefined) return "none";
|
|
1433
|
+
return String(v);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function __diffLayerPhrases(entry) {
|
|
1437
|
+
var label = "layer " + entry.id;
|
|
1438
|
+
var c = entry.changes;
|
|
1439
|
+
var out = [];
|
|
1440
|
+
if (c.name) out.push(label + ' renamed "' + c.name.from + '" -> "' + c.name.to + '"');
|
|
1441
|
+
if (c.enabled) {
|
|
1442
|
+
if (c.enabled.to) out.push(label + " enabled");
|
|
1443
|
+
else out.push(label + " disabled");
|
|
1444
|
+
}
|
|
1445
|
+
var times = [];
|
|
1446
|
+
if (c.inPoint) times.push("in " + c.inPoint.from + " -> " + c.inPoint.to);
|
|
1447
|
+
if (c.outPoint) times.push("out " + c.outPoint.from + " -> " + c.outPoint.to);
|
|
1448
|
+
if (c.startTime) times.push("start " + c.startTime.from + " -> " + c.startTime.to);
|
|
1449
|
+
if (times.length) out.push(label + " retimed (" + times.join(", ") + ")");
|
|
1450
|
+
if (c.parentId) {
|
|
1451
|
+
out.push(label + " re-parented " + __diffParentLabel(c.parentId.from) + " -> " + __diffParentLabel(c.parentId.to));
|
|
1452
|
+
}
|
|
1453
|
+
if (c.keyCounts) {
|
|
1454
|
+
for (var k in c.keyCounts) {
|
|
1455
|
+
if (!c.keyCounts.hasOwnProperty(k)) continue;
|
|
1456
|
+
out.push(label + " " + k + " keys " + c.keyCounts[k].from + " -> " + c.keyCounts[k].to);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (c.effectCount) out.push(label + " effects " + c.effectCount.from + " -> " + c.effectCount.to);
|
|
1460
|
+
if (c.expressionCount) {
|
|
1461
|
+
out.push(label + " expressions " + c.expressionCount.from + " -> " + c.expressionCount.to);
|
|
1462
|
+
}
|
|
1463
|
+
if (c.type) out.push(label + " type " + c.type.from + " -> " + c.type.to);
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function __diffCompPhrases(ch) {
|
|
1468
|
+
var out = [];
|
|
1469
|
+
if (ch.name) out.push('comp renamed "' + ch.name.from + '" -> "' + ch.name.to + '"');
|
|
1470
|
+
if (ch.size) {
|
|
1471
|
+
out.push("comp resized " + ch.size.from[0] + "x" + ch.size.from[1] + " -> " + ch.size.to[0] + "x" + ch.size.to[1]);
|
|
1472
|
+
}
|
|
1473
|
+
if (ch.duration) out.push("comp duration " + ch.duration.from + " -> " + ch.duration.to);
|
|
1474
|
+
if (ch.frameRate) out.push("comp frame rate " + ch.frameRate.from + " -> " + ch.frameRate.to);
|
|
1475
|
+
if (ch.workArea) out.push("comp work area moved");
|
|
1476
|
+
if (ch.markers) {
|
|
1477
|
+
if (ch.markers.edited) out.push("comp markers edited");
|
|
1478
|
+
else out.push("comp markers " + ch.markers.from + " -> " + ch.markers.to);
|
|
1479
|
+
}
|
|
1480
|
+
return out;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// One line. Past this many clauses the rest is counted rather than spelled out
|
|
1484
|
+
// — the structured fields carry everything, and a summary nobody reads to the
|
|
1485
|
+
// end is worse than a short one that names where to look.
|
|
1486
|
+
var __DIFF_SUMMARY_CLAUSES = 8;
|
|
1487
|
+
|
|
1488
|
+
function __diffSummary(d) {
|
|
1489
|
+
var parts = [];
|
|
1490
|
+
var i;
|
|
1491
|
+
if (d.added) {
|
|
1492
|
+
var addedIds = [];
|
|
1493
|
+
for (i = 0; i < d.added.length; i++) addedIds.push(d.added[i].id);
|
|
1494
|
+
parts.push(d.added.length + " layer" + __diffPlural(d.added.length) + " added: ids " + __diffIdList(addedIds));
|
|
1495
|
+
}
|
|
1496
|
+
if (d.removed) {
|
|
1497
|
+
var removedIds = [];
|
|
1498
|
+
for (i = 0; i < d.removed.length; i++) removedIds.push(d.removed[i].id);
|
|
1499
|
+
parts.push(d.removed.length + " layer" + __diffPlural(d.removed.length) + " removed: ids " + __diffIdList(removedIds));
|
|
1500
|
+
}
|
|
1501
|
+
if (d.reordered) {
|
|
1502
|
+
parts.push(d.reordered.length + " layer" + __diffPlural(d.reordered.length) + " moved in the stack");
|
|
1503
|
+
}
|
|
1504
|
+
if (d.changed) {
|
|
1505
|
+
for (i = 0; i < d.changed.length; i++) {
|
|
1506
|
+
var phrases = __diffLayerPhrases(d.changed[i]);
|
|
1507
|
+
for (var p = 0; p < phrases.length; p++) parts.push(phrases[p]);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (d.comp) {
|
|
1511
|
+
var compPhrases = __diffCompPhrases(d.comp);
|
|
1512
|
+
for (i = 0; i < compPhrases.length; i++) parts.push(compPhrases[i]);
|
|
1513
|
+
}
|
|
1514
|
+
if (parts.length === 0) {
|
|
1515
|
+
return "No differences in the recorded fields. Property values, expression text, effect parameters " +
|
|
1516
|
+
"and shape contents are not compared, so this is not a claim that nothing changed at all.";
|
|
1517
|
+
}
|
|
1518
|
+
if (parts.length > __DIFF_SUMMARY_CLAUSES) {
|
|
1519
|
+
var extra = parts.length - __DIFF_SUMMARY_CLAUSES;
|
|
1520
|
+
parts = parts.slice(0, __DIFF_SUMMARY_CLAUSES);
|
|
1521
|
+
parts.push("and " + extra + " more change" + __diffPlural(extra) + " (see the fields below)");
|
|
1522
|
+
}
|
|
1523
|
+
return parts.join("; ");
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Two fingerprints -> only what moved. Unchanged layers are counted, never
|
|
1527
|
+
// listed: listing them is the cost this whole thing exists to avoid.
|
|
1528
|
+
function __diffFingerprints(before, after) {
|
|
1529
|
+
var aMap = __fpById(before);
|
|
1530
|
+
var bMap = __fpById(after);
|
|
1531
|
+
var added = [];
|
|
1532
|
+
var removed = [];
|
|
1533
|
+
var changed = [];
|
|
1534
|
+
var unchanged = 0;
|
|
1535
|
+
var i, l;
|
|
1536
|
+
for (i = 0; i < after.layers.length; i++) {
|
|
1537
|
+
l = after.layers[i];
|
|
1538
|
+
var prev = aMap["L" + l.id];
|
|
1539
|
+
if (!prev) {
|
|
1540
|
+
added.push({ id: l.id, name: l.name, index: l.index, type: l.type });
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
var d = __diffLayer(prev, l);
|
|
1544
|
+
if (d) changed.push(d);
|
|
1545
|
+
else unchanged += 1;
|
|
1546
|
+
}
|
|
1547
|
+
for (i = 0; i < before.layers.length; i++) {
|
|
1548
|
+
l = before.layers[i];
|
|
1549
|
+
if (bMap.hasOwnProperty("L" + l.id)) continue;
|
|
1550
|
+
removed.push({ id: l.id, name: l.name, index: l.index, type: l.type });
|
|
1551
|
+
}
|
|
1552
|
+
var reordered = __diffReordered(before, after, aMap, bMap);
|
|
1553
|
+
var comp = __diffCompFields(before, after);
|
|
1554
|
+
|
|
1555
|
+
var out = { compId: after.compId, compName: after.name };
|
|
1556
|
+
if (added.length) out.added = added;
|
|
1557
|
+
if (removed.length) out.removed = removed;
|
|
1558
|
+
if (changed.length) out.changed = changed;
|
|
1559
|
+
if (reordered) out.reordered = reordered;
|
|
1560
|
+
if (comp) out.comp = comp;
|
|
1561
|
+
out.unchangedLayers = unchanged;
|
|
1562
|
+
var count = added.length + removed.length + changed.length;
|
|
1563
|
+
if (reordered) count += reordered.length;
|
|
1564
|
+
if (comp) count += 1;
|
|
1565
|
+
out.changeCount = count;
|
|
1566
|
+
out.summary = __diffSummary(out);
|
|
1567
|
+
out.covers = __DIFF_COVERS;
|
|
1568
|
+
return out;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// ---------------------------------------------------------------------------
|
|
1572
|
+
// Ops
|
|
1573
|
+
// ---------------------------------------------------------------------------
|
|
1574
|
+
// Both are internal: the tools an agent sees are snapshot_comp and diff_comp,
|
|
1575
|
+
// and those are half server-resident. Only the panel can read After Effects,
|
|
1576
|
+
// and only the server can remember anything between calls — so the server
|
|
1577
|
+
// keeps the fingerprint and these two gather it. See snapshots/store.ts.
|
|
1578
|
+
|
|
1579
|
+
OPS._comp_fingerprint = noUndo(function (args) {
|
|
1580
|
+
return __compFingerprint(args.compId);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
OPS._comp_diff = noUndo(function (args) {
|
|
1584
|
+
var before = args.since;
|
|
1585
|
+
if (!before || !before.layers) {
|
|
1586
|
+
throw new Error("_comp_diff needs a stored fingerprint in `since` (the MCP server supplies it from the snapshot id)");
|
|
1587
|
+
}
|
|
1588
|
+
var compId = args.compId;
|
|
1589
|
+
if (compId === undefined || compId === null) compId = before.compId;
|
|
1590
|
+
var after = __compFingerprint(compId);
|
|
1591
|
+
return { diff: __diffFingerprints(before, after), fingerprint: after };
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// ---------------------------------------------------------------------------
|
|
1595
|
+
// diff:true on the write ops
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
// The before-fingerprint has to be taken inside the same bridge call as the
|
|
1598
|
+
// write, or it is not a before at all: a separate snapshot_comp is a second
|
|
1599
|
+
// round-trip during which anything can happen, and the agent has to remember to
|
|
1600
|
+
// make it. run_jsx and run_batch therefore fingerprint, run, fingerprint again,
|
|
1601
|
+
// and diff — one call, one answer.
|
|
1602
|
+
|
|
1603
|
+
function __diffPushUnique(list, v) {
|
|
1604
|
+
for (var i = 0; i < list.length; i++) {
|
|
1605
|
+
if (list[i] === v) return;
|
|
1606
|
+
}
|
|
1607
|
+
list.push(v);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Explicit diffCompId wins. Otherwise a batch names its own comps in its ops,
|
|
1611
|
+
// and a script gets the comp the user is looking at. When none of the three
|
|
1612
|
+
// yields anything the diff is refused with a reason rather than quietly
|
|
1613
|
+
// skipped — a missing diff that looks like "nothing changed" is the failure
|
|
1614
|
+
// this whole feature exists to prevent.
|
|
1615
|
+
function __diffCompIds(args, ops) {
|
|
1616
|
+
var ids = [];
|
|
1617
|
+
if (args && args.diffCompId !== undefined && args.diffCompId !== null) {
|
|
1618
|
+
__diffPushUnique(ids, args.diffCompId);
|
|
1619
|
+
return ids;
|
|
1620
|
+
}
|
|
1621
|
+
if (ops) {
|
|
1622
|
+
for (var i = 0; i < ops.length; i++) {
|
|
1623
|
+
var a = ops[i].args;
|
|
1624
|
+
if (a && typeof a.compId === "number") __diffPushUnique(ids, a.compId);
|
|
1625
|
+
}
|
|
1626
|
+
if (ids.length > 0) return ids;
|
|
1627
|
+
}
|
|
1628
|
+
var active = null;
|
|
1629
|
+
try { active = app.project.activeItem; } catch (e) {}
|
|
1630
|
+
if (active && active instanceof CompItem) __diffPushUnique(ids, active.id);
|
|
1631
|
+
return ids;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function __diffStart(args, ops) {
|
|
1635
|
+
if (!args || !args.diff) return null;
|
|
1636
|
+
var ids = __diffCompIds(args, ops);
|
|
1637
|
+
if (ids.length === 0) {
|
|
1638
|
+
return {
|
|
1639
|
+
unavailable: true,
|
|
1640
|
+
reason: "no compId appeared in this call and no composition is open in the viewer — pass diffCompId to name the comp to fingerprint"
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
var state = { ids: ids, before: [], unavailable: false };
|
|
1644
|
+
for (var i = 0; i < ids.length; i++) {
|
|
1645
|
+
try {
|
|
1646
|
+
state.before.push(__compFingerprint(ids[i]));
|
|
1647
|
+
} catch (e) {
|
|
1648
|
+
return { unavailable: true, reason: "comp " + ids[i] + " could not be fingerprinted before the call: " + e.message };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return state;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function __diffFinish(state) {
|
|
1655
|
+
if (!state) return null;
|
|
1656
|
+
if (state.unavailable) {
|
|
1657
|
+
return {
|
|
1658
|
+
unavailable: true,
|
|
1659
|
+
reason: state.reason,
|
|
1660
|
+
summary: "No diff was taken: " + state.reason + ". The call itself is unaffected."
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
var comps = [];
|
|
1664
|
+
var total = 0;
|
|
1665
|
+
for (var i = 0; i < state.before.length; i++) {
|
|
1666
|
+
var b = state.before[i];
|
|
1667
|
+
var after = null;
|
|
1668
|
+
try {
|
|
1669
|
+
after = __compFingerprint(b.compId);
|
|
1670
|
+
} catch (e) {
|
|
1671
|
+
comps.push({
|
|
1672
|
+
compId: b.compId,
|
|
1673
|
+
compName: b.name,
|
|
1674
|
+
gone: true,
|
|
1675
|
+
changeCount: 1,
|
|
1676
|
+
summary: "comp " + b.compId + ' ("' + b.name + '") can no longer be read: ' + e.message
|
|
1677
|
+
});
|
|
1678
|
+
total += 1;
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
var d = __diffFingerprints(b, after);
|
|
1682
|
+
comps.push(d);
|
|
1683
|
+
total += d.changeCount;
|
|
1684
|
+
}
|
|
1685
|
+
if (comps.length === 1) return comps[0];
|
|
1686
|
+
var parts = [];
|
|
1687
|
+
for (var j = 0; j < comps.length; j++) parts.push("comp " + comps[j].compId + ": " + comps[j].summary);
|
|
1688
|
+
return { comps: comps, changeCount: total, summary: parts.join(" | "), covers: __DIFF_COVERS };
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// A script or batch that threw still changed whatever it changed before it
|
|
1692
|
+
// stopped, and nothing rolls back. Finding that stop point by reading the comp
|
|
1693
|
+
// back is one of the three cases issue #52 was opened for, so the diff is put
|
|
1694
|
+
// where the agent will actually see it: on the error. The error object itself
|
|
1695
|
+
// is mutated rather than replaced, so `line` and `stack` survive for the
|
|
1696
|
+
// caller's own reporting.
|
|
1697
|
+
function __diffAnnotateError(e, state) {
|
|
1698
|
+
if (!state) return;
|
|
1699
|
+
var d = null;
|
|
1700
|
+
try { d = __diffFinish(state); } catch (e2) { return; }
|
|
1701
|
+
if (!d || !d.summary) return;
|
|
1702
|
+
try {
|
|
1703
|
+
e.message = String(e.message) + " || Changed before it stopped: " + d.summary +
|
|
1704
|
+
" (nothing rolls back - read the state back rather than re-running)";
|
|
1705
|
+
} catch (e3) {}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// run_jsx returns whatever the script returned, which may be a number or a
|
|
1709
|
+
// string with nowhere to hang a diff on. With diff:true it is enveloped
|
|
1710
|
+
// instead. The null envelope __rjResult already builds is the right shape
|
|
1711
|
+
// already, so that one is extended rather than nested inside a second one.
|
|
1712
|
+
function __rjWithDiff(out, diff, undoGroupName) {
|
|
1713
|
+
if (out && typeof out === "object" && !(out instanceof Array) &&
|
|
1714
|
+
out.ok === true && out.returned === null && out.note) {
|
|
1715
|
+
out.diff = diff;
|
|
1716
|
+
return out;
|
|
1717
|
+
}
|
|
1718
|
+
return { ok: true, returned: out, undoGroup: undoGroupName, diff: diff };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
|
|
835
1722
|
// ===== transforms.jsx =====
|
|
836
1723
|
|
|
837
1724
|
// transforms.jsx — fast-path for setting common transform properties.
|
|
@@ -889,31 +1776,99 @@ function __findKeyIndexAtTime(prop, time, eps) {
|
|
|
889
1776
|
return -1;
|
|
890
1777
|
}
|
|
891
1778
|
|
|
1779
|
+
// ---------- ease array sizing (issue #50) ----------
|
|
1780
|
+
//
|
|
1781
|
+
// setTemporalEaseAtKey wants an array of KeyframeEase whose length belongs to
|
|
1782
|
+
// the *property*, and it is not derivable from the value: a 2D layer Scale
|
|
1783
|
+
// wants 3, a shape Ellipse Size wants 2 while its value reads [w,h], Opacity
|
|
1784
|
+
// and a slider want 1, and a spatial Position wants 1 whether the layer is 2D
|
|
1785
|
+
// or 3D because the ease runs along the motion path. Get it wrong and AE throws
|
|
1786
|
+
// "parameter 2" and says nothing else — no property name, no expected count.
|
|
1787
|
+
//
|
|
1788
|
+
// So: derive the likely count from the property, then try the others. The
|
|
1789
|
+
// derivation is the fast path; the retry is the safety net, because the Ellipse
|
|
1790
|
+
// Size case is precisely the one no table gets right from the outside. Whatever
|
|
1791
|
+
// AE accepted is reported back, so the answer for a given property stops being
|
|
1792
|
+
// folklore and becomes something a caller can read off a result.
|
|
1793
|
+
var __EASE_ARITIES = [1, 2, 3, 4];
|
|
1794
|
+
|
|
1795
|
+
/** The count to try first, from the property rather than from the value passed in. */
|
|
1796
|
+
function __easePropertyArity(prop) {
|
|
1797
|
+
// Spatial first and unconditionally: a 3D Position is ThreeD_SPATIAL and
|
|
1798
|
+
// still takes one entry, so the value type must not get a say here.
|
|
1799
|
+
if (prop.isSpatial) return 1;
|
|
1800
|
+
var vt = null;
|
|
1801
|
+
try { vt = prop.propertyValueType; } catch (eType) {}
|
|
1802
|
+
if (vt !== null && vt !== undefined && typeof PropertyValueType !== "undefined") {
|
|
1803
|
+
if (vt === PropertyValueType.OneD) return 1;
|
|
1804
|
+
if (vt === PropertyValueType.TwoD) return 2;
|
|
1805
|
+
if (vt === PropertyValueType.TwoD_SPATIAL) return 1;
|
|
1806
|
+
if (vt === PropertyValueType.ThreeD) return 3;
|
|
1807
|
+
if (vt === PropertyValueType.ThreeD_SPATIAL) return 1;
|
|
1808
|
+
if (vt === PropertyValueType.COLOR) return 4;
|
|
1809
|
+
}
|
|
1810
|
+
var v = null;
|
|
1811
|
+
try { v = prop.value; } catch (eVal) {}
|
|
1812
|
+
if (v && v.length) return v.length;
|
|
1813
|
+
return 1;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Derived count first, then every other plausible one, no repeats. */
|
|
1817
|
+
function __easeArityCandidates(prop) {
|
|
1818
|
+
var first = __easePropertyArity(prop);
|
|
1819
|
+
var out = [first];
|
|
1820
|
+
for (var i = 0; i < __EASE_ARITIES.length; i++) {
|
|
1821
|
+
if (__EASE_ARITIES[i] !== first) out.push(__EASE_ARITIES[i]);
|
|
1822
|
+
}
|
|
1823
|
+
return out;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function __easeArray(ease, n) {
|
|
1827
|
+
var arr = [];
|
|
1828
|
+
for (var i = 0; i < n; i++) arr.push(new KeyframeEase(ease.speed, ease.influence));
|
|
1829
|
+
return arr;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Apply one {influence, speed} pair to every dimension of `prop` at `keyIndex`,
|
|
1834
|
+
* and return the number of KeyframeEase entries After Effects accepted.
|
|
1835
|
+
*
|
|
1836
|
+
* Throws only when no count works, naming every one it tried. An ease that
|
|
1837
|
+
* quietly failed to land is invisible until someone watches the render, which
|
|
1838
|
+
* is the same class of lie as a swallowed error.
|
|
1839
|
+
*/
|
|
1840
|
+
function __applyTemporalEase(prop, keyIndex, easeIn, easeOut) {
|
|
1841
|
+
var inEase = easeIn || { influence: 33, speed: 0 };
|
|
1842
|
+
var outEase = easeOut || { influence: 33, speed: 0 };
|
|
1843
|
+
var candidates = __easeArityCandidates(prop);
|
|
1844
|
+
var lastMessage = "";
|
|
1845
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
1846
|
+
var n = candidates[i];
|
|
1847
|
+
try {
|
|
1848
|
+
prop.setTemporalEaseAtKey(keyIndex, __easeArray(inEase, n), __easeArray(outEase, n));
|
|
1849
|
+
return n;
|
|
1850
|
+
} catch (e) {
|
|
1851
|
+
lastMessage = (e && e.message) ? String(e.message) : String(e);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
var label = "the property";
|
|
1855
|
+
try { label = "'" + prop.name + "'"; } catch (eName) {}
|
|
1856
|
+
throw new Error(
|
|
1857
|
+
"Could not set the temporal ease on " + label + " at key " + keyIndex + ": After Effects rejected ease " +
|
|
1858
|
+
"arrays of " + candidates.join(", ") + " entries. Last error from AE: " + lastMessage
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** Returns the ease arity that was used, or null when no ease was requested. */
|
|
892
1863
|
function __applyInterpolationToKey(prop, keyIndex, interp) {
|
|
893
|
-
if (!interp) return;
|
|
1864
|
+
if (!interp) return null;
|
|
894
1865
|
var inT = interp["in"] && __INTERP_MAP[interp["in"]] ? __INTERP_MAP[interp["in"]] : prop.keyInInterpolationType(keyIndex);
|
|
895
1866
|
var outT = interp["out"] && __INTERP_MAP[interp["out"]] ? __INTERP_MAP[interp["out"]] : prop.keyOutInterpolationType(keyIndex);
|
|
896
1867
|
prop.setInterpolationTypeAtKey(keyIndex, inT, outT);
|
|
897
1868
|
if (interp.easeIn || interp.easeOut) {
|
|
898
|
-
|
|
899
|
-
// BUT spatial properties (Position, Anchor Point) use a single ease entry that
|
|
900
|
-
// applies along the motion path, regardless of 2D/3D. Non-spatial multi-dim
|
|
901
|
-
// properties (Scale, Color) need one entry per dimension.
|
|
902
|
-
var dim;
|
|
903
|
-
if (prop.isSpatial) {
|
|
904
|
-
dim = 1;
|
|
905
|
-
} else {
|
|
906
|
-
dim = (prop.value && prop.value.length) ? prop.value.length : 1;
|
|
907
|
-
}
|
|
908
|
-
var inEase = interp.easeIn || { influence: 33, speed: 0 };
|
|
909
|
-
var outEase = interp.easeOut || { influence: 33, speed: 0 };
|
|
910
|
-
var inArr = []; var outArr = [];
|
|
911
|
-
for (var d = 0; d < dim; d++) {
|
|
912
|
-
inArr.push(new KeyframeEase(inEase.speed, inEase.influence));
|
|
913
|
-
outArr.push(new KeyframeEase(outEase.speed, outEase.influence));
|
|
914
|
-
}
|
|
915
|
-
prop.setTemporalEaseAtKey(keyIndex, inArr, outArr);
|
|
1869
|
+
return __applyTemporalEase(prop, keyIndex, interp.easeIn, interp.easeOut);
|
|
916
1870
|
}
|
|
1871
|
+
return null;
|
|
917
1872
|
}
|
|
918
1873
|
|
|
919
1874
|
OPS.add_keyframe = function (args) {
|
|
@@ -921,11 +1876,14 @@ OPS.add_keyframe = function (args) {
|
|
|
921
1876
|
var l = getLayerById(c, args.layerId);
|
|
922
1877
|
var prop = walkProperty(l, args.propertyPath);
|
|
923
1878
|
prop.setValueAtTime(args.time, args.value);
|
|
1879
|
+
var easeDimensions = null;
|
|
924
1880
|
if (args.interpolation) {
|
|
925
1881
|
var idx = __findKeyIndexAtTime(prop, args.time);
|
|
926
|
-
if (idx > 0) __applyInterpolationToKey(prop, idx, args.interpolation);
|
|
1882
|
+
if (idx > 0) easeDimensions = __applyInterpolationToKey(prop, idx, args.interpolation);
|
|
927
1883
|
}
|
|
928
|
-
|
|
1884
|
+
var out = { ok: true, keyIndex: __findKeyIndexAtTime(prop, args.time) };
|
|
1885
|
+
if (easeDimensions !== null) out.easeDimensions = easeDimensions;
|
|
1886
|
+
return out;
|
|
929
1887
|
};
|
|
930
1888
|
|
|
931
1889
|
OPS.remove_keyframe = function (args) {
|
|
@@ -992,8 +1950,13 @@ OPS.set_temporal_ease = function (args) {
|
|
|
992
1950
|
var c = getCompById(args.compId);
|
|
993
1951
|
var l = getLayerById(c, args.layerId);
|
|
994
1952
|
var prop = walkProperty(l, args.propertyPath);
|
|
995
|
-
|
|
996
|
-
|
|
1953
|
+
// A call with neither ease would previously return {ok:true} having done
|
|
1954
|
+
// nothing at all, which reads as "the ease is set" to whoever asked for it.
|
|
1955
|
+
if (!args.easeIn && !args.easeOut) {
|
|
1956
|
+
throw new Error("set_temporal_ease needs easeIn, easeOut or both — nothing was changed.");
|
|
1957
|
+
}
|
|
1958
|
+
var n = __applyTemporalEase(prop, args.keyIndex, args.easeIn, args.easeOut);
|
|
1959
|
+
return { ok: true, easeDimensions: n };
|
|
997
1960
|
};
|
|
998
1961
|
|
|
999
1962
|
OPS.set_spatial_tangents = function (args) {
|
|
@@ -1600,10 +2563,26 @@ var __SCREENSHOT_TARGET_PX = 1280;
|
|
|
1600
2563
|
// The correct downsample was always derivable from the comp, and an agent that
|
|
1601
2564
|
// forgot it got a full-resolution 4K frame — the most expensive accident
|
|
1602
2565
|
// available through these tools. So derive it, and let an explicit value win.
|
|
2566
|
+
function __autoDownsampleFor(comp, targetPx) {
|
|
2567
|
+
var longEdge = comp.width > comp.height ? comp.width : comp.height;
|
|
2568
|
+
var n = Math.ceil(longEdge / targetPx);
|
|
2569
|
+
if (!(n > 1)) return 1;
|
|
2570
|
+
return n > 8 ? 8 : n;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
1603
2573
|
// 1080p -> 2 (960px), 4K -> 3 (1280px), and anything already small -> 1.
|
|
1604
2574
|
function __autoDownsample(comp) {
|
|
1605
|
-
|
|
1606
|
-
|
|
2575
|
+
return __autoDownsampleFor(comp, __SCREENSHOT_TARGET_PX);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// A contact sheet of N frames has to cost about what one frame costs, so each
|
|
2579
|
+
// tile gets 1/sqrt(N) of the single-frame long edge — N tiles of 1/N the area
|
|
2580
|
+
// each. Expressed as a multiple of what this comp's *single* frame would have
|
|
2581
|
+
// used rather than as its own target, for two reasons: it cannot drift from
|
|
2582
|
+
// __autoDownsample, and the factor is an integer, so deriving from the target
|
|
2583
|
+
// instead would let the rounding leave a 1080p sheet at nearly twice the budget.
|
|
2584
|
+
function __tileDownsample(comp, count) {
|
|
2585
|
+
var n = Math.ceil(__autoDownsample(comp) * Math.sqrt(count));
|
|
1607
2586
|
if (!(n > 1)) return 1;
|
|
1608
2587
|
return n > 8 ? 8 : n;
|
|
1609
2588
|
}
|
|
@@ -1617,24 +2596,68 @@ function __resolveDownsample(comp, requested) {
|
|
|
1617
2596
|
// reduced frame directly instead of writing full size and resampling
|
|
1618
2597
|
// afterwards. That is faster (a quarter of the pixels at factor 2) and needs no
|
|
1619
2598
|
// external image tool, which is what makes it work off macOS.
|
|
2599
|
+
//
|
|
2600
|
+
// Factor 1 is *set*, not skipped. Skipping it rendered at whatever the user had
|
|
2601
|
+
// left the viewer on, so a comp parked at Quarter answered `downsample: 1` with
|
|
2602
|
+
// a quarter-size frame and `downsample: 2` came back **larger** than
|
|
2603
|
+
// `downsample: 1` (issue #72). Designers leave heavy comps at Quarter or Third
|
|
2604
|
+
// as a matter of course, so that was the common case rather than a corner. The
|
|
2605
|
+
// panel reads the real dimensions out of the PNG's IHDR, which means the
|
|
2606
|
+
// response stayed honest while the *picture* was not the one asked for — the
|
|
2607
|
+
// worse of the two failures, because an agent that can see a frame believes it.
|
|
2608
|
+
// Every factor reaching this function now names the resolution it renders at.
|
|
1620
2609
|
function __saveFrameAt(comp, time, file, factor) {
|
|
1621
|
-
|
|
1622
|
-
comp.saveFrameToPng(time, file);
|
|
1623
|
-
return;
|
|
1624
|
-
}
|
|
2610
|
+
var f = (factor > 1) ? Math.round(factor) : 1;
|
|
1625
2611
|
var previous = comp.resolutionFactor;
|
|
1626
2612
|
try {
|
|
1627
|
-
comp.resolutionFactor = [
|
|
2613
|
+
comp.resolutionFactor = [f, f];
|
|
1628
2614
|
comp.saveFrameToPng(time, file);
|
|
1629
2615
|
} finally {
|
|
1630
2616
|
// Restore unconditionally — a failed render must never leave the user
|
|
1631
|
-
// looking at a half-resolution comp.
|
|
2617
|
+
// looking at a half-resolution comp. This covers factor 1 too: the render
|
|
2618
|
+
// happens at Full and the viewer goes back to Quarter afterwards.
|
|
1632
2619
|
comp.resolutionFactor = previous;
|
|
1633
2620
|
}
|
|
1634
2621
|
}
|
|
1635
2622
|
|
|
2623
|
+
// Several times in one call. Every requested time gets an entry, in order,
|
|
2624
|
+
// whether or not it rendered — the panel draws a marked block for the ones that
|
|
2625
|
+
// did not, so the sheet it composes still lines up with the times that were
|
|
2626
|
+
// asked for. Dropping a failed tile would silently renumber the rest, which is
|
|
2627
|
+
// the same class of lie as swallowing an error.
|
|
2628
|
+
function __contactSheetFrames(comp, args) {
|
|
2629
|
+
var times = args.times;
|
|
2630
|
+
var ds;
|
|
2631
|
+
if (args.downsample === undefined || args.downsample === null) {
|
|
2632
|
+
ds = __tileDownsample(comp, times.length);
|
|
2633
|
+
} else {
|
|
2634
|
+
ds = __clampDownsample(args.downsample);
|
|
2635
|
+
}
|
|
2636
|
+
var tiles = [];
|
|
2637
|
+
for (var i = 0; i < times.length; i++) {
|
|
2638
|
+
var entry = { time: times[i], downsample: ds };
|
|
2639
|
+
try {
|
|
2640
|
+
var p = __tmpPngPath();
|
|
2641
|
+
__saveFrameAt(comp, times[i], new File(p), ds);
|
|
2642
|
+
entry.path = p;
|
|
2643
|
+
} catch (e) {
|
|
2644
|
+
// One time that will not render must not cost the other five.
|
|
2645
|
+
entry.error = String(e && e.message ? e.message : e);
|
|
2646
|
+
}
|
|
2647
|
+
tiles.push(entry);
|
|
2648
|
+
}
|
|
2649
|
+
return {
|
|
2650
|
+
contactSheet: true,
|
|
2651
|
+
tiles: tiles,
|
|
2652
|
+
downsample: ds,
|
|
2653
|
+
width: comp.width, height: comp.height,
|
|
2654
|
+
times: times, compId: comp.id
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
|
|
1636
2658
|
OPS.screenshot_frame = noUndo(function (args) {
|
|
1637
2659
|
var c = getCompById(args.compId);
|
|
2660
|
+
if (args.times && args.times.length) return __contactSheetFrames(c, args);
|
|
1638
2661
|
var t = (args.time !== undefined && args.time !== null) ? args.time : c.time;
|
|
1639
2662
|
var ds = __resolveDownsample(c, args.downsample);
|
|
1640
2663
|
var path = __tmpPngPath();
|
|
@@ -1789,22 +2812,53 @@ function __brokenSvgAdvice() {
|
|
|
1789
2812
|
);
|
|
1790
2813
|
}
|
|
1791
2814
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
2815
|
+
/**
|
|
2816
|
+
* The bare import — ImportOptions, importFile, and the two ways AE can decline.
|
|
2817
|
+
* Kept as its own function because it is the only import in the codebase and
|
|
2818
|
+
* anything else that needs a file in the project (audio.jsx) must go through
|
|
2819
|
+
* exactly this, not a second copy that drifts.
|
|
2820
|
+
*/
|
|
2821
|
+
function __importFile(file, path, sequence) {
|
|
1799
2822
|
var opts = new ImportOptions(file);
|
|
1800
|
-
if (
|
|
2823
|
+
if (sequence === true) {
|
|
1801
2824
|
if (!opts.canImportAs(ImportAsType.FOOTAGE)) throw new Error("Cannot import " + path + " as footage, so it cannot be a sequence either");
|
|
1802
2825
|
opts.importAs = ImportAsType.FOOTAGE;
|
|
1803
2826
|
opts.sequence = true;
|
|
1804
2827
|
}
|
|
1805
|
-
|
|
1806
2828
|
var item = app.project.importFile(opts);
|
|
1807
2829
|
if (!item) throw new Error("After Effects returned no item for " + path);
|
|
2830
|
+
return item;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/**
|
|
2834
|
+
* fsName -> project item, for every item in the project that came from a file.
|
|
2835
|
+
* One pass: the caller with N paths to resolve would otherwise walk the project
|
|
2836
|
+
* N times, and a project with a few hundred items makes that visible.
|
|
2837
|
+
*/
|
|
2838
|
+
function __itemPathMap() {
|
|
2839
|
+
var map = {};
|
|
2840
|
+
var proj = app.project;
|
|
2841
|
+
for (var i = 1; i <= proj.numItems; i++) {
|
|
2842
|
+
var it = proj.item(i);
|
|
2843
|
+
var key = null;
|
|
2844
|
+
try {
|
|
2845
|
+
if (it.mainSource && it.mainSource.file) key = String(it.mainSource.file.fsName);
|
|
2846
|
+
} catch (e) {}
|
|
2847
|
+
// First one wins: two items on the same file are a duplicate import, and
|
|
2848
|
+
// reusing the earlier is what the user would have done by hand.
|
|
2849
|
+
if (key && !map.hasOwnProperty(key)) map[key] = it;
|
|
2850
|
+
}
|
|
2851
|
+
return map;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
OPS.import_footage = function (args) {
|
|
2855
|
+
var path = args && args.path;
|
|
2856
|
+
if (typeof path !== "string" || path.length === 0) throw new Error("path is required");
|
|
2857
|
+
|
|
2858
|
+
var file = new File(path);
|
|
2859
|
+
if (!file.exists) throw new Error("No file at " + path);
|
|
2860
|
+
|
|
2861
|
+
var item = __importFile(file, path, args.sequence);
|
|
1808
2862
|
|
|
1809
2863
|
if (typeof args.name === "string" && args.name.length > 0) item.name = args.name;
|
|
1810
2864
|
|
|
@@ -1850,22 +2904,448 @@ OPS.create_footage_layer = function (args) {
|
|
|
1850
2904
|
if (item instanceof FolderItem) throw new Error("Item " + args.itemId + " (\"" + item.name + "\") is a folder, not footage");
|
|
1851
2905
|
|
|
1852
2906
|
var layer = comp.layers.add(item);
|
|
1853
|
-
if (typeof args.name === "string" && args.name.length > 0) layer.name = args.name;
|
|
1854
|
-
if (args.position !== undefined && args.position !== null) {
|
|
1855
|
-
layer.property("Transform").property("Position").setValue(args.position);
|
|
2907
|
+
if (typeof args.name === "string" && args.name.length > 0) layer.name = args.name;
|
|
2908
|
+
if (args.position !== undefined && args.position !== null) {
|
|
2909
|
+
layer.property("Transform").property("Position").setValue(args.position);
|
|
2910
|
+
}
|
|
2911
|
+
if (args.startTime !== undefined && args.startTime !== null) layer.startTime = args.startTime;
|
|
2912
|
+
|
|
2913
|
+
return {
|
|
2914
|
+
layerId: layer.id,
|
|
2915
|
+
index: layer.index,
|
|
2916
|
+
name: layer.name,
|
|
2917
|
+
compId: comp.id,
|
|
2918
|
+
itemId: item.id,
|
|
2919
|
+
inPoint: layer.inPoint,
|
|
2920
|
+
outPoint: layer.outPoint
|
|
2921
|
+
};
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
|
|
2925
|
+
// ===== audio.jsx =====
|
|
2926
|
+
|
|
2927
|
+
// audio.jsx — place a list of sound effects into a comp in one pass.
|
|
2928
|
+
//
|
|
2929
|
+
// Scoring a scene is 40-90 layers: import or reuse the file, add a layer, set
|
|
2930
|
+
// its start time, set its level in dB, name it, sometimes trim it. Done through
|
|
2931
|
+
// the general tools that is dozens of round trips; done through run_jsx it is a
|
|
2932
|
+
// hand-written loop that has to know the one thing nothing tells you —
|
|
2933
|
+
// `layer.property("ADBE Audio Levels")` returns **null** on an audio layer,
|
|
2934
|
+
// because Audio Levels lives under the layer's "Audio" group and the only
|
|
2935
|
+
// reliable handle is the `layer.audioLevels` shortcut (issue #48).
|
|
2936
|
+
//
|
|
2937
|
+
// Two properties carry the design:
|
|
2938
|
+
//
|
|
2939
|
+
// - Nothing is created until every cue has been checked. A run that dies on
|
|
2940
|
+
// cue 30 of 90 leaves 29 sound effects in someone's timeline and an error
|
|
2941
|
+
// that does not say which ones — the same half-built failure
|
|
2942
|
+
// add_shape_content refuses to produce. So: plan first with no side
|
|
2943
|
+
// effects, throw naming the offending cue indices, and if a creation still
|
|
2944
|
+
// fails, remove everything this call made before rethrowing.
|
|
2945
|
+
// - A file named by several cues is imported once. Repeated imports of one
|
|
2946
|
+
// .wav are the normal shape of a cue list ("whoosh" nine times), and each
|
|
2947
|
+
// one would otherwise add another project item. Anything already in the
|
|
2948
|
+
// project from that path is reused rather than imported a second time.
|
|
2949
|
+
|
|
2950
|
+
var __MAX_AUDIO_CUES = 200;
|
|
2951
|
+
var __CUE_TIME_EPS = 1e-6;
|
|
2952
|
+
|
|
2953
|
+
// AE's layer label colours. Users can rename them in preferences, but the tool
|
|
2954
|
+
// takes an index and the indices do not move, so the names are accepted as a
|
|
2955
|
+
// convenience and translated here. 0 is "None".
|
|
2956
|
+
var __LABEL_NAMES = {
|
|
2957
|
+
none: 0, red: 1, yellow: 2, aqua: 3, pink: 4, lavender: 5, peach: 6,
|
|
2958
|
+
seafoam: 7, blue: 8, green: 9, purple: 10, orange: 11, brown: 12,
|
|
2959
|
+
fuchsia: 13, cyan: 14, sandstone: 15, darkgreen: 16
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
function __labelKey(s) {
|
|
2963
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
function __labelNameList() {
|
|
2967
|
+
var names = [];
|
|
2968
|
+
for (var k in __LABEL_NAMES) {
|
|
2969
|
+
if (__LABEL_NAMES.hasOwnProperty(k)) names.push(k);
|
|
2970
|
+
}
|
|
2971
|
+
return names.join(", ");
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
/** 0..16, or a thrown error naming everything it would have taken. */
|
|
2975
|
+
function __resolveLabel(label) {
|
|
2976
|
+
if (typeof label === "number") {
|
|
2977
|
+
if (label !== Math.floor(label) || label < 0 || label > 16) {
|
|
2978
|
+
throw new Error("label must be a whole number 0-16 or a colour name (" + __labelNameList() + "); got " + label);
|
|
2979
|
+
}
|
|
2980
|
+
return label;
|
|
2981
|
+
}
|
|
2982
|
+
var key = __labelKey(label);
|
|
2983
|
+
if (__LABEL_NAMES.hasOwnProperty(key)) return __LABEL_NAMES[key];
|
|
2984
|
+
throw new Error("unknown label \"" + label + "\" — use 0-16 or one of: " + __labelNameList());
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
function __basename(p) {
|
|
2988
|
+
var s = String(p).replace(/\\/g, "/");
|
|
2989
|
+
var slash = s.lastIndexOf("/");
|
|
2990
|
+
if (slash >= 0) s = s.substring(slash + 1);
|
|
2991
|
+
return s;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function __stripExtension(name) {
|
|
2995
|
+
var s = String(name);
|
|
2996
|
+
var dot = s.lastIndexOf(".");
|
|
2997
|
+
if (dot > 0) return s.substring(0, dot);
|
|
2998
|
+
return s;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* The Audio Levels property of a layer.
|
|
3003
|
+
*
|
|
3004
|
+
* `layer.audioLevels` is the shortcut that works. `layer.property("ADBE Audio
|
|
3005
|
+
* Levels")` returns null on an audio layer — the property sits inside the
|
|
3006
|
+
* layer's "Audio" group, not on the layer — and a null there is what silently
|
|
3007
|
+
* turns a scripted level into no level at all. The group walk is only a
|
|
3008
|
+
* fallback for a layer whose shortcut is somehow absent; if neither answers,
|
|
3009
|
+
* the caller is told rather than left with an unset level.
|
|
3010
|
+
*/
|
|
3011
|
+
function __audioLevelsProperty(layer) {
|
|
3012
|
+
var p = null;
|
|
3013
|
+
try { p = layer.audioLevels; } catch (e) {}
|
|
3014
|
+
if (p) return p;
|
|
3015
|
+
try {
|
|
3016
|
+
var g = layer.property("Audio");
|
|
3017
|
+
if (g) p = g.property("Audio Levels");
|
|
3018
|
+
} catch (e2) {}
|
|
3019
|
+
return p;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/** null when the item can carry an audio cue, else the reason it cannot. */
|
|
3023
|
+
function __audioItemProblem(item) {
|
|
3024
|
+
if (item instanceof FolderItem) return "\"" + item.name + "\" is a folder, not footage";
|
|
3025
|
+
if (item instanceof CompItem) return "\"" + item.name + "\" is a comp — use create_precomp_layer for that";
|
|
3026
|
+
if (item.footageMissing) return "\"" + item.name + "\" is offline; the file it points at is missing";
|
|
3027
|
+
// Explicitly `=== false`, not `!item.hasAudio`: an item that does not report
|
|
3028
|
+
// the flag at all must not be refused on the strength of a missing property.
|
|
3029
|
+
if (item.hasAudio === false) return "\"" + item.name + "\" has no audio track";
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
function __sourceReport(item, path) {
|
|
3034
|
+
return { itemId: item.id, name: item.name, path: path };
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
/**
|
|
3038
|
+
* Turn the cue list into a plan, touching nothing. Every problem found is
|
|
3039
|
+
* collected with the index of the cue that caused it rather than thrown on the
|
|
3040
|
+
* spot, so one call reports all of them instead of one per round trip.
|
|
3041
|
+
*/
|
|
3042
|
+
function __planAudioCues(comp, args) {
|
|
3043
|
+
var cues = args.cues;
|
|
3044
|
+
if (!(cues instanceof Array) || cues.length === 0) {
|
|
3045
|
+
throw new Error("place_audio_cues needs a non-empty `cues` array.");
|
|
3046
|
+
}
|
|
3047
|
+
if (cues.length > __MAX_AUDIO_CUES) {
|
|
3048
|
+
throw new Error(
|
|
3049
|
+
"place_audio_cues was given " + cues.length + " cues and the limit is " + __MAX_AUDIO_CUES + " per call. " +
|
|
3050
|
+
"ExtendScript is single-threaded, so one long run freezes After Effects' interface for its whole duration. " +
|
|
3051
|
+
"Split the list into calls of " + __MAX_AUDIO_CUES + " or fewer."
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
var prefix = "SFX_";
|
|
3056
|
+
if (typeof args.namePrefix === "string") prefix = args.namePrefix;
|
|
3057
|
+
|
|
3058
|
+
var byPath = __itemPathMap();
|
|
3059
|
+
var problems = [];
|
|
3060
|
+
var planned = [];
|
|
3061
|
+
var toImport = [];
|
|
3062
|
+
var toImportSeen = {};
|
|
3063
|
+
|
|
3064
|
+
for (var i = 0; i < cues.length; i++) {
|
|
3065
|
+
var cue = cues[i];
|
|
3066
|
+
if (!cue) { problems.push({ cue: i, reason: "is empty" }); continue; }
|
|
3067
|
+
|
|
3068
|
+
var hasId = (cue.footageId !== undefined && cue.footageId !== null);
|
|
3069
|
+
var hasPath = (typeof cue.path === "string" && cue.path.length > 0);
|
|
3070
|
+
if (hasId && hasPath) { problems.push({ cue: i, reason: "has both footageId and path — give exactly one" }); continue; }
|
|
3071
|
+
if (!hasId && !hasPath) { problems.push({ cue: i, reason: "has neither footageId nor path — give exactly one" }); continue; }
|
|
3072
|
+
|
|
3073
|
+
var time = cue.time;
|
|
3074
|
+
if (typeof time !== "number" || !isFinite(time)) {
|
|
3075
|
+
problems.push({ cue: i, reason: "time must be a number of seconds; got " + String(time) });
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
if (time < -__CUE_TIME_EPS || time > comp.duration + __CUE_TIME_EPS) {
|
|
3079
|
+
problems.push({ cue: i, reason: "time " + time + "s is outside the comp, which runs 0 to " + comp.duration + "s" });
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
var levelDb = 0;
|
|
3084
|
+
if (cue.levelDb !== undefined && cue.levelDb !== null) {
|
|
3085
|
+
if (typeof cue.levelDb !== "number" || !isFinite(cue.levelDb)) {
|
|
3086
|
+
problems.push({ cue: i, reason: "levelDb must be a number of decibels (0 is unedited); got " + String(cue.levelDb) });
|
|
3087
|
+
continue;
|
|
3088
|
+
}
|
|
3089
|
+
levelDb = cue.levelDb;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
var inPoint = null;
|
|
3093
|
+
var outPoint = null;
|
|
3094
|
+
var trimBad = false;
|
|
3095
|
+
if (cue.inPoint !== undefined && cue.inPoint !== null) {
|
|
3096
|
+
if (typeof cue.inPoint !== "number" || !isFinite(cue.inPoint)) {
|
|
3097
|
+
problems.push({ cue: i, reason: "inPoint must be a comp time in seconds; got " + String(cue.inPoint) });
|
|
3098
|
+
trimBad = true;
|
|
3099
|
+
} else {
|
|
3100
|
+
inPoint = cue.inPoint;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (!trimBad && cue.outPoint !== undefined && cue.outPoint !== null) {
|
|
3104
|
+
if (typeof cue.outPoint !== "number" || !isFinite(cue.outPoint)) {
|
|
3105
|
+
problems.push({ cue: i, reason: "outPoint must be a comp time in seconds; got " + String(cue.outPoint) });
|
|
3106
|
+
trimBad = true;
|
|
3107
|
+
} else {
|
|
3108
|
+
outPoint = cue.outPoint;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
if (trimBad) continue;
|
|
3112
|
+
// in/out are absolute comp times, like everywhere else in these tools, so
|
|
3113
|
+
// they are measured against `time` rather than against the file.
|
|
3114
|
+
if (inPoint !== null && inPoint < time - __CUE_TIME_EPS) {
|
|
3115
|
+
problems.push({ cue: i, reason: "inPoint " + inPoint + "s is before the cue's own time " + time + "s; both are comp times" });
|
|
3116
|
+
continue;
|
|
3117
|
+
}
|
|
3118
|
+
var trimStart = time;
|
|
3119
|
+
if (inPoint !== null) trimStart = inPoint;
|
|
3120
|
+
if (outPoint !== null && outPoint <= trimStart + __CUE_TIME_EPS) {
|
|
3121
|
+
problems.push({ cue: i, reason: "outPoint " + outPoint + "s is not after the cue starts at " + trimStart + "s" });
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
var label = null;
|
|
3126
|
+
if (cue.label !== undefined && cue.label !== null) {
|
|
3127
|
+
try {
|
|
3128
|
+
label = __resolveLabel(cue.label);
|
|
3129
|
+
} catch (eLabel) {
|
|
3130
|
+
problems.push({ cue: i, reason: eLabel.message });
|
|
3131
|
+
continue;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
// Resolve the source. A footageId names an item that must already be
|
|
3136
|
+
// usable; a path is either something the project already holds or an
|
|
3137
|
+
// import this call will do exactly once.
|
|
3138
|
+
var source = null;
|
|
3139
|
+
var defaultName = null;
|
|
3140
|
+
if (hasId) {
|
|
3141
|
+
var item = app.project.itemByID(cue.footageId);
|
|
3142
|
+
if (!item) { problems.push({ cue: i, reason: "no project item with id " + cue.footageId }); continue; }
|
|
3143
|
+
var why = __audioItemProblem(item);
|
|
3144
|
+
if (why) { problems.push({ cue: i, reason: why }); continue; }
|
|
3145
|
+
source = { kind: "item", item: item, fsName: null, path: null };
|
|
3146
|
+
defaultName = item.name;
|
|
3147
|
+
} else {
|
|
3148
|
+
var file = new File(cue.path);
|
|
3149
|
+
if (!file.exists) { problems.push({ cue: i, reason: "no file at " + cue.path }); continue; }
|
|
3150
|
+
var fsName = String(file.fsName);
|
|
3151
|
+
var existing = null;
|
|
3152
|
+
if (byPath.hasOwnProperty(fsName)) existing = byPath[fsName];
|
|
3153
|
+
if (existing) {
|
|
3154
|
+
var whyExisting = __audioItemProblem(existing);
|
|
3155
|
+
if (whyExisting) { problems.push({ cue: i, reason: whyExisting + " (already in the project from " + cue.path + ")" }); continue; }
|
|
3156
|
+
source = { kind: "reused", item: existing, fsName: fsName, path: cue.path };
|
|
3157
|
+
} else {
|
|
3158
|
+
if (!toImportSeen.hasOwnProperty(fsName)) {
|
|
3159
|
+
toImportSeen[fsName] = true;
|
|
3160
|
+
toImport.push({ fsName: fsName, path: cue.path, file: file });
|
|
3161
|
+
}
|
|
3162
|
+
source = { kind: "import", item: null, fsName: fsName, path: cue.path };
|
|
3163
|
+
}
|
|
3164
|
+
// The caller named a path, so the path's basename is the honest default
|
|
3165
|
+
// even when the project item it resolves to was renamed by hand.
|
|
3166
|
+
defaultName = __basename(cue.path);
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
var name = prefix + __stripExtension(defaultName);
|
|
3170
|
+
if (typeof cue.name === "string" && cue.name.length > 0) name = cue.name;
|
|
3171
|
+
|
|
3172
|
+
planned.push({
|
|
3173
|
+
index: i, name: name, time: time, levelDb: levelDb,
|
|
3174
|
+
inPoint: inPoint, outPoint: outPoint, label: label, source: source
|
|
3175
|
+
});
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
return { prefix: prefix, planned: planned, problems: problems, toImport: toImport };
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function __audioProblemMessage(problems, total) {
|
|
3182
|
+
var lines = [];
|
|
3183
|
+
for (var i = 0; i < problems.length; i++) {
|
|
3184
|
+
lines.push("cue " + problems[i].cue + ": " + problems[i].reason);
|
|
3185
|
+
}
|
|
3186
|
+
return (
|
|
3187
|
+
"place_audio_cues placed nothing — " + problems.length + " of " + total + " cues cannot be placed. " +
|
|
3188
|
+
lines.join("; ") + ". Every cue is checked before anything is created, so the comp and project are " +
|
|
3189
|
+
"untouched. Fix these and call again; dryRun:true checks a list without placing it."
|
|
3190
|
+
);
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
function __audioDryRunReport(comp, plan, total) {
|
|
3194
|
+
var cues = [];
|
|
3195
|
+
for (var i = 0; i < plan.planned.length; i++) {
|
|
3196
|
+
var p = plan.planned[i];
|
|
3197
|
+
var src = { kind: p.source.kind };
|
|
3198
|
+
if (p.source.item) {
|
|
3199
|
+
src.itemId = p.source.item.id;
|
|
3200
|
+
src.name = p.source.item.name;
|
|
3201
|
+
}
|
|
3202
|
+
if (p.source.path) src.path = p.source.path;
|
|
3203
|
+
cues.push({
|
|
3204
|
+
cue: p.index, name: p.name, time: p.time, levelDb: p.levelDb,
|
|
3205
|
+
inPoint: p.inPoint, outPoint: p.outPoint, label: p.label, source: src
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
var wouldImport = [];
|
|
3209
|
+
for (var j = 0; j < plan.toImport.length; j++) wouldImport.push(plan.toImport[j].path);
|
|
3210
|
+
|
|
3211
|
+
var out = {
|
|
3212
|
+
dryRun: true,
|
|
3213
|
+
ok: plan.problems.length === 0,
|
|
3214
|
+
compId: comp.id,
|
|
3215
|
+
compName: comp.name,
|
|
3216
|
+
cueCount: total,
|
|
3217
|
+
wouldPlace: cues.length,
|
|
3218
|
+
wouldImport: wouldImport,
|
|
3219
|
+
problems: plan.problems,
|
|
3220
|
+
cues: cues,
|
|
3221
|
+
note: "Nothing was imported, created or changed, and this call is not an undo step."
|
|
3222
|
+
};
|
|
3223
|
+
if (wouldImport.length > 0) {
|
|
3224
|
+
out.unverified =
|
|
3225
|
+
wouldImport.length + " of these files are not in the project yet. They exist on disk, but whether each " +
|
|
3226
|
+
"carries an audio track is only knowable once After Effects has imported it — a real run checks that and " +
|
|
3227
|
+
"refuses the whole call if one does not.";
|
|
3228
|
+
}
|
|
3229
|
+
return out;
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/** Undo everything this call made, newest first. Layers before items: an item still in use cannot go. */
|
|
3233
|
+
function __rollbackAudioCues(layers, items) {
|
|
3234
|
+
for (var i = layers.length - 1; i >= 0; i--) {
|
|
3235
|
+
try { layers[i].remove(); } catch (e) {}
|
|
3236
|
+
}
|
|
3237
|
+
for (var j = items.length - 1; j >= 0; j--) {
|
|
3238
|
+
try { items[j].remove(); } catch (e2) {}
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
/**
|
|
3243
|
+
* `created` is the rollback list, and the layer joins it the instant it exists
|
|
3244
|
+
* rather than once it is fully configured. A cue that dies between add() and
|
|
3245
|
+
* the last setValue is exactly the case rollback is for, and a layer that had
|
|
3246
|
+
* not been registered yet would be the one thing left behind.
|
|
3247
|
+
*/
|
|
3248
|
+
function __placeAudioCue(comp, p, item, created) {
|
|
3249
|
+
var layer = comp.layers.add(item);
|
|
3250
|
+
created.push(layer);
|
|
3251
|
+
layer.name = p.name;
|
|
3252
|
+
// startTime first: it slides the whole layer and would drag any trim with it.
|
|
3253
|
+
layer.startTime = p.time;
|
|
3254
|
+
var levels = __audioLevelsProperty(layer);
|
|
3255
|
+
if (!levels) {
|
|
3256
|
+
throw new Error(
|
|
3257
|
+
"the layer created for \"" + item.name + "\" has no Audio Levels property, so its level could not be set"
|
|
3258
|
+
);
|
|
1856
3259
|
}
|
|
1857
|
-
|
|
3260
|
+
// AE's Audio Levels is itself in decibels, one entry per channel.
|
|
3261
|
+
levels.setValue([p.levelDb, p.levelDb]);
|
|
3262
|
+
if (p.label !== null) layer.label = p.label;
|
|
3263
|
+
if (p.inPoint !== null) layer.inPoint = p.inPoint;
|
|
3264
|
+
if (p.outPoint !== null) layer.outPoint = p.outPoint;
|
|
3265
|
+
return layer;
|
|
3266
|
+
}
|
|
1858
3267
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
3268
|
+
OPS.place_audio_cues = noUndoWhen(
|
|
3269
|
+
// dryRun is not an undo step either. A plan that quietly appeared in the
|
|
3270
|
+
// user's undo history would make "this changed nothing" false in the one
|
|
3271
|
+
// place they can see it.
|
|
3272
|
+
function (args) { return !!(args && args.dryRun === true); },
|
|
3273
|
+
function (args) {
|
|
3274
|
+
var comp = getCompById(args.compId);
|
|
3275
|
+
var total = 0;
|
|
3276
|
+
if (args.cues instanceof Array) total = args.cues.length;
|
|
3277
|
+
var plan = __planAudioCues(comp, args);
|
|
3278
|
+
|
|
3279
|
+
if (args.dryRun === true) return __audioDryRunReport(comp, plan, total);
|
|
3280
|
+
if (plan.problems.length > 0) throw new Error(__audioProblemMessage(plan.problems, total));
|
|
3281
|
+
|
|
3282
|
+
var createdLayers = [];
|
|
3283
|
+
var importedItems = [];
|
|
3284
|
+
var importedReport = [];
|
|
3285
|
+
var reusedReport = [];
|
|
3286
|
+
var reusedSeen = {};
|
|
3287
|
+
var placed = [];
|
|
3288
|
+
|
|
3289
|
+
try {
|
|
3290
|
+
// One import per distinct file, before any layer exists, so a bad file
|
|
3291
|
+
// costs nothing but the import itself.
|
|
3292
|
+
var imported = {};
|
|
3293
|
+
for (var i = 0; i < plan.toImport.length; i++) {
|
|
3294
|
+
var spec = plan.toImport[i];
|
|
3295
|
+
var newItem = __importFile(spec.file, spec.path, false);
|
|
3296
|
+
importedItems.push(newItem);
|
|
3297
|
+
var why = __audioItemProblem(newItem);
|
|
3298
|
+
if (why) throw new Error("imported " + spec.path + " and then found that " + why);
|
|
3299
|
+
imported[spec.fsName] = newItem;
|
|
3300
|
+
importedReport.push(__sourceReport(newItem, spec.path));
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
for (var k = 0; k < plan.planned.length; k++) {
|
|
3304
|
+
var p = plan.planned[k];
|
|
3305
|
+
var item = p.source.item;
|
|
3306
|
+
if (!item) item = imported[p.source.fsName];
|
|
3307
|
+
if (p.source.kind !== "import" && !reusedSeen.hasOwnProperty(String(item.id))) {
|
|
3308
|
+
reusedSeen[String(item.id)] = true;
|
|
3309
|
+
reusedReport.push(__sourceReport(item, p.source.path));
|
|
3310
|
+
}
|
|
3311
|
+
var layer;
|
|
3312
|
+
try {
|
|
3313
|
+
layer = __placeAudioCue(comp, p, item, createdLayers);
|
|
3314
|
+
} catch (eCue) {
|
|
3315
|
+
throw new Error("cue " + p.index + " (\"" + p.name + "\" at " + p.time + "s): " + eCue.message);
|
|
3316
|
+
}
|
|
3317
|
+
placed.push({
|
|
3318
|
+
layerId: layer.id,
|
|
3319
|
+
index: layer.index,
|
|
3320
|
+
name: layer.name,
|
|
3321
|
+
time: p.time,
|
|
3322
|
+
levelDb: p.levelDb,
|
|
3323
|
+
itemId: item.id,
|
|
3324
|
+
// Read the trim back: AE clamps an in/out point to what the source
|
|
3325
|
+
// can actually supply, and the caller should see what it got.
|
|
3326
|
+
inPoint: layer.inPoint,
|
|
3327
|
+
outPoint: layer.outPoint,
|
|
3328
|
+
label: layer.label
|
|
3329
|
+
});
|
|
3330
|
+
}
|
|
3331
|
+
} catch (e) {
|
|
3332
|
+
__rollbackAudioCues(createdLayers, importedItems);
|
|
3333
|
+
throw new Error(
|
|
3334
|
+
"place_audio_cues failed on " + ((e && e.message) ? e.message : String(e)) +
|
|
3335
|
+
". The " + createdLayers.length + " layer(s) and " + importedItems.length +
|
|
3336
|
+
" import(s) it had already made were removed, so the comp and project are as they were."
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
return {
|
|
3341
|
+
compId: comp.id,
|
|
3342
|
+
placed: placed,
|
|
3343
|
+
count: placed.length,
|
|
3344
|
+
sources: { imported: importedReport, reused: reusedReport },
|
|
3345
|
+
levelUnit: "dB"
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3348
|
+
);
|
|
1869
3349
|
|
|
1870
3350
|
|
|
1871
3351
|
// ===== mogrt.jsx =====
|
|
@@ -1901,6 +3381,19 @@ OPS.create_footage_layer = function (args) {
|
|
|
1901
3381
|
// a project writes Untitled.mogrt over the last one.
|
|
1902
3382
|
// * The export invalidates every object reference held across it, including
|
|
1903
3383
|
// `app.project`. See the re-fetch below.
|
|
3384
|
+
//
|
|
3385
|
+
// And one thing suppression costs, which is the whole of issue #71.
|
|
3386
|
+
// exportAsMotionGraphicsTemplate reports a boolean and nothing else — AE never
|
|
3387
|
+
// says *why* it declined — so once dialogs are suppressed a refusal and a
|
|
3388
|
+
// success-that-wrote-nothing look identical from a script. That silence is
|
|
3389
|
+
// evidence of nothing in particular, and it must not be read as "a dialog
|
|
3390
|
+
// blocked us": under beginSuppressDialogs() a blocking dialog is the one cause
|
|
3391
|
+
// ruled out by construction, because suppressing them is exactly what that call
|
|
3392
|
+
// did. A comp with an empty Essential Graphics panel is not exportable, and it
|
|
3393
|
+
// used to come back blaming a dialog that did not exist and offering a remedy
|
|
3394
|
+
// (retry unsuppressed and click it) that led nowhere. So: check every
|
|
3395
|
+
// precondition that can be checked *before* the export and name it, and when
|
|
3396
|
+
// the export still fails, report the cause as unknown rather than as a dialog.
|
|
1904
3397
|
|
|
1905
3398
|
/** Distinct fonts used by the text layers in a comp, following nested comps. */
|
|
1906
3399
|
function __collectFonts(comp, depth, seenComps, fonts) {
|
|
@@ -1937,6 +3430,106 @@ function __mogrtFileName(name) {
|
|
|
1937
3430
|
return String(name) + ".mogrt";
|
|
1938
3431
|
}
|
|
1939
3432
|
|
|
3433
|
+
/** Trailing separators off, so two spellings of the same folder compare equal. */
|
|
3434
|
+
function __trimTrailingSeparator(p) {
|
|
3435
|
+
var s = String(p);
|
|
3436
|
+
while (s.length > 1) {
|
|
3437
|
+
var last = s.charAt(s.length - 1);
|
|
3438
|
+
if (last !== "/" && last !== "\\") break;
|
|
3439
|
+
s = s.substring(0, s.length - 1);
|
|
3440
|
+
}
|
|
3441
|
+
return s;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
function __isBlank(s) {
|
|
3445
|
+
var t = String(s);
|
|
3446
|
+
for (var i = 0; i < t.length; i++) {
|
|
3447
|
+
var ch = t.charAt(i);
|
|
3448
|
+
if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") return false;
|
|
3449
|
+
}
|
|
3450
|
+
return true;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/**
|
|
3454
|
+
* How many controllers the comp's Essential Graphics panel holds, or null when
|
|
3455
|
+
* the host will not say.
|
|
3456
|
+
*
|
|
3457
|
+
* null is not zero. A read that throws means this AE cannot be asked, and
|
|
3458
|
+
* refusing an export on the strength of a question that was never answered
|
|
3459
|
+
* would be its own confidently wrong diagnosis. Only an explicit 0 refuses.
|
|
3460
|
+
*/
|
|
3461
|
+
function __controllerCount(comp) {
|
|
3462
|
+
var n = null;
|
|
3463
|
+
try { n = comp.motionGraphicsTemplateControllerCount; } catch (e) { return null; }
|
|
3464
|
+
if (typeof n !== "number") return null;
|
|
3465
|
+
return n;
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* Whether After Effects can actually write into the destination folder.
|
|
3470
|
+
*
|
|
3471
|
+
* ExtendScript has no permission API, so the only reliable test is to write.
|
|
3472
|
+
* A folder that exists and refuses writes is another cause the export reports
|
|
3473
|
+
* as nothing at all, and it is worth naming before spending the export rather
|
|
3474
|
+
* than after. The probe is removed on every path; it never outlives the call.
|
|
3475
|
+
*/
|
|
3476
|
+
function __folderIsWritable(folder) {
|
|
3477
|
+
var probe = new File(__joinPath(folder.fsName, ".ae-mcp-write-probe"));
|
|
3478
|
+
var opened = false;
|
|
3479
|
+
try {
|
|
3480
|
+
opened = probe.open("w");
|
|
3481
|
+
if (opened) probe.write("");
|
|
3482
|
+
} catch (eW) {
|
|
3483
|
+
opened = false;
|
|
3484
|
+
}
|
|
3485
|
+
try { probe.close(); } catch (eC) {}
|
|
3486
|
+
try { if (probe.exists) probe.remove(); } catch (eR) {}
|
|
3487
|
+
return opened === true;
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
/**
|
|
3491
|
+
* The failure message for an export that wrote nothing.
|
|
3492
|
+
*
|
|
3493
|
+
* Built rather than fixed, because the only honest message names what was
|
|
3494
|
+
* actually established and stops there. `suppressed` is the hinge: with dialogs
|
|
3495
|
+
* suppressed a modal cannot be the cause and must not be mentioned as one
|
|
3496
|
+
* (issue #71); without suppression it is the first thing to look at. Everything
|
|
3497
|
+
* after that is unknown, and says so — AE's own UI is the only place the real
|
|
3498
|
+
* reason exists, which is why the diagnostic step is to run the export by hand.
|
|
3499
|
+
*/
|
|
3500
|
+
function __exportFailureMessage(info) {
|
|
3501
|
+
var lines = [];
|
|
3502
|
+
lines.push(
|
|
3503
|
+
"After Effects did not write a template. It returned " + String(info.exported) +
|
|
3504
|
+
" and there is no file at " + info.outPath + "."
|
|
3505
|
+
);
|
|
3506
|
+
lines.push(
|
|
3507
|
+
"Ruled out before the export ran: the project is saved (" + info.projectPath + "); the destination " +
|
|
3508
|
+
"folder exists and accepts writes; the template name resolves to that folder; and the comp has " +
|
|
3509
|
+
info.controllerText + "."
|
|
3510
|
+
);
|
|
3511
|
+
if (!info.suppressed) {
|
|
3512
|
+
lines.push(
|
|
3513
|
+
"Dialogs were NOT suppressed for this call, so a modal dialog waiting in After Effects is the " +
|
|
3514
|
+
"likeliest cause — switch to After Effects and look for one. Retry with the default " +
|
|
3515
|
+
"suppressDialogs:true once it is cleared."
|
|
3516
|
+
);
|
|
3517
|
+
} else {
|
|
3518
|
+
lines.push(
|
|
3519
|
+
"Dialogs were suppressed, so a modal dialog cannot be what stopped this — do not go looking for " +
|
|
3520
|
+
"one to click. After Effects reported no reason at all, and there is no reason for a script to " +
|
|
3521
|
+
"read, so the cause is unknown."
|
|
3522
|
+
);
|
|
3523
|
+
lines.push(
|
|
3524
|
+
"To find out what After Effects would have said, ask the user to export the same comp by hand: " +
|
|
3525
|
+
"Window > Essential Graphics, select the comp, then Export Motion Graphics Template. After " +
|
|
3526
|
+
"Effects shows its own error there. Running this tool again with suppressDialogs:false shows the " +
|
|
3527
|
+
"same dialog, but it will freeze this connection until someone clicks it."
|
|
3528
|
+
);
|
|
3529
|
+
}
|
|
3530
|
+
return lines.join(" ");
|
|
3531
|
+
}
|
|
3532
|
+
|
|
1940
3533
|
OPS.export_mogrt = noUndo(function (args) {
|
|
1941
3534
|
var comp = getCompById(args.compId);
|
|
1942
3535
|
|
|
@@ -1952,6 +3545,29 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
1952
3545
|
);
|
|
1953
3546
|
}
|
|
1954
3547
|
|
|
3548
|
+
// Every precondition that can be established from a script runs here, before
|
|
3549
|
+
// the project is touched at all: the template is not renamed, the project is
|
|
3550
|
+
// not saved and no export is attempted until they all pass. A refusal below
|
|
3551
|
+
// therefore costs the user nothing and names its own remedy — which is the
|
|
3552
|
+
// whole point, since a failure *after* the export names nothing.
|
|
3553
|
+
|
|
3554
|
+
// The Essential Graphics panel has to have something in it. AE will not build
|
|
3555
|
+
// a template with no controllers, and under suppression it declines without a
|
|
3556
|
+
// word — which is what made this look like a dialog (issue #71). An explicit
|
|
3557
|
+
// 0 is the whole diagnosis; a count the host refuses to give is not.
|
|
3558
|
+
var preControllerCount = __controllerCount(comp);
|
|
3559
|
+
if (preControllerCount === 0) {
|
|
3560
|
+
throw new Error(
|
|
3561
|
+
"This comp has 0 Essential Graphics controllers, and After Effects cannot export a template " +
|
|
3562
|
+
"from a comp with an empty Essential Graphics panel. It refuses without writing a file and " +
|
|
3563
|
+
"without reporting anything a script can read, so nothing was exported and nothing in the " +
|
|
3564
|
+
"project was changed. To fix it, put at least one property into the panel: in After Effects " +
|
|
3565
|
+
"open Window > Essential Graphics, pick this comp in the panel's dropdown, then drag a layer " +
|
|
3566
|
+
"property into it — a text layer's Source Text, a colour, a slider, a position. Then call " +
|
|
3567
|
+
"export_mogrt again."
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
|
|
1955
3571
|
var templateName = (typeof args.name === "string" && args.name.length > 0) ? args.name : null;
|
|
1956
3572
|
var previousTemplateName = null;
|
|
1957
3573
|
try { previousTemplateName = comp.motionGraphicsTemplateName; } catch (e) {}
|
|
@@ -1959,7 +3575,16 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
1959
3575
|
// AE's own default is "Untitled" for a template built by script, which
|
|
1960
3576
|
// silently collides with every other comp in the project. The comp name is
|
|
1961
3577
|
// what the user would have typed. A name they *did* type is left alone.
|
|
1962
|
-
|
|
3578
|
+
if (!previousTemplateName || previousTemplateName === "Untitled") {
|
|
3579
|
+
templateName = comp.name;
|
|
3580
|
+
} else {
|
|
3581
|
+
templateName = previousTemplateName;
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
if (__isBlank(templateName)) {
|
|
3585
|
+
throw new Error(
|
|
3586
|
+
"The template name is empty, and it becomes the .mogrt filename. Pass a `name`."
|
|
3587
|
+
);
|
|
1963
3588
|
}
|
|
1964
3589
|
|
|
1965
3590
|
var destDir = (typeof args.destDir === "string" && args.destDir.length > 0)
|
|
@@ -1974,6 +3599,22 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
1974
3599
|
|
|
1975
3600
|
var outPath = __joinPath(folder.fsName, __mogrtFileName(templateName));
|
|
1976
3601
|
var outFile = new File(outPath);
|
|
3602
|
+
|
|
3603
|
+
// The name becomes a filename, so a separator in it sends the write somewhere
|
|
3604
|
+
// else — or nowhere, silently. Measured rather than pattern-matched: ask the
|
|
3605
|
+
// File where it actually landed and compare. That works the same on both
|
|
3606
|
+
// platforms, which a list of illegal characters would not.
|
|
3607
|
+
var resolvedParent = null;
|
|
3608
|
+
try { resolvedParent = outFile.parent.fsName; } catch (eP) {}
|
|
3609
|
+
if (resolvedParent !== null &&
|
|
3610
|
+
__trimTrailingSeparator(resolvedParent) !== __trimTrailingSeparator(folder.fsName)) {
|
|
3611
|
+
throw new Error(
|
|
3612
|
+
"The template name \"" + templateName + "\" is used as the .mogrt filename, and it resolves to " +
|
|
3613
|
+
outPath + ", which is not inside " + folder.fsName + ". Pass a `name` with no slashes in it, and " +
|
|
3614
|
+
"a `destDir` if you want it written somewhere else."
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
|
|
1977
3618
|
var existed = outFile.exists;
|
|
1978
3619
|
if (existed && args.overwrite !== true) {
|
|
1979
3620
|
throw new Error(
|
|
@@ -1982,6 +3623,17 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
1982
3623
|
);
|
|
1983
3624
|
}
|
|
1984
3625
|
|
|
3626
|
+
// Last of the checkable preconditions, and the last thing that costs nothing
|
|
3627
|
+
// to get wrong. A folder that will not accept a file is one more cause the
|
|
3628
|
+
// export reports as silence.
|
|
3629
|
+
if (!__folderIsWritable(folder)) {
|
|
3630
|
+
throw new Error(
|
|
3631
|
+
"After Effects cannot write into " + folder.fsName + " — a test file could not be created there. " +
|
|
3632
|
+
"Nothing was exported. Pass a `destDir` the user can write to, or ask them to fix the folder's " +
|
|
3633
|
+
"permissions."
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
1985
3637
|
var fonts = [];
|
|
1986
3638
|
try { __collectFonts(comp, 0, [], fonts); } catch (eF) {}
|
|
1987
3639
|
|
|
@@ -1995,6 +3647,7 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
1995
3647
|
// Everything the result needs, read *before* the export. See below.
|
|
1996
3648
|
var compId = comp.id;
|
|
1997
3649
|
var compName = comp.name;
|
|
3650
|
+
var projectPath = app.project.file.fsName;
|
|
1998
3651
|
|
|
1999
3652
|
var suppress = args.suppressDialogs !== false;
|
|
2000
3653
|
var suppressed = false;
|
|
@@ -2023,10 +3676,21 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
2023
3676
|
// codebase refuses to pass on.
|
|
2024
3677
|
var written = new File(outPath);
|
|
2025
3678
|
if (!written.exists) {
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
)
|
|
3679
|
+
var controllerText;
|
|
3680
|
+
if (preControllerCount === null) {
|
|
3681
|
+
controllerText = "controllers this version of After Effects would not count";
|
|
3682
|
+
} else if (preControllerCount === 1) {
|
|
3683
|
+
controllerText = "1 Essential Graphics controller";
|
|
3684
|
+
} else {
|
|
3685
|
+
controllerText = String(preControllerCount) + " Essential Graphics controllers";
|
|
3686
|
+
}
|
|
3687
|
+
throw new Error(__exportFailureMessage({
|
|
3688
|
+
exported: exported,
|
|
3689
|
+
outPath: outPath,
|
|
3690
|
+
projectPath: projectPath,
|
|
3691
|
+
suppressed: suppress,
|
|
3692
|
+
controllerText: controllerText
|
|
3693
|
+
}));
|
|
2030
3694
|
}
|
|
2031
3695
|
var bytes = written.length;
|
|
2032
3696
|
if (!(bytes > 0)) throw new Error("The exported template at " + outPath + " is empty (0 bytes).");
|
|
@@ -2042,7 +3706,9 @@ OPS.export_mogrt = noUndo(function (args) {
|
|
|
2042
3706
|
createdDir: createdDir,
|
|
2043
3707
|
projectSaved: app.project.file.fsName,
|
|
2044
3708
|
dialogsSuppressed: suppress,
|
|
2045
|
-
|
|
3709
|
+
// Re-read rather than echoed: the export is the only thing that can change
|
|
3710
|
+
// it, and a host that will not answer says null instead of a made-up 0.
|
|
3711
|
+
controllerCount: __controllerCount(comp),
|
|
2046
3712
|
fonts: fonts
|
|
2047
3713
|
};
|
|
2048
3714
|
if (!suppress) {
|
|
@@ -2196,32 +3862,66 @@ OPS.set_house_style = noUndo(function (args) {
|
|
|
2196
3862
|
|
|
2197
3863
|
// batch.jsx — execute many ops in one ExtendScript pass.
|
|
2198
3864
|
// For huge batches, registers a job and processes in chunks via _continue_job.
|
|
3865
|
+
//
|
|
3866
|
+
// UNDO GROUPING, which is the whole design constraint here (issue #69):
|
|
3867
|
+
// After Effects **discards** an undo group opened in one `evalScript` call and
|
|
3868
|
+
// closed in another. Measured, not inferred — a 600-op batch that opened its
|
|
3869
|
+
// group in `run_batch` and closed it from a later `_continue_job` produced ~600
|
|
3870
|
+
// undo steps, and AE's Edit menu read `Undo New Solid` rather than the group's
|
|
3871
|
+
// name. So a group only survives if it opens and closes inside one call, and
|
|
3872
|
+
// that gives the two paths below genuinely different answers:
|
|
3873
|
+
//
|
|
3874
|
+
// inline (<= 500 ops, or any size with singleUndo) — the whole batch runs in
|
|
3875
|
+
// one call, so one group round it is real. One undo step.
|
|
3876
|
+
// chunked (> 500 ops) — the batch spans N calls, so the most AE will keep is
|
|
3877
|
+
// one group per chunk. N undo steps, counted and reported, never claimed
|
|
3878
|
+
// to be one.
|
|
3879
|
+
|
|
3880
|
+
// The inline cutoff. Inline stays sub-second for typical create/keyframe ops up
|
|
3881
|
+
// to a few hundred; the async-job overhead (jobId envelope, polling, progress
|
|
3882
|
+
// notifications) is only worth it for genuinely long jobs.
|
|
3883
|
+
var __BATCH_INLINE_MAX = 500;
|
|
3884
|
+
// Chunk size for the async path. The panel reads this back off the envelope
|
|
3885
|
+
// rather than holding its own copy, so the two cannot drift.
|
|
3886
|
+
var __BATCH_CHUNK = 25;
|
|
3887
|
+
// The ceiling on singleUndo. Past this, one blocking ExtendScript call freezes
|
|
3888
|
+
// After Effects for long enough that the user will think it has hung, with no
|
|
3889
|
+
// progress events to say otherwise — so it is refused rather than attempted.
|
|
3890
|
+
var __BATCH_SINGLE_UNDO_MAX = 2000;
|
|
2199
3891
|
|
|
2200
3892
|
OPS.run_batch = function (args) {
|
|
2201
3893
|
var ops = args.ops || [];
|
|
2202
3894
|
var transactional = args.transactional !== false;
|
|
2203
3895
|
var name = args.undoGroupName || "AE MCP Batch";
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
if (ops.length
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
3896
|
+
var singleUndo = args.singleUndo === true;
|
|
3897
|
+
|
|
3898
|
+
// Refused before anything else happens, including the diff fingerprint: a
|
|
3899
|
+
// call that is not going to run should cost nothing.
|
|
3900
|
+
if (singleUndo && ops.length > __BATCH_SINGLE_UNDO_MAX) {
|
|
3901
|
+
throw new Error(
|
|
3902
|
+
"singleUndo refuses " + ops.length + " ops (the limit is " + __BATCH_SINGLE_UNDO_MAX + "). " +
|
|
3903
|
+
"One undo step means the whole batch runs inside a single ExtendScript call, which " +
|
|
3904
|
+
"freezes After Effects' interface for the whole of it and reports no progress; at this " +
|
|
3905
|
+
"size the user would reasonably think it had hung. Either split the work into batches of " +
|
|
3906
|
+
__BATCH_SINGLE_UNDO_MAX + " or fewer, or drop singleUndo and accept one undo step per " +
|
|
3907
|
+
"chunk of " + __BATCH_CHUNK + " (the result says exactly how many)."
|
|
3908
|
+
);
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
// diff:true fingerprints the comps this batch names, before and after, inside
|
|
3912
|
+
// this one call — see snapshot.jsx. Null unless asked for.
|
|
3913
|
+
var diffState = __diffStart(args, ops);
|
|
3914
|
+
|
|
3915
|
+
if (ops.length <= __BATCH_INLINE_MAX || singleUndo) {
|
|
3916
|
+
return __batchInline(ops, transactional, name, diffState);
|
|
2222
3917
|
}
|
|
3918
|
+
|
|
2223
3919
|
// Long batches: register job, return jobId; panel polls _continue_job.
|
|
3920
|
+
// No undo group is opened here on purpose. One opened now would be closed
|
|
3921
|
+
// from a different call and AE would throw it away, which is exactly how this
|
|
3922
|
+
// op spent three releases reporting one undo step and delivering hundreds.
|
|
2224
3923
|
var jobId = __newJobId();
|
|
3924
|
+
var estimate = Math.ceil(ops.length / __BATCH_CHUNK);
|
|
2225
3925
|
JOBS[jobId] = {
|
|
2226
3926
|
cursor: 0,
|
|
2227
3927
|
total: ops.length,
|
|
@@ -2231,48 +3931,212 @@ OPS.run_batch = function (args) {
|
|
|
2231
3931
|
cancelled: false,
|
|
2232
3932
|
transactional: transactional,
|
|
2233
3933
|
name: name,
|
|
3934
|
+
chunkSize: __BATCH_CHUNK,
|
|
3935
|
+
// Counted as groups are actually opened, never predicted. See __UNDO_GROUPS.
|
|
3936
|
+
undoSteps: 0,
|
|
3937
|
+
// Carried across the chunked continuations: the before-fingerprint has to
|
|
3938
|
+
// outlive the call that took it, or a long batch could never be diffed.
|
|
3939
|
+
diffState: diffState,
|
|
3940
|
+
};
|
|
3941
|
+
return {
|
|
3942
|
+
jobId: jobId,
|
|
3943
|
+
async: true,
|
|
3944
|
+
total: ops.length,
|
|
3945
|
+
chunkSize: __BATCH_CHUNK,
|
|
3946
|
+
// Labelled an estimate because it is one: a transactional failure ends the
|
|
3947
|
+
// batch early, and the exact count comes back with the final result.
|
|
3948
|
+
undoStepsEstimate: estimate,
|
|
3949
|
+
undoGroupName: name,
|
|
3950
|
+
note: "This batch runs in " + estimate + " chunks of " + __BATCH_CHUNK + " and will land as " +
|
|
3951
|
+
"about " + estimate + " undo steps, NOT one — After Effects discards an undo group that " +
|
|
3952
|
+
"spans two script calls, so a chunked batch can only group per chunk. The final result " +
|
|
3953
|
+
"reports the exact number. If the user needs a single Cmd-Z, re-send with singleUndo:true " +
|
|
3954
|
+
"(up to " + __BATCH_SINGLE_UNDO_MAX + " ops), which runs the whole batch in one blocking " +
|
|
3955
|
+
"call with no progress events.",
|
|
2234
3956
|
};
|
|
2235
|
-
// Wrap async run already inside an undoGroup chain. We open it now, the
|
|
2236
|
-
// continuations stay inside it until finalization.
|
|
2237
|
-
app.beginUndoGroup(name);
|
|
2238
|
-
JOBS[jobId].undoOpen = true;
|
|
2239
|
-
return { jobId: jobId, async: true, total: ops.length };
|
|
2240
3957
|
};
|
|
2241
|
-
// run_batch is allowed to manage its own undo
|
|
3958
|
+
// run_batch is allowed to manage its own undo: the inline path opens exactly one
|
|
3959
|
+
// group of its own, and the chunked path opens one per continuation.
|
|
2242
3960
|
OPS.run_batch.__meta = { noUndo: true };
|
|
2243
3961
|
|
|
3962
|
+
// The inline path: every op inside one undo group inside one evalScript, which
|
|
3963
|
+
// is the only shape After Effects keeps.
|
|
3964
|
+
//
|
|
3965
|
+
// withUndo() rather than a bare beginUndoGroup/endUndoGroup pair, for two
|
|
3966
|
+
// reasons: its `finally` closes the group when a transactional op throws, and it
|
|
3967
|
+
// is the only thing that keeps __UNDO_OPEN honest, so a batched op that calls
|
|
3968
|
+
// withoutUndoGroup() still works instead of silently doing nothing.
|
|
3969
|
+
function __batchInline(ops, transactional, name, diffState) {
|
|
3970
|
+
var results = [];
|
|
3971
|
+
var errors = [];
|
|
3972
|
+
var before = __undoGroupsOpened();
|
|
3973
|
+
// A run_batch listed as an op inside another run_batch would otherwise open a
|
|
3974
|
+
// second group inside the first, and AE's groups do not nest: the inner
|
|
3975
|
+
// endUndoGroup closes the outer one and the rest of the outer batch writes
|
|
3976
|
+
// ungrouped. Run inside the group that is already open instead, and say so —
|
|
3977
|
+
// an inner batch reporting "one undo step" of its own would be a second lie
|
|
3978
|
+
// in the same shape as the one this fixes.
|
|
3979
|
+
var nested = __UNDO_OPEN;
|
|
3980
|
+
// An empty batch opens nothing. AE records no undo step for a group that
|
|
3981
|
+
// changed nothing, so opening one would make `undoSteps: 1` an over-report on
|
|
3982
|
+
// the one call where the truth is unambiguous.
|
|
3983
|
+
var empty = ops.length === 0;
|
|
3984
|
+
var run = function () {
|
|
3985
|
+
for (var i = 0; i < ops.length; i++) {
|
|
3986
|
+
var step = ops[i];
|
|
3987
|
+
try {
|
|
3988
|
+
var handler = OPS[step.op];
|
|
3989
|
+
if (!handler) throw new Error("Unknown op: " + step.op);
|
|
3990
|
+
results.push(handler(step.args || {}));
|
|
3991
|
+
} catch (e) {
|
|
3992
|
+
errors.push({ index: i, op: step.op, error: e.message });
|
|
3993
|
+
if (transactional) {
|
|
3994
|
+
// Nothing rolls back, so the half-applied state is what the caller
|
|
3995
|
+
// has to reason about. The diff says where it stopped.
|
|
3996
|
+
var failure = new Error("Batch failed at op[" + i + "] " + step.op + ": " + e.message);
|
|
3997
|
+
__diffAnnotateError(failure, diffState);
|
|
3998
|
+
throw failure;
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
};
|
|
4003
|
+
try {
|
|
4004
|
+
if (nested || empty) run();
|
|
4005
|
+
else withUndo(name, run);
|
|
4006
|
+
} catch (e) {
|
|
4007
|
+
// The group is already closed by withUndo's finally. Say what it cost, so a
|
|
4008
|
+
// failed batch is as clear about its undo history as a successful one.
|
|
4009
|
+
try {
|
|
4010
|
+
e.message = String(e.message) + " || " +
|
|
4011
|
+
__batchUndoNote(__undoGroupsOpened() - before, name, nested, false);
|
|
4012
|
+
} catch (e2) {}
|
|
4013
|
+
throw e;
|
|
4014
|
+
}
|
|
4015
|
+
var out = {
|
|
4016
|
+
results: results,
|
|
4017
|
+
errors: errors,
|
|
4018
|
+
total: ops.length,
|
|
4019
|
+
undoSteps: __undoGroupsOpened() - before,
|
|
4020
|
+
undoGroupName: name,
|
|
4021
|
+
};
|
|
4022
|
+
if (nested) out.nested = true;
|
|
4023
|
+
out.note = __batchUndoNote(out.undoSteps, name, nested, false);
|
|
4024
|
+
if (diffState) out.diff = __diffFinish(diffState);
|
|
4025
|
+
return out;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
// The one sentence every batch result carries about its own undo history.
|
|
4029
|
+
// Written for an agent that is about to tell a person how to undo the work.
|
|
4030
|
+
// `steps` is always the measured count, so this describes what happened rather
|
|
4031
|
+
// than what the path intended.
|
|
4032
|
+
function __batchUndoNote(steps, name, nested, chunked) {
|
|
4033
|
+
if (nested) {
|
|
4034
|
+
return "This batch ran inside an undo group that was already open (a run_batch nested in " +
|
|
4035
|
+
"another one), so it adds no undo step of its own — its work is part of the outer step.";
|
|
4036
|
+
}
|
|
4037
|
+
if (steps <= 0) {
|
|
4038
|
+
return "This batch opened no undo group, so it added no undo steps.";
|
|
4039
|
+
}
|
|
4040
|
+
if (steps === 1) {
|
|
4041
|
+
// A chunked batch reaches one step only by stopping after its first chunk —
|
|
4042
|
+
// cancelled, or failed in it — and that group is named "(1)", not the bare
|
|
4043
|
+
// name. Quoting the wrong one would send the user looking for a menu entry
|
|
4044
|
+
// that is not there.
|
|
4045
|
+
return 'This batch is one undo step ("' + name + (chunked ? " (1)" : "") +
|
|
4046
|
+
'"): a single Cmd-Z (Ctrl-Z on Windows) undoes all of it.';
|
|
4047
|
+
}
|
|
4048
|
+
if (chunked) {
|
|
4049
|
+
return "This batch landed as " + steps + " undo steps, NOT one — named \"" + name + " (1)\" " +
|
|
4050
|
+
'through "' + name + " (" + steps + ')". After Effects discards an undo group that spans ' +
|
|
4051
|
+
"two script calls, so a chunked batch can only group per chunk. Undoing all of it takes up " +
|
|
4052
|
+
"to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows) — tell the user that number rather " +
|
|
4053
|
+
"than saying one. (A chunk whose ops changed nothing records no step, so that is the " +
|
|
4054
|
+
"ceiling.) singleUndo:true buys a single step back, at the cost of freezing AE's interface " +
|
|
4055
|
+
"for the whole batch and emitting no progress.";
|
|
4056
|
+
}
|
|
4057
|
+
return "This batch landed as " + steps + " undo steps rather than one: an op inside it closed " +
|
|
4058
|
+
"the batch's undo group and opened a new one (withoutUndoGroup — After Effects refuses " +
|
|
4059
|
+
"copyToComp on a parented or expression-linked layer while a group is open). Undoing all of " +
|
|
4060
|
+
"it takes up to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows).";
|
|
4061
|
+
}
|
|
4062
|
+
|
|
2244
4063
|
// Continuation step. Returns one chunk's worth of progress.
|
|
4064
|
+
//
|
|
4065
|
+
// Cancellation, which used to need bookkeeping across calls and no longer does:
|
|
4066
|
+
// _cancel_job is its own evalScript and the panel serializes those, so a cancel
|
|
4067
|
+
// can only ever be observed *between* chunks. A chunk's group opens and closes
|
|
4068
|
+
// inside this one call, so there is never an open group for a cancel — or a
|
|
4069
|
+
// throw, or a dropped WebSocket — to leave behind. That is what the old
|
|
4070
|
+
// `j.undoOpen` flag was for, and it was tracking a group AE had already dropped.
|
|
2245
4071
|
OPS._continue_job = noUndo(function (args) {
|
|
2246
4072
|
var jobId = args.jobId;
|
|
2247
4073
|
var j = JOBS[jobId];
|
|
2248
4074
|
if (!j) throw new Error("No job: " + jobId);
|
|
2249
4075
|
if (j.cancelled) {
|
|
2250
|
-
|
|
2251
|
-
|
|
4076
|
+
var stopped = {
|
|
4077
|
+
done: true, cancelled: true, jobId: jobId,
|
|
4078
|
+
results: j.results, errors: j.errors,
|
|
4079
|
+
undoSteps: j.undoSteps, undoGroupName: j.name,
|
|
4080
|
+
note: __batchUndoNote(j.undoSteps, j.name, false, true),
|
|
4081
|
+
};
|
|
4082
|
+
if (j.diffState) stopped.diff = __diffFinish(j.diffState);
|
|
4083
|
+
return stopped;
|
|
2252
4084
|
}
|
|
2253
|
-
var chunkSize = args.chunkSize ||
|
|
4085
|
+
var chunkSize = args.chunkSize || j.chunkSize || __BATCH_CHUNK;
|
|
2254
4086
|
var endAt = Math.min(j.cursor + chunkSize, j.total);
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
if (
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
4087
|
+
var before = __undoGroupsOpened();
|
|
4088
|
+
var failure = null;
|
|
4089
|
+
// One group per chunk, opened and closed inside this call. The chunk number
|
|
4090
|
+
// is in the name so the Edit menu says which part of the batch a step is.
|
|
4091
|
+
withUndo(j.name + " (" + (j.undoSteps + 1) + ")", function () {
|
|
4092
|
+
for (; j.cursor < endAt; j.cursor++) {
|
|
4093
|
+
var step = j.ops[j.cursor];
|
|
4094
|
+
try {
|
|
4095
|
+
var handler = OPS[step.op];
|
|
4096
|
+
if (!handler) throw new Error("Unknown op: " + step.op);
|
|
4097
|
+
j.results.push(handler(step.args || {}));
|
|
4098
|
+
} catch (e) {
|
|
4099
|
+
j.errors.push({ index: j.cursor, op: step.op, error: e.message });
|
|
4100
|
+
if (j.transactional) {
|
|
4101
|
+
failure = { error: e.message, atIndex: j.cursor };
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
2268
4104
|
}
|
|
2269
4105
|
}
|
|
4106
|
+
});
|
|
4107
|
+
j.undoSteps += __undoGroupsOpened() - before;
|
|
4108
|
+
|
|
4109
|
+
if (failure) {
|
|
4110
|
+
// No rollback is attempted. The old code fired one `Undo` menu command
|
|
4111
|
+
// here, which undoes at most the last step even when it works at all —
|
|
4112
|
+
// menu commands depend on host focus and the active selection, neither of
|
|
4113
|
+
// which this bridge has. Reporting a rollback that did not happen is the
|
|
4114
|
+
// same class of lie as swallowing an error, so it says plainly what is on
|
|
4115
|
+
// disk and how many steps it took.
|
|
4116
|
+
var failed = {
|
|
4117
|
+
done: true, failed: true, jobId: jobId,
|
|
4118
|
+
error: failure.error, atIndex: failure.atIndex,
|
|
4119
|
+
results: j.results, errors: j.errors,
|
|
4120
|
+
rolledBack: false,
|
|
4121
|
+
undoSteps: j.undoSteps, undoGroupName: j.name,
|
|
4122
|
+
note: __batchUndoNote(j.undoSteps, j.name, false, true) +
|
|
4123
|
+
" Nothing was rolled back: the ops before op[" + failure.atIndex + "] are applied and " +
|
|
4124
|
+
"stay applied. Read the state back rather than re-running the batch.",
|
|
4125
|
+
};
|
|
4126
|
+
if (j.diffState) failed.diff = __diffFinish(j.diffState);
|
|
4127
|
+
return failed;
|
|
2270
4128
|
}
|
|
2271
4129
|
if (j.cursor >= j.total) {
|
|
2272
|
-
|
|
2273
|
-
|
|
4130
|
+
var done = {
|
|
4131
|
+
done: true, jobId: jobId,
|
|
4132
|
+
results: j.results, errors: j.errors, total: j.total,
|
|
4133
|
+
undoSteps: j.undoSteps, undoGroupName: j.name,
|
|
4134
|
+
note: __batchUndoNote(j.undoSteps, j.name, false, true),
|
|
4135
|
+
};
|
|
4136
|
+
if (j.diffState) done.diff = __diffFinish(j.diffState);
|
|
4137
|
+
return done;
|
|
2274
4138
|
}
|
|
2275
|
-
return { done: false, jobId: jobId, progress: j.cursor, total: j.total };
|
|
4139
|
+
return { done: false, jobId: jobId, progress: j.cursor, total: j.total, undoSteps: j.undoSteps };
|
|
2276
4140
|
});
|
|
2277
4141
|
|
|
2278
4142
|
OPS._cancel_job = noUndo(function (args) {
|
|
@@ -2285,7 +4149,10 @@ OPS._cancel_job = noUndo(function (args) {
|
|
|
2285
4149
|
OPS._get_job = noUndo(function (args) {
|
|
2286
4150
|
var j = JOBS[args.jobId];
|
|
2287
4151
|
if (!j) return null;
|
|
2288
|
-
return {
|
|
4152
|
+
return {
|
|
4153
|
+
cursor: j.cursor, total: j.total, cancelled: j.cancelled,
|
|
4154
|
+
errorCount: j.errors.length, undoSteps: j.undoSteps,
|
|
4155
|
+
};
|
|
2289
4156
|
});
|
|
2290
4157
|
|
|
2291
4158
|
|
|
@@ -2824,6 +4691,115 @@ OPS.find_layers = noUndo(function (args) {
|
|
|
2824
4691
|
});
|
|
2825
4692
|
|
|
2826
4693
|
|
|
4694
|
+
// ===== helpers.jsx =====
|
|
4695
|
+
|
|
4696
|
+
// helpers.jsx — the scope a run_jsx script runs in.
|
|
4697
|
+
//
|
|
4698
|
+
// Everything in this file is a global function, which is what makes it visible
|
|
4699
|
+
// to `eval`d script bodies and to anything loaded through run_jsx's
|
|
4700
|
+
// `libraries`. It exists because the run_jsx description promised "helpers in
|
|
4701
|
+
// scope" and never said which, so every session re-derived the same four or
|
|
4702
|
+
// five functions — find-a-layer-by-id, ease-with-the-right-array-size, a shape
|
|
4703
|
+
// builder that zeros the position (issue #53). Each of these was written from
|
|
4704
|
+
// scratch in the transcript, at token cost, with a fresh chance of getting the
|
|
4705
|
+
// AE quirk wrong.
|
|
4706
|
+
//
|
|
4707
|
+
// The rule for adding one: it has to be something an agent would otherwise
|
|
4708
|
+
// write badly, not merely something it would write often. All four below wrap
|
|
4709
|
+
// a documented AE trap.
|
|
4710
|
+
//
|
|
4711
|
+
// These are listed by signature in the run_jsx tool description. If you change
|
|
4712
|
+
// one, change that too — it is the only place a caller ever sees them.
|
|
4713
|
+
|
|
4714
|
+
// getCompById by a shorter name, because that is the name agents guess.
|
|
4715
|
+
function compById(id) {
|
|
4716
|
+
return getCompById(id);
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
// getLayerById, but the comp may be given as an id. An agent holding
|
|
4720
|
+
// (compId, layerId) — the pair every tool returns — can use it directly.
|
|
4721
|
+
function layerById(comp, layerId) {
|
|
4722
|
+
var c = comp;
|
|
4723
|
+
if (typeof c === "number") c = getCompById(c);
|
|
4724
|
+
return getLayerById(c, layerId);
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
// {influence, speed}, or a bare number read as influence. AE's own default
|
|
4728
|
+
// ease is 33% influence at zero speed, so that is what an omitted field gets.
|
|
4729
|
+
function __hEaseSpec(spec) {
|
|
4730
|
+
if (spec === null || spec === undefined) return { influence: 33, speed: 0 };
|
|
4731
|
+
if (typeof spec === "number") return { influence: spec, speed: 0 };
|
|
4732
|
+
var inf = 33;
|
|
4733
|
+
var spd = 0;
|
|
4734
|
+
if (typeof spec.influence === "number") inf = spec.influence;
|
|
4735
|
+
if (typeof spec.speed === "number") spd = spec.speed;
|
|
4736
|
+
return { influence: inf, speed: spd };
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
// ease(prop, keyIndex, easeIn, easeOut) — sizes its own ease array.
|
|
4740
|
+
//
|
|
4741
|
+
// setTemporalEaseAtKey wants one KeyframeEase per dimension and the count is
|
|
4742
|
+
// NOT derivable from the value: a spatial property takes exactly one whatever
|
|
4743
|
+
// its dimension (the ease runs along the motion path), a 2D layer's Scale takes
|
|
4744
|
+
// three, a shape's Ellipse Size takes two, Opacity and sliders take one. The
|
|
4745
|
+
// wrong count throws "parameter 2" and says nothing else (issue #50).
|
|
4746
|
+
//
|
|
4747
|
+
// The sizing lives in `__applyTemporalEase` in keyframes.jsx, which is what
|
|
4748
|
+
// `set_temporal_ease` and `add_keyframe` use. This helper is the same function
|
|
4749
|
+
// with a friendlier signature — deliberately not a second implementation, so a
|
|
4750
|
+
// script written through run_jsx and the same work done through the tools can
|
|
4751
|
+
// never disagree about what a property wanted.
|
|
4752
|
+
//
|
|
4753
|
+
// easeOut omitted means the same ease on both sides. Returns the number of
|
|
4754
|
+
// entries that worked, so a caller can see what the property actually wanted.
|
|
4755
|
+
function ease(prop, keyIndex, easeIn, easeOut) {
|
|
4756
|
+
var inSpec = __hEaseSpec(easeIn);
|
|
4757
|
+
var outSpec = __hEaseSpec(easeOut === undefined ? easeIn : easeOut);
|
|
4758
|
+
return __applyTemporalEase(prop, keyIndex, inSpec, outSpec);
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
// addKeys(prop, [[time, value], ...]) — or [{time, value}, ...].
|
|
4762
|
+
// Returns the key index of each, in the order given, so the next call can ease
|
|
4763
|
+
// them without searching for them again.
|
|
4764
|
+
function addKeys(prop, pairs) {
|
|
4765
|
+
if (!pairs || !pairs.length) return [];
|
|
4766
|
+
var out = [];
|
|
4767
|
+
for (var i = 0; i < pairs.length; i++) {
|
|
4768
|
+
var p = pairs[i];
|
|
4769
|
+
var t;
|
|
4770
|
+
var v;
|
|
4771
|
+
if (p instanceof Array) {
|
|
4772
|
+
t = p[0];
|
|
4773
|
+
v = p[1];
|
|
4774
|
+
} else {
|
|
4775
|
+
t = p.time;
|
|
4776
|
+
v = p.value;
|
|
4777
|
+
}
|
|
4778
|
+
prop.setValueAtTime(t, v);
|
|
4779
|
+
out.push(prop.nearestKeyIndex(t));
|
|
4780
|
+
}
|
|
4781
|
+
return out;
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
// shape(comp, {name, position}) — a shape layer that lands at [0,0].
|
|
4785
|
+
//
|
|
4786
|
+
// AE spawns a scripted shape layer at the comp centre with its anchor at
|
|
4787
|
+
// (0,0), so paths authored in comp pixels come out offset by half a frame —
|
|
4788
|
+
// easy to miss on a downsampled screenshot (issue #51). Position [0,0] makes
|
|
4789
|
+
// the layer's coordinate space the comp's, which is what path vertices assume.
|
|
4790
|
+
function shape(comp, opts) {
|
|
4791
|
+
var c = comp;
|
|
4792
|
+
if (typeof c === "number") c = getCompById(c);
|
|
4793
|
+
var o = opts || {};
|
|
4794
|
+
var l = c.layers.addShape();
|
|
4795
|
+
if (o.name) l.name = o.name;
|
|
4796
|
+
var pos = o.position ? o.position : [0, 0];
|
|
4797
|
+
var value = (pos.length === 3) ? pos : [pos[0], pos[1]];
|
|
4798
|
+
l.property("Transform").property("Position").setValue(value);
|
|
4799
|
+
return l;
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
|
|
2827
4803
|
// ===== raw.jsx =====
|
|
2828
4804
|
|
|
2829
4805
|
// raw.jsx — escape hatch. Eval arbitrary ExtendScript and return the value.
|
|
@@ -2949,17 +4925,369 @@ function __rjResult(result, undoGroupName) {
|
|
|
2949
4925
|
};
|
|
2950
4926
|
}
|
|
2951
4927
|
|
|
4928
|
+
// ---------- Mapping a failure back onto the caller's own script ----------
|
|
4929
|
+
// A failed run_jsx reported After Effects' line number with nothing to measure
|
|
4930
|
+
// it against, and that number does not count from where the caller thinks it
|
|
4931
|
+
// does: the same "line 22" pointed at two different statements in consecutive
|
|
4932
|
+
// calls (issue #46). Nothing rolls back, so an agent that cannot locate the
|
|
4933
|
+
// throw has to read the whole project back to find out where the script stopped.
|
|
4934
|
+
//
|
|
4935
|
+
// Two halves, and the second is the one that actually helps. The caller's line
|
|
4936
|
+
// 1 sits a *counted* distance down the evaluated source: with no libraries the
|
|
4937
|
+
// prefix carries no newline and the distance is zero, and with libraries it is
|
|
4938
|
+
// however many lines of library text were inlined ahead of the script.
|
|
4939
|
+
// __rjBuildSource measures the preamble it actually built rather than asserting
|
|
4940
|
+
// a constant, so the number can never drift from the string it describes —
|
|
4941
|
+
// a hand-written constant beside a string is exactly how the two came apart the
|
|
4942
|
+
// first time. Then the mapped line is reported with its TEXT, which an agent
|
|
4943
|
+
// can act on without trusting any numbering at all.
|
|
4944
|
+
//
|
|
4945
|
+
// The mapping refuses to guess: a line that falls outside the submitted source
|
|
4946
|
+
// is reported as unmappable, never clamped — unless it lands in a library that
|
|
4947
|
+
// was inlined ahead of it, which is a real file with real lines and is named.
|
|
4948
|
+
// A confident wrong line number is worse than none — it sends the reader to a
|
|
4949
|
+
// statement that did not fail.
|
|
4950
|
+
var __RJ_WRAP_PREFIX = "(function(){ ";
|
|
4951
|
+
// The closer sits on its own line. Appended to the caller's last line, a script
|
|
4952
|
+
// ending in a `//` comment commented out its own `})()` and failed to parse for
|
|
4953
|
+
// a reason nothing in the error mentioned.
|
|
4954
|
+
var __RJ_WRAP_SUFFIX = "\n})()";
|
|
4955
|
+
|
|
4956
|
+
// No regex literal: tests/unit/jsx-ternary.mjs strips string and comment
|
|
4957
|
+
// literals from these sources and does not understand regex literals, so one
|
|
4958
|
+
// here could desynchronise its scan of this file. (audio.jsx and footage.jsx do
|
|
4959
|
+
// carry a few; they survive only because their contents happen to pair up.)
|
|
4960
|
+
function __rjLines(s) {
|
|
4961
|
+
return String(s).split("\r\n").join("\n").split("\r").join("\n").split("\n");
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
// The preamble of a call with no libraries: the bare wrapper opener, which
|
|
4965
|
+
// carries no newline, so zero. __rjBuildSource recomputes this per call once
|
|
4966
|
+
// libraries are in it; this is the default __rjSourceInfo falls back on when a
|
|
4967
|
+
// caller hands it no layout.
|
|
4968
|
+
var __RJ_PREAMBLE_LINES = __rjLines(__RJ_WRAP_PREFIX).length - 1;
|
|
4969
|
+
|
|
4970
|
+
function __rjTrim(s) {
|
|
4971
|
+
var t = String(s);
|
|
4972
|
+
var a = 0;
|
|
4973
|
+
var b = t.length;
|
|
4974
|
+
while (a < b && (t.charAt(a) === " " || t.charAt(a) === "\t")) a++;
|
|
4975
|
+
while (b > a && (t.charAt(b - 1) === " " || t.charAt(b - 1) === "\t")) b--;
|
|
4976
|
+
return t.substring(a, b);
|
|
4977
|
+
}
|
|
4978
|
+
|
|
4979
|
+
function __rjClip(s, max) {
|
|
4980
|
+
var t = String(s);
|
|
4981
|
+
if (t.length <= max) return t;
|
|
4982
|
+
return t.substring(0, max) + " ...";
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
// What After Effects reported, mapped onto the source the caller submitted.
|
|
4986
|
+
// Nothing here is fabricated: when the number does not land inside the script,
|
|
4987
|
+
// sourceLine stays null and the server says the number could not be mapped.
|
|
4988
|
+
//
|
|
4989
|
+
// `layout` is what __rjBuildSource returned for this call — how many lines of
|
|
4990
|
+
// preamble sit ahead of the caller's line 1, and where each inlined library
|
|
4991
|
+
// landed in the evaluated source. Omitted, it means the bare wrapper: no
|
|
4992
|
+
// libraries and a zero-line preamble, which is every call without `libraries`.
|
|
4993
|
+
function __rjSourceInfo(e, code, scriptPath, layout) {
|
|
4994
|
+
var lay = layout ? layout : { preambleLines: __RJ_PREAMBLE_LINES, segments: [] };
|
|
4995
|
+
var preamble = lay.preambleLines;
|
|
4996
|
+
var segs = lay.segments ? lay.segments : [];
|
|
4997
|
+
var lines = __rjLines(code);
|
|
4998
|
+
var info = {
|
|
4999
|
+
lineCount: lines.length,
|
|
5000
|
+
rawLine: null,
|
|
5001
|
+
sourceLine: null,
|
|
5002
|
+
sourceText: null
|
|
5003
|
+
};
|
|
5004
|
+
if (scriptPath) info.sourceName = String(scriptPath);
|
|
5005
|
+
|
|
5006
|
+
var raw = null;
|
|
5007
|
+
try {
|
|
5008
|
+
if (e && typeof e.line === "number" && isFinite(e.line)) raw = e.line;
|
|
5009
|
+
} catch (e1) {}
|
|
5010
|
+
info.rawLine = raw;
|
|
5011
|
+
|
|
5012
|
+
// Which line of the *evaluated source* — the wrapper, the inlined libraries
|
|
5013
|
+
// and the caller's script together — the failure sits on. After Effects' own
|
|
5014
|
+
// `line` already counts from there, which is what makes it usable at all.
|
|
5015
|
+
var wrapperLine = null;
|
|
5016
|
+
|
|
5017
|
+
// ExtendScript's Error also carries `source` (the text the error was raised
|
|
5018
|
+
// in) with `start`/`end` character offsets into it, and the documentation
|
|
5019
|
+
// presents those as the better answer, because they need no assumption about
|
|
5020
|
+
// what `line` counts from. On After Effects 2026 they are not offsets at all.
|
|
5021
|
+
//
|
|
5022
|
+
// Probed inside AE, catching from a four-line script that throws on line 4:
|
|
5023
|
+
//
|
|
5024
|
+
// eval("(function(){ var a=1;\nvar b=2;\nvar c=3;\nnope.boom();\n})()")
|
|
5025
|
+
// caught -> { "line": 4, "start": 0, "end": 0, "srcLen": 57 }
|
|
5026
|
+
//
|
|
5027
|
+
// `line` is already correct. `start` and `end` came back 0 on every error
|
|
5028
|
+
// measured, however far into the source it was raised. Read as an offset, a
|
|
5029
|
+
// zero start puts *every* failure on line 1 and prints line 1's text — which
|
|
5030
|
+
// is exactly what issue #46 still did after it was reported fixed, with the
|
|
5031
|
+
// true number demoted to the parenthetical afterwards.
|
|
5032
|
+
//
|
|
5033
|
+
// So the branch survives only for offsets that could actually be real: 0/0 is
|
|
5034
|
+
// After Effects declining to say, not After Effects pointing at the first
|
|
5035
|
+
// character. Do not restore this from the documentation.
|
|
5036
|
+
try {
|
|
5037
|
+
var src = null;
|
|
5038
|
+
if (e && typeof e.source === "string") src = e.source;
|
|
5039
|
+
var start = null;
|
|
5040
|
+
if (e && typeof e.start === "number" && isFinite(e.start)) start = e.start;
|
|
5041
|
+
var end = null;
|
|
5042
|
+
if (e && typeof e.end === "number" && isFinite(e.end)) end = e.end;
|
|
5043
|
+
var offsetsUsable = false;
|
|
5044
|
+
if (start !== null && start > 0) offsetsUsable = true;
|
|
5045
|
+
if (start === 0 && end !== null && end > 0) offsetsUsable = true;
|
|
5046
|
+
if (offsetsUsable && src !== null &&
|
|
5047
|
+
src.substring(0, __RJ_WRAP_PREFIX.length) === __RJ_WRAP_PREFIX) {
|
|
5048
|
+
wrapperLine = __rjLines(src.substring(0, start)).length;
|
|
5049
|
+
}
|
|
5050
|
+
} catch (e2) {}
|
|
5051
|
+
if (wrapperLine === null) wrapperLine = raw;
|
|
5052
|
+
if (wrapperLine === null) return info;
|
|
5053
|
+
|
|
5054
|
+
var mapped = wrapperLine - preamble;
|
|
5055
|
+
if (mapped >= 1 && mapped <= lines.length) {
|
|
5056
|
+
info.sourceLine = mapped;
|
|
5057
|
+
info.sourceText = __rjClip(__rjTrim(lines[mapped - 1]), 200);
|
|
5058
|
+
return info;
|
|
5059
|
+
}
|
|
5060
|
+
|
|
5061
|
+
// Not the caller's script. A library inlined ahead of it is a real file with
|
|
5062
|
+
// real lines, so name it and point into it rather than reporting the number
|
|
5063
|
+
// as unmappable: that file is where the reader has to look, and naming the
|
|
5064
|
+
// script instead would send them to a line that did not fail.
|
|
5065
|
+
for (var s = 0; s < segs.length; s++) {
|
|
5066
|
+
var seg = segs[s];
|
|
5067
|
+
var count = seg.lines.length - 1;
|
|
5068
|
+
var within = wrapperLine - seg.firstLine + 1;
|
|
5069
|
+
if (within >= 1 && within <= count) {
|
|
5070
|
+
info.sourceName = seg.path;
|
|
5071
|
+
info.lineCount = count;
|
|
5072
|
+
info.sourceLine = within;
|
|
5073
|
+
info.sourceText = __rjClip(__rjTrim(seg.lines[within - 1]), 200);
|
|
5074
|
+
return info;
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
return info;
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
// Rethrow with the mapping attached. dispatch()'s catch copies whatever sits on
|
|
5081
|
+
// `aeDetail` onto the error result; the panel forwards the fields it names and
|
|
5082
|
+
// the server prints the line's text. A plain object rather than an Error
|
|
5083
|
+
// because ExtendScript will not reliably let us write `line` on one, and
|
|
5084
|
+
// __mkError only ever reads message/stack/line.
|
|
5085
|
+
function __rjThrowWithSource(e, code, scriptPath, layout) {
|
|
5086
|
+
var info = __rjSourceInfo(e, code, scriptPath, layout);
|
|
5087
|
+
var msg = "";
|
|
5088
|
+
try { if (e && e.message) msg = String(e.message); } catch (e1) {}
|
|
5089
|
+
if (!msg) {
|
|
5090
|
+
try { msg = String(e); } catch (e2) { msg = "ExtendScript error"; }
|
|
5091
|
+
}
|
|
5092
|
+
var stack = "";
|
|
5093
|
+
try { if (e && e.stack) stack = String(e.stack); } catch (e3) {}
|
|
5094
|
+
throw { message: msg, stack: stack, line: info.rawLine, aeDetail: info };
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
// ---------- Helper libraries ----------
|
|
5098
|
+
// A library's source is inlined into the SAME eval as the caller's script,
|
|
5099
|
+
// ahead of it. That is not the obvious design, and it is the only one that
|
|
5100
|
+
// works.
|
|
5101
|
+
//
|
|
5102
|
+
// The first version used $.evalFile, on the documented premise that it
|
|
5103
|
+
// evaluates at global scope — load once, call for the rest of the After Effects
|
|
5104
|
+
// session (issue #53). Probed inside AE 2026, calling $.evalFile from the body
|
|
5105
|
+
// of an eval'd script, on a library declaring `function rig2()` and
|
|
5106
|
+
// `var RIGVAR = 3`:
|
|
5107
|
+
//
|
|
5108
|
+
// {"exists":true, "typeofRig2_local":"function", "typeofRIGVAR_local":"number",
|
|
5109
|
+
// "globalRig2":"undefined", "viaGlobal":null}
|
|
5110
|
+
// // and on the next run_jsx call, in the same AE session:
|
|
5111
|
+
// {"typeofRig2":"undefined", "viaGlobal":"undefined"}
|
|
5112
|
+
//
|
|
5113
|
+
// $.evalFile evaluates into the *calling function's* scope, exactly as eval
|
|
5114
|
+
// does. Everything a library defined therefore lived inside the loader and was
|
|
5115
|
+
// gone before the wrapper ran, so `libraries` answered "Function rig is
|
|
5116
|
+
// undefined" every time, for every library, in every session. The per-session
|
|
5117
|
+
// cache keyed on a content hash has gone with it: nothing was ever left loaded
|
|
5118
|
+
// to reuse, so the only work it ever skipped was work whose result had already
|
|
5119
|
+
// been discarded.
|
|
5120
|
+
//
|
|
5121
|
+
// One eval means one scope: a library's `function helper(){}` is a declaration
|
|
5122
|
+
// in the same function body as the script, so the script can call it. Two
|
|
5123
|
+
// consequences, both handled here rather than left to surprise someone:
|
|
5124
|
+
//
|
|
5125
|
+
// * A library is re-evaluated on every call. That is the price of the scoping
|
|
5126
|
+
// After Effects actually has. Keep libraries to declarations, not to work.
|
|
5127
|
+
// * The library text shifts the caller's line 1 down, which is precisely the
|
|
5128
|
+
// failure issue #46 was about. __rjBuildSource *counts* the preamble it
|
|
5129
|
+
// built rather than asserting a constant, and __rjSourceInfo subtracts that
|
|
5130
|
+
// count — so a caller's line 1 is line 1 of their own script whether they
|
|
5131
|
+
// passed libraries or not.
|
|
5132
|
+
|
|
5133
|
+
// No regex literal, for the same reason __rjLines has none.
|
|
5134
|
+
function __rjIsBlank(s) {
|
|
5135
|
+
var t = String(s);
|
|
5136
|
+
for (var i = 0; i < t.length; i++) {
|
|
5137
|
+
var c = t.charAt(i);
|
|
5138
|
+
if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r") return false;
|
|
5139
|
+
}
|
|
5140
|
+
return true;
|
|
5141
|
+
}
|
|
5142
|
+
|
|
5143
|
+
// Every library ends in a newline before the next one — or the caller's script
|
|
5144
|
+
// — follows it. Appended directly, a library whose last line is a `//` comment
|
|
5145
|
+
// would comment out whatever came after it: the same trap __RJ_WRAP_SUFFIX
|
|
5146
|
+
// exists for at the other end of the wrapper.
|
|
5147
|
+
function __rjEndWithNewline(s) {
|
|
5148
|
+
var t = String(s);
|
|
5149
|
+
if (t.length === 0) return "\n";
|
|
5150
|
+
var last = t.charAt(t.length - 1);
|
|
5151
|
+
if (last === "\n" || last === "\r") return t;
|
|
5152
|
+
return t + "\n";
|
|
5153
|
+
}
|
|
5154
|
+
|
|
5155
|
+
// The server reads library files and sends {path, text}, exactly as it does for
|
|
5156
|
+
// scriptPath — one place reads files, and its errors can name the path it was
|
|
5157
|
+
// given. Arriving with a path and no text means the call did not come through
|
|
5158
|
+
// the run_jsx tool: a run_batch step, whose args are never validated, or a
|
|
5159
|
+
// hand-rolled POST /op.
|
|
5160
|
+
function __rjLibrarySource(lib) {
|
|
5161
|
+
var p = null;
|
|
5162
|
+
var text = null;
|
|
5163
|
+
if (typeof lib === "string") {
|
|
5164
|
+
p = lib;
|
|
5165
|
+
} else if (lib) {
|
|
5166
|
+
p = lib.path;
|
|
5167
|
+
if (typeof lib.text === "string") text = lib.text;
|
|
5168
|
+
}
|
|
5169
|
+
if (!p) throw new Error("run_jsx: a libraries entry has no path.");
|
|
5170
|
+
if (text === null) {
|
|
5171
|
+
throw new Error(
|
|
5172
|
+
"run_jsx library \"" + p + "\" arrived with no source text. The server reads library files " +
|
|
5173
|
+
"and substitutes their contents, so this call did not go through the run_jsx tool — " +
|
|
5174
|
+
"run_batch steps and direct /op posts must pass {path, text} themselves."
|
|
5175
|
+
);
|
|
5176
|
+
}
|
|
5177
|
+
if (__rjIsBlank(text)) throw new Error("run_jsx library is empty: " + p);
|
|
5178
|
+
return { path: String(p), text: __rjEndWithNewline(text) };
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
// Parse each library on its own before any of it reaches the shared eval.
|
|
5182
|
+
// Inlining means a library that does not parse takes the whole wrapper with it,
|
|
5183
|
+
// and the line After Effects reports for a syntax error is wherever its parser
|
|
5184
|
+
// gave up — frequently inside the caller's script, which would blame the wrong
|
|
5185
|
+
// file for someone else's missing brace. An uncalled function expression forces
|
|
5186
|
+
// a full parse of exactly this library and nothing else, so the failure names
|
|
5187
|
+
// it and counts from its own line 1.
|
|
5188
|
+
function __rjCheckLibraryParses(lib) {
|
|
5189
|
+
try {
|
|
5190
|
+
eval("(function(){ " + lib.text + "})");
|
|
5191
|
+
} catch (e) {
|
|
5192
|
+
var m = "";
|
|
5193
|
+
try { m = (e && e.message) ? String(e.message) : String(e); } catch (e1) { m = "unknown error"; }
|
|
5194
|
+
var ln = null;
|
|
5195
|
+
try { if (e && typeof e.line === "number" && isFinite(e.line)) ln = e.line; } catch (e2) {}
|
|
5196
|
+
var libLines = __rjLines(lib.text);
|
|
5197
|
+
var count = libLines.length - 1;
|
|
5198
|
+
var detail = {
|
|
5199
|
+
lineCount: count,
|
|
5200
|
+
rawLine: ln,
|
|
5201
|
+
sourceLine: null,
|
|
5202
|
+
sourceText: null,
|
|
5203
|
+
sourceName: lib.path
|
|
5204
|
+
};
|
|
5205
|
+
if (ln !== null && ln >= 1 && ln <= count) {
|
|
5206
|
+
detail.sourceLine = ln;
|
|
5207
|
+
detail.sourceText = __rjClip(__rjTrim(libLines[ln - 1]), 200);
|
|
5208
|
+
}
|
|
5209
|
+
throw {
|
|
5210
|
+
message: "run_jsx library failed to parse: " + lib.path + " — " + m,
|
|
5211
|
+
stack: "",
|
|
5212
|
+
line: ln,
|
|
5213
|
+
aeDetail: detail
|
|
5214
|
+
};
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
|
|
5218
|
+
// The evaluated source for one call, plus the map that turns any line of it
|
|
5219
|
+
// back into a line of a file the caller knows about.
|
|
5220
|
+
//
|
|
5221
|
+
// `preambleLines` is COUNTED from the text that precedes the script rather than
|
|
5222
|
+
// assumed. That is the invariant issue #46 turned on, and it now has to hold
|
|
5223
|
+
// for a preamble whose length changes from call to call: it can never drift
|
|
5224
|
+
// from the string it describes, however many libraries there are.
|
|
5225
|
+
function __rjBuildSource(code, libraries) {
|
|
5226
|
+
var prefix = __RJ_WRAP_PREFIX;
|
|
5227
|
+
var segments = [];
|
|
5228
|
+
if (libraries && libraries.length) {
|
|
5229
|
+
for (var i = 0; i < libraries.length; i++) {
|
|
5230
|
+
var lib = __rjLibrarySource(libraries[i]);
|
|
5231
|
+
__rjCheckLibraryParses(lib);
|
|
5232
|
+
segments.push({
|
|
5233
|
+
path: lib.path,
|
|
5234
|
+
// The wrapper opener carries no newline, so the first library's line 1
|
|
5235
|
+
// shares line 1 of the evaluated source with it.
|
|
5236
|
+
firstLine: __rjLines(prefix).length,
|
|
5237
|
+
lines: __rjLines(lib.text)
|
|
5238
|
+
});
|
|
5239
|
+
prefix = prefix + lib.text;
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
return {
|
|
5243
|
+
wrapper: prefix + code + __RJ_WRAP_SUFFIX,
|
|
5244
|
+
preambleLines: __rjLines(prefix).length - 1,
|
|
5245
|
+
segments: segments
|
|
5246
|
+
};
|
|
5247
|
+
}
|
|
5248
|
+
|
|
2952
5249
|
// undoGroup:false is a per-call opt-out, read by dispatch() through the
|
|
2953
5250
|
// predicate form of __meta.noUndo. It exists because AE refuses copyToComp for
|
|
2954
5251
|
// a layer with a parent or a linked expression while an undo group is open,
|
|
2955
5252
|
// which is exactly the layer worth copying (issue #30).
|
|
2956
5253
|
OPS.run_jsx = noUndoWhen(function (args) { return args.undoGroup === false; }, function (args) {
|
|
2957
5254
|
var code = args.code || "";
|
|
2958
|
-
//
|
|
2959
|
-
|
|
5255
|
+
// scriptPath is resolved to code by the *server* before the payload is built.
|
|
5256
|
+
// Arriving here with a path and no code means it came in some other way — a
|
|
5257
|
+
// run_batch step, whose args are never validated, or a hand-rolled POST /op.
|
|
5258
|
+
// Running an empty script would return the "completed with no return value"
|
|
5259
|
+
// envelope, which is a success result for a file nobody read.
|
|
5260
|
+
if (!code && args.scriptPath) {
|
|
5261
|
+
throw new Error(
|
|
5262
|
+
"run_jsx received scriptPath \"" + args.scriptPath + "\" with no code. The server reads that " +
|
|
5263
|
+
"file and substitutes it, so this path did not go through the run_jsx tool — run_batch steps " +
|
|
5264
|
+
"and direct /op posts must pass `code` themselves."
|
|
5265
|
+
);
|
|
5266
|
+
}
|
|
5267
|
+
if (!code) throw new Error("run_jsx needs `code` — an empty script would report success for nothing.");
|
|
5268
|
+
// We wrap in a function so `return` works, with any libraries inlined ahead
|
|
5269
|
+
// of the script so they share its scope. Assembled and parse-checked before
|
|
5270
|
+
// anything runs, and outside the try below: a library that fails to parse is
|
|
5271
|
+
// not a line in the caller's script and must not be reported as one.
|
|
5272
|
+
var built = __rjBuildSource(code, args.libraries);
|
|
2960
5273
|
// Which undo step to look for in AE if the script has to be backed out. The
|
|
2961
5274
|
// name mirrors dispatch()'s default ("AE MCP: " + op); false means the caller
|
|
2962
5275
|
// asked for no group and the changes landed as whatever steps AE recorded.
|
|
2963
5276
|
var undoGroupName = (args.undoGroup === false) ? false : "AE MCP: run_jsx";
|
|
2964
|
-
|
|
5277
|
+
// diff:true fingerprints the comp before and after, inside this one call —
|
|
5278
|
+
// see snapshot.jsx. Null unless asked for, so the ordinary path is untouched.
|
|
5279
|
+
var __d = __diffStart(args, null);
|
|
5280
|
+
var __value;
|
|
5281
|
+
try {
|
|
5282
|
+
__value = eval(built.wrapper);
|
|
5283
|
+
} catch (e) {
|
|
5284
|
+
// Annotate before mapping the line: __rjThrowWithSource reads e.message,
|
|
5285
|
+
// so the diff note has to be on it by then, and the source mapping is what
|
|
5286
|
+
// makes the reported line the caller's own (#46).
|
|
5287
|
+
__diffAnnotateError(e, __d);
|
|
5288
|
+
__rjThrowWithSource(e, code, args.scriptPath, built);
|
|
5289
|
+
}
|
|
5290
|
+
var __out = __rjResult(__value, undoGroupName);
|
|
5291
|
+
if (__d) return __rjWithDiff(__out, __diffFinish(__d), undoGroupName);
|
|
5292
|
+
return __out;
|
|
2965
5293
|
});
|