@engine-room/after-effects-mcp 0.4.0 → 0.5.1

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.
@@ -295,10 +295,68 @@ OPS.set_comp = function (args) {
295
295
  return __compSummary(c);
296
296
  };
297
297
 
298
+ // A solid layer's source is a FootageItem in the project's Solids folder, and
299
+ // removing the comp removes the layer, not the item (issue #83). The solids
300
+ // this comp used are collected from ITS OWN layers before the removal and
301
+ // nothing else — deleting comp A must never reach a solid that only some other
302
+ // comp uses — and after it, `usedIn` decides: an item some other comp still
303
+ // places is kept and reported with the comps using it. The helpers live in
304
+ // footage.jsx beside purge_unused_footage, the project-wide sweep.
298
305
  OPS.delete_comp = function (args) {
299
306
  var c = getCompById(args.compId);
307
+ var compId = c.id;
308
+ var compName = c.name;
309
+ var purge = (args.purgeUnusedSolids === true);
310
+
311
+ var solids = __solidItemsOf(c);
312
+
300
313
  c.remove();
301
- return { ok: true };
314
+
315
+ var out = { ok: true, compId: compId, name: compName };
316
+ var kept = [];
317
+ var toRemove = [];
318
+ var gone = [];
319
+ for (var i = 0; i < solids.length; i++) {
320
+ // Re-fetched by id after the removal rather than held across it — the
321
+ // handle staleness this repo has measured elsewhere is not worth risking
322
+ // for one lookup per solid.
323
+ var item = app.project.itemByID(solids[i].id);
324
+ if (!item) { gone.push(solids[i]); continue; }
325
+ var uses = __usedInList(item);
326
+ if (uses.length > 0) {
327
+ kept.push({ id: item.id, name: item.name, usedIn: uses });
328
+ continue;
329
+ }
330
+ toRemove.push({ item: item, id: item.id, name: item.name, kind: "solid" });
331
+ }
332
+ if (gone.length > 0) out.solidsAlreadyGone = gone;
333
+
334
+ if (!purge) {
335
+ out.unusedSolidsLeft = toRemove.length;
336
+ if (toRemove.length > 0) {
337
+ out.note = toRemove.length + " solid item(s) this comp used are now used by nothing and remain in the project's Solids folder. " +
338
+ "Pass purgeUnusedSolids:true to remove them with the comp, or purge_unused_footage to sweep the whole project.";
339
+ }
340
+ return out;
341
+ }
342
+
343
+ var r = __removeItems(toRemove);
344
+ out.removedSolids = r.removed;
345
+ out.keptSolids = kept;
346
+ if (r.failed) {
347
+ var msg = "delete_comp removed comp \"" + compName + "\" (id " + compId + "), then failed purging its solids at \"" +
348
+ r.failed.name + "\" (id " + r.failed.id + "): " + r.error + ".";
349
+ if (r.removed.length > 0) msg += " Removed before it: " + __nameList(r.removed, 20) + ".";
350
+ else msg += " No solid had been removed yet.";
351
+ if (r.notAttempted.length > 0) msg += " Not attempted: " + __nameList(r.notAttempted, 20) + ".";
352
+ msg += " The comp is gone, so do not call delete_comp for it again. One Undo in After Effects restores the comp and " +
353
+ "these solids together; purge_unused_footage removes whatever is still left.";
354
+ throw new Error(msg);
355
+ }
356
+ if (kept.length > 0) {
357
+ out.note = kept.length + " solid item(s) were kept because another comp still uses them; see keptSolids.usedIn.";
358
+ }
359
+ return out;
302
360
  };
303
361
 
304
362
  // ---------------------------------------------------------------------------
@@ -377,20 +435,24 @@ function __dupRepoint(comp, opts, depth) {
377
435
  }
378
436
  }
379
437
 
438
+ // A `folderId` argument resolved to a FolderItem, or a thrown error naming the
439
+ // id and what it actually is. Shared by duplicate_comp and purge_unused_footage.
440
+ function __folderArg(id) {
441
+ var f = app.project.itemByID(id);
442
+ if (!f) throw new Error("No project item with id " + id + " to use as folderId");
443
+ if (!(f instanceof FolderItem)) {
444
+ throw new Error(
445
+ "folderId " + id + ' ("' + f.name + '") is a ' + __itemKind(f) +
446
+ ", not a project folder. Pass the id of a folder from get_project_summary, or omit folderId."
447
+ );
448
+ }
449
+ return f;
450
+ }
451
+
380
452
  OPS.duplicate_comp = function (args) {
381
453
  var src = getCompById(args.compId);
382
454
  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
- }
455
+ if (args.folderId !== undefined && args.folderId !== null) folder = __folderArg(args.folderId);
394
456
 
395
457
  var opts = { seen: {}, created: [], repointed: 0, nameSuffix: null };
396
458
  if (args.nameSuffix) opts.nameSuffix = args.nameSuffix;
@@ -553,15 +615,33 @@ OPS.create_text_layer = function (args) {
553
615
  var align = args.anchorAlign === undefined ? "left" : args.anchorAlign;
554
616
  var wantJustify = align !== "none" && __JUSTIFICATION[align] !== undefined;
555
617
  var wantTracking = args.tracking !== undefined || align !== "none";
618
+ var verified = null;
556
619
  if (args.font || args.size || args.color || wantJustify || wantTracking) {
557
620
  var srcText = l.property("Source Text");
558
621
  var td = srcText.value;
622
+ // The same TextDocument round trip set_text does, so the same read-back
623
+ // (issue #93 / #94, see __verifyJustification in text.jsx): the requested
624
+ // justification if there is one, otherwise whatever addText() gave the
625
+ // layer — 'none' promised to leave it alone, and that is checked too.
626
+ var justificationWanted = wantJustify ? __JUSTIFICATION[align] : td.justification;
559
627
  if (args.font) td.font = args.font;
560
628
  if (args.size) td.fontSize = args.size;
561
629
  if (args.color) { td.applyFill = true; td.fillColor = [args.color[0], args.color[1], args.color[2]]; }
562
630
  if (wantTracking) td.tracking = (args.tracking !== undefined ? args.tracking : 0);
563
- if (wantJustify) td.justification = __JUSTIFICATION[align];
631
+ if (wantJustify) td.justification = justificationWanted;
564
632
  srcText.setValue(td);
633
+ try {
634
+ verified = __verifyJustification(srcText, justificationWanted);
635
+ } catch (e) {
636
+ // A throw here would leave a layer the caller has no id for, and the
637
+ // natural next move — call again — makes two. Same rule as
638
+ // add_shape_content: take the node back, then say so. The message is
639
+ // amended on the same Error object so nothing about it is lost.
640
+ try { l.remove(); } catch (e2) {}
641
+ e.message = String(e.message) + " The new layer was removed, so nothing was created; " +
642
+ "create it with anchorAlign:\"none\" and set the alignment afterwards with set_text.";
643
+ throw e;
644
+ }
565
645
  }
566
646
  if (align !== "none") {
567
647
  l.property("Transform").property("Anchor Point").setValue([0, 0, 0]);
@@ -570,7 +650,12 @@ OPS.create_text_layer = function (args) {
570
650
  var p = args.position;
571
651
  l.property("Transform").property("Position").setValue(p.length === 3 ? p : [p[0], p[1]]);
572
652
  }
573
- return __layerSummary(l);
653
+ var out = __layerSummary(l);
654
+ if (verified) {
655
+ out.justification = __justificationName(verified.justification);
656
+ out.justificationReasserted = verified.reasserted;
657
+ }
658
+ return out;
574
659
  };
575
660
 
576
661
  OPS.create_solid_layer = function (args) {
@@ -1973,11 +2058,54 @@ OPS.set_spatial_tangents = function (args) {
1973
2058
 
1974
2059
  // expressions.jsx — get/set/toggle/clear expressions on any property.
1975
2060
 
2061
+ // After Effects reports a broken expression on `Property.expressionError` and
2062
+ // nowhere a script can otherwise see: assigning `.expression` succeeds whatever
2063
+ // the text says, and the failure is a warning banner in the UI (issue #97).
2064
+ // The field is filled in when the expression is *evaluated*, which a bare
2065
+ // assignment does not necessarily do — so the value is read first, inside a
2066
+ // try, purely to force one evaluation. Whether AE would populate the field
2067
+ // without that nudge cannot be settled offline; forcing it costs one evaluation
2068
+ // and makes the answer independent of the question. `.value` throws on a
2069
+ // property that has none (a group), which is also not an expression error.
2070
+ function __expressionError(prop) {
2071
+ try { var forced = prop.value; } catch (e) {}
2072
+ var err = "";
2073
+ try { err = prop.expressionError; } catch (e2) { err = ""; }
2074
+ return err ? String(err) : "";
2075
+ }
2076
+
2077
+ function __propertyPathLabel(path) {
2078
+ var parts = [];
2079
+ for (var i = 0; i < path.length; i++) parts.push(String(path[i]));
2080
+ return parts.join(" > ");
2081
+ }
2082
+
2083
+ // Throws if the expression on `prop` does not evaluate, naming the property,
2084
+ // AE's own message and both ways forward. The expression stays written — the
2085
+ // message says so — because nothing here rolls back and a caller fixing a
2086
+ // typo wants the property it was aimed at, not a cleared one.
2087
+ function __assertExpressionEvaluates(prop, path, verb) {
2088
+ var err = __expressionError(prop);
2089
+ if (!err) return;
2090
+ var enabledNow;
2091
+ try { enabledNow = !!prop.expressionEnabled; } catch (e) { enabledNow = null; }
2092
+ throw new Error(
2093
+ "Expression on " + __propertyPathLabel(path) + " was " + verb +
2094
+ " but After Effects cannot evaluate it: " + err +
2095
+ (enabledNow === false ? " (After Effects has disabled it.)" : "") +
2096
+ " Fix the expression and call set_expression again, or clear_expression to remove it."
2097
+ );
2098
+ }
2099
+
1976
2100
  OPS.get_expression = noUndo(function (args) {
1977
2101
  var c = getCompById(args.compId);
1978
2102
  var l = getLayerById(c, args.layerId);
1979
2103
  var prop = walkProperty(l, args.propertyPath);
1980
- return { expression: prop.expression || "", enabled: !!prop.expressionEnabled };
2104
+ return {
2105
+ expression: prop.expression || "",
2106
+ enabled: !!prop.expressionEnabled,
2107
+ expressionError: __expressionError(prop),
2108
+ };
1981
2109
  });
1982
2110
 
1983
2111
  OPS.set_expression = function (args) {
@@ -1986,6 +2114,7 @@ OPS.set_expression = function (args) {
1986
2114
  var prop = walkProperty(l, args.propertyPath);
1987
2115
  prop.expression = args.expression || "";
1988
2116
  prop.expressionEnabled = true;
2117
+ __assertExpressionEvaluates(prop, args.propertyPath, "written");
1989
2118
  return { ok: true };
1990
2119
  };
1991
2120
 
@@ -1994,6 +2123,9 @@ OPS.toggle_expression = function (args) {
1994
2123
  var l = getLayerById(c, args.layerId);
1995
2124
  var prop = walkProperty(l, args.propertyPath);
1996
2125
  prop.expressionEnabled = !!args.enabled;
2126
+ // Disabling cannot fail this way; enabling can surface an error that has
2127
+ // been sitting on the property since it was disabled.
2128
+ if (args.enabled) __assertExpressionEvaluates(prop, args.propertyPath, "enabled");
1997
2129
  return { ok: true };
1998
2130
  };
1999
2131
 
@@ -2147,12 +2279,63 @@ var __JUSTIFICATION = {
2147
2279
  full: ParagraphJustification.FULL_JUSTIFY_LASTLINE_LEFT,
2148
2280
  };
2149
2281
 
2282
+ // The name for an enum value, for results and errors. AE has more FULL_JUSTIFY
2283
+ // variants than the map offers, so a value the map does not know comes back as
2284
+ // its number rather than as the nearest name.
2285
+ function __justificationName(v) {
2286
+ for (var k in __JUSTIFICATION) {
2287
+ if (__JUSTIFICATION.hasOwnProperty(k) && __JUSTIFICATION[k] === v) return k;
2288
+ }
2289
+ return String(v);
2290
+ }
2291
+
2292
+ // Issue #93: on AE 26.3 the TextDocument round trip — read `.value`, change
2293
+ // `.text`, `setValue()` — has come back with justification reset to CENTER
2294
+ // even though the document that went in still said LEFT; issue #94 reports the
2295
+ // setter storing RIGHT when handed CENTER from a script. Nothing raises in
2296
+ // either case and the mechanism is unknown, so the only guard that can be built
2297
+ // blind is the one this repo asks for anyway: read the property back and
2298
+ // compare with what should be there. A second write of the justification alone
2299
+ // has been seen to land (`set_text({justification})` on its own always did), so
2300
+ // one re-assert is tried before giving up. If that also disagrees, the caller
2301
+ // is told what was expected and what the layer actually shows — a text layer
2302
+ // that silently re-centres is the failure this codebase refuses everywhere.
2303
+ //
2304
+ // `expected` is an enum value: the requested one, or the one read before the
2305
+ // mutation. Returns {justification, reasserted}; throws when it cannot deliver.
2306
+ function __verifyJustification(src, expected) {
2307
+ var actual = src.value.justification;
2308
+ if (actual === expected) return { justification: actual, reasserted: false };
2309
+ var again = src.value;
2310
+ again.justification = expected;
2311
+ src.setValue(again);
2312
+ actual = src.value.justification;
2313
+ if (actual === expected) return { justification: actual, reasserted: true };
2314
+ throw new Error(
2315
+ "Source Text justification did not land: expected " + __justificationName(expected) +
2316
+ " (" + String(expected) + "), the layer reads " + __justificationName(actual) +
2317
+ " (" + String(actual) + ") after a second write. The other fields were written. " +
2318
+ "Read the layer back with get_layer_full include:[\"text\"] before relying on its alignment."
2319
+ );
2320
+ }
2321
+
2150
2322
  OPS.set_text = function (args) {
2151
2323
  var c = getCompById(args.compId);
2152
2324
  var l = getLayerById(c, args.layerId);
2153
2325
  if (!(l instanceof TextLayer)) throw new Error("Layer is not a TextLayer");
2154
2326
  var src = l.property("Source Text");
2155
2327
  var td = src.value;
2328
+ // Captured before anything is touched: "undefined fields unchanged" is a
2329
+ // claim about this value, and it is what the read-back is checked against
2330
+ // when no justification was asked for.
2331
+ var justificationBefore = td.justification;
2332
+ var justificationWanted = justificationBefore;
2333
+ if (args.justification !== undefined) {
2334
+ if (__JUSTIFICATION[args.justification] === undefined) {
2335
+ throw new Error("Unknown justification '" + String(args.justification) + "'; expected one of left, center, right, full. Nothing was changed.");
2336
+ }
2337
+ justificationWanted = __JUSTIFICATION[args.justification];
2338
+ }
2156
2339
  if (args.text !== undefined) td.text = args.text;
2157
2340
  if (args.font !== undefined) td.font = args.font;
2158
2341
  if (args.size !== undefined) td.fontSize = args.size;
@@ -2161,9 +2344,7 @@ OPS.set_text = function (args) {
2161
2344
  if (args.strokeWidth !== undefined) td.strokeWidth = args.strokeWidth;
2162
2345
  if (args.tracking !== undefined) td.tracking = args.tracking;
2163
2346
  if (args.leading !== undefined) td.leading = args.leading;
2164
- if (args.justification !== undefined) {
2165
- if (__JUSTIFICATION[args.justification]) td.justification = __JUSTIFICATION[args.justification];
2166
- }
2347
+ if (args.justification !== undefined) td.justification = justificationWanted;
2167
2348
  if (args.applyFill !== undefined) td.applyFill = args.applyFill;
2168
2349
  if (args.applyStroke !== undefined) td.applyStroke = args.applyStroke;
2169
2350
  if (args.fauxBold !== undefined) td.fauxBold = args.fauxBold;
@@ -2172,7 +2353,12 @@ OPS.set_text = function (args) {
2172
2353
  if (args.smallCaps !== undefined) td.smallCaps = args.smallCaps;
2173
2354
  if (args.baselineShift !== undefined) td.baselineShift = args.baselineShift;
2174
2355
  src.setValue(td);
2175
- return { ok: true };
2356
+ var verified = __verifyJustification(src, justificationWanted);
2357
+ return {
2358
+ ok: true,
2359
+ justification: __justificationName(verified.justification),
2360
+ justificationReasserted: verified.reasserted,
2361
+ };
2176
2362
  };
2177
2363
 
2178
2364
  OPS.add_text_animator = function (args) {
@@ -2921,6 +3107,221 @@ OPS.create_footage_layer = function (args) {
2921
3107
  };
2922
3108
  };
2923
3109
 
3110
+ // ---------------------------------------------------------------------------
3111
+ // Unused footage, and the solids delete_comp leaves behind (issue #83)
3112
+ // ---------------------------------------------------------------------------
3113
+ // A solid layer's source is a FootageItem in the project's Solids folder, not
3114
+ // something the comp owns. Deleting the comp (or the layer) deletes the layer
3115
+ // and leaves the item: nothing in AE's DOM ties the two lifetimes together and
3116
+ // nothing warns, so a session that builds and discards comps grows the project
3117
+ // bin without bound — a verification pass had to sweep 1,863 of them out by
3118
+ // name. Once orphaned they are indistinguishable from solids still in use
3119
+ // unless something reads `usedIn`, which is what everything below does.
3120
+ //
3121
+ // Two ops share these helpers. `delete_comp` (comps.jsx) can purge the solids
3122
+ // the deleted comp referenced — only those, and only the ones nothing else
3123
+ // still uses — and `purge_unused_footage` is the project-wide sweep: AE's own
3124
+ // Remove Unused Footage, with a dryRun and a report of what went.
3125
+
3126
+ /** The item behind a solid or adjustment layer: a FootageItem whose source is a SolidSource. */
3127
+ function __isSolidItem(it) {
3128
+ if (!(it instanceof FootageItem)) return false;
3129
+ var src = null;
3130
+ try { src = it.mainSource; } catch (e) { return false; }
3131
+ if (!src) return false;
3132
+ return (src instanceof SolidSource);
3133
+ }
3134
+
3135
+ /** "solid" | "placeholder" | "footage". Only ever called on a FootageItem — a comp or folder is never footage. */
3136
+ function __footageKind(it) {
3137
+ if (__isSolidItem(it)) return "solid";
3138
+ try {
3139
+ if (typeof PlaceholderSource !== "undefined" && it.mainSource instanceof PlaceholderSource) return "placeholder";
3140
+ } catch (e) {}
3141
+ return "footage";
3142
+ }
3143
+
3144
+ /**
3145
+ * The comps an item is placed in, as {id, name}. Read from After Effects on
3146
+ * every call rather than inferred from anything this bundle remembers — the
3147
+ * whole safety of a purge is that the answer is AE's, at the moment of asking.
3148
+ */
3149
+ function __usedInList(it) {
3150
+ var out = [];
3151
+ var comps = null;
3152
+ try { comps = it.usedIn; } catch (e) { comps = null; }
3153
+ if (!comps) return out;
3154
+ for (var i = 0; i < comps.length; i++) {
3155
+ var c = comps[i];
3156
+ if (!c) continue;
3157
+ out.push({ id: c.id, name: c.name });
3158
+ }
3159
+ return out;
3160
+ }
3161
+
3162
+ /** True when `it` sits in `folder` or in any folder nested under it. */
3163
+ function __isInFolder(it, folder) {
3164
+ var cur = null;
3165
+ try { cur = it.parentFolder; } catch (e) { return false; }
3166
+ for (var depth = 0; cur && depth < 64; depth++) {
3167
+ if (cur.id === folder.id) return true;
3168
+ var next = null;
3169
+ try { next = cur.parentFolder; } catch (e2) { next = null; }
3170
+ cur = next;
3171
+ }
3172
+ return false;
3173
+ }
3174
+
3175
+ /** The distinct solid items this comp's own layers point at, as {id, name}. Nothing from any other comp. */
3176
+ function __solidItemsOf(comp) {
3177
+ var out = [];
3178
+ var seen = {};
3179
+ for (var i = 1; i <= comp.numLayers; i++) {
3180
+ var l = comp.layer(i);
3181
+ if (!(l instanceof AVLayer)) continue;
3182
+ var src = null;
3183
+ try { src = l.source; } catch (e) { continue; }
3184
+ if (!src || !__isSolidItem(src)) continue;
3185
+ var key = "I" + src.id;
3186
+ if (seen.hasOwnProperty(key)) continue;
3187
+ seen[key] = true;
3188
+ out.push({ id: src.id, name: src.name });
3189
+ }
3190
+ return out;
3191
+ }
3192
+
3193
+ function __itemReport(entry) {
3194
+ var r = { id: entry.id, name: entry.name, kind: entry.kind };
3195
+ if (entry.footageMissing) r.footageMissing = true;
3196
+ return r;
3197
+ }
3198
+
3199
+ function __nameList(reports, cap) {
3200
+ var names = [];
3201
+ for (var i = 0; i < reports.length && i < cap; i++) names.push("\"" + reports[i].name + "\" (id " + reports[i].id + ")");
3202
+ var s = names.join(", ");
3203
+ if (reports.length > cap) s += " and " + (reports.length - cap) + " more";
3204
+ return s;
3205
+ }
3206
+
3207
+ /**
3208
+ * Remove a list of project items. Entries are {item, id, name, kind, index?}.
3209
+ *
3210
+ * Highest project index first when indices are known, so no removal shifts an
3211
+ * item still to be visited — the entries were collected in one pass over
3212
+ * app.project.item(i) and removed in a second, never removed mid-walk. It stops
3213
+ * at the first failure: nothing rolls back, so the caller is handed what went
3214
+ * before the failure and what was never attempted, and reports that, rather
3215
+ * than a sweep that claims to have finished.
3216
+ */
3217
+ function __removeItems(entries) {
3218
+ var ordered = entries.slice(0);
3219
+ ordered.sort(function (a, b) { return (b.index || 0) - (a.index || 0); });
3220
+ var removed = [];
3221
+ for (var i = 0; i < ordered.length; i++) {
3222
+ var entry = ordered[i];
3223
+ try {
3224
+ entry.item.remove();
3225
+ } catch (e) {
3226
+ var pending = [];
3227
+ for (var j = i + 1; j < ordered.length; j++) pending.push(__itemReport(ordered[j]));
3228
+ return {
3229
+ removed: removed,
3230
+ failed: __itemReport(entry),
3231
+ error: (e && e.message) ? String(e.message) : String(e),
3232
+ notAttempted: pending
3233
+ };
3234
+ }
3235
+ removed.push(__itemReport(entry));
3236
+ }
3237
+ return { removed: removed, failed: null, error: null, notAttempted: [] };
3238
+ }
3239
+
3240
+ OPS.purge_unused_footage = noUndoWhen(
3241
+ // A dry run is not an undo step. A sweep that changed nothing appearing in
3242
+ // the user's undo history would make "this changed nothing" false in the one
3243
+ // place they can see it — the same stance place_audio_cues takes.
3244
+ function (args) { return !!(args && args.dryRun === true); },
3245
+ function (args) {
3246
+ args = args || {};
3247
+ var solidsOnly = (args.solidsOnly !== false);
3248
+ var dryRun = (args.dryRun === true);
3249
+ var folder = null;
3250
+ if (args.folderId !== undefined && args.folderId !== null) folder = __folderArg(args.folderId);
3251
+ var what = "footage item";
3252
+ if (solidsOnly) what = "solid";
3253
+
3254
+ // One pass to decide, a second to remove. Removing inside the walk would
3255
+ // shift every index above the removed item under the loop's feet.
3256
+ var candidates = [];
3257
+ var scanned = 0;
3258
+ var inUse = 0;
3259
+ var proj = app.project;
3260
+ for (var i = 1; i <= proj.numItems; i++) {
3261
+ var it = proj.item(i);
3262
+ // A comp is an AVItem with a usedIn of its own, and a nested comp nothing
3263
+ // uses any more is exactly what a naive sweep would delete. Only footage.
3264
+ if (!(it instanceof FootageItem)) continue;
3265
+ if (solidsOnly && !__isSolidItem(it)) continue;
3266
+ if (folder && !__isInFolder(it, folder)) continue;
3267
+ scanned += 1;
3268
+ if (__usedInList(it).length > 0) { inUse += 1; continue; }
3269
+ var missing = false;
3270
+ try { missing = !!it.footageMissing; } catch (eMissing) {}
3271
+ candidates.push({ item: it, id: it.id, name: it.name, kind: __footageKind(it), index: i, footageMissing: missing });
3272
+ }
3273
+ // Sorted here, once, so a dry run lists items in exactly the order a real
3274
+ // run removes them — the plan has to be the truth about order too.
3275
+ candidates.sort(function (a, b) { return b.index - a.index; });
3276
+
3277
+ var out = { solidsOnly: solidsOnly, scanned: scanned, inUse: inUse };
3278
+ var where = "in the project";
3279
+ if (folder) {
3280
+ out.folderId = folder.id;
3281
+ out.folderName = folder.name;
3282
+ where = "in folder \"" + folder.name + "\"";
3283
+ }
3284
+ var nothingWhy = "every " + what + " " + where + " is still used in a comp";
3285
+ if (scanned === 0) nothingWhy = "no " + what + "s " + where;
3286
+
3287
+ if (dryRun) {
3288
+ var would = [];
3289
+ for (var w = 0; w < candidates.length; w++) would.push(__itemReport(candidates[w]));
3290
+ out.dryRun = true;
3291
+ out.wouldRemove = would;
3292
+ out.wouldRemoveCount = would.length;
3293
+ if (would.length === 0) {
3294
+ out.note = "Nothing to remove: " + nothingWhy + ". Nothing was changed and this call is not an undo step.";
3295
+ } else {
3296
+ out.note = "Nothing was removed and this call is not an undo step. Call again without dryRun to remove these " +
3297
+ would.length + " " + what + (would.length === 1 ? "" : "s") + " " + where + ".";
3298
+ }
3299
+ return out;
3300
+ }
3301
+
3302
+ var r = __removeItems(candidates);
3303
+ out.removed = r.removed;
3304
+ out.removedCount = r.removed.length;
3305
+ if (r.failed) {
3306
+ var msg = "purge_unused_footage stopped at \"" + r.failed.name + "\" (id " + r.failed.id + ", " + r.failed.kind + "): " + r.error + ".";
3307
+ if (r.removed.length > 0) {
3308
+ msg += " " + r.removed.length + " item(s) were removed before it: " + __nameList(r.removed, 20) + ".";
3309
+ } else {
3310
+ msg += " Nothing had been removed yet.";
3311
+ }
3312
+ msg += " " + r.notAttempted.length + " unused item(s) were not attempted." +
3313
+ " Nothing rolls back; one Undo in After Effects restores what this call removed. Run again, or with dryRun:true, to see what is left.";
3314
+ throw new Error(msg);
3315
+ }
3316
+ if (r.removed.length === 0) {
3317
+ out.note = "Nothing to remove: " + nothingWhy + ".";
3318
+ } else {
3319
+ out.note = "Removed " + r.removed.length + " unused " + what + (r.removed.length === 1 ? "" : "s") + " " + where + ", as one undo step.";
3320
+ }
3321
+ return out;
3322
+ }
3323
+ );
3324
+
2924
3325
 
2925
3326
  // ===== audio.jsx =====
2926
3327
 
@@ -2946,10 +3347,21 @@ OPS.create_footage_layer = function (args) {
2946
3347
  // .wav are the normal shape of a cue list ("whoosh" nine times), and each
2947
3348
  // one would otherwise add another project item. Anything already in the
2948
3349
  // project from that path is reused rather than imported a second time.
3350
+ //
3351
+ // Three per-cue options (issue #85) — `loop`, `fadeIn`/`fadeOut`, `stretch` —
3352
+ // are the patterns every scoring pass used to re-implement in a run_jsx
3353
+ // follow-up. Each one moves something After Effects then quietly resets, so
3354
+ // the order inside __placeAudioCue is load-bearing; see the comment there.
2949
3355
 
2950
3356
  var __MAX_AUDIO_CUES = 200;
2951
3357
  var __CUE_TIME_EPS = 1e-6;
2952
3358
 
3359
+ // The level a fade starts from and ends at when the caller gives none. -48 dB
3360
+ // is the bottom of the range After Effects' own Audio Levels slider offers, and
3361
+ // 1/256 of the recorded amplitude — near-silence for a fade to begin from while
3362
+ // staying inside the range a user can drag the property through by hand.
3363
+ var __DEFAULT_FADE_FLOOR_DB = -48;
3364
+
2953
3365
  // AE's layer label colours. Users can rename them in preferences, but the tool
2954
3366
  // takes an index and the indices do not move, so the names are accepted as a
2955
3367
  // convenience and translated here. 0 is "None".
@@ -2998,6 +3410,10 @@ function __stripExtension(name) {
2998
3410
  return s;
2999
3411
  }
3000
3412
 
3413
+ function __isFiniteNumber(v) {
3414
+ return typeof v === "number" && isFinite(v);
3415
+ }
3416
+
3001
3417
  /**
3002
3418
  * The Audio Levels property of a layer.
3003
3419
  *
@@ -3019,6 +3435,21 @@ function __audioLevelsProperty(layer) {
3019
3435
  return p;
3020
3436
  }
3021
3437
 
3438
+ /**
3439
+ * The Time Remap property of a layer that has remapping enabled. Same shape of
3440
+ * trap as Audio Levels: `layer.property("ADBE Time Remap")` is null on an
3441
+ * audio layer even after `timeRemapEnabled = true`, and the shortcut
3442
+ * `layer.timeRemap` is the handle that answers. The match-name lookup is kept
3443
+ * only as a fallback; a null from both is reported, never worked around.
3444
+ */
3445
+ function __timeRemapProperty(layer) {
3446
+ var p = null;
3447
+ try { p = layer.timeRemap; } catch (e) {}
3448
+ if (p) return p;
3449
+ try { p = layer.property("ADBE Time Remapping"); } catch (e2) {}
3450
+ return p;
3451
+ }
3452
+
3022
3453
  /** null when the item can carry an audio cue, else the reason it cannot. */
3023
3454
  function __audioItemProblem(item) {
3024
3455
  if (item instanceof FolderItem) return "\"" + item.name + "\" is a folder, not footage";
@@ -3034,6 +3465,84 @@ function __sourceReport(item, path) {
3034
3465
  return { itemId: item.id, name: item.name, path: path };
3035
3466
  }
3036
3467
 
3468
+ /** The duration of a project item, or null when it does not say. */
3469
+ function __itemDuration(item) {
3470
+ if (!item) return null;
3471
+ var d = null;
3472
+ try { d = item.duration; } catch (e) {}
3473
+ if (__isFiniteNumber(d)) return d;
3474
+ return null;
3475
+ }
3476
+
3477
+ /**
3478
+ * Where a planned cue's layer will start and end, in comp time, given what is
3479
+ * known about its source. `end` is null when it cannot be known yet — an
3480
+ * unlooped cue on a file that has not been imported, with no outPoint to cap
3481
+ * it — and the caller decides what to do with that.
3482
+ */
3483
+ function __cueExtent(p, itemDuration) {
3484
+ var start = p.time;
3485
+ if (p.inPoint !== null) start = p.inPoint;
3486
+ var end = null;
3487
+ if (p.loop) {
3488
+ // A loop has no natural end, so its end is the one the caller gave or the
3489
+ // comp's, whichever the planner settled on.
3490
+ end = p.loopEnd;
3491
+ } else {
3492
+ var natural = null;
3493
+ if (__isFiniteNumber(itemDuration)) natural = p.time + itemDuration * p.stretchFactor;
3494
+ end = p.outPoint;
3495
+ // AE clamps an out point to what the source can supply, so a requested
3496
+ // outPoint past the file's end is not where the layer will end.
3497
+ if (end === null || (natural !== null && natural < end)) end = natural;
3498
+ }
3499
+ return { start: start, end: end };
3500
+ }
3501
+
3502
+ function __fmtSeconds(n) {
3503
+ // Rounded for prose only; nothing downstream reads these numbers back.
3504
+ return String(Math.round(n * 1000) / 1000) + "s";
3505
+ }
3506
+
3507
+ /**
3508
+ * null when the cue's fades fit inside its extent; a reason string when they
3509
+ * do not; and the string "unknown" when the extent cannot be computed yet.
3510
+ * Pure — used at plan time and again after the imports, on the same plan.
3511
+ */
3512
+ function __fadeFitProblem(p, extent) {
3513
+ var total = p.fadeIn + p.fadeOut;
3514
+ if (total <= 0 && !p.loop) return null;
3515
+ if (extent.end === null) {
3516
+ if (total <= 0) return null;
3517
+ return "unknown";
3518
+ }
3519
+ var dur = extent.end - extent.start;
3520
+ var span = " (from " + __fmtSeconds(extent.start) + " to " + __fmtSeconds(extent.end) + ")";
3521
+ if (dur <= __CUE_TIME_EPS) {
3522
+ if (p.loop) {
3523
+ var why = "the comp's end, since no outPoint was given";
3524
+ if (p.outPoint !== null) why = "its outPoint";
3525
+ return "inPoint " + __fmtSeconds(extent.start) + " is not before the end of the loop at " + __fmtSeconds(extent.end) + " (" + why + ")";
3526
+ }
3527
+ return "inPoint " + __fmtSeconds(extent.start) + " is past where the cue ends" + span;
3528
+ }
3529
+ if (total <= 0) return null;
3530
+ if (total > dur + __CUE_TIME_EPS) {
3531
+ var what;
3532
+ if (p.fadeIn > 0 && p.fadeOut > 0) {
3533
+ what = "fadeIn " + __fmtSeconds(p.fadeIn) + " + fadeOut " + __fmtSeconds(p.fadeOut) + " = " + __fmtSeconds(total);
3534
+ } else if (p.fadeIn > 0) {
3535
+ what = "fadeIn " + __fmtSeconds(p.fadeIn);
3536
+ } else {
3537
+ what = "fadeOut " + __fmtSeconds(p.fadeOut);
3538
+ }
3539
+ var tail = "";
3540
+ if (!p.loop && p.outPoint === null) tail = " — the file's own length, with no outPoint to extend it and no loop";
3541
+ return what + " is longer than the cue, which runs " + __fmtSeconds(dur) + span + tail;
3542
+ }
3543
+ return null;
3544
+ }
3545
+
3037
3546
  /**
3038
3547
  * Turn the cue list into a plan, touching nothing. Every problem found is
3039
3548
  * collected with the index of the cue that caused it rather than thrown on the
@@ -3055,6 +3564,15 @@ function __planAudioCues(comp, args) {
3055
3564
  var prefix = "SFX_";
3056
3565
  if (typeof args.namePrefix === "string") prefix = args.namePrefix;
3057
3566
 
3567
+ // One floor for the whole list: it is a property of the mix, not of a cue.
3568
+ var floorDb = __DEFAULT_FADE_FLOOR_DB;
3569
+ if (args.fadeFloorDb !== undefined && args.fadeFloorDb !== null) {
3570
+ if (!__isFiniteNumber(args.fadeFloorDb)) {
3571
+ throw new Error("fadeFloorDb must be a number of decibels (the default is " + __DEFAULT_FADE_FLOOR_DB + "); got " + String(args.fadeFloorDb));
3572
+ }
3573
+ floorDb = args.fadeFloorDb;
3574
+ }
3575
+
3058
3576
  var byPath = __itemPathMap();
3059
3577
  var problems = [];
3060
3578
  var planned = [];
@@ -3071,7 +3589,7 @@ function __planAudioCues(comp, args) {
3071
3589
  if (!hasId && !hasPath) { problems.push({ cue: i, reason: "has neither footageId nor path — give exactly one" }); continue; }
3072
3590
 
3073
3591
  var time = cue.time;
3074
- if (typeof time !== "number" || !isFinite(time)) {
3592
+ if (!__isFiniteNumber(time)) {
3075
3593
  problems.push({ cue: i, reason: "time must be a number of seconds; got " + String(time) });
3076
3594
  continue;
3077
3595
  }
@@ -3082,7 +3600,7 @@ function __planAudioCues(comp, args) {
3082
3600
 
3083
3601
  var levelDb = 0;
3084
3602
  if (cue.levelDb !== undefined && cue.levelDb !== null) {
3085
- if (typeof cue.levelDb !== "number" || !isFinite(cue.levelDb)) {
3603
+ if (!__isFiniteNumber(cue.levelDb)) {
3086
3604
  problems.push({ cue: i, reason: "levelDb must be a number of decibels (0 is unedited); got " + String(cue.levelDb) });
3087
3605
  continue;
3088
3606
  }
@@ -3093,7 +3611,7 @@ function __planAudioCues(comp, args) {
3093
3611
  var outPoint = null;
3094
3612
  var trimBad = false;
3095
3613
  if (cue.inPoint !== undefined && cue.inPoint !== null) {
3096
- if (typeof cue.inPoint !== "number" || !isFinite(cue.inPoint)) {
3614
+ if (!__isFiniteNumber(cue.inPoint)) {
3097
3615
  problems.push({ cue: i, reason: "inPoint must be a comp time in seconds; got " + String(cue.inPoint) });
3098
3616
  trimBad = true;
3099
3617
  } else {
@@ -3101,7 +3619,7 @@ function __planAudioCues(comp, args) {
3101
3619
  }
3102
3620
  }
3103
3621
  if (!trimBad && cue.outPoint !== undefined && cue.outPoint !== null) {
3104
- if (typeof cue.outPoint !== "number" || !isFinite(cue.outPoint)) {
3622
+ if (!__isFiniteNumber(cue.outPoint)) {
3105
3623
  problems.push({ cue: i, reason: "outPoint must be a comp time in seconds; got " + String(cue.outPoint) });
3106
3624
  trimBad = true;
3107
3625
  } else {
@@ -3132,6 +3650,53 @@ function __planAudioCues(comp, args) {
3132
3650
  }
3133
3651
  }
3134
3652
 
3653
+ // The #85 options. Type-checked here, before the source is resolved, so a
3654
+ // cue with a bad stretch AND a missing file reports the first of the two
3655
+ // it hits — every cue still gets a line, which is what matters.
3656
+ var loop = false;
3657
+ if (cue.loop !== undefined && cue.loop !== null) {
3658
+ if (cue.loop !== true && cue.loop !== false) {
3659
+ problems.push({ cue: i, reason: "loop must be true or false; got " + String(cue.loop) });
3660
+ continue;
3661
+ }
3662
+ loop = cue.loop;
3663
+ }
3664
+ var stretch = null;
3665
+ if (cue.stretch !== undefined && cue.stretch !== null) {
3666
+ if (!__isFiniteNumber(cue.stretch) || cue.stretch <= 0) {
3667
+ problems.push({ cue: i, reason: "stretch must be a percentage greater than 0 (100 is unchanged, 200 is half speed); got " + String(cue.stretch) });
3668
+ continue;
3669
+ }
3670
+ stretch = cue.stretch;
3671
+ }
3672
+ var fadeIn = 0;
3673
+ var fadeOut = 0;
3674
+ var fadeBad = false;
3675
+ if (cue.fadeIn !== undefined && cue.fadeIn !== null) {
3676
+ if (!__isFiniteNumber(cue.fadeIn) || cue.fadeIn < 0) {
3677
+ problems.push({ cue: i, reason: "fadeIn must be a number of seconds, 0 or more; got " + String(cue.fadeIn) });
3678
+ fadeBad = true;
3679
+ } else {
3680
+ fadeIn = cue.fadeIn;
3681
+ }
3682
+ }
3683
+ if (!fadeBad && cue.fadeOut !== undefined && cue.fadeOut !== null) {
3684
+ if (!__isFiniteNumber(cue.fadeOut) || cue.fadeOut < 0) {
3685
+ problems.push({ cue: i, reason: "fadeOut must be a number of seconds, 0 or more; got " + String(cue.fadeOut) });
3686
+ fadeBad = true;
3687
+ } else {
3688
+ fadeOut = cue.fadeOut;
3689
+ }
3690
+ }
3691
+ if (fadeBad) continue;
3692
+ if ((fadeIn > 0 || fadeOut > 0) && floorDb >= levelDb) {
3693
+ problems.push({
3694
+ cue: i,
3695
+ reason: "fadeFloorDb " + floorDb + " is not below the cue's level " + levelDb + " dB, so its fade would go nowhere — lower the floor or raise the level"
3696
+ });
3697
+ continue;
3698
+ }
3699
+
3135
3700
  // Resolve the source. A footageId names an item that must already be
3136
3701
  // usable; a path is either something the project already holds or an
3137
3702
  // import this call will do exactly once.
@@ -3169,44 +3734,85 @@ function __planAudioCues(comp, args) {
3169
3734
  var name = prefix + __stripExtension(defaultName);
3170
3735
  if (typeof cue.name === "string" && cue.name.length > 0) name = cue.name;
3171
3736
 
3172
- planned.push({
3737
+ var stretchFactor = 1;
3738
+ if (stretch !== null) stretchFactor = stretch / 100;
3739
+ // A looped cue needs an end, because a loop has none of its own.
3740
+ var loopEnd = null;
3741
+ if (loop) {
3742
+ loopEnd = comp.duration;
3743
+ if (outPoint !== null) loopEnd = outPoint;
3744
+ }
3745
+
3746
+ var p = {
3173
3747
  index: i, name: name, time: time, levelDb: levelDb,
3174
- inPoint: inPoint, outPoint: outPoint, label: label, source: source
3175
- });
3748
+ inPoint: inPoint, outPoint: outPoint, label: label, source: source,
3749
+ loop: loop, loopEnd: loopEnd, stretch: stretch, stretchFactor: stretchFactor,
3750
+ fadeIn: fadeIn, fadeOut: fadeOut,
3751
+ // true when the fade could not be measured against the file yet
3752
+ fadeUnchecked: false
3753
+ };
3754
+
3755
+ // Does the cue fit? Known now for a loop (its end is chosen here), for a
3756
+ // footageId or reused item (After Effects reports its duration) and for a
3757
+ // capped outPoint; not yet for an unlooped cue on a file still to import.
3758
+ var fit = __fadeFitProblem(p, __cueExtent(p, __itemDuration(source.item)));
3759
+ if (fit === "unknown") {
3760
+ p.fadeUnchecked = true;
3761
+ } else if (fit !== null) {
3762
+ problems.push({ cue: i, reason: fit });
3763
+ continue;
3764
+ }
3765
+
3766
+ planned.push(p);
3176
3767
  }
3177
3768
 
3178
- return { prefix: prefix, planned: planned, problems: problems, toImport: toImport };
3769
+ return { prefix: prefix, planned: planned, problems: problems, toImport: toImport, floorDb: floorDb };
3179
3770
  }
3180
3771
 
3181
- function __audioProblemMessage(problems, total) {
3772
+ function __audioProblemLines(problems) {
3182
3773
  var lines = [];
3183
3774
  for (var i = 0; i < problems.length; i++) {
3184
3775
  lines.push("cue " + problems[i].cue + ": " + problems[i].reason);
3185
3776
  }
3777
+ return lines.join("; ");
3778
+ }
3779
+
3780
+ function __audioProblemMessage(problems, total) {
3186
3781
  return (
3187
3782
  "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 " +
3783
+ __audioProblemLines(problems) + ". Every cue is checked before anything is created, so the comp and project are " +
3189
3784
  "untouched. Fix these and call again; dryRun:true checks a list without placing it."
3190
3785
  );
3191
3786
  }
3192
3787
 
3788
+ /**
3789
+ * Counts and the two lists a caller cannot work out alone — what would be
3790
+ * imported and what is already there — plus the failing cues. Never the
3791
+ * resolved list: a 7-cue "all fine" used to cost ~1.5k tokens of echo (#88).
3792
+ */
3193
3793
  function __audioDryRunReport(comp, plan, total) {
3194
- var cues = [];
3794
+ var wouldImport = [];
3795
+ for (var j = 0; j < plan.toImport.length; j++) wouldImport.push(plan.toImport[j].path);
3796
+
3797
+ var wouldReuse = [];
3798
+ var reuseSeen = {};
3799
+ var looped = 0;
3800
+ var faded = 0;
3801
+ var stretched = 0;
3802
+ var fadesUnchecked = 0;
3195
3803
  for (var i = 0; i < plan.planned.length; i++) {
3196
3804
  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;
3805
+ if (p.source.item && !reuseSeen.hasOwnProperty(String(p.source.item.id))) {
3806
+ reuseSeen[String(p.source.item.id)] = true;
3807
+ var r = { itemId: p.source.item.id, name: p.source.item.name };
3808
+ if (p.source.path) r.path = p.source.path;
3809
+ wouldReuse.push(r);
3201
3810
  }
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
- });
3811
+ if (p.loop) looped++;
3812
+ if (p.fadeIn > 0 || p.fadeOut > 0) faded++;
3813
+ if (p.stretch !== null) stretched++;
3814
+ if (p.fadeUnchecked) fadesUnchecked++;
3207
3815
  }
3208
- var wouldImport = [];
3209
- for (var j = 0; j < plan.toImport.length; j++) wouldImport.push(plan.toImport[j].path);
3210
3816
 
3211
3817
  var out = {
3212
3818
  dryRun: true,
@@ -3214,17 +3820,24 @@ function __audioDryRunReport(comp, plan, total) {
3214
3820
  compId: comp.id,
3215
3821
  compName: comp.name,
3216
3822
  cueCount: total,
3217
- wouldPlace: cues.length,
3823
+ wouldPlace: plan.planned.length,
3218
3824
  wouldImport: wouldImport,
3825
+ wouldReuse: wouldReuse,
3219
3826
  problems: plan.problems,
3220
- cues: cues,
3221
3827
  note: "Nothing was imported, created or changed, and this call is not an undo step."
3222
3828
  };
3829
+ if (looped > 0) out.looped = looped;
3830
+ if (faded > 0) out.faded = faded;
3831
+ if (stretched > 0) out.stretched = stretched;
3223
3832
  if (wouldImport.length > 0) {
3833
+ var fadeNote = "";
3834
+ if (fadesUnchecked > 0) {
3835
+ fadeNote = " — and, for " + fadesUnchecked + " cue(s) with a fade, whether the fade fits the file's length —";
3836
+ }
3224
3837
  out.unverified =
3225
3838
  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.";
3839
+ "carries an audio track" + fadeNote + " is only knowable once After Effects has imported it; a real run checks " +
3840
+ "that and refuses the whole call if one does not.";
3228
3841
  }
3229
3842
  return out;
3230
3843
  }
@@ -3239,16 +3852,40 @@ function __rollbackAudioCues(layers, items) {
3239
3852
  }
3240
3853
  }
3241
3854
 
3855
+ /**
3856
+ * The Time Remap expression that loops a cue. It is the pattern measured in
3857
+ * issue #85 — comp time since the layer began, wrapped at the file's length —
3858
+ * with two substitutions: `startTime` rather than `inPoint`, so trimming the
3859
+ * in point hides the front of the file the way it does on every other layer
3860
+ * instead of delaying it; and the source's duration read live off the layer,
3861
+ * so a relinked file keeps looping. An expression cannot read a layer's
3862
+ * stretch, so a stretched cue has its factor baked in, and that constant goes
3863
+ * stale if the stretch is changed by hand afterwards.
3864
+ */
3865
+ function __loopExpression(p) {
3866
+ var elapsed = "(time - startTime)";
3867
+ if (p.stretch !== null && p.stretch !== 100) elapsed = "((time - startTime) * 100 / " + p.stretch + ")";
3868
+ return elapsed + " % thisLayer.source.duration";
3869
+ }
3870
+
3242
3871
  /**
3243
3872
  * `created` is the rollback list, and the layer joins it the instant it exists
3244
3873
  * rather than once it is fully configured. A cue that dies between add() and
3245
3874
  * the last setValue is exactly the case rollback is for, and a layer that had
3246
3875
  * not been registered yet would be the one thing left behind.
3876
+ *
3877
+ * The order after that is not free to change. Each of the #85 options moves
3878
+ * something After Effects then resets: `stretch` moves startTime, so startTime
3879
+ * is set after it; enabling time remap resets the out point, so the extent is
3880
+ * set after that; and the fade keys sit at the in/out the layer actually has,
3881
+ * so they go last. Out point before in point throughout — extending first is
3882
+ * what lets an in point past the file's natural end land on a looped layer.
3247
3883
  */
3248
- function __placeAudioCue(comp, p, item, created) {
3884
+ function __placeAudioCue(comp, p, item, created, floorDb) {
3249
3885
  var layer = comp.layers.add(item);
3250
3886
  created.push(layer);
3251
3887
  layer.name = p.name;
3888
+ if (p.stretch !== null) layer.stretch = p.stretch;
3252
3889
  // startTime first: it slides the whole layer and would drag any trim with it.
3253
3890
  layer.startTime = p.time;
3254
3891
  var levels = __audioLevelsProperty(layer);
@@ -3257,14 +3894,81 @@ function __placeAudioCue(comp, p, item, created) {
3257
3894
  "the layer created for \"" + item.name + "\" has no Audio Levels property, so its level could not be set"
3258
3895
  );
3259
3896
  }
3260
- // AE's Audio Levels is itself in decibels, one entry per channel.
3261
- levels.setValue([p.levelDb, p.levelDb]);
3262
3897
  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;
3898
+
3899
+ if (p.loop) {
3900
+ // `=== false`, as with hasAudio: a layer that does not answer the question
3901
+ // is not refused on the strength of a missing property.
3902
+ if (layer.canSetTimeRemapEnabled === false) {
3903
+ throw new Error(
3904
+ "After Effects reports that time remapping cannot be enabled on the layer created for \"" + item.name + "\", so it cannot loop"
3905
+ );
3906
+ }
3907
+ layer.timeRemapEnabled = true;
3908
+ var remap = __timeRemapProperty(layer);
3909
+ if (!remap) {
3910
+ throw new Error(
3911
+ "time remapping was enabled on the layer created for \"" + item.name + "\" but its Time Remap property could not be found, so the loop expression could not be set"
3912
+ );
3913
+ }
3914
+ // The two keyframes AE created when remapping was enabled stay exactly
3915
+ // where they are. Removing them hides the property, and the next write to
3916
+ // it throws (issue #86); the expression overrides them anyway.
3917
+ remap.expression = __loopExpression(p);
3918
+ layer.outPoint = p.loopEnd;
3919
+ if (p.inPoint !== null) layer.inPoint = p.inPoint;
3920
+ } else {
3921
+ if (p.outPoint !== null) layer.outPoint = p.outPoint;
3922
+ if (p.inPoint !== null) layer.inPoint = p.inPoint;
3923
+ }
3924
+
3925
+ // AE's Audio Levels is itself in decibels, one entry per channel.
3926
+ var level = [p.levelDb, p.levelDb];
3927
+ if (p.fadeIn > 0 || p.fadeOut > 0) {
3928
+ var inP = layer.inPoint;
3929
+ var outP = layer.outPoint;
3930
+ var dur = outP - inP;
3931
+ // The planner measured the fade against what it could know; this is the
3932
+ // extent After Effects actually gave the layer, and the last line.
3933
+ if (p.fadeIn + p.fadeOut > dur + __CUE_TIME_EPS) {
3934
+ throw new Error(
3935
+ "its fades (" + __fmtSeconds(p.fadeIn) + " in, " + __fmtSeconds(p.fadeOut) + " out) are longer than the " +
3936
+ __fmtSeconds(dur) + " After Effects gave the layer (" + __fmtSeconds(inP) + " to " + __fmtSeconds(outP) + ")"
3937
+ );
3938
+ }
3939
+ var floor = [floorDb, floorDb];
3940
+ if (p.fadeIn > 0) {
3941
+ levels.setValueAtTime(inP, floor);
3942
+ levels.setValueAtTime(inP + p.fadeIn, level);
3943
+ }
3944
+ if (p.fadeOut > 0) {
3945
+ levels.setValueAtTime(outP - p.fadeOut, level);
3946
+ levels.setValueAtTime(outP, floor);
3947
+ }
3948
+ } else {
3949
+ levels.setValue(level);
3950
+ }
3265
3951
  return layer;
3266
3952
  }
3267
3953
 
3954
+ /**
3955
+ * What the caller gets back per cue: the id it has to carry forward, the name,
3956
+ * the time, and — only where an option could have changed it — what After
3957
+ * Effects actually did. Never an echo of the request.
3958
+ */
3959
+ function __placedEntry(layer, p) {
3960
+ var entry = { layerId: layer.id, name: layer.name, time: p.time };
3961
+ // Read the trim back: AE clamps an in/out point to what the source can
3962
+ // actually supply, and the caller should see what it got.
3963
+ if (p.inPoint !== null) entry.inPoint = layer.inPoint;
3964
+ if (p.outPoint !== null || p.loop || p.stretch !== null) entry.outPoint = layer.outPoint;
3965
+ if (p.loop) entry.looped = true;
3966
+ if (p.stretch !== null) entry.stretch = layer.stretch;
3967
+ if (p.fadeIn > 0) entry.fadeIn = p.fadeIn;
3968
+ if (p.fadeOut > 0) entry.fadeOut = p.fadeOut;
3969
+ return entry;
3970
+ }
3971
+
3268
3972
  OPS.place_audio_cues = noUndoWhen(
3269
3973
  // dryRun is not an undo step either. A plan that quietly appeared in the
3270
3974
  // user's undo history would make "this changed nothing" false in the one
@@ -3285,6 +3989,7 @@ OPS.place_audio_cues = noUndoWhen(
3285
3989
  var reusedReport = [];
3286
3990
  var reusedSeen = {};
3287
3991
  var placed = [];
3992
+ var anyFade = false;
3288
3993
 
3289
3994
  try {
3290
3995
  // One import per distinct file, before any layer exists, so a bad file
@@ -3300,6 +4005,26 @@ OPS.place_audio_cues = noUndoWhen(
3300
4005
  importedReport.push(__sourceReport(newItem, spec.path));
3301
4006
  }
3302
4007
 
4008
+ // The fades the planner could not measure — on files it had not seen —
4009
+ // are measured now, with every duration known and no layer yet made, so
4010
+ // a misfit still names every offending cue at once and costs only the
4011
+ // imports, which the rollback takes back.
4012
+ var lateProblems = [];
4013
+ for (var f = 0; f < plan.planned.length; f++) {
4014
+ var pf = plan.planned[f];
4015
+ if (!pf.fadeUnchecked) continue;
4016
+ var fit = __fadeFitProblem(pf, __cueExtent(pf, __itemDuration(imported[pf.source.fsName])));
4017
+ if (fit === "unknown") {
4018
+ fit = "After Effects did not report a duration for the imported " + pf.source.path + ", so its fade cannot be checked";
4019
+ }
4020
+ if (fit !== null) lateProblems.push({ cue: pf.index, reason: fit });
4021
+ }
4022
+ if (lateProblems.length > 0) {
4023
+ throw new Error(
4024
+ "the fades on " + lateProblems.length + " cue(s) do not fit the files once imported — " + __audioProblemLines(lateProblems)
4025
+ );
4026
+ }
4027
+
3303
4028
  for (var k = 0; k < plan.planned.length; k++) {
3304
4029
  var p = plan.planned[k];
3305
4030
  var item = p.source.item;
@@ -3310,23 +4035,12 @@ OPS.place_audio_cues = noUndoWhen(
3310
4035
  }
3311
4036
  var layer;
3312
4037
  try {
3313
- layer = __placeAudioCue(comp, p, item, createdLayers);
4038
+ layer = __placeAudioCue(comp, p, item, createdLayers, plan.floorDb);
3314
4039
  } catch (eCue) {
3315
4040
  throw new Error("cue " + p.index + " (\"" + p.name + "\" at " + p.time + "s): " + eCue.message);
3316
4041
  }
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
- });
4042
+ if (p.fadeIn > 0 || p.fadeOut > 0) anyFade = true;
4043
+ placed.push(__placedEntry(layer, p));
3330
4044
  }
3331
4045
  } catch (e) {
3332
4046
  __rollbackAudioCues(createdLayers, importedItems);
@@ -3337,13 +4051,15 @@ OPS.place_audio_cues = noUndoWhen(
3337
4051
  );
3338
4052
  }
3339
4053
 
3340
- return {
4054
+ var out = {
3341
4055
  compId: comp.id,
3342
4056
  placed: placed,
3343
4057
  count: placed.length,
3344
4058
  sources: { imported: importedReport, reused: reusedReport },
3345
4059
  levelUnit: "dB"
3346
4060
  };
4061
+ if (anyFade) out.fadeFloorDb = plan.floorDb;
4062
+ return out;
3347
4063
  }
3348
4064
  );
3349
4065
 
@@ -3841,6 +4557,11 @@ OPS.set_house_style = noUndo(function (args) {
3841
4557
  }
3842
4558
 
3843
4559
  file.encoding = "UTF-8";
4560
+ // ExtendScript's default lineFeed on macOS is "Macintosh", which turns every
4561
+ // "\n" into a bare "\r" on disk (measured on 26.3: a guide written through
4562
+ // this op read back fine, because File.read normalises, but opened as one
4563
+ // long line in a text editor). The file is meant to be hand-edited.
4564
+ file.lineFeed = "Unix";
3844
4565
  if (!file.open("w")) throw new Error("Could not open " + file.fsName + " for writing");
3845
4566
  try {
3846
4567
  if (!file.write(content)) throw new Error("Write failed for " + file.fsName);
@@ -4654,7 +5375,16 @@ OPS.get_project_summary = noUndo(function (args) {
4654
5375
  };
4655
5376
  });
4656
5377
 
5378
+ // A search, so the default is the opposite of list_layers: absent `include`
5379
+ // means the core alone — id/index/name/sourceType plus the comp — because the
5380
+ // reason to call this is to learn which layers exist and what to address them
5381
+ // by, not to read them (issue #87: nine matches cost ~2k tokens, re-sent on
5382
+ // every later request). `include: []` and omitting it are therefore the same
5383
+ // here, and the section names are list_layers' own, read through the same
5384
+ // __layerSummary. What was included is echoed, so a bounded answer is never
5385
+ // read as a full one.
4657
5386
  OPS.find_layers = noUndo(function (args) {
5387
+ var sections = (args && args.include) ? args.include : [];
4658
5388
  var out = [];
4659
5389
  var comps = [];
4660
5390
  if (args.compId) comps.push(getCompById(args.compId));
@@ -4681,13 +5411,13 @@ OPS.find_layers = noUndo(function (args) {
4681
5411
  }
4682
5412
  if (!hit) continue;
4683
5413
  }
4684
- var s = __layerSummary(l);
5414
+ var s = __layerSummary(l, sections);
4685
5415
  s.compId = c.id;
4686
5416
  s.compName = c.name;
4687
5417
  out.push(s);
4688
5418
  }
4689
5419
  }
4690
- return out;
5420
+ return { matches: out, count: out.length, compsSearched: comps.length, included: sections };
4691
5421
  });
4692
5422
 
4693
5423