@pixerate/editor 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/index.cjs +36 -35
- package/dist/agent/index.js +7 -6
- package/dist/animation/index.cjs +5 -4
- package/dist/animation/index.js +4 -3
- package/dist/animation-editor/index.cjs +62 -61
- package/dist/animation-editor/index.js +6 -5
- package/dist/canvas/index.cjs +33 -32
- package/dist/canvas/index.js +6 -5
- package/dist/chart/index.cjs +88 -87
- package/dist/chart/index.js +7 -6
- package/dist/{chunk-QMI5HZWA.cjs → chunk-236TKNUR.cjs} +3 -3
- package/dist/{chunk-6UPGM3JB.js → chunk-2TKHLX6H.js} +1 -1
- package/dist/{chunk-3YDQLQH2.cjs → chunk-32MMQYCQ.cjs} +81 -81
- package/dist/{chunk-MHEXLG3M.cjs → chunk-3GJH2MGH.cjs} +37 -37
- package/dist/{chunk-AKDWAWSY.cjs → chunk-3IHNXRUV.cjs} +45 -45
- package/dist/{chunk-VKF3PWKR.cjs → chunk-3OSXHLIT.cjs} +259 -28
- package/dist/{chunk-TVO6W2Q6.cjs → chunk-4NGVHHJB.cjs} +17 -17
- package/dist/chunk-4VU4CP7R.js +769 -0
- package/dist/chunk-6DM3PUQD.cjs +767 -0
- package/dist/{chunk-DBIVICAO.js → chunk-7CKPUUN4.js} +1 -1
- package/dist/{chunk-57JLBGJ5.cjs → chunk-7YBDRJXT.cjs} +8 -8
- package/dist/{chunk-KY3FYKP3.js → chunk-BQWGN5WH.js} +1 -1
- package/dist/{chunk-LEUZ4FRC.cjs → chunk-CIPIESX4.cjs} +3 -3
- package/dist/chunk-CJE5MOIS.js +347 -0
- package/dist/{chunk-5QBRAIVK.js → chunk-CX6JLDFC.js} +3 -3
- package/dist/{chunk-R3QY7PPL.js → chunk-DTSTNRPH.js} +1 -1
- package/dist/{chunk-6LUYFHHN.cjs → chunk-EQ46F75O.cjs} +51 -51
- package/dist/{chunk-UKCJ43YL.cjs → chunk-ES3UEVUT.cjs} +130 -898
- package/dist/{chunk-UM3RPYC5.js → chunk-EVPZ5IKN.js} +1 -1
- package/dist/{chunk-XNQ5EFB4.cjs → chunk-EY22AY6R.cjs} +16 -16
- package/dist/chunk-FKUKJ3XA.cjs +347 -0
- package/dist/{chunk-YABNLNY7.js → chunk-GLMP22FT.js} +3 -3
- package/dist/{chunk-SUF6WRXR.cjs → chunk-GTELFJRE.cjs} +2 -2
- package/dist/{chunk-72BGL3I2.js → chunk-GWV4T5NZ.js} +1 -1
- package/dist/{chunk-AY65VQSU.js → chunk-H3OEM3Q2.js} +1 -1
- package/dist/{chunk-4YWAEV4W.cjs → chunk-HDGBM7EA.cjs} +26 -26
- package/dist/{chunk-H5XX4KHB.js → chunk-HLOKB7WW.js} +5 -3
- package/dist/{chunk-WNAAWLZ5.js → chunk-KGTF55R3.js} +2 -2
- package/dist/{chunk-WPPJH6X4.cjs → chunk-LL3H5XUH.cjs} +6 -4
- package/dist/{chunk-4T44CJ5K.cjs → chunk-NA4UVVJ4.cjs} +15 -15
- package/dist/{chunk-Q6PA362P.js → chunk-NO5QQDHZ.js} +2 -2
- package/dist/{chunk-AA34BA7G.js → chunk-QYZGBVNC.js} +30 -798
- package/dist/chunk-S6X2QRVU.cjs +769 -0
- package/dist/{chunk-SSCL7QQ4.js → chunk-XDCFJEU7.js} +2 -2
- package/dist/{chunk-IQ6KKXOU.js → chunk-YN4AYYBJ.js} +243 -12
- package/dist/chunk-ZGOHKS3X.js +767 -0
- package/dist/color/index.cjs +6 -2
- package/dist/color/index.d.cts +1 -0
- package/dist/color/index.d.ts +1 -0
- package/dist/color/index.js +28 -24
- package/dist/color/suggest.d.cts +12 -0
- package/dist/color/suggest.d.ts +12 -0
- package/dist/effects/index.cjs +44 -771
- package/dist/effects/index.js +34 -761
- package/dist/effects/ui.d.cts +20 -3
- package/dist/effects/ui.d.ts +20 -3
- package/dist/export/index.cjs +6 -5
- package/dist/export/index.js +5 -4
- package/dist/extensions/index.cjs +9 -8
- package/dist/extensions/index.js +8 -7
- package/dist/gradient/index.cjs +28 -26
- package/dist/gradient/index.js +7 -5
- package/dist/image-editor/index.cjs +638 -637
- package/dist/image-editor/index.js +5 -4
- package/dist/index.cjs +15 -14
- package/dist/index.js +9 -8
- package/dist/paint-editor/index.cjs +17 -16
- package/dist/paint-editor/index.js +6 -5
- package/dist/typography/accessibility.d.cts +44 -0
- package/dist/typography/accessibility.d.ts +44 -0
- package/dist/typography/catalog.d.cts +98 -0
- package/dist/typography/catalog.d.ts +98 -0
- package/dist/typography/index.cjs +1099 -0
- package/dist/typography/index.d.cts +16 -0
- package/dist/typography/index.d.ts +16 -0
- package/dist/typography/index.js +1099 -0
- package/dist/typography/metrics.d.cts +31 -0
- package/dist/typography/metrics.d.ts +31 -0
- package/dist/typography/picker.d.cts +132 -0
- package/dist/typography/picker.d.ts +132 -0
- package/dist/typography/registry.d.cts +66 -0
- package/dist/typography/registry.d.ts +66 -0
- package/dist/typography/scale.d.cts +62 -0
- package/dist/typography/scale.d.ts +62 -0
- package/dist/typography/segment.d.cts +26 -0
- package/dist/typography/segment.d.ts +26 -0
- package/dist/typography/stack.d.cts +26 -0
- package/dist/typography/stack.d.ts +26 -0
- package/dist/typography/tools.d.cts +34 -0
- package/dist/typography/tools.d.ts +34 -0
- package/dist/typography/types.d.cts +96 -0
- package/dist/typography/types.d.ts +96 -0
- package/dist/typography/weight.d.cts +13 -0
- package/dist/typography/weight.d.ts +13 -0
- package/dist/vector/index.cjs +950 -949
- package/dist/vector/index.js +5 -4
- package/dist/video-editor/audio.d.cts +9 -0
- package/dist/video-editor/audio.d.ts +9 -0
- package/dist/video-editor/check.d.cts +1 -1
- package/dist/video-editor/check.d.ts +1 -1
- package/dist/video-editor/controller.d.cts +47 -0
- package/dist/video-editor/controller.d.ts +47 -0
- package/dist/video-editor/dialogue-tools.d.cts +19 -0
- package/dist/video-editor/dialogue-tools.d.ts +19 -0
- package/dist/video-editor/effect-authoring.d.cts +54 -0
- package/dist/video-editor/effect-authoring.d.ts +54 -0
- package/dist/video-editor/evaluate.d.cts +11 -0
- package/dist/video-editor/evaluate.d.ts +11 -0
- package/dist/video-editor/export.d.cts +7 -1
- package/dist/video-editor/export.d.ts +7 -1
- package/dist/video-editor/index.cjs +1857 -531
- package/dist/video-editor/index.d.cts +7 -0
- package/dist/video-editor/index.d.ts +7 -0
- package/dist/video-editor/index.js +1516 -190
- package/dist/video-editor/lut.d.cts +12 -2
- package/dist/video-editor/lut.d.ts +12 -2
- package/dist/video-editor/mask.d.cts +48 -0
- package/dist/video-editor/mask.d.ts +48 -0
- package/dist/video-editor/mix.d.cts +107 -0
- package/dist/video-editor/mix.d.ts +107 -0
- package/dist/video-editor/music-tools.d.cts +19 -0
- package/dist/video-editor/music-tools.d.ts +19 -0
- package/dist/video-editor/parse.d.cts +3 -1
- package/dist/video-editor/parse.d.ts +3 -1
- package/dist/video-editor/plan.d.cts +75 -0
- package/dist/video-editor/plan.d.ts +75 -0
- package/dist/video-editor/render.d.cts +1 -1
- package/dist/video-editor/render.d.ts +1 -1
- package/dist/video-editor/timeline.d.cts +2 -0
- package/dist/video-editor/timeline.d.ts +2 -0
- package/dist/video-editor/tools.d.cts +90 -0
- package/dist/video-editor/tools.d.ts +90 -0
- package/dist/video-editor/transcript-controller.d.cts +106 -0
- package/dist/video-editor/transcript-controller.d.ts +106 -0
- package/dist/video-editor/transcript.d.cts +40 -0
- package/dist/video-editor/transcript.d.ts +40 -0
- package/dist/video-editor/types.d.cts +62 -2
- package/dist/video-editor/types.d.ts +62 -2
- package/dist/video-editor/ui.d.cts +21 -1
- package/dist/video-editor/ui.d.ts +21 -1
- package/package.json +11 -1
- package/skills/README.md +3 -0
- package/skills/pixerate-bgm-alignment/SKILL.md +82 -0
- package/skills/pixerate-talking-head-edit/SKILL.md +80 -0
- package/skills/pixerate-typography/SKILL.md +91 -0
- package/skills/pixerate-video-editor/SKILL.md +40 -2
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createEffectAuthoringSession
|
|
3
|
+
} from "../chunk-ZGOHKS3X.js";
|
|
4
|
+
import {
|
|
5
|
+
VIDEO_ADJUSTMENT_SCOPES,
|
|
2
6
|
VIDEO_BLEND_MODES,
|
|
3
7
|
VIDEO_CLIP_ANIMATION_DURATION,
|
|
4
8
|
VIDEO_CLIP_ANIMATION_EFFECTS,
|
|
@@ -13,6 +17,8 @@ import {
|
|
|
13
17
|
VIDEO_KEYFRAME_LIMITS,
|
|
14
18
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
15
19
|
VIDEO_LUT_MAX_SIZE,
|
|
20
|
+
VIDEO_MASK_DEFAULTS,
|
|
21
|
+
VIDEO_MASK_SHAPES,
|
|
16
22
|
VIDEO_PLACEMENT_MODES,
|
|
17
23
|
VIDEO_TIME_EPSILON,
|
|
18
24
|
VIDEO_TRANSFORM_PROPERTIES,
|
|
@@ -41,6 +47,7 @@ import {
|
|
|
41
47
|
isVideoKeyframes,
|
|
42
48
|
mixEffectPixels,
|
|
43
49
|
nextFreeVideoSlot,
|
|
50
|
+
normalizeVideoClipMask,
|
|
44
51
|
normalizeVideoGrade,
|
|
45
52
|
normalizeVideoGradeLut,
|
|
46
53
|
overEffectPixels,
|
|
@@ -75,6 +82,7 @@ import {
|
|
|
75
82
|
videoClipSourceTime,
|
|
76
83
|
videoClipSpeed,
|
|
77
84
|
videoClipSpeedAt,
|
|
85
|
+
videoClipTypeLabel,
|
|
78
86
|
videoClipsAt,
|
|
79
87
|
videoContactSheetSvg,
|
|
80
88
|
videoEaseBezier,
|
|
@@ -84,6 +92,7 @@ import {
|
|
|
84
92
|
videoEffectValuesAt,
|
|
85
93
|
videoFadeCurveGain,
|
|
86
94
|
videoFfmpegUnsupportedEffects,
|
|
95
|
+
videoFfmpegUnsupportedFeatures,
|
|
87
96
|
videoFrameAt,
|
|
88
97
|
videoFrameDuration,
|
|
89
98
|
videoFrameLeafLayers,
|
|
@@ -105,12 +114,16 @@ import {
|
|
|
105
114
|
videoKeyframeBreakpoints,
|
|
106
115
|
videoKeyframeFfmpegExpr,
|
|
107
116
|
videoKeysOf,
|
|
117
|
+
videoLayerMask,
|
|
108
118
|
videoLayersUseEffects,
|
|
109
119
|
videoLayersWithoutTransitionEffects,
|
|
110
120
|
videoLutFromFunction,
|
|
111
121
|
videoLutSvgApproximation,
|
|
112
122
|
videoLutSvgPrimitives,
|
|
113
123
|
videoLutToCube,
|
|
124
|
+
videoMaskContains,
|
|
125
|
+
videoMaskPolygon,
|
|
126
|
+
videoMaskSvgPath,
|
|
114
127
|
videoOverlap,
|
|
115
128
|
videoProjectDuration,
|
|
116
129
|
videoProjectEffects,
|
|
@@ -119,6 +132,7 @@ import {
|
|
|
119
132
|
videoSequenceDuration,
|
|
120
133
|
videoSequenceMap,
|
|
121
134
|
videoShellQuote,
|
|
135
|
+
videoSkinToneWeight,
|
|
122
136
|
videoSlowMotionRamp,
|
|
123
137
|
videoSnapPoints,
|
|
124
138
|
videoSpeedRampEdit,
|
|
@@ -134,7 +148,7 @@ import {
|
|
|
134
148
|
videoTransitionEase,
|
|
135
149
|
videoValueAt,
|
|
136
150
|
videoWipeRect
|
|
137
|
-
} from "../chunk-
|
|
151
|
+
} from "../chunk-YN4AYYBJ.js";
|
|
138
152
|
import "../chunk-EHLOCVWB.js";
|
|
139
153
|
import {
|
|
140
154
|
namedWithSuggestion
|
|
@@ -154,11 +168,15 @@ import {
|
|
|
154
168
|
} from "../chunk-RNSANXPT.js";
|
|
155
169
|
import {
|
|
156
170
|
AnimationPlayer
|
|
157
|
-
} from "../chunk-
|
|
158
|
-
import
|
|
159
|
-
|
|
171
|
+
} from "../chunk-H3OEM3Q2.js";
|
|
172
|
+
import {
|
|
173
|
+
CUBIC_BEZIER_PRESETS,
|
|
174
|
+
evaluateEasing
|
|
175
|
+
} from "../chunk-5UJHB3BW.js";
|
|
176
|
+
import "../chunk-BQWGN5WH.js";
|
|
160
177
|
import "../chunk-2WPNAGHI.js";
|
|
161
|
-
import "../chunk-
|
|
178
|
+
import "../chunk-QYZGBVNC.js";
|
|
179
|
+
import "../chunk-4VU4CP7R.js";
|
|
162
180
|
import "../chunk-6ONMQ6WF.js";
|
|
163
181
|
|
|
164
182
|
// src/video-editor/parse.ts
|
|
@@ -260,9 +278,38 @@ function parseVideoGrade(v, path) {
|
|
|
260
278
|
function parseVideoGradeLut(v, path) {
|
|
261
279
|
if (v === void 0) return void 0;
|
|
262
280
|
if (!isObject(v)) return fail(path, "must be an object { src, strength } naming a .cube file");
|
|
263
|
-
const unknown = Object.keys(v).find((k) => !["src", "strength", "name"].includes(k));
|
|
264
|
-
if (unknown) fail(`${path}.${unknown}`, "is not a LUT setting; use src, strength, name");
|
|
265
|
-
return compact({ src: str(v.src, `${path}.src`), strength: num(v.strength, `${path}.strength`, { optional: true, min: 0, max: 1 }), name: str(v.name, `${path}.name`, true) });
|
|
281
|
+
const unknown = Object.keys(v).find((k) => !["src", "strength", "name", "preserveSkinTones"].includes(k));
|
|
282
|
+
if (unknown) fail(`${path}.${unknown}`, "is not a LUT setting; use src, strength, name, preserveSkinTones");
|
|
283
|
+
return compact({ src: str(v.src, `${path}.src`), strength: num(v.strength, `${path}.strength`, { optional: true, min: 0, max: 1 }), name: str(v.name, `${path}.name`, true), preserveSkinTones: num(v.preserveSkinTones, `${path}.preserveSkinTones`, { optional: true, min: 0, max: 1 }) });
|
|
284
|
+
}
|
|
285
|
+
var MASK_KEYS = ["shape", "x", "y", "width", "height", "rotation", "feather", "cornerRadius", "inverted", "points"];
|
|
286
|
+
function parseVideoClipMask(v, path) {
|
|
287
|
+
if (v === void 0) return void 0;
|
|
288
|
+
if (!isObject(v)) return fail(path, 'must be an object such as { shape: "ellipse", x: 0.5, y: 0.5, width: 0.4, height: 0.6, feather: 20 }');
|
|
289
|
+
const unknown = Object.keys(v).find((k) => !MASK_KEYS.includes(k));
|
|
290
|
+
if (unknown) fail(`${path}.${unknown}`, `is not a mask setting; use ${MASK_KEYS.join(", ")}`);
|
|
291
|
+
const shape = oneOf(v.shape, VIDEO_MASK_SHAPES, `${path}.shape`, false);
|
|
292
|
+
let points;
|
|
293
|
+
if (v.points !== void 0) {
|
|
294
|
+
if (shape !== "polygon") fail(`${path}.points`, "is only for polygon masks");
|
|
295
|
+
if (!Array.isArray(v.points) || v.points.length < 3) fail(`${path}.points`, "must be a list of at least 3 { x, y } points");
|
|
296
|
+
points = v.points.map((p, i) => {
|
|
297
|
+
if (!isObject(p)) return fail(`${path}.points[${i}]`, "must be an object { x, y }");
|
|
298
|
+
return { x: num(p.x, `${path}.points[${i}].x`), y: num(p.y, `${path}.points[${i}].y`) };
|
|
299
|
+
});
|
|
300
|
+
} else if (shape === "polygon") fail(`${path}.points`, "is required for a polygon mask: at least 3 { x, y } points as fractions of the picture");
|
|
301
|
+
return compact({
|
|
302
|
+
shape,
|
|
303
|
+
x: num(v.x, `${path}.x`, { optional: true }),
|
|
304
|
+
y: num(v.y, `${path}.y`, { optional: true }),
|
|
305
|
+
width: num(v.width, `${path}.width`, { optional: true, min: 0 }),
|
|
306
|
+
height: num(v.height, `${path}.height`, { optional: true, min: 0 }),
|
|
307
|
+
rotation: num(v.rotation, `${path}.rotation`, { optional: true }),
|
|
308
|
+
feather: num(v.feather, `${path}.feather`, { optional: true, min: 0 }),
|
|
309
|
+
cornerRadius: num(v.cornerRadius, `${path}.cornerRadius`, { optional: true, min: 0, max: 1 }),
|
|
310
|
+
inverted: bool(v.inverted, `${path}.inverted`),
|
|
311
|
+
points
|
|
312
|
+
});
|
|
266
313
|
}
|
|
267
314
|
function parseVideoTransition(v, path) {
|
|
268
315
|
if (v === void 0) return void 0;
|
|
@@ -384,7 +431,8 @@ function parseVideoClip(v, path = "clip") {
|
|
|
384
431
|
fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
|
|
385
432
|
fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
|
|
386
433
|
transition: parseVideoTransition(v.transition, `${path}.transition`),
|
|
387
|
-
effects: parseVideoClipEffects(v.effects, `${path}.effects`)
|
|
434
|
+
effects: parseVideoClipEffects(v.effects, `${path}.effects`),
|
|
435
|
+
mask: parseVideoClipMask(v.mask, `${path}.mask`)
|
|
388
436
|
};
|
|
389
437
|
if (type === "media")
|
|
390
438
|
return compact({
|
|
@@ -424,6 +472,13 @@ function parseVideoClip(v, path = "clip") {
|
|
|
424
472
|
fontWeight: num(v.fontWeight, `${path}.fontWeight`, { optional: true, min: 100, max: 1e3 }),
|
|
425
473
|
background: str(v.background, `${path}.background`, true)
|
|
426
474
|
});
|
|
475
|
+
if (type === "adjustment") {
|
|
476
|
+
const scope = oneOf(v.scope, VIDEO_ADJUSTMENT_SCOPES, `${path}.scope`);
|
|
477
|
+
const targetTrack = str(v.targetTrack, `${path}.targetTrack`, true);
|
|
478
|
+
if (scope === "track" && !targetTrack) fail(`${path}.targetTrack`, 'is required when scope is "track": the id of the track whose pictures the adjustment changes');
|
|
479
|
+
if (targetTrack !== void 0 && scope !== "track") fail(`${path}.targetTrack`, 'is only for scope "track"');
|
|
480
|
+
return compact({ type, ...base, scope, targetTrack });
|
|
481
|
+
}
|
|
427
482
|
if (type === "effect") {
|
|
428
483
|
const ref = parseVideoEffectRef({ effect: v.effect, provider: v.provider, name: v.name === void 0 ? void 0 : v.name, values: v.values }, path);
|
|
429
484
|
return compact({ type, ...base, ...ref, name: base.name });
|
|
@@ -545,6 +600,7 @@ var VIDEO_ISSUE_CODES = [
|
|
|
545
600
|
"unused-asset",
|
|
546
601
|
"unused-sequence",
|
|
547
602
|
"marker-past-end",
|
|
603
|
+
"missing-target-track",
|
|
548
604
|
"empty"
|
|
549
605
|
];
|
|
550
606
|
var videoIssueKey = (i) => [i.code, i.sequence ?? "", i.track ?? "", i.clip ?? "", i.asset ?? "", i.marker ?? ""].join("|");
|
|
@@ -589,6 +645,8 @@ function checkVideoProject(project) {
|
|
|
589
645
|
issues.push({ code: "source-overrun", severity: "error", ...at, message: `Clip "${clip.id}" plays to ${formatVideoSeconds(range.out)} of "${asset.id}", which is only ${formatVideoSeconds(asset.duration)} long; shorten the clip or move its in point.` });
|
|
590
646
|
}
|
|
591
647
|
} else if (track.kind === "audio") issues.push({ code: "wrong-track", severity: "error", ...at, message: `Clip "${clip.id}" is a ${clip.type} clip on audio track "${track.id}"; move it to a video track.` });
|
|
648
|
+
if (clip.type === "adjustment" && (clip.scope ?? "underneath") === "track" && !(sequence ? sequences.get(sequence)?.tracks ?? [] : project.tracks).some((t) => t.id === clip.targetTrack))
|
|
649
|
+
issues.push({ code: "missing-target-track", severity: "warning", ...at, message: `Adjustment clip "${clip.id}" changes track "${clip.targetTrack}", which is not ${sequence ? `in sequence "${sequence}"` : "in the project"}, so it changes nothing; set its targetTrack to a track beside it or change its scope.` });
|
|
592
650
|
const next = clips[i + 1];
|
|
593
651
|
if (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)
|
|
594
652
|
issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}"${where} from ${formatVideoSeconds(next.start)}; move one of them.` });
|
|
@@ -725,7 +783,7 @@ function retimeClipKeys(clip, fn) {
|
|
|
725
783
|
if (isVideoKeyframes(v)) a.set(fn(v));
|
|
726
784
|
}
|
|
727
785
|
}
|
|
728
|
-
var OPTIONAL_CLIP_FIELDS = ["name", "effects", "values", "provider", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
786
|
+
var OPTIONAL_CLIP_FIELDS = ["name", "effects", "mask", "scope", "targetTrack", "values", "provider", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
|
|
729
787
|
var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
|
|
730
788
|
var Editor = class {
|
|
731
789
|
constructor(p) {
|
|
@@ -763,6 +821,12 @@ var Editor = class {
|
|
|
763
821
|
refuse(`${what} at ${formatVideoSeconds(start)}\u2013${formatVideoSeconds(start + duration)} would overlap clip "${hit.id}" (${formatVideoSeconds(hit.start)}\u2013${formatVideoSeconds(videoClipEnd(hit))}) on track "${track.id}"; the next free place is ${formatVideoSeconds(free)}, or use mode "insert" to push later clips along or "overwrite" to replace what is there.`);
|
|
764
822
|
}
|
|
765
823
|
}
|
|
824
|
+
/** An adjustment clip's target track must exist. */
|
|
825
|
+
adjustmentTarget(clip) {
|
|
826
|
+
if (clip.type !== "adjustment" || clip.targetTrack === void 0) return;
|
|
827
|
+
const ids = allVideoTracks(this.p).map(({ track }) => track.id);
|
|
828
|
+
if (!ids.includes(clip.targetTrack)) refuse(`Adjustment clip "${clip.id}" targets track ${namedWithSuggestion(clip.targetTrack, ids)}, which does not exist; the tracks are ${ids.map((t) => `"${t}"`).join(", ")}.`);
|
|
829
|
+
}
|
|
766
830
|
kindFits(track, clip) {
|
|
767
831
|
const asset = clip.type === "media" ? this.asset(clip.asset) : null;
|
|
768
832
|
if (clip.type === "sequence") {
|
|
@@ -1028,6 +1092,7 @@ var Editor = class {
|
|
|
1028
1092
|
const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
|
|
1029
1093
|
draft.start ??= end;
|
|
1030
1094
|
const clip = parseVideoClip(draft, "clip");
|
|
1095
|
+
this.adjustmentTarget(clip);
|
|
1031
1096
|
const outcome = { op: e.op, clip: clip.id, track: track.id };
|
|
1032
1097
|
this.place(track, clip, mode, outcome);
|
|
1033
1098
|
return this.outcomes.push(outcome);
|
|
@@ -1161,6 +1226,7 @@ var Editor = class {
|
|
|
1161
1226
|
if (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));
|
|
1162
1227
|
for (const k of e.remove ?? []) delete next[k];
|
|
1163
1228
|
const clip = parseVideoClip(next, "set");
|
|
1229
|
+
this.adjustmentTarget(clip);
|
|
1164
1230
|
if (clip.type === "media" && clip.asset !== at.clip.asset) this.kindFits(at.track, clip);
|
|
1165
1231
|
if (clip.start !== at.clip.start || clip.duration !== at.clip.duration) this.fits(at.track, clip.start, clip.duration, /* @__PURE__ */ new Set([clip.id]), `Clip "${clip.id}"`);
|
|
1166
1232
|
at.track.clips[at.index] = clip;
|
|
@@ -1171,7 +1237,7 @@ var Editor = class {
|
|
|
1171
1237
|
const { track, clip } = this.clip(e.clip);
|
|
1172
1238
|
this.unlocked(track);
|
|
1173
1239
|
if (clip.type === "sequence") refuse(`Clip "${clip.id}" is a compound clip, which plays its sequence at normal speed; ramp the clips inside the sequence instead.`);
|
|
1174
|
-
if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip
|
|
1240
|
+
if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${videoClipTypeLabel(clip)}, so it has no source to ramp; only video and audio clips change speed.`);
|
|
1175
1241
|
const asset = this.asset(clip.asset);
|
|
1176
1242
|
if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no motion to ramp.`);
|
|
1177
1243
|
const media = clip;
|
|
@@ -1228,7 +1294,7 @@ var Editor = class {
|
|
|
1228
1294
|
case "slip_clip": {
|
|
1229
1295
|
const { track, clip } = this.clip(e.clip);
|
|
1230
1296
|
this.unlocked(track);
|
|
1231
|
-
if (clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${clip
|
|
1297
|
+
if (clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${videoClipTypeLabel(clip)}, so it has no source to slip; only video, audio and compound clips slip.`);
|
|
1232
1298
|
const source = clip.type === "sequence" ? `sequence "${clip.sequence}"` : `"${this.asset(clip.asset).id}"`;
|
|
1233
1299
|
if (clip.type === "media" && this.asset(clip.asset).kind === "image") refuse(`Clip "${clip.id}" is a still ("${clip.asset}"), so it has no source to slip; only video and audio clips slip.`);
|
|
1234
1300
|
const handles = videoClipHandles(clip, videoAssetMap(this.p), videoSequenceMap(this.p));
|
|
@@ -1428,6 +1494,432 @@ function applyVideoEdits(project, edits) {
|
|
|
1428
1494
|
return { ok: true, project: next, issues, outcomes: editor.outcomes };
|
|
1429
1495
|
}
|
|
1430
1496
|
|
|
1497
|
+
// src/video-editor/transcript.ts
|
|
1498
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1499
|
+
function cueTime(s) {
|
|
1500
|
+
const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
|
|
1501
|
+
if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
|
|
1502
|
+
return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
|
|
1503
|
+
}
|
|
1504
|
+
function cueWords(text, start, end) {
|
|
1505
|
+
const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
|
|
1506
|
+
const step = (end - start) / Math.max(1, words.length);
|
|
1507
|
+
return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
|
|
1508
|
+
}
|
|
1509
|
+
function parseVideoTranscript(input) {
|
|
1510
|
+
let data = input;
|
|
1511
|
+
if (typeof input === "string") {
|
|
1512
|
+
const text = input.replace(/^/, "").trim();
|
|
1513
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
1514
|
+
try {
|
|
1515
|
+
data = JSON.parse(text);
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw new Error("The transcript looks like JSON but is not valid JSON.");
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
const words2 = [];
|
|
1521
|
+
for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
|
|
1522
|
+
const lines = block.split("\n");
|
|
1523
|
+
const at = lines.findIndex((l) => l.includes("-->"));
|
|
1524
|
+
if (at < 0) continue;
|
|
1525
|
+
const [a, b] = lines[at].split("-->");
|
|
1526
|
+
words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
|
|
1527
|
+
}
|
|
1528
|
+
if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
1529
|
+
return { words: words2, approximate: true };
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
|
|
1533
|
+
const fromList = (list, toMs) => list.map((w, i) => {
|
|
1534
|
+
if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
|
|
1535
|
+
const word = String(w.word ?? w.text ?? "").trim();
|
|
1536
|
+
const start = toMs(w.start);
|
|
1537
|
+
const end = toMs(w.end);
|
|
1538
|
+
if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
|
|
1539
|
+
return { word, start, end: Math.max(start, end) };
|
|
1540
|
+
});
|
|
1541
|
+
let words = [];
|
|
1542
|
+
if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
|
|
1543
|
+
else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
|
|
1544
|
+
else if (isRecord(data) && Array.isArray(data.segments)) {
|
|
1545
|
+
const segments = data.segments.filter(isRecord);
|
|
1546
|
+
if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
|
|
1547
|
+
else {
|
|
1548
|
+
for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
|
|
1549
|
+
if (!words.length) throw new Error("No words found in the segments.");
|
|
1550
|
+
return { words, approximate: true };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
1554
|
+
return { words: words.sort((a, b) => a.start - b.start), approximate: false };
|
|
1555
|
+
}
|
|
1556
|
+
function videoTranscriptTrack(project, track) {
|
|
1557
|
+
if (track !== void 0) {
|
|
1558
|
+
if (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track "${track}"; the tracks are ${project.tracks.map((t) => `"${t.id}"`).join(", ")}.`);
|
|
1559
|
+
return track;
|
|
1560
|
+
}
|
|
1561
|
+
const assets = videoAssetMap(project);
|
|
1562
|
+
const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
|
|
1563
|
+
return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
|
|
1564
|
+
}
|
|
1565
|
+
function videoTimelineTranscript(project, options = {}) {
|
|
1566
|
+
const id = videoTranscriptTrack(project, options.track);
|
|
1567
|
+
if (!id) return [];
|
|
1568
|
+
const track = project.tracks.find((t) => t.id === id);
|
|
1569
|
+
const assets = videoAssetMap(project);
|
|
1570
|
+
const out = [];
|
|
1571
|
+
for (const clip of sortedVideoClips(track)) {
|
|
1572
|
+
if (clip.type !== "media") continue;
|
|
1573
|
+
const words = assets.get(clip.asset)?.transcript;
|
|
1574
|
+
if (!words?.length) continue;
|
|
1575
|
+
const speed = videoClipSpeed(clip);
|
|
1576
|
+
const from = clip.in ?? 0;
|
|
1577
|
+
const to = from + clip.duration * speed;
|
|
1578
|
+
const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
|
|
1579
|
+
words.forEach((w, index) => {
|
|
1580
|
+
const mid = (w.start + w.end) / 2;
|
|
1581
|
+
if (mid < from || mid >= to) return;
|
|
1582
|
+
out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
return out;
|
|
1586
|
+
}
|
|
1587
|
+
var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
|
|
1588
|
+
var rangeOf = (words, i, n) => ({ from: words[i].start, to: words[i + n - 1].end, text: words.slice(i, i + n).map((w) => w.word).join(" ") });
|
|
1589
|
+
function findVideoTranscriptText(words, query) {
|
|
1590
|
+
const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
|
|
1591
|
+
if (!q.length) throw new Error("Search for at least one word.");
|
|
1592
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
1593
|
+
const out = [];
|
|
1594
|
+
for (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));
|
|
1595
|
+
return out;
|
|
1596
|
+
}
|
|
1597
|
+
var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
|
|
1598
|
+
function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
|
|
1599
|
+
const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
|
|
1600
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
1601
|
+
const out = [];
|
|
1602
|
+
for (let i = 0; i < norm.length; ) {
|
|
1603
|
+
const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
|
|
1604
|
+
if (hit) {
|
|
1605
|
+
out.push(rangeOf(words, i, hit.length));
|
|
1606
|
+
i += hit.length;
|
|
1607
|
+
} else i++;
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
}
|
|
1611
|
+
function findVideoTranscriptPauses(words, options = {}) {
|
|
1612
|
+
const min = options.min ?? 700;
|
|
1613
|
+
const keep = options.keep ?? 150;
|
|
1614
|
+
const out = [];
|
|
1615
|
+
for (let i = 1; i < words.length; i++) {
|
|
1616
|
+
const a = words[i - 1];
|
|
1617
|
+
const b = words[i];
|
|
1618
|
+
if (a.clip !== b.clip || b.start - a.end < min) continue;
|
|
1619
|
+
let from = a.end + keep;
|
|
1620
|
+
let to = b.start - keep;
|
|
1621
|
+
if (options.fps) {
|
|
1622
|
+
const f = 1e3 / options.fps;
|
|
1623
|
+
from = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);
|
|
1624
|
+
to = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);
|
|
1625
|
+
}
|
|
1626
|
+
if (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after "${a.word}")` });
|
|
1627
|
+
}
|
|
1628
|
+
return out;
|
|
1629
|
+
}
|
|
1630
|
+
function mergeVideoRanges(ranges) {
|
|
1631
|
+
const sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);
|
|
1632
|
+
const out = [];
|
|
1633
|
+
for (const r of sorted) {
|
|
1634
|
+
const last = out[out.length - 1];
|
|
1635
|
+
if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
|
|
1636
|
+
else out.push(r);
|
|
1637
|
+
}
|
|
1638
|
+
return out;
|
|
1639
|
+
}
|
|
1640
|
+
function videoRemoveRangesEdits(ranges, tracks) {
|
|
1641
|
+
return mergeVideoRanges(ranges).reverse().map((r) => ({ op: "extract_range", from: r.from, to: r.to, ...tracks ? { tracks } : {} }));
|
|
1642
|
+
}
|
|
1643
|
+
function videoWordsRange(words, a, b) {
|
|
1644
|
+
const [i, j] = a <= b ? [a, b] : [b, a];
|
|
1645
|
+
return { from: words[i].start, to: words[j].end };
|
|
1646
|
+
}
|
|
1647
|
+
function videoTranscriptEdit(assetId, text) {
|
|
1648
|
+
return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
|
|
1649
|
+
}
|
|
1650
|
+
function videoWordAt(words, time) {
|
|
1651
|
+
let lo = 0;
|
|
1652
|
+
let hi = words.length - 1;
|
|
1653
|
+
while (lo <= hi) {
|
|
1654
|
+
const mid = lo + hi >> 1;
|
|
1655
|
+
if (words[mid].end <= time) lo = mid + 1;
|
|
1656
|
+
else if (words[mid].start > time) hi = mid - 1;
|
|
1657
|
+
else return mid;
|
|
1658
|
+
}
|
|
1659
|
+
return -1;
|
|
1660
|
+
}
|
|
1661
|
+
function findVideoRepetitions(words, options = {}) {
|
|
1662
|
+
const max = Math.max(1, options.maxPhrase ?? 3);
|
|
1663
|
+
const maxGap = options.maxGap ?? 1e3;
|
|
1664
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
1665
|
+
const out = [];
|
|
1666
|
+
for (let i = 0; i < norm.length; i++) {
|
|
1667
|
+
for (let n = Math.min(max, Math.floor((norm.length - i) / 2)); n >= 1; n--) {
|
|
1668
|
+
let same = true;
|
|
1669
|
+
for (let k = 0; k < n && same; k++) same = !!norm[i + k] && norm[i + k] === norm[i + n + k];
|
|
1670
|
+
if (!same || words[i + n].start - words[i + n - 1].end > maxGap) continue;
|
|
1671
|
+
out.push({ from: words[i].start, to: words[i + n].start, text: words.slice(i, i + n).map((w) => w.word).join(" ") });
|
|
1672
|
+
i += n - 1;
|
|
1673
|
+
break;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return out;
|
|
1677
|
+
}
|
|
1678
|
+
var VIDEO_DISFLUENCY_KINDS = ["filler", "pause", "repetition"];
|
|
1679
|
+
function detectVideoDisfluencies(words, options = {}) {
|
|
1680
|
+
const kinds = new Set(options.kinds ?? VIDEO_DISFLUENCY_KINDS);
|
|
1681
|
+
const startIndex = (time) => Math.max(0, words.findIndex((w) => w.start >= time - VIDEO_TIME_EPSILON));
|
|
1682
|
+
const out = [];
|
|
1683
|
+
if (kinds.has("filler"))
|
|
1684
|
+
for (const r of findVideoFillers(words, options.fillers)) {
|
|
1685
|
+
const first = startIndex(r.from);
|
|
1686
|
+
out.push({ ...r, kind: "filler", words: [first, first + r.text.split(" ").length - 1] });
|
|
1687
|
+
}
|
|
1688
|
+
if (kinds.has("repetition"))
|
|
1689
|
+
for (const r of findVideoRepetitions(words, options.repetitions)) {
|
|
1690
|
+
const first = startIndex(r.from);
|
|
1691
|
+
out.push({ ...r, kind: "repetition", words: [first, first + r.text.split(" ").length - 1] });
|
|
1692
|
+
}
|
|
1693
|
+
if (kinds.has("pause"))
|
|
1694
|
+
for (const r of findVideoTranscriptPauses(words, options.pauses)) {
|
|
1695
|
+
const after = words.findIndex((w) => w.start >= r.to - VIDEO_TIME_EPSILON);
|
|
1696
|
+
out.push({ ...r, kind: "pause", words: [Math.max(0, after - 1), Math.max(0, after)] });
|
|
1697
|
+
}
|
|
1698
|
+
return out.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
1699
|
+
}
|
|
1700
|
+
function videoCutsWithMargin(ranges, keep) {
|
|
1701
|
+
return ranges.map((r) => ({ ...r, from: r.from + keep, to: r.to - keep })).filter((r) => r.to - r.from > VIDEO_TIME_EPSILON);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// src/video-editor/mix.ts
|
|
1705
|
+
var VIDEO_LOUDNESS_HOP = 100;
|
|
1706
|
+
function kWeighting(sampleRate) {
|
|
1707
|
+
let K = Math.tan(Math.PI * 1681.974450955533 / sampleRate);
|
|
1708
|
+
const Vh = 10 ** (3.999843853973347 / 20);
|
|
1709
|
+
const Vb = Vh ** 0.4996667741545416;
|
|
1710
|
+
let Q = 0.7071752369554196;
|
|
1711
|
+
let a0 = 1 + K / Q + K * K;
|
|
1712
|
+
const shelf = { b: [(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0], a: [2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0] };
|
|
1713
|
+
K = Math.tan(Math.PI * 38.13547087602444 / sampleRate);
|
|
1714
|
+
Q = 0.5003270373238773;
|
|
1715
|
+
a0 = 1 + K / Q + K * K;
|
|
1716
|
+
const highPass = { b: [1, -2, 1], a: [2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0] };
|
|
1717
|
+
return [shelf, highPass];
|
|
1718
|
+
}
|
|
1719
|
+
function videoLoudnessEnergy(channels, sampleRate) {
|
|
1720
|
+
const length = channels.reduce((n, c) => Math.max(n, c.length), 0);
|
|
1721
|
+
const hopSamples = Math.max(1, Math.round(sampleRate * VIDEO_LOUDNESS_HOP / 1e3));
|
|
1722
|
+
const hops = Math.max(1, Math.ceil(length / hopSamples));
|
|
1723
|
+
const energy = new Float32Array(hops);
|
|
1724
|
+
const filters = kWeighting(sampleRate);
|
|
1725
|
+
for (const channel of channels) {
|
|
1726
|
+
const x1 = [0, 0];
|
|
1727
|
+
const x2 = [0, 0];
|
|
1728
|
+
const y1 = [0, 0];
|
|
1729
|
+
const y2 = [0, 0];
|
|
1730
|
+
for (let i = 0; i < channel.length; i++) {
|
|
1731
|
+
let v = Number.isFinite(channel[i]) ? channel[i] : 0;
|
|
1732
|
+
for (let f = 0; f < 2; f++) {
|
|
1733
|
+
const { b, a } = filters[f];
|
|
1734
|
+
const y = b[0] * v + b[1] * x1[f] + b[2] * x2[f] - a[0] * y1[f] - a[1] * y2[f];
|
|
1735
|
+
x2[f] = x1[f];
|
|
1736
|
+
x1[f] = v;
|
|
1737
|
+
y2[f] = y1[f];
|
|
1738
|
+
y1[f] = y;
|
|
1739
|
+
v = y;
|
|
1740
|
+
}
|
|
1741
|
+
energy[Math.floor(i / hopSamples)] += v * v;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
for (let h = 0; h < hops; h++) energy[h] /= Math.min(hopSamples, length - h * hopSamples) || 1;
|
|
1745
|
+
return { hop: VIDEO_LOUDNESS_HOP, energy };
|
|
1746
|
+
}
|
|
1747
|
+
var lufs = (meanSquare) => -0.691 + 10 * Math.log10(meanSquare);
|
|
1748
|
+
function videoLoudnessOf(loudness, from = 0, to = Infinity) {
|
|
1749
|
+
const { hop, energy } = loudness;
|
|
1750
|
+
const a = Math.max(0, Math.floor(from / hop));
|
|
1751
|
+
const b = Math.min(energy.length, Math.ceil(to / hop));
|
|
1752
|
+
if (b <= a) return null;
|
|
1753
|
+
const blocks = [];
|
|
1754
|
+
if (b - a < 4) {
|
|
1755
|
+
let sum = 0;
|
|
1756
|
+
for (let h = a; h < b; h++) sum += energy[h];
|
|
1757
|
+
blocks.push(sum / (b - a));
|
|
1758
|
+
} else
|
|
1759
|
+
for (let h = a; h + 4 <= b; h++) blocks.push((energy[h] + energy[h + 1] + energy[h + 2] + energy[h + 3]) / 4);
|
|
1760
|
+
const absolute = blocks.filter((z) => z > 0 && lufs(z) > -70);
|
|
1761
|
+
if (!absolute.length) return null;
|
|
1762
|
+
const threshold = lufs(absolute.reduce((s, z) => s + z, 0) / absolute.length) - 10;
|
|
1763
|
+
const gated = absolute.filter((z) => lufs(z) > threshold);
|
|
1764
|
+
if (!gated.length) return null;
|
|
1765
|
+
return Math.round(lufs(gated.reduce((s, z) => s + z, 0) / gated.length) * 10) / 10;
|
|
1766
|
+
}
|
|
1767
|
+
function measureVideoLoudness(channels, sampleRate) {
|
|
1768
|
+
return videoLoudnessOf(videoLoudnessEnergy(channels, sampleRate));
|
|
1769
|
+
}
|
|
1770
|
+
function videoAudioLoudness(peaks, from = 0, to = Infinity) {
|
|
1771
|
+
if (!peaks.loudness) throw new Error("These peaks have no loudness data; build them again with buildVideoAudioPeaks (or decodeVideoAudioPeaks), which measures it.");
|
|
1772
|
+
return videoLoudnessOf(peaks.loudness, from, to);
|
|
1773
|
+
}
|
|
1774
|
+
function videoLoudnessGain(measured, target = -14, options = {}) {
|
|
1775
|
+
let db = target - measured;
|
|
1776
|
+
let limitedBy;
|
|
1777
|
+
const maxDb = 20 * Math.log10(options.maxGain ?? 4);
|
|
1778
|
+
if (db > maxDb) {
|
|
1779
|
+
db = maxDb;
|
|
1780
|
+
limitedBy = "max-gain";
|
|
1781
|
+
}
|
|
1782
|
+
if (options.peakDb !== void 0 && options.peakDb > -100) {
|
|
1783
|
+
const room = (options.ceiling ?? -1) - options.peakDb;
|
|
1784
|
+
if (db > room) {
|
|
1785
|
+
db = room;
|
|
1786
|
+
limitedBy = "peak";
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return { gain: Math.round(10 ** (db / 20) * 1e3) / 1e3, gainDb: Math.round(db * 10) / 10, ...limitedBy ? { limitedBy } : {} };
|
|
1790
|
+
}
|
|
1791
|
+
function videoSpeechRanges(words, mergeGap = 500) {
|
|
1792
|
+
const merged = [];
|
|
1793
|
+
for (const r of mergeVideoRanges(words.map((w) => ({ from: w.start, to: w.end })))) {
|
|
1794
|
+
const last = merged[merged.length - 1];
|
|
1795
|
+
if (last && r.from - last.to < mergeGap) last.to = Math.max(last.to, r.to);
|
|
1796
|
+
else merged.push({ ...r });
|
|
1797
|
+
}
|
|
1798
|
+
return merged;
|
|
1799
|
+
}
|
|
1800
|
+
function videoDuckingKeys(clip, speech, options = {}) {
|
|
1801
|
+
const duckDb = options.duckDb ?? -14;
|
|
1802
|
+
const attack = Math.max(0, options.attack ?? 200);
|
|
1803
|
+
const release = Math.max(0, options.release ?? 400);
|
|
1804
|
+
const gap = Math.max(options.mergeGap ?? 600, attack + release);
|
|
1805
|
+
const base = options.base ?? 1;
|
|
1806
|
+
const low = Math.round(base * 10 ** (duckDb / 20) * 1e4) / 1e4;
|
|
1807
|
+
const end = videoClipEnd(clip);
|
|
1808
|
+
const ranges = [];
|
|
1809
|
+
for (const r of mergeVideoRanges(speech)) {
|
|
1810
|
+
if (r.to <= clip.start - release || r.from >= end + attack) continue;
|
|
1811
|
+
const last = ranges[ranges.length - 1];
|
|
1812
|
+
if (last && r.from - last.to < gap) last.to = Math.max(last.to, r.to);
|
|
1813
|
+
else ranges.push({ ...r });
|
|
1814
|
+
}
|
|
1815
|
+
if (!ranges.length) return [];
|
|
1816
|
+
const keys = [];
|
|
1817
|
+
const at = (t) => Math.round(Math.min(clip.duration, Math.max(0, t - clip.start)));
|
|
1818
|
+
const push = (t, v, ease) => {
|
|
1819
|
+
const last = keys[keys.length - 1];
|
|
1820
|
+
if (last && t <= last.t) {
|
|
1821
|
+
last.v = v;
|
|
1822
|
+
if (ease) last.ease = ease;
|
|
1823
|
+
else delete last.ease;
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
keys.push({ t, v, ...ease ? { ease } : {} });
|
|
1827
|
+
};
|
|
1828
|
+
for (const r of ranges) {
|
|
1829
|
+
const down = at(r.from - attack);
|
|
1830
|
+
const held = at(r.from);
|
|
1831
|
+
if (held > down) push(down, base, "ease-in-out");
|
|
1832
|
+
push(held, low);
|
|
1833
|
+
const hold = at(r.to);
|
|
1834
|
+
const up = at(r.to + release);
|
|
1835
|
+
if (up > hold) {
|
|
1836
|
+
push(hold, low, "ease-in-out");
|
|
1837
|
+
push(up, base);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return keys;
|
|
1841
|
+
}
|
|
1842
|
+
function findVideoAudioBeats(peaks, from, to, options = {}) {
|
|
1843
|
+
const res = Math.max(1, options.resolution ?? 10);
|
|
1844
|
+
const n = Math.max(2, Math.ceil((to - from) / res));
|
|
1845
|
+
const spans = videoAudioPeakSpans(peaks, from, to, n);
|
|
1846
|
+
const env = new Float32Array(n);
|
|
1847
|
+
for (let i = 0; i < n; i++) env[i] = Math.max(Math.abs(spans[2 * i]), Math.abs(spans[2 * i + 1]));
|
|
1848
|
+
const onset = new Float32Array(n);
|
|
1849
|
+
for (let i = 1; i < n; i++) onset[i] = Math.max(0, env[i] - env[i - 1]);
|
|
1850
|
+
const win = Math.max(1, Math.round(500 / res));
|
|
1851
|
+
const k = options.sensitivity ?? 1.5;
|
|
1852
|
+
const minGap = Math.max(1, Math.round((options.minInterval ?? 250) / res));
|
|
1853
|
+
const onsets = [];
|
|
1854
|
+
const strength = [];
|
|
1855
|
+
let lastAt = -Infinity;
|
|
1856
|
+
for (let i = 1; i < n - 1; i++) {
|
|
1857
|
+
if (!(onset[i] > 0 && onset[i] >= onset[i - 1] && onset[i] >= onset[i + 1])) continue;
|
|
1858
|
+
let sum = 0;
|
|
1859
|
+
let sq = 0;
|
|
1860
|
+
let count = 0;
|
|
1861
|
+
for (let j = Math.max(0, i - win); j < Math.min(n, i + win + 1); j++) {
|
|
1862
|
+
sum += onset[j];
|
|
1863
|
+
sq += onset[j] * onset[j];
|
|
1864
|
+
count++;
|
|
1865
|
+
}
|
|
1866
|
+
const mean = sum / count;
|
|
1867
|
+
const sd = Math.sqrt(Math.max(0, sq / count - mean * mean));
|
|
1868
|
+
if (onset[i] < mean + k * sd || onset[i] < 0.02) continue;
|
|
1869
|
+
if (i - lastAt < minGap) {
|
|
1870
|
+
if (onset[i] > strength[strength.length - 1]) {
|
|
1871
|
+
onsets[onsets.length - 1] = from + i * res;
|
|
1872
|
+
strength[strength.length - 1] = onset[i];
|
|
1873
|
+
lastAt = i;
|
|
1874
|
+
}
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
onsets.push(from + i * res);
|
|
1878
|
+
strength.push(onset[i]);
|
|
1879
|
+
lastAt = i;
|
|
1880
|
+
}
|
|
1881
|
+
const minLag = Math.max(1, Math.round(6e4 / (options.maxBpm ?? 200) / res));
|
|
1882
|
+
const maxLag = Math.min(n - 1, Math.round(6e4 / (options.minBpm ?? 60) / res));
|
|
1883
|
+
let bestLag = 0;
|
|
1884
|
+
let best = 0;
|
|
1885
|
+
let total = 0;
|
|
1886
|
+
for (let i = 0; i < n; i++) total += onset[i] * onset[i];
|
|
1887
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
1888
|
+
let s = 0;
|
|
1889
|
+
for (let i = 0; i + lag < n; i++) s += onset[i] * onset[i + lag];
|
|
1890
|
+
s /= n - lag;
|
|
1891
|
+
if (s > best * 1.0001) {
|
|
1892
|
+
best = s;
|
|
1893
|
+
bestLag = lag;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
const repeats = onsets.length >= 3 && bestLag > 0 && best * n > 0.2 * total;
|
|
1897
|
+
if (!repeats) return { onsets, bpm: null, beats: [] };
|
|
1898
|
+
const period = bestLag * res;
|
|
1899
|
+
const bpm = Math.round(6e4 / period * 10) / 10;
|
|
1900
|
+
let phase = onsets[0];
|
|
1901
|
+
let agree = -1;
|
|
1902
|
+
for (const o of onsets) {
|
|
1903
|
+
let a = 0;
|
|
1904
|
+
for (const p of onsets) {
|
|
1905
|
+
const d = Math.abs(((p - o) % period + period) % period);
|
|
1906
|
+
if (Math.min(d, period - d) <= res * 2) a++;
|
|
1907
|
+
}
|
|
1908
|
+
if (a > agree) {
|
|
1909
|
+
agree = a;
|
|
1910
|
+
phase = o;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
const beats = [];
|
|
1914
|
+
for (let t = phase - Math.floor((phase - from) / period) * period; t <= to + 1e-6; t += period) if (t >= from - 1e-6) beats.push(Math.round(t * 10) / 10);
|
|
1915
|
+
return { onsets, bpm, beats };
|
|
1916
|
+
}
|
|
1917
|
+
function nearestVideoBeat(beats, time, tolerance = Infinity) {
|
|
1918
|
+
let best = null;
|
|
1919
|
+
for (const b of beats) if (Math.abs(b - time) <= tolerance && (best === null || Math.abs(b - time) < Math.abs(best - time))) best = b;
|
|
1920
|
+
return best;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1431
1923
|
// src/video-editor/audio.ts
|
|
1432
1924
|
var VIDEO_PEAK_BLOCK = 32;
|
|
1433
1925
|
function buildVideoAudioPeaks(channels, sampleRate, blockSize = VIDEO_PEAK_BLOCK) {
|
|
@@ -1467,7 +1959,7 @@ function buildVideoAudioPeaks(channels, sampleRate, blockSize = VIDEO_PEAK_BLOCK
|
|
|
1467
1959
|
}
|
|
1468
1960
|
levels.push(up);
|
|
1469
1961
|
}
|
|
1470
|
-
return { sampleRate, length, blockSize, levels };
|
|
1962
|
+
return { sampleRate, length, blockSize, levels, loudness: videoLoudnessEnergy(channels, sampleRate) };
|
|
1471
1963
|
}
|
|
1472
1964
|
var videoAudioPeaksDuration = (peaks) => peaks.length * 1e3 / peaks.sampleRate;
|
|
1473
1965
|
function videoAudioPeakRange(peaks, from, to) {
|
|
@@ -1645,6 +2137,50 @@ var VideoAudioPeaksCache = class {
|
|
|
1645
2137
|
}
|
|
1646
2138
|
};
|
|
1647
2139
|
|
|
2140
|
+
// src/video-editor/plan.ts
|
|
2141
|
+
function clipIndex(project) {
|
|
2142
|
+
const out = /* @__PURE__ */ new Map();
|
|
2143
|
+
for (const { track } of allVideoTracks(project)) for (const c of track.clips) out.set(c.id, c);
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
2146
|
+
function diffVideoProjects(before, after) {
|
|
2147
|
+
const a = clipIndex(before);
|
|
2148
|
+
const b = clipIndex(after);
|
|
2149
|
+
const clipsAdded = [...b.keys()].filter((id) => !a.has(id));
|
|
2150
|
+
const clipsRemoved = [...a.keys()].filter((id) => !b.has(id));
|
|
2151
|
+
const clipsChanged = [...b.keys()].filter((id) => a.has(id) && JSON.stringify(a.get(id)) !== JSON.stringify(b.get(id)));
|
|
2152
|
+
const durationBefore = videoProjectDuration(before);
|
|
2153
|
+
const durationAfter = videoProjectDuration(after);
|
|
2154
|
+
return { durationBefore, durationAfter, durationDelta: durationAfter - durationBefore, clipsAdded, clipsRemoved, clipsChanged };
|
|
2155
|
+
}
|
|
2156
|
+
function planVideoEdits(project, input) {
|
|
2157
|
+
if (!input.title?.trim()) return { ok: false, error: 'A plan needs a title saying what it does (e.g. "Remove 12 filler words").' };
|
|
2158
|
+
if (!input.edits.length) return { ok: false, error: "The plan has no edits; there is nothing to propose." };
|
|
2159
|
+
const result = applyVideoEdits(project, input.edits);
|
|
2160
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
2161
|
+
const known = new Set(checkVideoProject(project).map(videoIssueKey));
|
|
2162
|
+
const summary = { ...diffVideoProjects(project, result.project), newIssues: result.issues.filter((i) => !known.has(videoIssueKey(i))), edits: input.edits.length };
|
|
2163
|
+
const plan = {
|
|
2164
|
+
id: input.id ?? "plan",
|
|
2165
|
+
title: input.title.trim(),
|
|
2166
|
+
...input.description ? { description: input.description } : {},
|
|
2167
|
+
...input.source ? { source: input.source } : {},
|
|
2168
|
+
edits: structuredClone([...input.edits]),
|
|
2169
|
+
...input.ranges?.length ? { ranges: input.ranges.map((r) => ({ ...r })) } : {},
|
|
2170
|
+
summary
|
|
2171
|
+
};
|
|
2172
|
+
return { ok: true, plan, project: result.project };
|
|
2173
|
+
}
|
|
2174
|
+
function describeVideoEditPlan(plan) {
|
|
2175
|
+
const s = plan.summary;
|
|
2176
|
+
const parts = [];
|
|
2177
|
+
if (plan.ranges?.length) parts.push(`${plan.ranges.length} stretch${plan.ranges.length > 1 ? "es" : ""}`);
|
|
2178
|
+
const delta = s.durationDelta;
|
|
2179
|
+
const length = Math.abs(delta) < 0.5 ? "same length" : `${(Math.abs(delta) / 1e3).toFixed(1)} s ${delta < 0 ? "shorter" : "longer"}`;
|
|
2180
|
+
const clips = [s.clipsAdded.length && `${s.clipsAdded.length} added`, s.clipsChanged.length && `${s.clipsChanged.length} changed`, s.clipsRemoved.length && `${s.clipsRemoved.length} removed`].filter(Boolean).join(", ");
|
|
2181
|
+
return `${plan.title} (${parts.length ? `${parts.join(", ")}, ` : ""}${length})${clips ? `: clips ${clips}` : ""}`;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1648
2184
|
// src/video-editor/scopes.ts
|
|
1649
2185
|
var VIDEO_SCOPE_KINDS = ["histogram", "waveform", "vectorscope"];
|
|
1650
2186
|
var videoLuma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
@@ -2014,174 +2550,178 @@ function readVideoScopesPanel(feed, kinds = VIDEO_SCOPE_KINDS, options = {}) {
|
|
|
2014
2550
|
const opts = { maxPixels: options.maxPixels ?? 65536 };
|
|
2015
2551
|
const images = {};
|
|
2016
2552
|
for (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);
|
|
2017
|
-
const stats = videoExposureStats(snap.pixels, opts);
|
|
2018
|
-
return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
// src/video-editor/transcript.ts
|
|
2022
|
-
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2023
|
-
function cueTime(s) {
|
|
2024
|
-
const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
|
|
2025
|
-
if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
|
|
2026
|
-
return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
|
|
2027
|
-
}
|
|
2028
|
-
function cueWords(text, start, end) {
|
|
2029
|
-
const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
|
|
2030
|
-
const step = (end - start) / Math.max(1, words.length);
|
|
2031
|
-
return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
|
|
2032
|
-
}
|
|
2033
|
-
function parseVideoTranscript(input) {
|
|
2034
|
-
let data = input;
|
|
2035
|
-
if (typeof input === "string") {
|
|
2036
|
-
const text = input.replace(/^/, "").trim();
|
|
2037
|
-
if (text.startsWith("{") || text.startsWith("[")) {
|
|
2038
|
-
try {
|
|
2039
|
-
data = JSON.parse(text);
|
|
2040
|
-
} catch {
|
|
2041
|
-
throw new Error("The transcript looks like JSON but is not valid JSON.");
|
|
2042
|
-
}
|
|
2043
|
-
} else {
|
|
2044
|
-
const words2 = [];
|
|
2045
|
-
for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
|
|
2046
|
-
const lines = block.split("\n");
|
|
2047
|
-
const at = lines.findIndex((l) => l.includes("-->"));
|
|
2048
|
-
if (at < 0) continue;
|
|
2049
|
-
const [a, b] = lines[at].split("-->");
|
|
2050
|
-
words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
|
|
2051
|
-
}
|
|
2052
|
-
if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
2053
|
-
return { words: words2, approximate: true };
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
|
|
2057
|
-
const fromList = (list, toMs) => list.map((w, i) => {
|
|
2058
|
-
if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
|
|
2059
|
-
const word = String(w.word ?? w.text ?? "").trim();
|
|
2060
|
-
const start = toMs(w.start);
|
|
2061
|
-
const end = toMs(w.end);
|
|
2062
|
-
if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
|
|
2063
|
-
return { word, start, end: Math.max(start, end) };
|
|
2064
|
-
});
|
|
2065
|
-
let words = [];
|
|
2066
|
-
if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
|
|
2067
|
-
else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
|
|
2068
|
-
else if (isRecord(data) && Array.isArray(data.segments)) {
|
|
2069
|
-
const segments = data.segments.filter(isRecord);
|
|
2070
|
-
if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
|
|
2071
|
-
else {
|
|
2072
|
-
for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
|
|
2073
|
-
if (!words.length) throw new Error("No words found in the segments.");
|
|
2074
|
-
return { words, approximate: true };
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
2078
|
-
return { words: words.sort((a, b) => a.start - b.start), approximate: false };
|
|
2079
|
-
}
|
|
2080
|
-
function videoTranscriptTrack(project, track) {
|
|
2081
|
-
if (track !== void 0) {
|
|
2082
|
-
if (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track "${track}"; the tracks are ${project.tracks.map((t) => `"${t.id}"`).join(", ")}.`);
|
|
2083
|
-
return track;
|
|
2084
|
-
}
|
|
2085
|
-
const assets = videoAssetMap(project);
|
|
2086
|
-
const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
|
|
2087
|
-
return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
|
|
2088
|
-
}
|
|
2089
|
-
function videoTimelineTranscript(project, options = {}) {
|
|
2090
|
-
const id = videoTranscriptTrack(project, options.track);
|
|
2091
|
-
if (!id) return [];
|
|
2092
|
-
const track = project.tracks.find((t) => t.id === id);
|
|
2093
|
-
const assets = videoAssetMap(project);
|
|
2094
|
-
const out = [];
|
|
2095
|
-
for (const clip of sortedVideoClips(track)) {
|
|
2096
|
-
if (clip.type !== "media") continue;
|
|
2097
|
-
const words = assets.get(clip.asset)?.transcript;
|
|
2098
|
-
if (!words?.length) continue;
|
|
2099
|
-
const speed = videoClipSpeed(clip);
|
|
2100
|
-
const from = clip.in ?? 0;
|
|
2101
|
-
const to = from + clip.duration * speed;
|
|
2102
|
-
const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
|
|
2103
|
-
words.forEach((w, index) => {
|
|
2104
|
-
const mid = (w.start + w.end) / 2;
|
|
2105
|
-
if (mid < from || mid >= to) return;
|
|
2106
|
-
out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
|
|
2107
|
-
});
|
|
2108
|
-
}
|
|
2109
|
-
return out;
|
|
2110
|
-
}
|
|
2111
|
-
var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
|
|
2112
|
-
var rangeOf = (words, i, n) => ({ from: words[i].start, to: words[i + n - 1].end, text: words.slice(i, i + n).map((w) => w.word).join(" ") });
|
|
2113
|
-
function findVideoTranscriptText(words, query) {
|
|
2114
|
-
const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
|
|
2115
|
-
if (!q.length) throw new Error("Search for at least one word.");
|
|
2116
|
-
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2117
|
-
const out = [];
|
|
2118
|
-
for (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));
|
|
2119
|
-
return out;
|
|
2120
|
-
}
|
|
2121
|
-
var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
|
|
2122
|
-
function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
|
|
2123
|
-
const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
|
|
2124
|
-
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2125
|
-
const out = [];
|
|
2126
|
-
for (let i = 0; i < norm.length; ) {
|
|
2127
|
-
const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
|
|
2128
|
-
if (hit) {
|
|
2129
|
-
out.push(rangeOf(words, i, hit.length));
|
|
2130
|
-
i += hit.length;
|
|
2131
|
-
} else i++;
|
|
2132
|
-
}
|
|
2133
|
-
return out;
|
|
2553
|
+
const stats = videoExposureStats(snap.pixels, opts);
|
|
2554
|
+
return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
|
|
2134
2555
|
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2556
|
+
|
|
2557
|
+
// src/video-editor/transcript-controller.ts
|
|
2558
|
+
var TranscriptVideoController = class {
|
|
2559
|
+
constructor(controller, options = {}) {
|
|
2560
|
+
this.controller = controller;
|
|
2561
|
+
this.options = options;
|
|
2562
|
+
this.track = options.track;
|
|
2563
|
+
this.state = this.compute();
|
|
2564
|
+
this.connect();
|
|
2565
|
+
}
|
|
2566
|
+
controller;
|
|
2567
|
+
options;
|
|
2568
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2569
|
+
unsubscribe = null;
|
|
2570
|
+
track;
|
|
2571
|
+
selection = null;
|
|
2572
|
+
error = null;
|
|
2573
|
+
state;
|
|
2574
|
+
/** Words and disfluencies are worked out once per project. */
|
|
2575
|
+
cache = null;
|
|
2576
|
+
getState = () => this.state;
|
|
2577
|
+
connect() {
|
|
2578
|
+
if (this.unsubscribe) return;
|
|
2579
|
+
this.unsubscribe = this.controller.subscribe(() => this.refresh());
|
|
2580
|
+
this.refresh();
|
|
2581
|
+
}
|
|
2582
|
+
/** Listens for changes. Subscribing after `destroy` follows the editor again (React StrictMode remounts). */
|
|
2583
|
+
subscribe = (listener) => {
|
|
2584
|
+
this.connect();
|
|
2585
|
+
this.listeners.add(listener);
|
|
2586
|
+
return () => {
|
|
2587
|
+
this.listeners.delete(listener);
|
|
2588
|
+
};
|
|
2589
|
+
};
|
|
2590
|
+
/** Stops following the editor. */
|
|
2591
|
+
destroy() {
|
|
2592
|
+
this.unsubscribe?.();
|
|
2593
|
+
this.unsubscribe = null;
|
|
2594
|
+
this.listeners.clear();
|
|
2595
|
+
}
|
|
2596
|
+
words(project) {
|
|
2597
|
+
const c = this.cache;
|
|
2598
|
+
if (c && c.project === project && c.track === this.track) return c;
|
|
2599
|
+
let words = [];
|
|
2600
|
+
let trackId = null;
|
|
2601
|
+
try {
|
|
2602
|
+
trackId = videoTranscriptTrack(project, this.track);
|
|
2603
|
+
words = videoTimelineTranscript(project, { track: this.track });
|
|
2604
|
+
} catch {
|
|
2149
2605
|
}
|
|
2150
|
-
|
|
2606
|
+
const disfluencies = detectVideoDisfluencies(words, { ...this.options.disfluencies, pauses: { fps: project.fps, ...this.options.disfluencies?.pauses } });
|
|
2607
|
+
const flags = {};
|
|
2608
|
+
for (const d of disfluencies)
|
|
2609
|
+
for (let i = d.words[0]; i <= d.words[1]; i++) {
|
|
2610
|
+
if (d.kind === "pause" && i !== d.words[0]) continue;
|
|
2611
|
+
const f = flags[i] ??= [];
|
|
2612
|
+
if (!f.includes(d.kind)) f.push(d.kind);
|
|
2613
|
+
}
|
|
2614
|
+
this.cache = { project, track: this.track, words, trackId, text: words.map((w) => w.word).join(" "), disfluencies, flags };
|
|
2615
|
+
return this.cache;
|
|
2616
|
+
}
|
|
2617
|
+
compute() {
|
|
2618
|
+
const editor = this.controller.getState();
|
|
2619
|
+
const c = this.words(editor.project);
|
|
2620
|
+
if (this.selection && (this.selection[0] >= c.words.length || this.selection[1] >= c.words.length)) this.selection = null;
|
|
2621
|
+
return { track: c.trackId, words: c.words, text: c.text, current: videoWordAt(c.words, editor.time), selection: this.selection, disfluencies: c.disfluencies, flags: c.flags, editable: !editor.readOnly, error: this.error };
|
|
2622
|
+
}
|
|
2623
|
+
refresh() {
|
|
2624
|
+
const next = this.compute();
|
|
2625
|
+
const prev = this.state;
|
|
2626
|
+
if (next.words === prev.words && next.current === prev.current && next.selection === prev.selection && next.editable === prev.editable && next.error === prev.error && next.disfluencies === prev.disfluencies) return;
|
|
2627
|
+
this.state = next;
|
|
2628
|
+
this.listeners.forEach((l) => l(next));
|
|
2629
|
+
}
|
|
2630
|
+
/** Follows another track's words (`undefined`: the default track). */
|
|
2631
|
+
setTrack(track) {
|
|
2632
|
+
this.track = track;
|
|
2633
|
+
this.selection = null;
|
|
2634
|
+
this.refresh();
|
|
2635
|
+
}
|
|
2636
|
+
/** Moves the playhead to the start of a word. */
|
|
2637
|
+
seekToWord(index) {
|
|
2638
|
+
const w = this.state.words[index];
|
|
2639
|
+
if (w) this.controller.seek(w.start);
|
|
2640
|
+
}
|
|
2641
|
+
/** Selects words `a` to `b` (either order), or clears the selection with `null`. */
|
|
2642
|
+
select(a, b = a) {
|
|
2643
|
+
const n = this.state.words.length;
|
|
2644
|
+
this.selection = a === null || b === null || a < 0 || b < 0 || a >= n || b >= n ? null : a <= b ? [a, b] : [b, a];
|
|
2645
|
+
this.error = null;
|
|
2646
|
+
this.refresh();
|
|
2647
|
+
}
|
|
2648
|
+
/** Index of the word playing at a timeline time, or -1. */
|
|
2649
|
+
wordAt(time) {
|
|
2650
|
+
return videoWordAt(this.state.words, time);
|
|
2651
|
+
}
|
|
2652
|
+
/** Where word `index` begins in `text` (characters). */
|
|
2653
|
+
textOffset(index) {
|
|
2654
|
+
let offset = 0;
|
|
2655
|
+
for (let i = 0; i < Math.min(index, this.state.words.length); i++) offset += this.state.words[i].word.length + 1;
|
|
2656
|
+
return offset;
|
|
2657
|
+
}
|
|
2658
|
+
/** The word a caret at `offset` characters into `text` is in (or just after), as an index; -1 when there are no words. */
|
|
2659
|
+
wordAtOffset(offset) {
|
|
2660
|
+
const { words } = this.state;
|
|
2661
|
+
let at = 0;
|
|
2662
|
+
for (let i = 0; i < words.length; i++) {
|
|
2663
|
+
at += words[i].word.length;
|
|
2664
|
+
if (offset <= at) return i;
|
|
2665
|
+
at += 1;
|
|
2666
|
+
}
|
|
2667
|
+
return words.length - 1;
|
|
2151
2668
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
const out = [];
|
|
2157
|
-
for (const r of sorted) {
|
|
2158
|
-
const last = out[out.length - 1];
|
|
2159
|
-
if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
|
|
2160
|
-
else out.push(r);
|
|
2669
|
+
/** The timeline time a caret at `offset` characters into `text` stands for: the start of its word. */
|
|
2670
|
+
timeAtOffset(offset) {
|
|
2671
|
+
const i = this.wordAtOffset(offset);
|
|
2672
|
+
return i < 0 ? null : this.state.words[i].start;
|
|
2161
2673
|
}
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
}
|
|
2167
|
-
function videoWordsRange(words, a, b) {
|
|
2168
|
-
const [i, j] = a <= b ? [a, b] : [b, a];
|
|
2169
|
-
return { from: words[i].start, to: words[j].end };
|
|
2170
|
-
}
|
|
2171
|
-
function videoTranscriptEdit(assetId, text) {
|
|
2172
|
-
return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
|
|
2173
|
-
}
|
|
2174
|
-
function videoWordAt(words, time) {
|
|
2175
|
-
let lo = 0;
|
|
2176
|
-
let hi = words.length - 1;
|
|
2177
|
-
while (lo <= hi) {
|
|
2178
|
-
const mid = lo + hi >> 1;
|
|
2179
|
-
if (words[mid].end <= time) lo = mid + 1;
|
|
2180
|
-
else if (words[mid].start > time) hi = mid - 1;
|
|
2181
|
-
else return mid;
|
|
2674
|
+
fail(message) {
|
|
2675
|
+
this.error = message;
|
|
2676
|
+
this.refresh();
|
|
2677
|
+
return { ok: false, error: message };
|
|
2182
2678
|
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2679
|
+
/** Cuts ranges out (or proposes a plan to, with `plan`), as one undo step. */
|
|
2680
|
+
cut(ranges, title, plan) {
|
|
2681
|
+
if (!ranges.length) return this.fail("There is nothing to cut.");
|
|
2682
|
+
const merged = mergeVideoRanges(ranges);
|
|
2683
|
+
const edits = videoRemoveRangesEdits(merged);
|
|
2684
|
+
if (plan) {
|
|
2685
|
+
const r2 = this.controller.proposePlan({ title, edits, ranges, source: "transcript" });
|
|
2686
|
+
if (!r2.ok) return this.fail(r2.error);
|
|
2687
|
+
this.error = null;
|
|
2688
|
+
this.select(null);
|
|
2689
|
+
return { ok: true, plan: r2.plan };
|
|
2690
|
+
}
|
|
2691
|
+
const r = this.controller.apply(edits, title);
|
|
2692
|
+
if (!r.ok) return this.fail(r.error);
|
|
2693
|
+
this.error = null;
|
|
2694
|
+
this.select(null);
|
|
2695
|
+
return { ok: true, applied: r };
|
|
2696
|
+
}
|
|
2697
|
+
/** Deletes the selected words, cutting their stretch out of the timeline (or proposing it with `planFirst`). */
|
|
2698
|
+
deleteSelection(options = {}) {
|
|
2699
|
+
const sel = this.state.selection;
|
|
2700
|
+
if (!sel) return this.fail("Select the words to delete first.");
|
|
2701
|
+
const { words } = this.state;
|
|
2702
|
+
const range = videoWordsRange(words, sel[0], sel[1]);
|
|
2703
|
+
const text = words.slice(sel[0], sel[1] + 1).map((w) => w.word).join(" ");
|
|
2704
|
+
return this.cut([{ ...range, text, reason: "deleted" }], sel[0] === sel[1] ? `Delete "${text}"` : `Delete ${sel[1] - sel[0] + 1} words`, options.plan ?? this.options.planFirst ?? false);
|
|
2705
|
+
}
|
|
2706
|
+
/** Cuts one disfluency out at once (its index in `disfluencies`), as one undo step. */
|
|
2707
|
+
removeDisfluency(index) {
|
|
2708
|
+
const d = this.state.disfluencies[index];
|
|
2709
|
+
if (!d) return this.fail(`There is no disfluency ${index}; there are ${this.state.disfluencies.length}.`);
|
|
2710
|
+
return this.cut([{ from: d.from, to: d.to, text: d.text, reason: d.kind }], d.kind === "pause" ? "Tighten pause" : `Remove "${d.text}"`, false);
|
|
2711
|
+
}
|
|
2712
|
+
/** Proposes one plan that cuts every disfluency of the given kinds (default all), for review. */
|
|
2713
|
+
planCleanup(kinds) {
|
|
2714
|
+
const found = this.state.disfluencies.filter((d) => !kinds || kinds.includes(d.kind));
|
|
2715
|
+
if (!found.length) return this.fail(kinds ? `No ${kinds.join(", ")} found.` : "No fillers, long pauses or repetitions found.");
|
|
2716
|
+
const counts = ["filler", "pause", "repetition"].map((k) => [k, found.filter((d) => d.kind === k).length]).filter(([, n]) => n);
|
|
2717
|
+
const title = `Clean up ${counts.map(([k, n]) => `${n} ${k}${n > 1 ? "s" : ""}`).join(", ")}`;
|
|
2718
|
+
return this.cut(
|
|
2719
|
+
found.map((d) => ({ from: d.from, to: d.to, text: d.text, reason: d.kind })),
|
|
2720
|
+
title,
|
|
2721
|
+
true
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
};
|
|
2185
2725
|
|
|
2186
2726
|
// src/video-editor/proxy.ts
|
|
2187
2727
|
var VIDEO_PROXY_SCALES = [0.5, 0.25];
|
|
@@ -2773,7 +3313,7 @@ var VideoPreviewEngine = class {
|
|
|
2773
3313
|
};
|
|
2774
3314
|
|
|
2775
3315
|
// src/video-editor/controller.ts
|
|
2776
|
-
var VideoEditorController = class {
|
|
3316
|
+
var VideoEditorController = class _VideoEditorController {
|
|
2777
3317
|
history;
|
|
2778
3318
|
player;
|
|
2779
3319
|
/** Sound levels per asset, loaded on demand for waveforms and agents. */
|
|
@@ -2800,6 +3340,10 @@ var VideoEditorController = class {
|
|
|
2800
3340
|
listeners = /* @__PURE__ */ new Set();
|
|
2801
3341
|
unsubscribers = [];
|
|
2802
3342
|
state;
|
|
3343
|
+
plans = [];
|
|
3344
|
+
/** The project each open plan was worked out against. */
|
|
3345
|
+
planBasis = /* @__PURE__ */ new Map();
|
|
3346
|
+
planCount = 0;
|
|
2803
3347
|
constructor(options = {}) {
|
|
2804
3348
|
this.history = options.history ?? new HistoryManager();
|
|
2805
3349
|
this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
|
|
@@ -2865,6 +3409,7 @@ var VideoEditorController = class {
|
|
|
2865
3409
|
redoLabel: h.redoName,
|
|
2866
3410
|
previewScale: this.previewScale,
|
|
2867
3411
|
effectsProvider: this.effects.provider,
|
|
3412
|
+
plans: this.plans,
|
|
2868
3413
|
revision: this.revision
|
|
2869
3414
|
};
|
|
2870
3415
|
}
|
|
@@ -2933,6 +3478,86 @@ var VideoEditorController = class {
|
|
|
2933
3478
|
if (!options.dryRun) this.commit(label, result.project, options.merge);
|
|
2934
3479
|
return result;
|
|
2935
3480
|
}
|
|
3481
|
+
// -----------------------------------------------------------------------------------------
|
|
3482
|
+
// Edit plans: propose, review, apply or discard
|
|
3483
|
+
// -----------------------------------------------------------------------------------------
|
|
3484
|
+
/** Most plans kept open at once; proposing another drops the oldest. */
|
|
3485
|
+
static MAX_PLANS = 20;
|
|
3486
|
+
/**
|
|
3487
|
+
* Works out a batch of edits without applying it and keeps it for review (see `VideoEditPlan`):
|
|
3488
|
+
* the result says what it would do. Allowed while read-only (nothing changes); applying is not.
|
|
3489
|
+
* A plan with the id of an open one replaces it.
|
|
3490
|
+
*/
|
|
3491
|
+
proposePlan(input) {
|
|
3492
|
+
const ids = new Set(this.plans.map((p) => p.id));
|
|
3493
|
+
let id = input.id;
|
|
3494
|
+
let count = this.planCount;
|
|
3495
|
+
if (id === void 0) {
|
|
3496
|
+
do
|
|
3497
|
+
id = `plan${++count}`;
|
|
3498
|
+
while (ids.has(id));
|
|
3499
|
+
}
|
|
3500
|
+
const result = planVideoEdits(this.project, { ...input, id });
|
|
3501
|
+
if (!result.ok) return result;
|
|
3502
|
+
this.planCount = count;
|
|
3503
|
+
const kept = this.plans.filter((p) => p.id !== id);
|
|
3504
|
+
while (kept.length >= _VideoEditorController.MAX_PLANS) this.planBasis.delete(kept.shift().id);
|
|
3505
|
+
this.plans = [...kept, result.plan];
|
|
3506
|
+
this.planBasis.set(id, this.project);
|
|
3507
|
+
this.notify();
|
|
3508
|
+
return { ok: true, plan: result.plan };
|
|
3509
|
+
}
|
|
3510
|
+
/** An open plan, or `undefined`. */
|
|
3511
|
+
getPlan(id) {
|
|
3512
|
+
return this.plans.find((p) => p.id === id);
|
|
3513
|
+
}
|
|
3514
|
+
/** Whether the project changed since a plan was made (its summary may then be out of date). */
|
|
3515
|
+
isPlanStale(id) {
|
|
3516
|
+
const basis = this.planBasis.get(id);
|
|
3517
|
+
return !!basis && basis !== this.project;
|
|
3518
|
+
}
|
|
3519
|
+
/** The project as an open plan would leave it, worked out against the current project; `null` when it no longer applies. */
|
|
3520
|
+
planPreview(id) {
|
|
3521
|
+
const plan = this.getPlan(id);
|
|
3522
|
+
if (!plan) return null;
|
|
3523
|
+
const result = applyVideoEdits(this.project, plan.edits);
|
|
3524
|
+
return result.ok ? result.project : null;
|
|
3525
|
+
}
|
|
3526
|
+
/**
|
|
3527
|
+
* Applies an open plan as one undo step labelled with its title and closes it. If the project
|
|
3528
|
+
* changed since it was made, its edits are checked again against the project as it is now and
|
|
3529
|
+
* refused (the plan stays open) when they no longer apply.
|
|
3530
|
+
*/
|
|
3531
|
+
applyPlan(id) {
|
|
3532
|
+
const plan = this.getPlan(id);
|
|
3533
|
+
if (!plan) return this.refused(`There is no open plan "${id}"; the open plans are ${this.plans.map((p) => `"${p.id}"`).join(", ") || "none"}.`);
|
|
3534
|
+
const reason = this.readOnlyReason();
|
|
3535
|
+
if (reason) return this.refused(reason);
|
|
3536
|
+
const stale = this.isPlanStale(id);
|
|
3537
|
+
const result = applyVideoEdits(this.project, plan.edits);
|
|
3538
|
+
if (!result.ok) return this.refused(stale ? `The project changed since plan "${id}" was made and it no longer applies (${result.error}); discard it and make a new plan.` : result.error);
|
|
3539
|
+
this.dropPlan(id);
|
|
3540
|
+
this.commit(plan.title, result.project);
|
|
3541
|
+
return { ...result, plan, stale };
|
|
3542
|
+
}
|
|
3543
|
+
/** Closes an open plan without applying it. False when there is no such plan. */
|
|
3544
|
+
discardPlan(id) {
|
|
3545
|
+
if (!this.getPlan(id)) return false;
|
|
3546
|
+
this.dropPlan(id);
|
|
3547
|
+
this.notify();
|
|
3548
|
+
return true;
|
|
3549
|
+
}
|
|
3550
|
+
/** Closes every open plan. */
|
|
3551
|
+
discardPlans() {
|
|
3552
|
+
if (!this.plans.length) return;
|
|
3553
|
+
this.plans = [];
|
|
3554
|
+
this.planBasis.clear();
|
|
3555
|
+
this.notify();
|
|
3556
|
+
}
|
|
3557
|
+
dropPlan(id) {
|
|
3558
|
+
this.plans = this.plans.filter((p) => p.id !== id);
|
|
3559
|
+
this.planBasis.delete(id);
|
|
3560
|
+
}
|
|
2936
3561
|
/** Opens a project (JSON or object). `clearHistory` drops the undo stack (opening a file); otherwise it is one undo step. */
|
|
2937
3562
|
load(project, options = {}) {
|
|
2938
3563
|
let parsed;
|
|
@@ -3345,6 +3970,7 @@ function videoClipTint(clip, asset) {
|
|
|
3345
3970
|
if (clip.type === "sequence") return "#0d9488";
|
|
3346
3971
|
if (clip.type === "color") return clip.color;
|
|
3347
3972
|
if (clip.type === "effect") return "#db2777";
|
|
3973
|
+
if (clip.type === "adjustment") return "#64748b";
|
|
3348
3974
|
if (!asset) return "#ef4444";
|
|
3349
3975
|
return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
|
|
3350
3976
|
}
|
|
@@ -3354,6 +3980,7 @@ function videoClipLabel(clip, asset, sequence) {
|
|
|
3354
3980
|
if (clip.type === "color") return clip.color;
|
|
3355
3981
|
if (clip.type === "sequence") return `Compound: ${sequence?.name ?? clip.sequence}`;
|
|
3356
3982
|
if (clip.type === "effect") return `Effect: ${clip.effect}`;
|
|
3983
|
+
if (clip.type === "adjustment") return `Adjustment${clip.scope === "all" ? " (all tracks)" : clip.scope === "track" ? ` (${clip.targetTrack})` : ""}`;
|
|
3357
3984
|
const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
|
|
3358
3985
|
const speed = videoClipSpeed(clip);
|
|
3359
3986
|
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
@@ -3518,6 +4145,50 @@ function videoLutEdits(project, clips, lut) {
|
|
|
3518
4145
|
}
|
|
3519
4146
|
return edits;
|
|
3520
4147
|
}
|
|
4148
|
+
var VIDEO_ADJUSTMENT_SCOPE_LABELS = {
|
|
4149
|
+
underneath: "Tracks underneath",
|
|
4150
|
+
all: "Whole frame",
|
|
4151
|
+
track: "One track"
|
|
4152
|
+
};
|
|
4153
|
+
var VIDEO_MASK_SHAPE_LABELS = {
|
|
4154
|
+
rectangle: "Rectangle",
|
|
4155
|
+
ellipse: "Ellipse",
|
|
4156
|
+
linear: "Split",
|
|
4157
|
+
polygon: "Polygon"
|
|
4158
|
+
};
|
|
4159
|
+
var videoClipHeading = (clip) => ({ media: "Media clip", text: "Title", sequence: "Compound clip", color: "Colour clip", effect: "Effect clip", adjustment: "Adjustment clip" })[clip.type];
|
|
4160
|
+
function videoAddAdjustmentEdits(project, time, options = {}) {
|
|
4161
|
+
const duration = options.duration ?? 3e3;
|
|
4162
|
+
const start = Math.max(0, time);
|
|
4163
|
+
const top = project.tracks[0];
|
|
4164
|
+
const free = top && top.kind === "video" && !top.locked && !top.clips.some((c) => c.start < start + duration && videoClipEnd(c) > start);
|
|
4165
|
+
const edits = [];
|
|
4166
|
+
let track = top?.id;
|
|
4167
|
+
if (!free) {
|
|
4168
|
+
const ids = new Set(project.tracks.map((t) => t.id));
|
|
4169
|
+
let n = 1;
|
|
4170
|
+
while (ids.has(n === 1 ? "adj" : `adj${n}`)) n++;
|
|
4171
|
+
track = n === 1 ? "adj" : `adj${n}`;
|
|
4172
|
+
edits.push({ op: "add_track", track: { id: track, kind: "video", name: "Adjustments" }, at: 0 });
|
|
4173
|
+
}
|
|
4174
|
+
edits.push({ op: "add_clip", track, clip: { type: "adjustment", ...options.id ? { id: options.id } : {}, start, duration, ...options.grade ? { grade: options.grade } : {} } });
|
|
4175
|
+
return edits;
|
|
4176
|
+
}
|
|
4177
|
+
function videoMaskEdit(clip, mask) {
|
|
4178
|
+
return mask ? { op: "update_clip", clip, set: { mask } } : { op: "update_clip", clip, remove: ["mask"] };
|
|
4179
|
+
}
|
|
4180
|
+
function videoSkinProtectionEdits(project, clips, amount) {
|
|
4181
|
+
const edits = [];
|
|
4182
|
+
for (const id of clips) {
|
|
4183
|
+
const clip = findVideoClip(project, id)?.clip;
|
|
4184
|
+
const lut = clip?.grade?.lut;
|
|
4185
|
+
if (!clip || !lut) continue;
|
|
4186
|
+
const { preserveSkinTones: _p, ...rest } = lut;
|
|
4187
|
+
const k = Math.min(1, Math.max(0, amount));
|
|
4188
|
+
edits.push({ op: "update_clip", clip: id, set: { grade: { ...clip.grade, lut: k > 0 ? { ...rest, preserveSkinTones: k } : rest } } });
|
|
4189
|
+
}
|
|
4190
|
+
return edits;
|
|
4191
|
+
}
|
|
3521
4192
|
|
|
3522
4193
|
// src/video-editor/tools.ts
|
|
3523
4194
|
var round = (ms) => Math.round(ms * 10) / 10;
|
|
@@ -3571,10 +4242,11 @@ var TRANSITION = object(
|
|
|
3571
4242
|
);
|
|
3572
4243
|
var LUT_SRC = { type: "string", minLength: 1, description: 'A .cube 3D LUT file the host can load and ffmpeg can read: a URL or path, often from the user (e.g. "luts/teal-orange.cube").' };
|
|
3573
4244
|
var LUT_STRENGTH = { type: "number", minimum: 0, maximum: 1, description: "How much of the LUT's change to apply, 0 (none) to 1 (full, the default); 0.3-0.7 for a subtler look." };
|
|
4245
|
+
var SKIN = { type: "number", minimum: 0, maximum: 1, description: "How much of the LUT's change to hold back on skin tones, 0 (none, the default) to 1 (skin keeps its colour); 0.5-0.8 keeps faces natural under a heavy look. Applied in the preview and frame renders; ffmpeg's lut3d cannot, so the export notes it." };
|
|
3574
4246
|
var GRADE = object(
|
|
3575
4247
|
{
|
|
3576
4248
|
...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
|
|
3577
|
-
lut: object({ src: LUT_SRC, strength: LUT_STRENGTH, name: { type: "string", minLength: 1, description: "A label for the look." } }, ["src"], "A 3D LUT applied after the other settings (set_video_lut checks the file first).")
|
|
4249
|
+
lut: object({ src: LUT_SRC, strength: LUT_STRENGTH, name: { type: "string", minLength: 1, description: "A label for the look." }, preserveSkinTones: SKIN }, ["src"], "A 3D LUT applied after the other settings (set_video_lut checks the file first).")
|
|
3578
4250
|
},
|
|
3579
4251
|
[],
|
|
3580
4252
|
"Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint), then a LUT. Leave a setting out for no change; the object replaces the clip's whole grade."
|
|
@@ -4053,12 +4725,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4053
4725
|
const updateClip = {
|
|
4054
4726
|
name: "update_video_clip",
|
|
4055
4727
|
title: "Change a clip",
|
|
4056
|
-
description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for compound clips in point, volume, mute and fade curves (no speed); for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
4728
|
+
description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for compound clips in point, volume, mute and fade curves (no speed); for titles text and style; for colours the colour; for adjustment clips the scope and target track (masks: set_video_clip_mask). Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
|
|
4057
4729
|
inputSchema: object(
|
|
4058
4730
|
{
|
|
4059
4731
|
clip: CLIP_ID,
|
|
4060
|
-
set: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: "For media: play another asset (same in point)." }, ...TEXT_FIELDS, color: COLOR("Text colour, or a colour clip's fill") }, [], "Fields to replace (only those that fit the clip's type)."),
|
|
4061
|
-
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description:
|
|
4732
|
+
set: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: "For media: play another asset (same in point)." }, ...TEXT_FIELDS, color: COLOR("Text colour, or a colour clip's fill"), scope: { type: "string", enum: [...VIDEO_ADJUSTMENT_SCOPES], description: "For adjustment clips: which pictures it changes, underneath (default), all or track." }, targetTrack: { ...TRACK_ID, description: 'For adjustment clips with scope "track": the track whose pictures it changes.' } }, [], "Fields to replace (only those that fit the clip's type)."),
|
|
4733
|
+
remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background", "scope", "targetTrack"] }, description: 'Fields to reset to their defaults (an adjustment clip going from scope "track" to another scope must also remove targetTrack).' }
|
|
4062
4734
|
},
|
|
4063
4735
|
["clip"]
|
|
4064
4736
|
),
|
|
@@ -4127,17 +4799,18 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4127
4799
|
clips: { type: "array", items: CLIP_ID, minItems: 1, uniqueItems: true, description: "Clips on video tracks to grade." },
|
|
4128
4800
|
src: LUT_SRC,
|
|
4129
4801
|
strength: LUT_STRENGTH,
|
|
4130
|
-
name: { type: "string", minLength: 1, description: "A label for the look (default the file's TITLE)." }
|
|
4802
|
+
name: { type: "string", minLength: 1, description: "A label for the look (default the file's TITLE)." },
|
|
4803
|
+
preserve_skin_tones: SKIN
|
|
4131
4804
|
},
|
|
4132
4805
|
["clips", "src"]
|
|
4133
4806
|
),
|
|
4134
4807
|
annotations: { title: "Apply a LUT", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
4135
|
-
handler: async ({ clips, src, strength, name }) => {
|
|
4808
|
+
handler: async ({ clips, src, strength, name, preserve_skin_tones }) => {
|
|
4136
4809
|
if (strength === 0) throw new Error("A strength of 0 changes nothing; pass a strength above 0, or remove the LUT with remove_video_lut.");
|
|
4137
4810
|
gradeTargets(clips);
|
|
4138
4811
|
const lut = await controller.luts.load(src);
|
|
4139
4812
|
const label = name ?? lut.title;
|
|
4140
|
-
const setting = { src, ...strength !== void 0 && strength !== 1 ? { strength } : {}, ...label ? { name: label } : {} };
|
|
4813
|
+
const setting = { src, ...strength !== void 0 && strength !== 1 ? { strength } : {}, ...label ? { name: label } : {}, ...preserve_skin_tones ? { preserveSkinTones: preserve_skin_tones } : {} };
|
|
4141
4814
|
return apply(videoLutEdits(project(), clips, setting), "Apply LUT", (r) => ({ lut: { src, ...lut.title ? { title: lut.title } : {}, size: lut.size }, clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
|
|
4142
4815
|
}
|
|
4143
4816
|
};
|
|
@@ -4341,7 +5014,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4341
5014
|
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
4342
5015
|
const { clip } = at;
|
|
4343
5016
|
if (clip.type === "sequence") throw new Error(`Clip "${id}" is a compound clip, which plays its sequence at normal speed; change the speed of the clips inside the sequence instead.`);
|
|
4344
|
-
if (clip.type !== "media") throw new Error(`Clip "${id}" is a ${clip
|
|
5017
|
+
if (clip.type !== "media") throw new Error(`Clip "${id}" is a ${videoClipTypeLabel(clip)}, so it has no source to change the speed of; use a video or audio clip.`);
|
|
4345
5018
|
if (project().assets.find((a) => a.id === clip.asset)?.kind === "image") throw new Error(`Clip "${id}" is a still, so it has no motion to change the speed of.`);
|
|
4346
5019
|
return clip;
|
|
4347
5020
|
};
|
|
@@ -4595,7 +5268,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4595
5268
|
if (!(to > from)) throw new Error(`The stretch to read is empty (${from}\u2013${to} ms); "to" must be after "from"${args.clip !== void 0 ? " and both inside the clip" : asset.duration === void 0 ? ', and the asset has no duration, so pass "to"' : ""}.`);
|
|
4596
5269
|
return { asset, from, to, toSource, toOut };
|
|
4597
5270
|
};
|
|
4598
|
-
const
|
|
5271
|
+
const SOUND_TARGET2 = {
|
|
4599
5272
|
clip: { ...CLIP_ID, description: "A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset." },
|
|
4600
5273
|
asset: { ...ASSET_ID, description: "A video or audio asset: read its source, with times in the source. Pass this or clip." },
|
|
4601
5274
|
from: MS("Start of the stretch (default: the clip's start, or 0 for an asset)"),
|
|
@@ -4606,7 +5279,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4606
5279
|
name: "get_video_audio_levels",
|
|
4607
5280
|
title: "Read sound levels",
|
|
4608
5281
|
description: 'Measures how loud the source sound is over a clip or an asset, without changing anything: the peak level in dBFS (0 = full scale, -100 = silence) for each of "buckets" equal stretches, plus the loudest peak. Levels are the recording as it is, before clip volume and fades. Use it to find where speech or a beat starts, or to judge whether a clip is too loud. The first call for an asset decodes its file, which can take a moment.',
|
|
4609
|
-
inputSchema: object({ ...
|
|
5282
|
+
inputSchema: object({ ...SOUND_TARGET2, buckets: { type: "integer", minimum: 1, maximum: 500, description: "How many equal stretches to measure (default 50)." } }),
|
|
4610
5283
|
annotations: { title: "Read sound levels", ...SOUND_ANNOTATIONS },
|
|
4611
5284
|
handler: async (args) => {
|
|
4612
5285
|
const r = soundRange(args);
|
|
@@ -4620,7 +5293,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4620
5293
|
title: "Find silences",
|
|
4621
5294
|
description: "Lists the stretches of a clip or an asset whose source sound stays below a level for at least a minimum length, as { start, end } ms (timeline times for a clip, so they can go straight to extract_video_range or split_video_at; source times for an asset). Use it to cut pauses, dead air or the gaps between takes. The first call for an asset decodes its file.",
|
|
4622
5295
|
inputSchema: object({
|
|
4623
|
-
...
|
|
5296
|
+
...SOUND_TARGET2,
|
|
4624
5297
|
thresholdDb: { type: "number", minimum: -100, maximum: 0, description: "Quieter than this peak level counts as silence, in dBFS (default -40; -50 for a quiet room, -30 over background noise)." },
|
|
4625
5298
|
minDuration: { type: "number", exclusiveMinimum: 0, description: "Shortest silence to report, in ms (default 300)." }
|
|
4626
5299
|
}),
|
|
@@ -4671,8 +5344,167 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4671
5344
|
return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
|
|
4672
5345
|
}
|
|
4673
5346
|
};
|
|
4674
|
-
const
|
|
4675
|
-
|
|
5347
|
+
const MASK_FIELDS = {
|
|
5348
|
+
shape: { type: "string", enum: [...VIDEO_MASK_SHAPES], description: "rectangle (with corner_radius), ellipse, linear (a straight split: the side above the line at rotation 0 shows) or polygon (points)." },
|
|
5349
|
+
x: { type: "number", description: "Centre across the clip's picture, 0 (left edge) to 1 (right edge); default 0.5." },
|
|
5350
|
+
y: { type: "number", description: "Centre down the clip's picture, 0 (top) to 1 (bottom); default 0.5." },
|
|
5351
|
+
width: { type: "number", minimum: 0, description: "Width as a fraction of the picture's width (default 0.5). Unused by linear." },
|
|
5352
|
+
height: { type: "number", minimum: 0, description: "Height as a fraction of the picture's height (default 0.5). Unused by linear." },
|
|
5353
|
+
rotation: { type: "number", description: "Degrees clockwise (default 0)." },
|
|
5354
|
+
feather: { type: "number", minimum: 0, description: "Soft edge width in frame pixels (default 0 = hard edge)." },
|
|
5355
|
+
corner_radius: { type: "number", minimum: 0, maximum: 1, description: "For rectangle: corner rounding as a fraction of half the shorter side (default 0)." },
|
|
5356
|
+
inverted: { type: "boolean", description: "Show the outside of the shape instead of the inside (default false)." },
|
|
5357
|
+
points: { type: "array", minItems: 3, maxItems: 200, items: object({ x: { type: "number", description: "Across the picture, 0\u20131." }, y: { type: "number", description: "Down the picture, 0\u20131." } }, ["x", "y"], "A corner of the polygon."), description: "For polygon (required): its corners in order, as fractions of the clip's picture." }
|
|
5358
|
+
};
|
|
5359
|
+
const addAdjustment = {
|
|
5360
|
+
name: "add_video_adjustment_clip",
|
|
5361
|
+
title: "Add an adjustment clip",
|
|
5362
|
+
description: 'Adds an adjustment clip to a video track (one undo step): it has no picture, but while it plays its grade (and any effects added later with apply_effect, and a mask from set_video_clip_mask) change the composite of the pictures in its scope: "underneath" (every video track below its own, the default), "all" (the whole frame) or "track" (only target_track). Put it on a track above what it should change, usually a new top track. Give the look as "grade" and/or "preset" (a named look at "amount"). Returns the clip (id, start, duration, end, scope).',
|
|
5363
|
+
inputSchema: object(
|
|
5364
|
+
{
|
|
5365
|
+
track: { ...TRACK_ID, description: 'A video track to put it on; tracks below it are "underneath".' },
|
|
5366
|
+
start: MS("Where it starts on the timeline (default: the end of the track)"),
|
|
5367
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "How long it lasts, in ms (default 3000)." },
|
|
5368
|
+
scope: { type: "string", enum: [...VIDEO_ADJUSTMENT_SCOPES], description: "Which pictures it changes: underneath (default), all or track." },
|
|
5369
|
+
target_track: { ...TRACK_ID, description: 'For scope "track" (required then): the track whose pictures it changes.' },
|
|
5370
|
+
grade: GRADE,
|
|
5371
|
+
preset: { type: "string", enum: [...VIDEO_GRADE_PRESET_NAMES], description: "A named look to start from (grade settings given override it)." },
|
|
5372
|
+
amount: { type: "number", minimum: 0, maximum: 1, description: "Strength of the preset, 0\u20131 (default 1)." },
|
|
5373
|
+
name: { type: "string", minLength: 1, description: 'A label for the clip (e.g. "Night look").' },
|
|
5374
|
+
id: { type: "string", minLength: 1, description: 'Your own id for the clip (default: a fresh one such as "c7").' },
|
|
5375
|
+
mode: MODE
|
|
5376
|
+
},
|
|
5377
|
+
["track"]
|
|
5378
|
+
),
|
|
5379
|
+
annotations: { title: "Add an adjustment clip", ...WRITE },
|
|
5380
|
+
handler: ({ track, start, duration, scope, target_track, grade, preset, amount, name, id, mode }) => {
|
|
5381
|
+
const t = project().tracks.find((x) => x.id === track);
|
|
5382
|
+
if (t && t.kind !== "video") throw new Error(`Track "${track}" is an audio track; adjustment clips change pictures, so put it on a video track.`);
|
|
5383
|
+
if (scope === "track" && !target_track) throw new Error('Scope "track" needs target_track: the id of the track whose pictures it changes.');
|
|
5384
|
+
if (target_track !== void 0 && scope !== "track") throw new Error('target_track is only for scope "track"; leave it out or set scope to "track".');
|
|
5385
|
+
const look = { ...preset ? videoGradePreset(preset, amount ?? 1) : {}, ...grade ?? {} };
|
|
5386
|
+
const clip = { type: "adjustment", ...id ? { id } : {}, ...name ? { name } : {}, ...start !== void 0 ? { start } : {}, duration: duration ?? 3e3, ...scope ? { scope } : {}, ...target_track ? { targetTrack: target_track } : {}, ...Object.keys(look).length ? { grade: look } : {} };
|
|
5387
|
+
return apply({ op: "add_clip", track, clip, mode }, "Add adjustment clip", (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip), ...placed(r.outcomes[0]), ...Object.keys(look).length ? {} : { hint: "It changes nothing yet: give it a grade (update_video_clip), effects (apply_effect) or a mask." } }));
|
|
5388
|
+
}
|
|
5389
|
+
};
|
|
5390
|
+
const maskTarget = (id) => {
|
|
5391
|
+
const at = findVideoClip(project(), id);
|
|
5392
|
+
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
5393
|
+
if (at.track.kind !== "video") throw new Error(`Clip "${id}" is on audio track "${at.track.id}"; masks cut pictures, so pass a clip on a video track.`);
|
|
5394
|
+
return at;
|
|
5395
|
+
};
|
|
5396
|
+
const setMask = {
|
|
5397
|
+
name: "set_video_clip_mask",
|
|
5398
|
+
title: "Mask a clip",
|
|
5399
|
+
description: `Cuts a clip's picture to a shape (one undo step): only the inside shows, or the outside when inverted, with a soft edge of "feather" pixels. Positions are fractions of the clip's placed picture, so the mask follows the clip's transform. On an adjustment clip it limits the adjustment to the shape (e.g. brighten a face). Replaces any mask the clip had. Returns the clip with its mask. ffmpeg exports need rendered frames for masks.`,
|
|
5400
|
+
inputSchema: object({ clip: CLIP_ID, ...MASK_FIELDS }, ["clip", "shape"]),
|
|
5401
|
+
annotations: { title: "Mask a clip", ...WRITE_IDEMPOTENT },
|
|
5402
|
+
handler: ({ clip, corner_radius, ...fields }) => {
|
|
5403
|
+
maskTarget(clip);
|
|
5404
|
+
if (fields.shape === "polygon" && !fields.points) throw new Error('A polygon mask needs "points": at least 3 { x, y } corners as fractions of the picture.');
|
|
5405
|
+
if (fields.shape !== "polygon" && fields.points) throw new Error('"points" is only for shape "polygon".');
|
|
5406
|
+
if (corner_radius !== void 0 && fields.shape !== "rectangle") throw new Error('"corner_radius" is only for shape "rectangle".');
|
|
5407
|
+
const mask = { ...fields, ...corner_radius !== void 0 ? { cornerRadius: corner_radius } : {} };
|
|
5408
|
+
return apply({ op: "update_clip", clip, set: { mask } }, "Mask clip", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
5409
|
+
}
|
|
5410
|
+
};
|
|
5411
|
+
const removeMask = {
|
|
5412
|
+
name: "remove_video_clip_mask",
|
|
5413
|
+
title: "Remove a clip's mask",
|
|
5414
|
+
description: "Takes a clip's mask off, so its whole picture shows again (one undo step). Fails if it has none. Returns the clip.",
|
|
5415
|
+
inputSchema: object({ clip: CLIP_ID }, ["clip"]),
|
|
5416
|
+
annotations: { title: "Remove a clip's mask", ...DESTRUCTIVE },
|
|
5417
|
+
handler: ({ clip }) => {
|
|
5418
|
+
const at = maskTarget(clip);
|
|
5419
|
+
if (!at.clip.mask) throw new Error(`Clip "${clip}" has no mask.`);
|
|
5420
|
+
return apply({ op: "update_clip", clip, remove: ["mask"] }, "Remove mask", (r) => ({ clip: clipOut(r.project, clip) }));
|
|
5421
|
+
}
|
|
5422
|
+
};
|
|
5423
|
+
const setSkin = {
|
|
5424
|
+
name: "set_video_skin_protection",
|
|
5425
|
+
title: "Protect skin tones from a LUT",
|
|
5426
|
+
description: `Holds a LUT back on skin tones for clips that already have one (one undo step): at "amount" 1 skin keeps its colour while the rest of the picture takes the full look; 0 turns the protection off. Skin is found by colour, so warm orange or beige objects are protected too. Fails for clips without a LUT (add one with set_video_lut first). Returns each clip's LUT setting.`,
|
|
5427
|
+
inputSchema: object({ clips: { type: "array", items: CLIP_ID, minItems: 1, uniqueItems: true, description: "Clips with a LUT." }, amount: { ...SKIN, description: "How much of the LUT to hold back on skin, 0 (off) to 1 (all of it)." } }, ["clips", "amount"]),
|
|
5428
|
+
annotations: { title: "Protect skin tones from a LUT", ...WRITE_IDEMPOTENT },
|
|
5429
|
+
handler: ({ clips, amount }) => {
|
|
5430
|
+
gradeTargets(clips);
|
|
5431
|
+
const edits = [];
|
|
5432
|
+
for (const id of clips) {
|
|
5433
|
+
const clip = findVideoClip(project(), id).clip;
|
|
5434
|
+
const lut = clip.grade?.lut;
|
|
5435
|
+
if (!lut) throw new Error(`Clip "${id}" has no LUT to protect skin from; add one with set_video_lut first.`);
|
|
5436
|
+
const { preserveSkinTones: _p, ...rest } = lut;
|
|
5437
|
+
edits.push({ op: "update_clip", clip: id, set: { grade: { ...clip.grade, lut: amount > 0 ? { ...rest, preserveSkinTones: amount } : rest } } });
|
|
5438
|
+
}
|
|
5439
|
+
return apply(edits, amount > 0 ? "Protect skin tones" : "Stop protecting skin tones", (r) => ({ clips: clips.map((id) => ({ id, lut: findVideoClip(r.project, id)?.clip.grade?.lut ?? null })) }));
|
|
5440
|
+
}
|
|
5441
|
+
};
|
|
5442
|
+
const planOut = (plan) => ({
|
|
5443
|
+
id: plan.id,
|
|
5444
|
+
title: plan.title,
|
|
5445
|
+
...plan.description ? { description: plan.description } : {},
|
|
5446
|
+
...plan.source ? { source: plan.source } : {},
|
|
5447
|
+
summary: { ...plan.summary, newIssues: issueSummary(plan.summary.newIssues) },
|
|
5448
|
+
...plan.ranges ? { ranges: plan.ranges.map((r) => ({ ...r, from: round(r.from), to: round(r.to) })) } : {},
|
|
5449
|
+
edits: plan.edits.length,
|
|
5450
|
+
stale: controller.isPlanStale(plan.id),
|
|
5451
|
+
text: describeVideoEditPlan(plan)
|
|
5452
|
+
});
|
|
5453
|
+
const PLAN_ID = { type: "string", minLength: 1, description: 'A plan id from plan_video_edits, a plan_* tool or list_video_edit_plans (e.g. "plan1").' };
|
|
5454
|
+
const planEdits = {
|
|
5455
|
+
name: "plan_video_edits",
|
|
5456
|
+
title: "Propose edits for review",
|
|
5457
|
+
description: "Works out a batch of edits without applying them and keeps it as a plan for review: the project does not change. The plan says what it would do (length before and after, clips added, removed and changed, problems it would add) and shows in the editor for the user to accept or discard. Use it for anything the user should see before it happens (a rough cut, a cleanup, a re-time). Fails, keeping nothing, if the edits do not apply. Apply it with apply_video_edit_plan. Returns the plan.",
|
|
5458
|
+
inputSchema: object(
|
|
5459
|
+
{
|
|
5460
|
+
title: { type: "string", minLength: 1, maxLength: 120, description: 'What the plan does, shown to the user and used as the undo label (e.g. "Tighten the intro").' },
|
|
5461
|
+
description: { type: "string", maxLength: 2e3, description: "Why, and anything the reviewer should check." },
|
|
5462
|
+
ops: { type: "array", items: EDIT_OP, minItems: 1, maxItems: 500, description: "The edits, in order, as for edit_video." },
|
|
5463
|
+
ranges: { type: "array", maxItems: 500, items: object({ from: MS("Start, on the timeline"), to: MS("End, on the timeline"), text: { type: "string", description: "What is said there." }, reason: { type: "string", description: 'Why it is in the plan (e.g. "filler").' } }, ["from", "to"], "A stretch the plan cuts or changes."), description: "The stretches it cuts or changes, for the review view (optional)." }
|
|
5464
|
+
},
|
|
5465
|
+
["title", "ops"]
|
|
5466
|
+
),
|
|
5467
|
+
annotations: { title: "Propose edits for review", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
5468
|
+
handler: ({ title, description, ops, ranges }) => {
|
|
5469
|
+
if (ops.some((o) => o.op === "replace_project")) throw new Error("A plan cannot replace the whole project; use the individual ops.");
|
|
5470
|
+
const r = controller.proposePlan({ title, description, edits: ops, ranges, source: "agent" });
|
|
5471
|
+
if (!r.ok) throw new Error(r.error);
|
|
5472
|
+
return { plan: planOut(r.plan), hint: `Nothing changed yet. Ask the user to review it, then call apply_video_edit_plan with plan "${r.plan.id}" (or discard_video_edit_plan).` };
|
|
5473
|
+
}
|
|
5474
|
+
};
|
|
5475
|
+
const listPlans = {
|
|
5476
|
+
name: "list_video_edit_plans",
|
|
5477
|
+
title: "List proposed edit plans",
|
|
5478
|
+
description: "Lists the plans waiting for review, oldest first: id, title, summary (length change, clips added, removed and changed, new problems), the stretches they cut, and whether the project changed since each was made (stale: it is checked again when applied).",
|
|
5479
|
+
inputSchema: object({}),
|
|
5480
|
+
annotations: { title: "List proposed edit plans", ...READ },
|
|
5481
|
+
handler: () => ({ plans: controller.getState().plans.map(planOut) })
|
|
5482
|
+
};
|
|
5483
|
+
const applyPlan = {
|
|
5484
|
+
name: "apply_video_edit_plan",
|
|
5485
|
+
title: "Apply an edit plan",
|
|
5486
|
+
description: "Applies a proposed plan as one undo step (labelled with its title) and closes it. A plan made before the project changed is checked again first and refused, changing nothing, if its edits no longer apply. Returns what it did (each op's outcome), the new length and issues, and whether it was stale.",
|
|
5487
|
+
inputSchema: object({ plan: PLAN_ID }, ["plan"]),
|
|
5488
|
+
annotations: { title: "Apply an edit plan", ...DESTRUCTIVE },
|
|
5489
|
+
handler: ({ plan }) => {
|
|
5490
|
+
const r = controller.applyPlan(plan);
|
|
5491
|
+
if (!r.ok) throw new Error(r.error);
|
|
5492
|
+
return { plan: r.plan.id, title: r.plan.title, stale: r.stale, outcomes: r.outcomes, duration: videoProjectDuration(r.project), issues: issueSummary(r.issues) };
|
|
5493
|
+
}
|
|
5494
|
+
};
|
|
5495
|
+
const discardPlan = {
|
|
5496
|
+
name: "discard_video_edit_plan",
|
|
5497
|
+
title: "Discard an edit plan",
|
|
5498
|
+
description: "Closes a proposed plan without applying it; the project is unchanged. Returns the plans still open.",
|
|
5499
|
+
inputSchema: object({ plan: PLAN_ID }, ["plan"]),
|
|
5500
|
+
annotations: { title: "Discard an edit plan", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
5501
|
+
handler: ({ plan }) => {
|
|
5502
|
+
if (!controller.discardPlan(plan)) throw new Error(`There is no open plan "${plan}"; the open plans are ${controller.getState().plans.map((p) => `"${p.id}"`).join(", ") || "none"}.`);
|
|
5503
|
+
return { discarded: plan, open: controller.getState().plans.map((p) => p.id) };
|
|
5504
|
+
}
|
|
5505
|
+
};
|
|
5506
|
+
const reads = [getProject, listPlans, listSequences, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];
|
|
5507
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, setProxies, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setLut, removeLut, setSkin, addAdjustment, setMask, removeMask, planEdits, applyPlan, discardPlan, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
4676
5508
|
return withVideoPlayback({
|
|
4677
5509
|
id: options.id ?? "video",
|
|
4678
5510
|
namespace: options.namespace,
|
|
@@ -4868,6 +5700,456 @@ function createVideoEffectsToolProvider(controller, options = {}) {
|
|
|
4868
5700
|
}), options.playbackSurface);
|
|
4869
5701
|
}
|
|
4870
5702
|
|
|
5703
|
+
// src/video-editor/dialogue-tools.ts
|
|
5704
|
+
var round2 = (ms) => Math.round(ms * 10) / 10;
|
|
5705
|
+
var object2 = (properties, required = []) => ({ type: "object", properties, ...required.length ? { required } : {}, additionalProperties: false });
|
|
5706
|
+
var FIND_FIELDS = {
|
|
5707
|
+
track: { type: "string", minLength: 1, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." },
|
|
5708
|
+
kinds: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", enum: [...VIDEO_DISFLUENCY_KINDS] }, description: 'What to look for: filler (um, uh, you know\u2026), pause (long gaps between words), repetition (stutters such as "I I think"). Default all three.' },
|
|
5709
|
+
fillers: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: `Words or phrases counted as fillers instead of the defaults (${VIDEO_DEFAULT_FILLERS.join(", ")}).` },
|
|
5710
|
+
min_pause: { type: "number", exclusiveMinimum: 0, description: "Shortest gap between words that counts as a pause, in ms (default 700; 600 for brisk talking heads)." },
|
|
5711
|
+
keep: { type: "number", minimum: 0, maximum: 1e3, description: "Breath kept either side of a pause cut, in ms (default 150; 150\u2013250 sounds natural)." },
|
|
5712
|
+
max_phrase: { type: "integer", minimum: 1, maximum: 6, description: "Longest phrase checked for a repeat, in words (default 3)." }
|
|
5713
|
+
};
|
|
5714
|
+
function createVideoDialogueToolProvider(controller, options = {}) {
|
|
5715
|
+
const readOnlyReason = () => {
|
|
5716
|
+
const r = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
|
|
5717
|
+
if (r) return typeof r === "string" ? r : "This video project is read-only.";
|
|
5718
|
+
return controller.readOnlyReason();
|
|
5719
|
+
};
|
|
5720
|
+
const find = (args) => {
|
|
5721
|
+
const project = controller.getProject();
|
|
5722
|
+
const words = videoTimelineTranscript(project, { track: args.track });
|
|
5723
|
+
if (!words.length) throw new Error(args.track ? `No clip on track "${args.track}" plays a transcribed asset; attach one with set_video_transcript.` : "No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.");
|
|
5724
|
+
return detectVideoDisfluencies(words, {
|
|
5725
|
+
kinds: args.kinds,
|
|
5726
|
+
fillers: args.fillers,
|
|
5727
|
+
pauses: { fps: project.fps, ...args.min_pause !== void 0 ? { min: args.min_pause } : {}, ...args.keep !== void 0 ? { keep: args.keep } : {} },
|
|
5728
|
+
repetitions: args.max_phrase !== void 0 ? { maxPhrase: args.max_phrase } : void 0
|
|
5729
|
+
});
|
|
5730
|
+
};
|
|
5731
|
+
const out = (d) => ({ kind: d.kind, from: round2(d.from), to: round2(d.to), text: d.text, words: d.words });
|
|
5732
|
+
const findDisfluencies = {
|
|
5733
|
+
name: "find_video_disfluencies",
|
|
5734
|
+
title: "Find fillers, pauses and stutters",
|
|
5735
|
+
description: `Reads the timeline transcript and lists, in time order, every filler word, long pause and repeated word or phrase as { kind, from, to, text, words } (timeline ms; "words" are the first and last word indexes in get_video_transcript's list), with a count and total per kind. Changes nothing. Review the list (some repeats and pauses are meant), then call plan_video_dialogue_cleanup or remove_video_ranges with the ones to cut.`,
|
|
5736
|
+
inputSchema: object2(FIND_FIELDS),
|
|
5737
|
+
annotations: { title: "Find fillers, pauses and stutters", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
5738
|
+
handler: (args) => {
|
|
5739
|
+
const found = find(args);
|
|
5740
|
+
const totals = Object.fromEntries(VIDEO_DISFLUENCY_KINDS.map((k) => [k, { count: found.filter((d) => d.kind === k).length, time: round2(found.filter((d) => d.kind === k).reduce((n, d) => n + d.to - d.from, 0)) }]));
|
|
5741
|
+
const merged = mergeVideoRanges(found);
|
|
5742
|
+
return { disfluencies: found.map(out), totals, removable: round2(merged.reduce((n, r) => n + r.to - r.from, 0)) };
|
|
5743
|
+
}
|
|
5744
|
+
};
|
|
5745
|
+
const planCleanup = {
|
|
5746
|
+
name: "plan_video_dialogue_cleanup",
|
|
5747
|
+
title: "Propose a dialogue cleanup",
|
|
5748
|
+
description: 'Finds fillers, long pauses and stutters (as find_video_disfluencies) and proposes cutting them as one plan for review: nothing changes until apply_video_edit_plan. Pass "exclude" to keep finds the user wants (by their from time), and "margin" to keep a little breath around filler and stutter cuts. Every sync-locked track is cut with the dialogue. Returns the plan (id, title, length change, the stretches it cuts) for the user to review.',
|
|
5749
|
+
inputSchema: object2(
|
|
5750
|
+
{
|
|
5751
|
+
...FIND_FIELDS,
|
|
5752
|
+
exclude: { type: "array", maxItems: 500, items: { type: "number", minimum: 0 }, description: 'The "from" times (ms) of finds to keep, from find_video_disfluencies.' },
|
|
5753
|
+
margin: { type: "number", minimum: 0, maximum: 200, description: "Ms left uncut at each end of a filler or stutter cut, so the words around it are not clipped (default 0)." },
|
|
5754
|
+
title: { type: "string", minLength: 1, maxLength: 120, description: `The plan's title (default: what it cleans up, e.g. "Clean up 12 fillers, 3 pauses").` }
|
|
5755
|
+
}
|
|
5756
|
+
),
|
|
5757
|
+
annotations: { title: "Propose a dialogue cleanup", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
5758
|
+
handler: (args) => {
|
|
5759
|
+
const skip = new Set((args.exclude ?? []).map((t) => Math.round(t)));
|
|
5760
|
+
const found = find(args).filter((d) => !skip.has(Math.round(d.from)));
|
|
5761
|
+
if (!found.length) throw new Error("Nothing to clean up: no fillers, long pauses or repetitions were found (or all were excluded).");
|
|
5762
|
+
const margin = args.margin ?? 0;
|
|
5763
|
+
const cuts = found.flatMap((d) => d.kind === "pause" || !margin ? [d] : videoCutsWithMargin([d], margin));
|
|
5764
|
+
const counts = VIDEO_DISFLUENCY_KINDS.map((k) => [k, found.filter((d) => d.kind === k).length]).filter(([, n]) => n);
|
|
5765
|
+
const title = args.title ?? `Clean up ${counts.map(([k, n]) => `${n} ${k}${n > 1 ? "s" : ""}`).join(", ")}`;
|
|
5766
|
+
const r = controller.proposePlan({ title, description: "Cuts fillers, long pauses and stutters found in the transcript, closing up every sync-locked track.", edits: videoRemoveRangesEdits(cuts), ranges: cuts.map((d) => ({ from: d.from, to: d.to, text: d.text, reason: d.kind })), source: "plan_video_dialogue_cleanup" });
|
|
5767
|
+
if (!r.ok) throw new Error(r.error);
|
|
5768
|
+
const p = r.plan;
|
|
5769
|
+
return { plan: { id: p.id, title: p.title, summary: p.summary, text: describeVideoEditPlan(p), cuts: cuts.length }, durationNow: videoProjectDuration(controller.getProject()), hint: `Nothing changed yet. Show the user the plan, then call apply_video_edit_plan with plan "${p.id}" (or discard_video_edit_plan).` };
|
|
5770
|
+
}
|
|
5771
|
+
};
|
|
5772
|
+
return withVideoPlayback(
|
|
5773
|
+
{
|
|
5774
|
+
id: options.id ?? "video-dialogue",
|
|
5775
|
+
namespace: options.namespace,
|
|
5776
|
+
title: "Video dialogue cleanup",
|
|
5777
|
+
description: "Talking-head cleanup by transcript: find fillers, long pauses and stutters, and propose cutting them as one plan for review.",
|
|
5778
|
+
skill: "pixerate-talking-head-edit",
|
|
5779
|
+
tools: () => readOnlyReason() ? [findDisfluencies] : [findDisfluencies, planCleanup],
|
|
5780
|
+
subscribe: (notify) => {
|
|
5781
|
+
let last = readOnlyReason();
|
|
5782
|
+
return controller.subscribe(() => {
|
|
5783
|
+
const now = readOnlyReason();
|
|
5784
|
+
if (now !== last) {
|
|
5785
|
+
last = now;
|
|
5786
|
+
notify();
|
|
5787
|
+
}
|
|
5788
|
+
});
|
|
5789
|
+
}
|
|
5790
|
+
},
|
|
5791
|
+
options.playbackSurface
|
|
5792
|
+
);
|
|
5793
|
+
}
|
|
5794
|
+
|
|
5795
|
+
// src/video-editor/music-tools.ts
|
|
5796
|
+
var round4 = (ms) => Math.round(ms * 10) / 10;
|
|
5797
|
+
var object3 = (properties, required = [], description) => ({ type: "object", ...description ? { description } : {}, properties, ...required.length ? { required } : {}, additionalProperties: false });
|
|
5798
|
+
var MS3 = (what) => ({ type: "number", minimum: 0, description: `${what}, in milliseconds.` });
|
|
5799
|
+
var CLIP_ID2 = { type: "string", minLength: 1, description: 'A clip id from get_video_project (e.g. "c1").' };
|
|
5800
|
+
var TRACK_ID2 = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
|
|
5801
|
+
var SOUND_TARGET = {
|
|
5802
|
+
clip: { ...CLIP_ID2, description: "A media clip with sound: read the stretch of source it plays, times on the timeline. Pass this or asset." },
|
|
5803
|
+
asset: { type: "string", minLength: 1, description: "A video or audio asset: read its source, times in the source. Pass this or clip." },
|
|
5804
|
+
from: MS3("Start of the stretch (default: the clip's start, or 0 for an asset)"),
|
|
5805
|
+
to: MS3("End of the stretch (default: the clip's end, or the asset's duration)")
|
|
5806
|
+
};
|
|
5807
|
+
var SOUND = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
5808
|
+
function createVideoMusicToolProvider(controller, options = {}) {
|
|
5809
|
+
const readOnlyReason = () => {
|
|
5810
|
+
const r = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
|
|
5811
|
+
if (r) return typeof r === "string" ? r : "This video project is read-only.";
|
|
5812
|
+
return controller.readOnlyReason();
|
|
5813
|
+
};
|
|
5814
|
+
const project = () => controller.getProject();
|
|
5815
|
+
const soundClip = (p, id) => {
|
|
5816
|
+
const at = findVideoClip(p, id);
|
|
5817
|
+
if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
|
|
5818
|
+
const c = at.clip;
|
|
5819
|
+
if (c.type !== "media") throw new Error(`Clip "${id}" is a ${c.type} clip and has no sound of its own; pass a video or audio clip.`);
|
|
5820
|
+
const asset = p.assets.find((a) => a.id === c.asset);
|
|
5821
|
+
if (!asset) throw new Error(`Clip "${id}" plays a missing asset "${c.asset}".`);
|
|
5822
|
+
if (asset.kind === "image" || asset.hasAudio === false) throw new Error(`Clip "${id}" has no sound (asset "${asset.id}").`);
|
|
5823
|
+
return { clip: c, asset, track: at.track.id };
|
|
5824
|
+
};
|
|
5825
|
+
const stretch = (args) => {
|
|
5826
|
+
const p = project();
|
|
5827
|
+
if (args.clip === void 0 === (args.asset === void 0)) throw new Error('Pass either "clip" (a media clip, times on the timeline) or "asset" (times in the source), not both.');
|
|
5828
|
+
if (args.clip !== void 0) {
|
|
5829
|
+
const { clip, asset: asset2 } = soundClip(p, args.clip);
|
|
5830
|
+
const speed = clip.speed ?? 1;
|
|
5831
|
+
const from2 = Math.max(clip.start, args.from ?? clip.start);
|
|
5832
|
+
const to2 = Math.min(videoClipEnd(clip), args.to ?? videoClipEnd(clip));
|
|
5833
|
+
if (!(to2 > from2)) throw new Error(`The stretch is empty (${from2}\u2013${to2} ms); "from" and "to" must be inside the clip (${clip.start}\u2013${videoClipEnd(clip)} ms) with "to" after "from".`);
|
|
5834
|
+
return { asset: asset2, from: (clip.in ?? 0) + (from2 - clip.start) * speed, to: (clip.in ?? 0) + (to2 - clip.start) * speed, out: (s) => clip.start + (s - (clip.in ?? 0)) / speed };
|
|
5835
|
+
}
|
|
5836
|
+
const asset = p.assets.find((a) => a.id === args.asset);
|
|
5837
|
+
if (!asset) throw new Error(`There is no asset "${args.asset}"; the assets are ${p.assets.map((a) => `"${a.id}"`).join(", ") || "none"}.`);
|
|
5838
|
+
if (asset.kind === "image" || asset.hasAudio === false) throw new Error(`Asset "${asset.id}" has no sound.`);
|
|
5839
|
+
const from = args.from ?? 0;
|
|
5840
|
+
const to = args.to ?? asset.duration ?? 0;
|
|
5841
|
+
if (!(to > from)) throw new Error(`The stretch is empty (${from}\u2013${to} ms)${asset.duration === void 0 ? '; the asset has no duration, so pass "to"' : ""}.`);
|
|
5842
|
+
return { asset, from, to, out: (s) => s };
|
|
5843
|
+
};
|
|
5844
|
+
const measureLoudness = {
|
|
5845
|
+
name: "measure_video_loudness",
|
|
5846
|
+
title: "Measure loudness",
|
|
5847
|
+
description: "Measures how loud the source sound of a clip or an asset is, as streaming services and broadcasters do (ITU-R BS.1770, gated): integrated loudness in LUFS, the sample peak in dBFS, and the gain that would bring it to -14 LUFS (streaming) without the peak passing -1 dBFS. Before clip volume and fades. Changes nothing. The first call for an asset decodes its file. Use normalize_video_loudness to apply the gain.",
|
|
5848
|
+
inputSchema: object3(SOUND_TARGET),
|
|
5849
|
+
annotations: { title: "Measure loudness", ...SOUND },
|
|
5850
|
+
handler: async (args) => {
|
|
5851
|
+
const r = stretch(args);
|
|
5852
|
+
const peaks = await controller.audioPeaks.load(r.asset);
|
|
5853
|
+
const lufs2 = videoAudioLoudness(peaks, r.from, r.to);
|
|
5854
|
+
const peakDb = Math.max(...videoAudioLevels(peaks, r.from, r.to, 50).map((l) => l.peakDb));
|
|
5855
|
+
return { asset: r.asset.id, lufs: lufs2, peakDb, ...lufs2 === null ? { note: "The stretch is silent (everything is below the -70 LUFS gate)." } : { toStreaming: videoLoudnessGain(lufs2, -14, { peakDb }) } };
|
|
5856
|
+
}
|
|
5857
|
+
};
|
|
5858
|
+
const normalizeLoudness = {
|
|
5859
|
+
name: "normalize_video_loudness",
|
|
5860
|
+
title: "Normalise loudness",
|
|
5861
|
+
description: "Sets each clip's volume so its sound plays at a target loudness (default -14 LUFS, streaming; -16 podcasts, -23 EBU R128 broadcast), keeping its peak under the ceiling and the gain at most 4\xD7 (one undo step). Measures each clip's own stretch of source first; silent clips are skipped. Refuses clips with volume keyframes (a constant gain would replace them) unless replace is true. Returns each clip's measured loudness, the gain set (linear and dB) and whether a cap held it short of the target.",
|
|
5862
|
+
inputSchema: object3(
|
|
5863
|
+
{
|
|
5864
|
+
clips: { type: "array", items: CLIP_ID2, minItems: 1, maxItems: 200, uniqueItems: true, description: "Media clips with sound." },
|
|
5865
|
+
target: { type: "number", minimum: -40, maximum: -5, description: "Loudness to reach, in LUFS (default -14)." },
|
|
5866
|
+
ceiling: { type: "number", minimum: -20, maximum: 0, description: "Highest sample peak allowed after the gain, in dBFS (default -1)." },
|
|
5867
|
+
replace: { type: "boolean", description: "Replace volume keyframes with the constant gain (default false: refuse)." }
|
|
5868
|
+
},
|
|
5869
|
+
["clips"]
|
|
5870
|
+
),
|
|
5871
|
+
annotations: { title: "Normalise loudness", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5872
|
+
handler: async ({ clips, target, ceiling, replace }) => {
|
|
5873
|
+
const p = project();
|
|
5874
|
+
const targets = clips.map((id) => soundClip(p, id));
|
|
5875
|
+
for (const t of targets) if (isVideoKeyframes(t.clip.volume) && !replace) throw new Error(`Clip "${t.clip.id}" has volume keyframes, which a constant gain would replace; pass replace: true, or remove them first.`);
|
|
5876
|
+
const results = [];
|
|
5877
|
+
const edits = [];
|
|
5878
|
+
for (const { clip, asset } of targets) {
|
|
5879
|
+
const peaks = await controller.audioPeaks.load(asset);
|
|
5880
|
+
const speed = clip.speed ?? 1;
|
|
5881
|
+
const from = clip.in ?? 0;
|
|
5882
|
+
const to = from + clip.duration * speed;
|
|
5883
|
+
const lufs2 = videoAudioLoudness(peaks, from, to);
|
|
5884
|
+
if (lufs2 === null) {
|
|
5885
|
+
results.push({ clip: clip.id, skipped: "silent" });
|
|
5886
|
+
continue;
|
|
5887
|
+
}
|
|
5888
|
+
const peakDb = Math.max(...videoAudioLevels(peaks, from, to, 50).map((l) => l.peakDb));
|
|
5889
|
+
const g = videoLoudnessGain(lufs2, target ?? -14, { peakDb, ceiling });
|
|
5890
|
+
edits.push({ op: "update_clip", clip: clip.id, set: { volume: g.gain } });
|
|
5891
|
+
results.push({ clip: clip.id, lufs: lufs2, gain: g.gain, gainDb: g.gainDb, ...g.limitedBy ? { limitedBy: g.limitedBy } : {} });
|
|
5892
|
+
}
|
|
5893
|
+
if (!edits.length) throw new Error("Every clip is silent, so there is nothing to normalise.");
|
|
5894
|
+
const r = controller.apply(edits, "Normalise loudness");
|
|
5895
|
+
if (!r.ok) throw new Error(r.error);
|
|
5896
|
+
return { target: target ?? -14, clips: results };
|
|
5897
|
+
}
|
|
5898
|
+
};
|
|
5899
|
+
const duckMusic = {
|
|
5900
|
+
name: "duck_video_music",
|
|
5901
|
+
title: "Duck music under speech",
|
|
5902
|
+
description: `Dips music clips under speech with volume keyframes (one undo step): before each stretch of speech the level eases down by duck_db over "attack" ms, holds, and eases back over "release" ms after it; gaps shorter than merge_gap stay ducked. Speech comes from the transcript of the dialogue track ("transcript", the default when it has one) or from where its sound is above -40 dBFS ("audio", which decodes it). Refuses music clips that already have volume keyframes unless replace is true. Returns each music clip's keys and the speech stretches used.`,
|
|
5903
|
+
inputSchema: object3(
|
|
5904
|
+
{
|
|
5905
|
+
music: { type: "array", items: CLIP_ID2, minItems: 1, maxItems: 100, uniqueItems: true, description: "The music clips to duck (media clips with sound)." },
|
|
5906
|
+
dialogue_track: { ...TRACK_ID2, description: 'Track the speech is on (default: the transcript track for "transcript"; required for "audio").' },
|
|
5907
|
+
speech: { type: "string", enum: ["transcript", "audio"], description: "Where speech is found: transcript words (default when transcribed) or the dialogue track's sound levels." },
|
|
5908
|
+
duck_db: { type: "number", minimum: -40, maximum: -1, description: "How far to dip, in dB (default -14; -12 to -18 is usual)." },
|
|
5909
|
+
attack: { type: "number", minimum: 0, maximum: 2e3, description: "Ms the dip takes to come in before speech (default 200)." },
|
|
5910
|
+
release: { type: "number", minimum: 0, maximum: 4e3, description: "Ms the music takes to come back after speech (default 400)." },
|
|
5911
|
+
merge_gap: { type: "number", minimum: 0, maximum: 1e4, description: "Speech gaps shorter than this stay ducked, in ms (default 600)." },
|
|
5912
|
+
replace: { type: "boolean", description: "Replace existing volume keyframes (default false: refuse)." }
|
|
5913
|
+
},
|
|
5914
|
+
["music"]
|
|
5915
|
+
),
|
|
5916
|
+
annotations: { title: "Duck music under speech", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5917
|
+
handler: async (args) => {
|
|
5918
|
+
const p = project();
|
|
5919
|
+
const music = args.music.map((id) => soundClip(p, id));
|
|
5920
|
+
for (const m of music) if (isVideoKeyframes(m.clip.volume) && !args.replace) throw new Error(`Music clip "${m.clip.id}" already has volume keyframes; pass replace: true to replace them, or remove them first.`);
|
|
5921
|
+
const transcriptTrack = videoTranscriptTrack(p, args.dialogue_track);
|
|
5922
|
+
const mode = args.speech ?? (transcriptTrack && videoTimelineTranscript(p, { track: transcriptTrack }).length ? "transcript" : "audio");
|
|
5923
|
+
let ranges = [];
|
|
5924
|
+
if (mode === "transcript") {
|
|
5925
|
+
const words = videoTimelineTranscript(p, { track: args.dialogue_track });
|
|
5926
|
+
if (!words.length) throw new Error(`No transcribed speech${args.dialogue_track ? ` on track "${args.dialogue_track}"` : ""}; attach a transcript with set_video_transcript, or pass speech: "audio" with dialogue_track.`);
|
|
5927
|
+
ranges = videoSpeechRanges(words, 250);
|
|
5928
|
+
} else {
|
|
5929
|
+
if (!args.dialogue_track) throw new Error('speech "audio" needs dialogue_track: the track whose sound is the speech.');
|
|
5930
|
+
const track = p.tracks.find((t) => t.id === args.dialogue_track);
|
|
5931
|
+
if (!track) throw new Error(`There is no track "${args.dialogue_track}".`);
|
|
5932
|
+
const musicIds = new Set(music.map((m) => m.clip.id));
|
|
5933
|
+
for (const c of sortedVideoClips(track)) {
|
|
5934
|
+
if (c.type !== "media" || musicIds.has(c.id)) continue;
|
|
5935
|
+
const asset = p.assets.find((a) => a.id === c.asset);
|
|
5936
|
+
if (!asset || asset.kind === "image" || asset.hasAudio === false) continue;
|
|
5937
|
+
const peaks = await controller.audioPeaks.load(asset);
|
|
5938
|
+
const speed = c.speed ?? 1;
|
|
5939
|
+
const src0 = c.in ?? 0;
|
|
5940
|
+
const silences = findVideoAudioSilences(peaks, src0, src0 + c.duration * speed, { thresholdDb: -40, minDuration: 250 });
|
|
5941
|
+
let at = c.start;
|
|
5942
|
+
for (const s of silences) {
|
|
5943
|
+
const a = c.start + (s.start - src0) / speed;
|
|
5944
|
+
if (a - at > VIDEO_TIME_EPSILON) ranges.push({ from: at, to: a });
|
|
5945
|
+
at = c.start + (s.end - src0) / speed;
|
|
5946
|
+
}
|
|
5947
|
+
if (videoClipEnd(c) - at > VIDEO_TIME_EPSILON) ranges.push({ from: at, to: videoClipEnd(c) });
|
|
5948
|
+
}
|
|
5949
|
+
if (!ranges.length) throw new Error(`Track "${args.dialogue_track}" has no sound above -40 dBFS, so there is no speech to duck under.`);
|
|
5950
|
+
}
|
|
5951
|
+
const edits = [];
|
|
5952
|
+
const out = [];
|
|
5953
|
+
for (const { clip } of music) {
|
|
5954
|
+
const base = typeof clip.volume === "number" ? clip.volume : 1;
|
|
5955
|
+
const keys = videoDuckingKeys(clip, ranges, { duckDb: args.duck_db, attack: args.attack, release: args.release, mergeGap: args.merge_gap, base });
|
|
5956
|
+
out.push({ clip: clip.id, keys: keys.length, ...keys.length ? { keyframes: keys } : { note: "No speech overlaps it." } });
|
|
5957
|
+
if (keys.length) edits.push({ op: "set_keyframes", clip: clip.id, property: "volume", keys });
|
|
5958
|
+
}
|
|
5959
|
+
if (!edits.length) throw new Error("No speech overlaps the music clips, so there is nothing to duck.");
|
|
5960
|
+
const r = controller.apply(edits, "Duck music");
|
|
5961
|
+
if (!r.ok) throw new Error(r.error);
|
|
5962
|
+
return { speech: mode, speechRanges: ranges.map((x) => ({ from: round4(x.from), to: round4(x.to) })), music: out };
|
|
5963
|
+
}
|
|
5964
|
+
};
|
|
5965
|
+
const BEAT_FIELDS = {
|
|
5966
|
+
sensitivity: { type: "number", minimum: 0.5, maximum: 4, description: "How far above the local average a hit must rise, in standard deviations (default 1.5; lower finds more)." },
|
|
5967
|
+
min_bpm: { type: "number", minimum: 30, maximum: 300, description: "Slowest tempo to consider (default 60)." },
|
|
5968
|
+
max_bpm: { type: "number", minimum: 30, maximum: 300, description: "Fastest tempo to consider (default 200)." }
|
|
5969
|
+
};
|
|
5970
|
+
const beatsOf = async (args) => {
|
|
5971
|
+
const r = stretch(args);
|
|
5972
|
+
const peaks = await controller.audioPeaks.load(r.asset);
|
|
5973
|
+
const found = findVideoAudioBeats(peaks, r.from, r.to, { sensitivity: args.sensitivity, minBpm: args.min_bpm, maxBpm: args.max_bpm });
|
|
5974
|
+
return { asset: r.asset.id, bpm: found.bpm, beats: found.beats.map((t) => round4(r.out(t))), onsets: found.onsets.map((t) => round4(r.out(t))) };
|
|
5975
|
+
};
|
|
5976
|
+
const findBeats = {
|
|
5977
|
+
name: "find_video_beats",
|
|
5978
|
+
title: "Find the beats",
|
|
5979
|
+
description: "Finds the beat of a music clip or asset: the tempo (BPM, or null when the hits do not repeat), a steady beat grid at that tempo, and the onsets (each hit) \u2014 timeline ms for a clip, source ms for an asset. Changes nothing. Use the beats to cut on the music (plan_video_beat_snap), place markers (add_video_marker) or time titles. Works best on percussive music. The first call for an asset decodes its file.",
|
|
5980
|
+
inputSchema: object3({ ...SOUND_TARGET, ...BEAT_FIELDS }),
|
|
5981
|
+
annotations: { title: "Find the beats", ...SOUND },
|
|
5982
|
+
handler: (args) => beatsOf(args)
|
|
5983
|
+
};
|
|
5984
|
+
const planBeatSnap = {
|
|
5985
|
+
name: "plan_video_beat_snap",
|
|
5986
|
+
title: "Propose cuts on the beat",
|
|
5987
|
+
description: 'Proposes, as a plan for review, moving each cut on a track to the nearest beat within "tolerance" ms with roll edits (the outgoing clip gets longer as the incoming one gets shorter, so nothing else moves). Beats come from a music clip (found as find_video_beats does) or are given. Cuts already on a beat, with none in reach, or without enough media to roll are left (listed in "skipped"). Nothing changes until apply_video_edit_plan. Returns the plan and what was skipped.',
|
|
5988
|
+
inputSchema: object3(
|
|
5989
|
+
{
|
|
5990
|
+
track: { ...TRACK_ID2, description: "The track whose cuts to move (default the first video track)." },
|
|
5991
|
+
music: { ...CLIP_ID2, description: "A music clip to take the beats from. Pass this or beats." },
|
|
5992
|
+
beats: { type: "array", minItems: 1, maxItems: 5e3, items: { type: "number", minimum: 0 }, description: "Beat times on the timeline, in ms. Pass this or music." },
|
|
5993
|
+
tolerance: { type: "number", exclusiveMinimum: 0, maximum: 2e3, description: "Furthest a cut may move, in ms (default 150)." },
|
|
5994
|
+
...BEAT_FIELDS
|
|
5995
|
+
}
|
|
5996
|
+
),
|
|
5997
|
+
annotations: { title: "Propose cuts on the beat", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
5998
|
+
handler: async (args) => {
|
|
5999
|
+
const p = project();
|
|
6000
|
+
if (args.music === void 0 === (args.beats === void 0)) throw new Error('Pass either "music" (a music clip to find the beats in) or "beats" (times in ms), not both.');
|
|
6001
|
+
const track = args.track !== void 0 ? p.tracks.find((t) => t.id === args.track) : p.tracks.find((t) => t.kind === "video");
|
|
6002
|
+
if (!track) throw new Error(args.track !== void 0 ? `There is no track "${args.track}".` : "The project has no video track.");
|
|
6003
|
+
let beats;
|
|
6004
|
+
if (args.music !== void 0) {
|
|
6005
|
+
const found = await beatsOf({ clip: args.music, sensitivity: args.sensitivity, min_bpm: args.min_bpm, max_bpm: args.max_bpm });
|
|
6006
|
+
beats = found.beats.length ? found.beats : found.onsets;
|
|
6007
|
+
if (!beats.length) throw new Error(`No beats were found in "${args.music}"; try a lower sensitivity, or pass beats.`);
|
|
6008
|
+
} else beats = [...args.beats].sort((a, b) => a - b);
|
|
6009
|
+
const tolerance = args.tolerance ?? 150;
|
|
6010
|
+
const clips = sortedVideoClips(track);
|
|
6011
|
+
const edits = [];
|
|
6012
|
+
const ranges = [];
|
|
6013
|
+
const skipped = [];
|
|
6014
|
+
let working = p;
|
|
6015
|
+
for (let i = 0; i + 1 < clips.length; i++) {
|
|
6016
|
+
const left = clips[i];
|
|
6017
|
+
const right = clips[i + 1];
|
|
6018
|
+
const cut = videoClipEnd(left);
|
|
6019
|
+
if (Math.abs(right.start - cut) > VIDEO_TIME_EPSILON) continue;
|
|
6020
|
+
const beat = nearestVideoBeat(beats, cut, tolerance);
|
|
6021
|
+
if (beat === null) {
|
|
6022
|
+
skipped.push({ cut: round4(cut), reason: `no beat within ${tolerance} ms` });
|
|
6023
|
+
continue;
|
|
6024
|
+
}
|
|
6025
|
+
const delta = Math.round((beat - cut) * 10) / 10;
|
|
6026
|
+
if (Math.abs(delta) < 1) {
|
|
6027
|
+
skipped.push({ cut: round4(cut), reason: "already on the beat" });
|
|
6028
|
+
continue;
|
|
6029
|
+
}
|
|
6030
|
+
const edit = { op: "roll_edit", left: left.id, right: right.id, delta };
|
|
6031
|
+
const tried = applyVideoEdits(working, edit);
|
|
6032
|
+
if (!tried.ok) {
|
|
6033
|
+
skipped.push({ cut: round4(cut), reason: tried.error });
|
|
6034
|
+
continue;
|
|
6035
|
+
}
|
|
6036
|
+
working = tried.project;
|
|
6037
|
+
edits.push(edit);
|
|
6038
|
+
ranges.push({ from: Math.min(cut, beat), to: Math.max(cut, beat), reason: "beat" });
|
|
6039
|
+
}
|
|
6040
|
+
if (!edits.length) throw new Error(`No cut on track "${track.id}" can move to a beat${skipped.length ? `: ${skipped.slice(0, 5).map((s) => `${s.cut} ms (${s.reason})`).join("; ")}` : " (it has no cuts between touching clips)"}.`);
|
|
6041
|
+
const r = controller.proposePlan({ title: `Snap ${edits.length} cut${edits.length > 1 ? "s" : ""} to the beat`, description: `Rolls cuts on track "${track.id}" to the nearest beat within ${tolerance} ms.`, edits, ranges, source: "plan_video_beat_snap" });
|
|
6042
|
+
if (!r.ok) throw new Error(r.error);
|
|
6043
|
+
return { plan: { id: r.plan.id, title: r.plan.title, summary: r.plan.summary, text: describeVideoEditPlan(r.plan) }, moved: edits.length, skipped, hint: `Nothing changed yet. Call apply_video_edit_plan with plan "${r.plan.id}" once the user agrees.` };
|
|
6044
|
+
}
|
|
6045
|
+
};
|
|
6046
|
+
const reads = [measureLoudness, findBeats];
|
|
6047
|
+
const writes = [normalizeLoudness, duckMusic, planBeatSnap];
|
|
6048
|
+
return withVideoPlayback(
|
|
6049
|
+
{
|
|
6050
|
+
id: options.id ?? "video-music",
|
|
6051
|
+
namespace: options.namespace,
|
|
6052
|
+
title: "Video music and mix",
|
|
6053
|
+
description: "Music and the mix: beats and cuts on the beat, ducking music under speech, and loudness in LUFS.",
|
|
6054
|
+
skill: "pixerate-bgm-alignment",
|
|
6055
|
+
tools: () => readOnlyReason() ? reads : [...reads, ...writes],
|
|
6056
|
+
subscribe: (notify) => {
|
|
6057
|
+
let last = readOnlyReason();
|
|
6058
|
+
return controller.subscribe(() => {
|
|
6059
|
+
const now = readOnlyReason();
|
|
6060
|
+
if (now !== last) {
|
|
6061
|
+
last = now;
|
|
6062
|
+
notify();
|
|
6063
|
+
}
|
|
6064
|
+
});
|
|
6065
|
+
}
|
|
6066
|
+
},
|
|
6067
|
+
options.playbackSurface
|
|
6068
|
+
);
|
|
6069
|
+
}
|
|
6070
|
+
|
|
6071
|
+
// src/video-editor/effect-authoring.ts
|
|
6072
|
+
function videoEaseToAnimationEasing(ease) {
|
|
6073
|
+
if (ease === void 0 || ease === "linear") return "linear";
|
|
6074
|
+
if (Array.isArray(ease)) return { type: "cubic-bezier", x1: ease[0], y1: ease[1], x2: ease[2], y2: ease[3] };
|
|
6075
|
+
return ease;
|
|
6076
|
+
}
|
|
6077
|
+
function animationEasingToVideoEase(easing) {
|
|
6078
|
+
if (easing === void 0 || easing === "linear") return "linear";
|
|
6079
|
+
if (easing === "hold" || easing === "ease-in" || easing === "ease-out" || easing === "ease-in-out") return easing;
|
|
6080
|
+
if (typeof easing === "string") {
|
|
6081
|
+
const p = CUBIC_BEZIER_PRESETS[easing];
|
|
6082
|
+
return p ? [p[0], p[1], p[2], p[3]] : null;
|
|
6083
|
+
}
|
|
6084
|
+
if (easing.type === "cubic-bezier") return [easing.x1, easing.y1, easing.x2, easing.y2];
|
|
6085
|
+
return null;
|
|
6086
|
+
}
|
|
6087
|
+
var SAMPLES = 12;
|
|
6088
|
+
function videoClipEffectDocument(clip, instance, providerId) {
|
|
6089
|
+
const effect = (clip.effects ?? []).find((e) => e.id === instance);
|
|
6090
|
+
if (!effect) throw new Error(`Clip "${clip.id}" has no effect "${instance}"; its effects are ${(clip.effects ?? []).map((e) => `"${e.id}" (${e.name ?? e.effect})`).join(", ") || "none"}.`);
|
|
6091
|
+
const values = {};
|
|
6092
|
+
const tracks = {};
|
|
6093
|
+
for (const [control, v] of Object.entries(effect.values ?? {})) {
|
|
6094
|
+
if (isVideoKeyframes(v)) {
|
|
6095
|
+
values[control] = v.keys[0].v;
|
|
6096
|
+
tracks[control] = v.keys.map((k) => ({ time: k.t, value: k.v, ...k.ease && k.ease !== "linear" ? { easing: videoEaseToAnimationEasing(k.ease) } : {} }));
|
|
6097
|
+
} else values[control] = v;
|
|
6098
|
+
}
|
|
6099
|
+
return { version: 1, providerId, effectId: effect.effect, name: effect.name ?? effect.effect, values, tracks, presets: [], duration: clip.duration, loop: false };
|
|
6100
|
+
}
|
|
6101
|
+
function videoEffectValuesFromDocument(doc) {
|
|
6102
|
+
const values = { ...doc.values };
|
|
6103
|
+
const notes = [];
|
|
6104
|
+
for (const [control, track] of Object.entries(doc.tracks ?? {})) {
|
|
6105
|
+
const kfs = [...track].sort((a, b) => a.time - b.time);
|
|
6106
|
+
if (!kfs.length) continue;
|
|
6107
|
+
if (!kfs.every((k) => typeof k.value === "number")) {
|
|
6108
|
+
values[control] = kfs[0].value;
|
|
6109
|
+
notes.push(`"${control}" is keyframed with non-numeric values, which clip effects cannot animate; it keeps its first value.`);
|
|
6110
|
+
continue;
|
|
6111
|
+
}
|
|
6112
|
+
const keys = [];
|
|
6113
|
+
let sampled = false;
|
|
6114
|
+
kfs.forEach((k, i) => {
|
|
6115
|
+
const next = kfs[i + 1];
|
|
6116
|
+
const ease = animationEasingToVideoEase(k.easing);
|
|
6117
|
+
if (!next || ease) {
|
|
6118
|
+
keys.push({ t: k.time, v: k.value, ...next && ease && ease !== "linear" ? { ease } : {} });
|
|
6119
|
+
return;
|
|
6120
|
+
}
|
|
6121
|
+
sampled = true;
|
|
6122
|
+
const a = k.value;
|
|
6123
|
+
const b = next.value;
|
|
6124
|
+
const span = next.time - k.time;
|
|
6125
|
+
for (let s = 0; s < SAMPLES; s++) keys.push({ t: Math.round((k.time + span * s / SAMPLES) * 1e3) / 1e3, v: a + (b - a) * evaluateEasing(k.easing, s / SAMPLES, span) });
|
|
6126
|
+
});
|
|
6127
|
+
values[control] = { keys: keys.filter((k, i) => i === 0 || k.t > keys[i - 1].t) };
|
|
6128
|
+
if (sampled) notes.push(`"${control}" uses a spring or elastic easing, sampled into ${SAMPLES} linear steps per segment.`);
|
|
6129
|
+
}
|
|
6130
|
+
return { values, notes };
|
|
6131
|
+
}
|
|
6132
|
+
async function openVideoClipEffectAuthoring(controller, clip, instance, options = {}) {
|
|
6133
|
+
const provider = controller.effectsProvider;
|
|
6134
|
+
if (!provider) throw new Error("No effects provider is registered, so the effect cannot be opened for authoring; register one with setEffectsProvider.");
|
|
6135
|
+
const at = findVideoClip(controller.getProject(), clip);
|
|
6136
|
+
if (!at) throw new Error(`There is no clip "${clip}".`);
|
|
6137
|
+
const doc = videoClipEffectDocument(at.clip, instance, provider.id);
|
|
6138
|
+
const session = await createEffectAuthoringSession({ document: doc }, { ...options, provider });
|
|
6139
|
+
return {
|
|
6140
|
+
clip,
|
|
6141
|
+
instance,
|
|
6142
|
+
session,
|
|
6143
|
+
async commit() {
|
|
6144
|
+
const { values, notes } = videoEffectValuesFromDocument(session.exportDocument());
|
|
6145
|
+
const result = await controller.updateClipEffect(clip, instance, { values });
|
|
6146
|
+
return result.ok && notes.length ? { ...result, notes } : result;
|
|
6147
|
+
},
|
|
6148
|
+
dispose: () => session.dispose()
|
|
6149
|
+
};
|
|
6150
|
+
}
|
|
6151
|
+
var videoAuthorableEffects = (clip) => clip.effects ?? [];
|
|
6152
|
+
|
|
4871
6153
|
// src/video-editor/editorDocument.ts
|
|
4872
6154
|
function createVideoEditorDocument(controller) {
|
|
4873
6155
|
return defineEditorDocument({
|
|
@@ -4894,6 +6176,9 @@ function createVideoEditorDocument(controller) {
|
|
|
4894
6176
|
});
|
|
4895
6177
|
}
|
|
4896
6178
|
export {
|
|
6179
|
+
TranscriptVideoController,
|
|
6180
|
+
VIDEO_ADJUSTMENT_SCOPES,
|
|
6181
|
+
VIDEO_ADJUSTMENT_SCOPE_LABELS,
|
|
4897
6182
|
VIDEO_BLEND_MODES,
|
|
4898
6183
|
VIDEO_BLEND_MODE_LABELS,
|
|
4899
6184
|
VIDEO_CLIP_ANIMATION_DURATION,
|
|
@@ -4903,6 +6188,7 @@ export {
|
|
|
4903
6188
|
VIDEO_DEFAULT_FILLERS,
|
|
4904
6189
|
VIDEO_DEFAULT_STILL_DURATION,
|
|
4905
6190
|
VIDEO_DEFAULT_TITLE_DURATION,
|
|
6191
|
+
VIDEO_DISFLUENCY_KINDS,
|
|
4906
6192
|
VIDEO_EASE_NAMES,
|
|
4907
6193
|
VIDEO_EDIT_OPS,
|
|
4908
6194
|
VIDEO_EFFECT_PLACES,
|
|
@@ -4915,7 +6201,11 @@ export {
|
|
|
4915
6201
|
VIDEO_ISSUE_CODES,
|
|
4916
6202
|
VIDEO_KEYFRAME_LIMITS,
|
|
4917
6203
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
6204
|
+
VIDEO_LOUDNESS_HOP,
|
|
4918
6205
|
VIDEO_LUT_MAX_SIZE,
|
|
6206
|
+
VIDEO_MASK_DEFAULTS,
|
|
6207
|
+
VIDEO_MASK_SHAPES,
|
|
6208
|
+
VIDEO_MASK_SHAPE_LABELS,
|
|
4919
6209
|
VIDEO_PEAK_BLOCK,
|
|
4920
6210
|
VIDEO_PLACEMENT_MODES,
|
|
4921
6211
|
VIDEO_PREVIEW_SCALE_OPTIONS,
|
|
@@ -4936,6 +6226,7 @@ export {
|
|
|
4936
6226
|
VideoPreviewPixels,
|
|
4937
6227
|
allVideoClipIds,
|
|
4938
6228
|
allVideoTracks,
|
|
6229
|
+
animationEasingToVideoEase,
|
|
4939
6230
|
applyVideoEdits,
|
|
4940
6231
|
applyVideoGrade,
|
|
4941
6232
|
applyVideoLut,
|
|
@@ -4945,20 +6236,27 @@ export {
|
|
|
4945
6236
|
checkVideoEffectValues,
|
|
4946
6237
|
checkVideoProject,
|
|
4947
6238
|
checkVideoTimeRemap,
|
|
6239
|
+
createVideoDialogueToolProvider,
|
|
4948
6240
|
createVideoEditorDocument,
|
|
4949
6241
|
createVideoEditorToolProvider,
|
|
4950
6242
|
createVideoEffectsToolProvider,
|
|
4951
6243
|
createVideoElementDecoder,
|
|
6244
|
+
createVideoMusicToolProvider,
|
|
4952
6245
|
createVideoProject,
|
|
4953
6246
|
decodeVideoAudioPeaks,
|
|
6247
|
+
describeVideoEditPlan,
|
|
4954
6248
|
describeVideoProject,
|
|
4955
6249
|
describeVideoSequence,
|
|
6250
|
+
detectVideoDisfluencies,
|
|
6251
|
+
diffVideoProjects,
|
|
4956
6252
|
drawVideoFrame,
|
|
4957
6253
|
ffmpegFilterValue,
|
|
6254
|
+
findVideoAudioBeats,
|
|
4958
6255
|
findVideoAudioSilences,
|
|
4959
6256
|
findVideoClip,
|
|
4960
6257
|
findVideoFillers,
|
|
4961
6258
|
findVideoGaps,
|
|
6259
|
+
findVideoRepetitions,
|
|
4962
6260
|
findVideoSequenceClip,
|
|
4963
6261
|
findVideoSequenceCycle,
|
|
4964
6262
|
findVideoTranscriptPauses,
|
|
@@ -4968,12 +6266,16 @@ export {
|
|
|
4968
6266
|
formatVideoTimecodeFrames,
|
|
4969
6267
|
isVideoDropFrameRate,
|
|
4970
6268
|
isVideoKeyframes,
|
|
6269
|
+
measureVideoLoudness,
|
|
4971
6270
|
mergeVideoRanges,
|
|
4972
6271
|
mixEffectPixels,
|
|
6272
|
+
nearestVideoBeat,
|
|
4973
6273
|
nextFreeVideoSlot,
|
|
6274
|
+
normalizeVideoClipMask,
|
|
4974
6275
|
normalizeVideoGrade,
|
|
4975
6276
|
normalizeVideoGradeLut,
|
|
4976
6277
|
normalizeVideoWord,
|
|
6278
|
+
openVideoClipEffectAuthoring,
|
|
4977
6279
|
overEffectPixels,
|
|
4978
6280
|
parseVideoAnimatable,
|
|
4979
6281
|
parseVideoAsset,
|
|
@@ -4981,6 +6283,7 @@ export {
|
|
|
4981
6283
|
parseVideoClip,
|
|
4982
6284
|
parseVideoClipAnimation,
|
|
4983
6285
|
parseVideoClipEffects,
|
|
6286
|
+
parseVideoClipMask,
|
|
4984
6287
|
parseVideoCubeLut,
|
|
4985
6288
|
parseVideoEffectRef,
|
|
4986
6289
|
parseVideoEffectValues,
|
|
@@ -5001,6 +6304,7 @@ export {
|
|
|
5001
6304
|
parseVideoTransition,
|
|
5002
6305
|
placeVideoRect,
|
|
5003
6306
|
planVideoClipDrag,
|
|
6307
|
+
planVideoEdits,
|
|
5004
6308
|
planVideoPrefetch,
|
|
5005
6309
|
planVideoToolPlayback,
|
|
5006
6310
|
probeVideoMedia,
|
|
@@ -5019,15 +6323,18 @@ export {
|
|
|
5019
6323
|
toVideoEdl,
|
|
5020
6324
|
toVideoFfmpeg,
|
|
5021
6325
|
toVideoLoudnessMeasure,
|
|
6326
|
+
videoAddAdjustmentEdits,
|
|
5022
6327
|
videoAssetDropEdit,
|
|
5023
6328
|
videoAssetHasSound,
|
|
5024
6329
|
videoAssetKindOf,
|
|
5025
6330
|
videoAssetMap,
|
|
5026
6331
|
videoAssetProxy,
|
|
5027
6332
|
videoAudioLevels,
|
|
6333
|
+
videoAudioLoudness,
|
|
5028
6334
|
videoAudioPeakRange,
|
|
5029
6335
|
videoAudioPeakSpans,
|
|
5030
6336
|
videoAudioPeaksDuration,
|
|
6337
|
+
videoAuthorableEffects,
|
|
5031
6338
|
videoCacheEvictions,
|
|
5032
6339
|
videoChroma,
|
|
5033
6340
|
videoClipAfter,
|
|
@@ -5036,9 +6343,11 @@ export {
|
|
|
5036
6343
|
videoClipAnimationSpans,
|
|
5037
6344
|
videoClipAppliedEffects,
|
|
5038
6345
|
videoClipBefore,
|
|
6346
|
+
videoClipEffectDocument,
|
|
5039
6347
|
videoClipEffectUses,
|
|
5040
6348
|
videoClipEnd,
|
|
5041
6349
|
videoClipHandles,
|
|
6350
|
+
videoClipHeading,
|
|
5042
6351
|
videoClipKeyframes,
|
|
5043
6352
|
videoClipLabel,
|
|
5044
6353
|
videoClipOverhang,
|
|
@@ -5049,21 +6358,27 @@ export {
|
|
|
5049
6358
|
videoClipSpeed,
|
|
5050
6359
|
videoClipSpeedAt,
|
|
5051
6360
|
videoClipTint,
|
|
6361
|
+
videoClipTypeLabel,
|
|
5052
6362
|
videoClipWaveform,
|
|
5053
6363
|
videoClipWaveformPath,
|
|
5054
6364
|
videoClipsAt,
|
|
5055
6365
|
videoContactSheetSvg,
|
|
6366
|
+
videoCutsWithMargin,
|
|
5056
6367
|
videoDragLabel,
|
|
5057
6368
|
videoDropTracks,
|
|
6369
|
+
videoDuckingKeys,
|
|
5058
6370
|
videoEaseBezier,
|
|
5059
6371
|
videoEaseProgress,
|
|
6372
|
+
videoEaseToAnimationEasing,
|
|
5060
6373
|
videoEffectFrameTime,
|
|
5061
6374
|
videoEffectLayerKey,
|
|
5062
6375
|
videoEffectValuesAt,
|
|
6376
|
+
videoEffectValuesFromDocument,
|
|
5063
6377
|
videoExposureNotes,
|
|
5064
6378
|
videoExposureStats,
|
|
5065
6379
|
videoFadeCurveGain,
|
|
5066
6380
|
videoFfmpegUnsupportedEffects,
|
|
6381
|
+
videoFfmpegUnsupportedFeatures,
|
|
5067
6382
|
videoFrameAt,
|
|
5068
6383
|
videoFrameCacheKey,
|
|
5069
6384
|
videoFrameDuration,
|
|
@@ -5093,8 +6408,12 @@ export {
|
|
|
5093
6408
|
videoKeyframeToggleEdit,
|
|
5094
6409
|
videoKeyframeValueEdit,
|
|
5095
6410
|
videoKeysOf,
|
|
6411
|
+
videoLayerMask,
|
|
5096
6412
|
videoLayersUseEffects,
|
|
5097
6413
|
videoLayersWithoutTransitionEffects,
|
|
6414
|
+
videoLoudnessEnergy,
|
|
6415
|
+
videoLoudnessGain,
|
|
6416
|
+
videoLoudnessOf,
|
|
5098
6417
|
videoLuma,
|
|
5099
6418
|
videoLumaWaveform,
|
|
5100
6419
|
videoLutEdits,
|
|
@@ -5102,6 +6421,10 @@ export {
|
|
|
5102
6421
|
videoLutSvgApproximation,
|
|
5103
6422
|
videoLutSvgPrimitives,
|
|
5104
6423
|
videoLutToCube,
|
|
6424
|
+
videoMaskContains,
|
|
6425
|
+
videoMaskEdit,
|
|
6426
|
+
videoMaskPolygon,
|
|
6427
|
+
videoMaskSvgPath,
|
|
5105
6428
|
videoOverlap,
|
|
5106
6429
|
videoPeakDb,
|
|
5107
6430
|
videoPreviewSrc,
|
|
@@ -5119,10 +6442,13 @@ export {
|
|
|
5119
6442
|
videoSequenceDuration,
|
|
5120
6443
|
videoSequenceMap,
|
|
5121
6444
|
videoShellQuote,
|
|
6445
|
+
videoSkinProtectionEdits,
|
|
6446
|
+
videoSkinToneWeight,
|
|
5122
6447
|
videoSlowMotionRamp,
|
|
5123
6448
|
videoSnapPoints,
|
|
5124
6449
|
videoSourceFrame,
|
|
5125
6450
|
videoSourceFrameTime,
|
|
6451
|
+
videoSpeechRanges,
|
|
5126
6452
|
videoSpeedRampEdit,
|
|
5127
6453
|
videoSpeedRampKeys,
|
|
5128
6454
|
videoTextStyle,
|