@nodetool-ai/video-nodes 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +58 -0
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +11 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/nodes/ffmpeg-helpers.d.ts +77 -0
  8. package/dist/nodes/ffmpeg-helpers.d.ts.map +1 -0
  9. package/dist/nodes/ffmpeg-helpers.js +220 -0
  10. package/dist/nodes/ffmpeg-helpers.js.map +1 -0
  11. package/dist/nodes/lib-video-download.d.ts +29 -0
  12. package/dist/nodes/lib-video-download.d.ts.map +1 -0
  13. package/dist/nodes/lib-video-download.js +353 -0
  14. package/dist/nodes/lib-video-download.js.map +1 -0
  15. package/dist/nodes/model3d/analysis.d.ts +16 -0
  16. package/dist/nodes/model3d/analysis.d.ts.map +1 -0
  17. package/dist/nodes/model3d/analysis.js +38 -0
  18. package/dist/nodes/model3d/analysis.js.map +1 -0
  19. package/dist/nodes/model3d/base.d.ts +28 -0
  20. package/dist/nodes/model3d/base.d.ts.map +1 -0
  21. package/dist/nodes/model3d/base.js +20 -0
  22. package/dist/nodes/model3d/base.js.map +1 -0
  23. package/dist/nodes/model3d/boolean-ops.d.ts +2 -0
  24. package/dist/nodes/model3d/boolean-ops.d.ts.map +1 -0
  25. package/dist/nodes/model3d/boolean-ops.js +172 -0
  26. package/dist/nodes/model3d/boolean-ops.js.map +1 -0
  27. package/dist/nodes/model3d/cleanup.d.ts +56 -0
  28. package/dist/nodes/model3d/cleanup.d.ts.map +1 -0
  29. package/dist/nodes/model3d/cleanup.js +144 -0
  30. package/dist/nodes/model3d/cleanup.js.map +1 -0
  31. package/dist/nodes/model3d/convert.d.ts +27 -0
  32. package/dist/nodes/model3d/convert.d.ts.map +1 -0
  33. package/dist/nodes/model3d/convert.js +66 -0
  34. package/dist/nodes/model3d/convert.js.map +1 -0
  35. package/dist/nodes/model3d/defaults.d.ts +34 -0
  36. package/dist/nodes/model3d/defaults.d.ts.map +1 -0
  37. package/dist/nodes/model3d/defaults.js +28 -0
  38. package/dist/nodes/model3d/defaults.js.map +1 -0
  39. package/dist/nodes/model3d/document-ops.d.ts +4 -0
  40. package/dist/nodes/model3d/document-ops.d.ts.map +1 -0
  41. package/dist/nodes/model3d/document-ops.js +40 -0
  42. package/dist/nodes/model3d/document-ops.js.map +1 -0
  43. package/dist/nodes/model3d/generation.d.ts +56 -0
  44. package/dist/nodes/model3d/generation.d.ts.map +1 -0
  45. package/dist/nodes/model3d/generation.js +203 -0
  46. package/dist/nodes/model3d/generation.js.map +1 -0
  47. package/dist/nodes/model3d/glb.d.ts +29 -0
  48. package/dist/nodes/model3d/glb.d.ts.map +1 -0
  49. package/dist/nodes/model3d/glb.js +255 -0
  50. package/dist/nodes/model3d/glb.js.map +1 -0
  51. package/dist/nodes/model3d/index.d.ts +22 -0
  52. package/dist/nodes/model3d/index.d.ts.map +1 -0
  53. package/dist/nodes/model3d/index.js +41 -0
  54. package/dist/nodes/model3d/index.js.map +1 -0
  55. package/dist/nodes/model3d/io.d.ts +81 -0
  56. package/dist/nodes/model3d/io.d.ts.map +1 -0
  57. package/dist/nodes/model3d/io.js +162 -0
  58. package/dist/nodes/model3d/io.js.map +1 -0
  59. package/dist/nodes/model3d/mesh-ops.d.ts +40 -0
  60. package/dist/nodes/model3d/mesh-ops.d.ts.map +1 -0
  61. package/dist/nodes/model3d/mesh-ops.js +775 -0
  62. package/dist/nodes/model3d/mesh-ops.js.map +1 -0
  63. package/dist/nodes/model3d/nodes.d.ts +2 -0
  64. package/dist/nodes/model3d/nodes.d.ts.map +1 -0
  65. package/dist/nodes/model3d/nodes.js +2 -0
  66. package/dist/nodes/model3d/nodes.js.map +1 -0
  67. package/dist/nodes/model3d/ops.d.ts +44 -0
  68. package/dist/nodes/model3d/ops.d.ts.map +1 -0
  69. package/dist/nodes/model3d/ops.js +142 -0
  70. package/dist/nodes/model3d/ops.js.map +1 -0
  71. package/dist/nodes/model3d/render.d.ts +51 -0
  72. package/dist/nodes/model3d/render.d.ts.map +1 -0
  73. package/dist/nodes/model3d/render.js +124 -0
  74. package/dist/nodes/model3d/render.js.map +1 -0
  75. package/dist/nodes/model3d/render3d-core.d.ts +177 -0
  76. package/dist/nodes/model3d/render3d-core.d.ts.map +1 -0
  77. package/dist/nodes/model3d/render3d-core.js +366 -0
  78. package/dist/nodes/model3d/render3d-core.js.map +1 -0
  79. package/dist/nodes/model3d/render3d-headless.d.ts +75 -0
  80. package/dist/nodes/model3d/render3d-headless.d.ts.map +1 -0
  81. package/dist/nodes/model3d/render3d-headless.js +190 -0
  82. package/dist/nodes/model3d/render3d-headless.js.map +1 -0
  83. package/dist/nodes/model3d/render3d-page.d.ts +16 -0
  84. package/dist/nodes/model3d/render3d-page.d.ts.map +1 -0
  85. package/dist/nodes/model3d/render3d-page.js +51 -0
  86. package/dist/nodes/model3d/render3d-page.js.map +1 -0
  87. package/dist/nodes/model3d/transforms.d.ts +56 -0
  88. package/dist/nodes/model3d/transforms.d.ts.map +1 -0
  89. package/dist/nodes/model3d/transforms.js +148 -0
  90. package/dist/nodes/model3d/transforms.js.map +1 -0
  91. package/dist/nodes/model3d/types.d.ts +82 -0
  92. package/dist/nodes/model3d/types.d.ts.map +1 -0
  93. package/dist/nodes/model3d/types.js +2 -0
  94. package/dist/nodes/model3d/types.js.map +1 -0
  95. package/dist/nodes/model3d/utils.d.ts +46 -0
  96. package/dist/nodes/model3d/utils.d.ts.map +1 -0
  97. package/dist/nodes/model3d/utils.js +190 -0
  98. package/dist/nodes/model3d/utils.js.map +1 -0
  99. package/dist/nodes/model3d.d.ts +2 -0
  100. package/dist/nodes/model3d.d.ts.map +1 -0
  101. package/dist/nodes/model3d.js +2 -0
  102. package/dist/nodes/model3d.js.map +1 -0
  103. package/dist/nodes/placeholder-values.d.ts +10 -0
  104. package/dist/nodes/placeholder-values.d.ts.map +1 -0
  105. package/dist/nodes/placeholder-values.js +19 -0
  106. package/dist/nodes/placeholder-values.js.map +1 -0
  107. package/dist/nodes/script.d.ts +161 -0
  108. package/dist/nodes/script.d.ts.map +1 -0
  109. package/dist/nodes/script.js +673 -0
  110. package/dist/nodes/script.js.map +1 -0
  111. package/dist/nodes/storyboard.d.ts +269 -0
  112. package/dist/nodes/storyboard.d.ts.map +1 -0
  113. package/dist/nodes/storyboard.js +1017 -0
  114. package/dist/nodes/storyboard.js.map +1 -0
  115. package/dist/nodes/timeline/assetFiles.d.ts +19 -0
  116. package/dist/nodes/timeline/assetFiles.d.ts.map +1 -0
  117. package/dist/nodes/timeline/assetFiles.js +52 -0
  118. package/dist/nodes/timeline/assetFiles.js.map +1 -0
  119. package/dist/nodes/timeline/compositeRender.d.ts +60 -0
  120. package/dist/nodes/timeline/compositeRender.d.ts.map +1 -0
  121. package/dist/nodes/timeline/compositeRender.js +508 -0
  122. package/dist/nodes/timeline/compositeRender.js.map +1 -0
  123. package/dist/nodes/timeline/contactSheet.d.ts +43 -0
  124. package/dist/nodes/timeline/contactSheet.d.ts.map +1 -0
  125. package/dist/nodes/timeline/contactSheet.js +78 -0
  126. package/dist/nodes/timeline/contactSheet.js.map +1 -0
  127. package/dist/nodes/timeline/outputFormats.d.ts +69 -0
  128. package/dist/nodes/timeline/outputFormats.d.ts.map +1 -0
  129. package/dist/nodes/timeline/outputFormats.js +173 -0
  130. package/dist/nodes/timeline/outputFormats.js.map +1 -0
  131. package/dist/nodes/timeline/pngSequence.d.ts +44 -0
  132. package/dist/nodes/timeline/pngSequence.d.ts.map +1 -0
  133. package/dist/nodes/timeline/pngSequence.js +172 -0
  134. package/dist/nodes/timeline/pngSequence.js.map +1 -0
  135. package/dist/nodes/timeline/rasterizers.d.ts +47 -0
  136. package/dist/nodes/timeline/rasterizers.d.ts.map +1 -0
  137. package/dist/nodes/timeline/rasterizers.js +103 -0
  138. package/dist/nodes/timeline/rasterizers.js.map +1 -0
  139. package/dist/nodes/timeline/rawFrames.d.ts +149 -0
  140. package/dist/nodes/timeline/rawFrames.d.ts.map +1 -0
  141. package/dist/nodes/timeline/rawFrames.js +598 -0
  142. package/dist/nodes/timeline/rawFrames.js.map +1 -0
  143. package/dist/nodes/timeline.d.ts +206 -0
  144. package/dist/nodes/timeline.d.ts.map +1 -0
  145. package/dist/nodes/timeline.js +1395 -0
  146. package/dist/nodes/timeline.js.map +1 -0
  147. package/dist/nodes/video.d.ts +705 -0
  148. package/dist/nodes/video.d.ts.map +1 -0
  149. package/dist/nodes/video.js +2672 -0
  150. package/dist/nodes/video.js.map +1 -0
  151. package/dist/render3d-page.js +4118 -0
  152. package/package.json +95 -0
@@ -0,0 +1,2672 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { BaseNode, prop } from "@nodetool-ai/node-sdk";
8
+ import { isRawRgbaImage } from "@nodetool-ai/protocol";
9
+ import { loadMediaRefBytes, mapPromptAssetsToInputs, resolveEntities } from "@nodetool-ai/runtime";
10
+ import { promises as fs } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { audioBytesAsync, toBytes } from "@nodetool-ai/audio-nodes";
14
+ import { MissingBinaryError, execFfmpeg, execFfprobe, videoRef, defaultVideoRef, filePath, folderPath, dateName, uniqueTargetPath, parseFrameRate, ffprobeDuration, withTempFile, coerceProviderBytes, FFMPEG_MAX_BUFFER } from "./ffmpeg-helpers.js";
15
+ import { isObjectLike, isString } from "@nodetool-ai/node-sdk";
16
+ import { writeSavedFile, VISIBLE_WHEN_NOT_SAVING_TO_WORKSPACE, SAVE_TO_WORKSPACE_DESCRIPTION, SAVE_TO_WORKSPACE_TITLE } from "@nodetool-ai/nodes-utils";
17
+ async function videoBytesAsync(video, context) {
18
+ if (!isObjectLike(video))
19
+ return new Uint8Array();
20
+ const bytes = await loadMediaRefBytes(video, context);
21
+ return bytes ?? new Uint8Array();
22
+ }
23
+ async function imageBytesAsync(image, context) {
24
+ if (!isObjectLike(image))
25
+ return new Uint8Array();
26
+ const bytes = await loadMediaRefBytes(image, context);
27
+ return bytes ?? new Uint8Array();
28
+ }
29
+ /**
30
+ * Whether an image ref already points at a source (wired upstream, a stored
31
+ * asset, or inline bytes) rather than the empty default — used to decide if an
32
+ * inline `asset://` mention in the prompt should supply the input.
33
+ */
34
+ function imageRefHasSource(image) {
35
+ if (!isObjectLike(image))
36
+ return false;
37
+ const ref = image;
38
+ if (isString(ref.uri) && ref.uri.trim() !== "")
39
+ return true;
40
+ if (ref.data != null && ref.data !== "")
41
+ return true;
42
+ return ref.asset_id != null && ref.asset_id !== "";
43
+ }
44
+ /**
45
+ * Normalize an image input that may be a single ImageRef (legacy single-image
46
+ * wiring) or a list of refs into an array, dropping non-object entries.
47
+ */
48
+ function normalizeImageList(value) {
49
+ const items = Array.isArray(value) ? value : value != null ? [value] : [];
50
+ return items.filter((item) => !!item && typeof item === "object");
51
+ }
52
+ /**
53
+ * Normalize a video input that may be a single VideoRef or a list of refs into
54
+ * an array, dropping non-object entries. Lets a concat input accept either a
55
+ * single wired video or a `list<video>` from an upstream loop/list node.
56
+ */
57
+ function normalizeVideoList(value) {
58
+ const items = Array.isArray(value) ? value : value != null ? [value] : [];
59
+ return items.filter((item) => !!item && typeof item === "object");
60
+ }
61
+ /**
62
+ * Parse cropdetect's suggested crop from ffmpeg stderr. cropdetect logs one
63
+ * `crop=w:h:x:y` per analyzed frame; the last suggestion reflects the full
64
+ * analysis, so return that. Null when no suggestion is present.
65
+ */
66
+ export function parseCropDetectCrop(stderr) {
67
+ const matches = stderr.match(/crop=(\d+:\d+:\d+:\d+)/g);
68
+ if (!matches || matches.length === 0)
69
+ return null;
70
+ const last = matches[matches.length - 1];
71
+ return last.slice("crop=".length);
72
+ }
73
+ /**
74
+ * Encode a sequence of frame refs into an H.264 MP4.
75
+ *
76
+ * Frames arrive in one of two shapes and the demuxer differs for each:
77
+ * - In-flight **raw-RGBA** refs ({@link isRawRgbaImage}) carry uncompressed
78
+ * pixels in `data` plus `width`/`height`. These are NOT a decodable image —
79
+ * writing them to `frame_*.png` makes ffmpeg fail with "Invalid PNG
80
+ * signature". They must be fed through the `rawvideo` demuxer instead.
81
+ * - **Encoded** refs carry PNG/JPEG bytes (Uint8Array or base64 string),
82
+ * which the `image2` demuxer reads from numbered files.
83
+ *
84
+ * A frame sequence from a single generator is homogeneous, so the encoding of
85
+ * the first usable frame selects the path; stray frames of the other kind (or
86
+ * with mismatched raw dimensions) are skipped.
87
+ */
88
+ async function combineFramesToVideo(frames, fps) {
89
+ const isUsable = (f) => {
90
+ if (!isObjectLike(f))
91
+ return false;
92
+ if (isRawRgbaImage(f))
93
+ return f.data.length > 0;
94
+ return toBytes(f.data).length > 0;
95
+ };
96
+ const first = frames.find(isUsable);
97
+ if (!first)
98
+ return new Uint8Array();
99
+ const inputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-ftv-in-"));
100
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-ftv-out-"));
101
+ const outputPath = path.join(outputDir, "output.mp4");
102
+ try {
103
+ if (isRawRgbaImage(first)) {
104
+ const { width, height } = first;
105
+ // Append each frame to the raw file as it is visited. 300 frames of 1080p
106
+ // RGBA is ~2.5 GB — Buffer.concat'ing them all would blow up memory.
107
+ const rawPath = path.join(inputDir, "frames.raw");
108
+ const handle = await fs.open(rawPath, "w");
109
+ let framesWritten = 0;
110
+ try {
111
+ for (const f of frames) {
112
+ if (isRawRgbaImage(f) &&
113
+ f.data.length > 0 &&
114
+ f.width === width &&
115
+ f.height === height) {
116
+ await handle.write(Buffer.from(f.data.buffer, f.data.byteOffset, f.data.byteLength));
117
+ framesWritten += 1;
118
+ }
119
+ }
120
+ }
121
+ finally {
122
+ await handle.close();
123
+ }
124
+ if (framesWritten === 0)
125
+ return new Uint8Array();
126
+ await execFfmpeg([
127
+ "-y",
128
+ "-f", "rawvideo",
129
+ "-pixel_format", "rgba",
130
+ "-video_size", `${width}x${height}`,
131
+ "-framerate", String(fps),
132
+ "-i", rawPath,
133
+ "-c:v", "libx264",
134
+ "-pix_fmt", "yuv420p",
135
+ outputPath
136
+ ], { maxBuffer: 50 * 1024 * 1024 });
137
+ }
138
+ else {
139
+ // Contiguous counter so skipped frames don't leave gaps in the numbered
140
+ // sequence (ffmpeg's image2 demuxer stops at the first missing number).
141
+ let written = 0;
142
+ for (const f of frames) {
143
+ if (!isObjectLike(f) || isRawRgbaImage(f))
144
+ continue;
145
+ const frameBytes = toBytes(f.data);
146
+ if (frameBytes.length === 0)
147
+ continue;
148
+ written += 1;
149
+ await fs.writeFile(path.join(inputDir, `frame_${String(written).padStart(6, "0")}.png`), frameBytes);
150
+ }
151
+ if (written === 0)
152
+ return new Uint8Array();
153
+ await execFfmpeg([
154
+ "-y",
155
+ "-framerate", String(fps),
156
+ "-i", path.join(inputDir, "frame_%06d.png"),
157
+ "-c:v", "libx264",
158
+ "-pix_fmt", "yuv420p",
159
+ outputPath
160
+ ], { maxBuffer: 50 * 1024 * 1024 });
161
+ }
162
+ return new Uint8Array(await fs.readFile(outputPath));
163
+ }
164
+ finally {
165
+ // execFfmpeg already maps a missing binary to a clear error; a genuine
166
+ // fs error (writing frames / reading output) propagates as itself rather
167
+ // than being mislabeled.
168
+ await fs.rm(inputDir, { recursive: true, force: true });
169
+ await fs.rm(outputDir, { recursive: true, force: true });
170
+ }
171
+ }
172
+ function modelConfig(props) {
173
+ const model = (props.model ?? {});
174
+ return {
175
+ providerId: isString(model.provider) ? model.provider : "",
176
+ modelId: isString(model.id) ? model.id : ""
177
+ };
178
+ }
179
+ function canUseProvider(context, providerId, modelId) {
180
+ return (!!context &&
181
+ typeof context.runProviderPrediction === "function" &&
182
+ !!providerId &&
183
+ !!modelId);
184
+ }
185
+ const VIDEO_ASPECT_RATIO_VALUES = [
186
+ "21:9",
187
+ "16:9",
188
+ "4:3",
189
+ "1:1",
190
+ "9:16",
191
+ "3:4"
192
+ ];
193
+ const VIDEO_RESOLUTION_VALUES = ["720p", "1080p", "1440p", "4K"];
194
+ const VIDEO_DURATION_VALUES = [2, 3, 4, 5, 6, 8, 10, 12, 15];
195
+ async function ffmpegTransform(video, args, options = {}) {
196
+ if (video.length === 0)
197
+ return new Uint8Array();
198
+ const mainInput = await withTempFile(".mp4", video);
199
+ const others = await Promise.all((options.extraInputs ?? []).map((item) => withTempFile(item.suffix, item.bytes)));
200
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-video-out-"));
201
+ const outputPath = path.join(outputDir, "output.mp4");
202
+ try {
203
+ const inputArgs = ["-y", ...(options.preInputArgs ?? []), "-i", mainInput.path];
204
+ for (const other of others)
205
+ inputArgs.push("-i", other.path);
206
+ await execFfmpeg([...inputArgs, ...args, outputPath], {
207
+ maxBuffer: 10 * 1024 * 1024
208
+ });
209
+ return new Uint8Array(await fs.readFile(outputPath));
210
+ }
211
+ catch (error) {
212
+ // A missing binary is unrecoverable — surface it. A genuine ffmpeg failure
213
+ // returns null only when the caller allows a chained fallback; otherwise it
214
+ // throws with stderr rather than silently passing the input through.
215
+ if (error instanceof MissingBinaryError)
216
+ throw error;
217
+ if (options.allowFallback)
218
+ return null;
219
+ const stderr = error.stderr;
220
+ const detail = (isString(stderr) && stderr.trim()) || error.message;
221
+ throw new Error(`ffmpeg failed: ${detail}`);
222
+ }
223
+ finally {
224
+ await mainInput.cleanup();
225
+ for (const other of others)
226
+ await other.cleanup();
227
+ await fs.rm(outputDir, { recursive: true, force: true });
228
+ }
229
+ }
230
+ /**
231
+ * Base for every ffmpeg-backed node. Carries only the runtime gate; each node
232
+ * declares its own props and `process()`.
233
+ */
234
+ class VideoTransformNode extends BaseNode {
235
+ static requiredRuntimes = ["ffmpeg"];
236
+ }
237
+ export class TextToVideoNode extends BaseNode {
238
+ static nodeType = "nodetool.video.TextToVideo";
239
+ static body = "content_card";
240
+ static title = "Text To Video";
241
+ static description = "Generate videos from text prompts using any supported video provider. Automatically routes to the appropriate backend (Gemini Veo, HuggingFace).\n video, generation, AI, text-to-video, t2v";
242
+ static metadataOutputTypes = {
243
+ output: "video"
244
+ };
245
+ static inlineFields = [];
246
+ static inputFields = ["prompt"];
247
+ static autoSaveAsset = true;
248
+ async process(context) {
249
+ const text = String(this.prompt ?? "");
250
+ const { providerId, modelId } = modelConfig(this.serialize());
251
+ if (!canUseProvider(context, providerId, modelId)) {
252
+ throw new Error("No provider available for text-to-video generation.");
253
+ }
254
+ const output = coerceProviderBytes(await context.runProviderPrediction({
255
+ provider: providerId,
256
+ capability: "text_to_video",
257
+ model: modelId,
258
+ params: {
259
+ prompt: text,
260
+ negative_prompt: this.negative_prompt,
261
+ entities: await resolveEntities(this.entities, context),
262
+ duration_seconds: Number(this.duration ?? 0) || undefined,
263
+ aspect_ratio: this.aspect_ratio,
264
+ resolution: this.resolution,
265
+ timeout_seconds: Number(this.timeout_seconds ?? 0) || undefined
266
+ }
267
+ }), "Text To Video");
268
+ return { output: videoRef(output) };
269
+ }
270
+ }
271
+ __decorate([
272
+ prop({
273
+ type: "video_model",
274
+ default: {
275
+ type: "video_model",
276
+ provider: "gemini",
277
+ id: "veo-3.1-generate-preview",
278
+ name: "Veo 3.1 Preview",
279
+ path: null,
280
+ supported_tasks: []
281
+ },
282
+ title: "Model",
283
+ description: "The video generation model to use"
284
+ })
285
+ ], TextToVideoNode.prototype, "model", void 0);
286
+ __decorate([
287
+ prop({
288
+ type: "str",
289
+ default: "A cat playing with a ball of yarn",
290
+ title: "Prompt",
291
+ description: "Text prompt describing the desired video"
292
+ })
293
+ ], TextToVideoNode.prototype, "prompt", void 0);
294
+ __decorate([
295
+ prop({
296
+ type: "str",
297
+ default: "",
298
+ title: "Negative Prompt",
299
+ description: "Text prompt describing what to avoid in the video"
300
+ })
301
+ ], TextToVideoNode.prototype, "negative_prompt", void 0);
302
+ __decorate([
303
+ prop({
304
+ type: "list[entity]",
305
+ default: [],
306
+ title: "Entities",
307
+ description: "Consistency entities (characters, styles, locations) whose descriptors are injected into the prompt"
308
+ })
309
+ ], TextToVideoNode.prototype, "entities", void 0);
310
+ __decorate([
311
+ prop({
312
+ type: "str",
313
+ default: "16:9",
314
+ title: "Aspect Ratio",
315
+ description: "Aspect ratio for the video",
316
+ values: VIDEO_ASPECT_RATIO_VALUES,
317
+ json_schema_extra: { type: "media_aspect_ratio_video" }
318
+ })
319
+ ], TextToVideoNode.prototype, "aspect_ratio", void 0);
320
+ __decorate([
321
+ prop({
322
+ type: "str",
323
+ default: "1080p",
324
+ title: "Resolution",
325
+ description: "Video resolution",
326
+ values: VIDEO_RESOLUTION_VALUES,
327
+ json_schema_extra: { type: "media_resolution_video" }
328
+ })
329
+ ], TextToVideoNode.prototype, "resolution", void 0);
330
+ __decorate([
331
+ prop({
332
+ type: "int",
333
+ default: 8,
334
+ title: "Duration",
335
+ description: "Video duration in seconds",
336
+ values: VIDEO_DURATION_VALUES,
337
+ json_schema_extra: { type: "media_duration" }
338
+ })
339
+ ], TextToVideoNode.prototype, "duration", void 0);
340
+ __decorate([
341
+ prop({
342
+ type: "int",
343
+ default: 0,
344
+ title: "Timeout Seconds",
345
+ description: "Timeout in seconds for API calls (0 = use provider default)",
346
+ min: 0,
347
+ max: 7200
348
+ })
349
+ ], TextToVideoNode.prototype, "timeout_seconds", void 0);
350
+ export class ImageToVideoNode extends BaseNode {
351
+ static nodeType = "nodetool.video.ImageToVideo";
352
+ static body = "content_card";
353
+ static title = "Image To Video";
354
+ static description = "Animate static images into video with AI-powered motion using any supported video provider.\n video, image-to-video, i2v, animation, ai, generation, sora, veo";
355
+ static metadataOutputTypes = {
356
+ output: "video"
357
+ };
358
+ static inlineFields = [];
359
+ static inputFields = ["image", "prompt"];
360
+ static autoSaveAsset = true;
361
+ async process(context) {
362
+ let images = normalizeImageList(this.image);
363
+ let prompt = String(this.prompt ?? "");
364
+ // An `asset://` image mentioned inline in the prompt (e.g. from the Prompt
365
+ // composer's @-mention) supplies the source frame when none is wired in,
366
+ // and is stripped from the textual instruction. Same mechanism as the
367
+ // image-to-image / provider video nodes.
368
+ const overrides = await mapPromptAssetsToInputs([{ name: "prompt", value: prompt }], [
369
+ {
370
+ name: "image",
371
+ kind: "image",
372
+ hasSource: images.some(imageRefHasSource)
373
+ }
374
+ ], context);
375
+ if (isString(overrides.prompt))
376
+ prompt = overrides.prompt;
377
+ if (overrides.image && images.length === 0) {
378
+ images = [overrides.image];
379
+ }
380
+ let image = new Uint8Array();
381
+ for (const candidate of images) {
382
+ const bytes = await imageBytesAsync(candidate, context);
383
+ if (bytes.length > 0) {
384
+ image = bytes;
385
+ break;
386
+ }
387
+ }
388
+ if (image.length === 0) {
389
+ throw new Error("A non-empty input image is required for Image To Video.");
390
+ }
391
+ const { providerId, modelId } = modelConfig(this.serialize());
392
+ if (!canUseProvider(context, providerId, modelId)) {
393
+ throw new Error("No provider available for image-to-video generation.");
394
+ }
395
+ const output = coerceProviderBytes(await context.runProviderPrediction({
396
+ provider: providerId,
397
+ capability: "image_to_video",
398
+ model: modelId,
399
+ params: {
400
+ image,
401
+ prompt,
402
+ negative_prompt: this.negative_prompt,
403
+ entities: await resolveEntities(this.entities, context),
404
+ duration_seconds: Number(this.duration ?? 0) || undefined,
405
+ aspect_ratio: this.aspect_ratio,
406
+ resolution: this.resolution,
407
+ timeout_seconds: Number(this.timeout_seconds ?? 0) || undefined
408
+ }
409
+ }), "Image To Video");
410
+ return { output: videoRef(output) };
411
+ }
412
+ }
413
+ __decorate([
414
+ prop({
415
+ type: "list[image]",
416
+ default: [],
417
+ title: "Images",
418
+ description: "Input image(s) to animate. Only the first non-empty image is used as the start frame. Use Reference To Video for reference media."
419
+ })
420
+ ], ImageToVideoNode.prototype, "image", void 0);
421
+ __decorate([
422
+ prop({
423
+ type: "video_model",
424
+ default: {
425
+ type: "video_model",
426
+ provider: "gemini",
427
+ id: "veo-3.1-generate-preview",
428
+ name: "Veo 3.1 Preview",
429
+ path: null,
430
+ supported_tasks: []
431
+ },
432
+ title: "Model",
433
+ description: "The video generation model to use"
434
+ })
435
+ ], ImageToVideoNode.prototype, "model", void 0);
436
+ __decorate([
437
+ prop({
438
+ type: "str",
439
+ default: "",
440
+ title: "Prompt",
441
+ description: "Text prompt to guide the video animation. Veo animates an image without one; Kling refuses an empty one. Leaving it blank ties the graph to the models that treat it as optional."
442
+ })
443
+ ], ImageToVideoNode.prototype, "prompt", void 0);
444
+ __decorate([
445
+ prop({
446
+ type: "str",
447
+ default: "",
448
+ title: "Negative Prompt",
449
+ description: "Text prompt describing what to avoid in the video"
450
+ })
451
+ ], ImageToVideoNode.prototype, "negative_prompt", void 0);
452
+ __decorate([
453
+ prop({
454
+ type: "list[entity]",
455
+ default: [],
456
+ title: "Entities",
457
+ description: "Consistency entities (characters, styles, locations) whose descriptors are injected into the prompt"
458
+ })
459
+ ], ImageToVideoNode.prototype, "entities", void 0);
460
+ __decorate([
461
+ prop({
462
+ type: "str",
463
+ default: "16:9",
464
+ title: "Aspect Ratio",
465
+ description: "Aspect ratio for the video",
466
+ values: VIDEO_ASPECT_RATIO_VALUES,
467
+ json_schema_extra: { type: "media_aspect_ratio_video" }
468
+ })
469
+ ], ImageToVideoNode.prototype, "aspect_ratio", void 0);
470
+ __decorate([
471
+ prop({
472
+ type: "str",
473
+ default: "1080p",
474
+ title: "Resolution",
475
+ description: "Video resolution",
476
+ values: VIDEO_RESOLUTION_VALUES,
477
+ json_schema_extra: { type: "media_resolution_video" }
478
+ })
479
+ ], ImageToVideoNode.prototype, "resolution", void 0);
480
+ __decorate([
481
+ prop({
482
+ type: "int",
483
+ default: 4,
484
+ title: "Duration",
485
+ description: "Video duration in seconds",
486
+ values: VIDEO_DURATION_VALUES,
487
+ json_schema_extra: { type: "media_duration" }
488
+ })
489
+ ], ImageToVideoNode.prototype, "duration", void 0);
490
+ __decorate([
491
+ prop({
492
+ type: "int",
493
+ default: 0,
494
+ title: "Timeout Seconds",
495
+ description: "Timeout in seconds for API calls (0 = use provider default)",
496
+ min: 0,
497
+ max: 7200
498
+ })
499
+ ], ImageToVideoNode.prototype, "timeout_seconds", void 0);
500
+ export class ReferenceToVideoNode extends BaseNode {
501
+ static nodeType = "nodetool.video.ReferenceToVideo";
502
+ static body = "content_card";
503
+ static title = "Reference To Video";
504
+ static description = "Generate video using ordered image and video references for identity, style, scene, motion, or audio conditioning.\n video, reference-to-video, r2v, generation, ai";
505
+ static metadataOutputTypes = { output: "video" };
506
+ static inlineFields = [];
507
+ static inputFields = ["reference_images", "reference_videos", "prompt"];
508
+ static autoSaveAsset = true;
509
+ async process(context) {
510
+ const images = normalizeImageList(this.reference_images);
511
+ const videos = normalizeVideoList(this.reference_videos);
512
+ const [imageBytes, videoBytes] = await Promise.all([
513
+ Promise.all(images.map((image) => imageBytesAsync(image, context))),
514
+ Promise.all(videos.map((video) => videoBytesAsync(video, context)))
515
+ ]);
516
+ const referenceImages = imageBytes.filter((bytes) => bytes.length > 0);
517
+ const referenceVideos = videoBytes.filter((bytes) => bytes.length > 0);
518
+ if (referenceImages.length === 0 && referenceVideos.length === 0) {
519
+ throw new Error("Reference To Video requires at least one reference image or video.");
520
+ }
521
+ const { providerId, modelId } = modelConfig(this.serialize());
522
+ if (!canUseProvider(context, providerId, modelId)) {
523
+ throw new Error("No provider available for reference-to-video generation.");
524
+ }
525
+ const output = coerceProviderBytes(await context.runProviderPrediction({
526
+ provider: providerId,
527
+ capability: "reference_to_video",
528
+ model: modelId,
529
+ params: {
530
+ reference_images: referenceImages,
531
+ reference_videos: referenceVideos,
532
+ prompt: String(this.prompt ?? ""),
533
+ use_reference_video_audio: this.use_reference_video_audio,
534
+ negative_prompt: this.negative_prompt,
535
+ entities: await resolveEntities(this.entities, context),
536
+ duration_seconds: Number(this.duration ?? 0) || undefined,
537
+ aspect_ratio: this.aspect_ratio,
538
+ resolution: this.resolution,
539
+ timeout_seconds: Number(this.timeout_seconds ?? 0) || undefined
540
+ }
541
+ }), "Reference To Video");
542
+ return { output: videoRef(output) };
543
+ }
544
+ }
545
+ __decorate([
546
+ prop({
547
+ type: "list[image]",
548
+ default: [],
549
+ title: "Reference Images",
550
+ description: "Ordered identity, style, subject, or scene image references."
551
+ })
552
+ ], ReferenceToVideoNode.prototype, "reference_images", void 0);
553
+ __decorate([
554
+ prop({
555
+ type: "list[video]",
556
+ default: [],
557
+ title: "Reference Videos",
558
+ description: "Ordered motion, scene, edit, or soundtrack video references."
559
+ })
560
+ ], ReferenceToVideoNode.prototype, "reference_videos", void 0);
561
+ __decorate([
562
+ prop({
563
+ type: "video_model",
564
+ default: {
565
+ type: "video_model",
566
+ provider: "",
567
+ id: "",
568
+ name: "",
569
+ path: null,
570
+ supported_tasks: []
571
+ },
572
+ title: "Model",
573
+ description: "The reference-to-video model to use"
574
+ })
575
+ ], ReferenceToVideoNode.prototype, "model", void 0);
576
+ __decorate([
577
+ prop({ type: "str", default: "", title: "Prompt", description: "Describe the video using image 1, image 2, video 1, and video 2 numbering in list order." })
578
+ ], ReferenceToVideoNode.prototype, "prompt", void 0);
579
+ __decorate([
580
+ prop({ type: "bool", default: false, title: "Use Reference Video Audio", description: "Use audio tracks carried by reference videos where supported." })
581
+ ], ReferenceToVideoNode.prototype, "use_reference_video_audio", void 0);
582
+ __decorate([
583
+ prop({ type: "str", default: "", title: "Negative Prompt", description: "Text prompt describing what to avoid in the video" })
584
+ ], ReferenceToVideoNode.prototype, "negative_prompt", void 0);
585
+ __decorate([
586
+ prop({ type: "list[entity]", default: [], title: "Entities", description: "Consistency entities whose descriptors are injected into the prompt" })
587
+ ], ReferenceToVideoNode.prototype, "entities", void 0);
588
+ __decorate([
589
+ prop({ type: "str", default: "16:9", title: "Aspect Ratio", description: "Aspect ratio for the video", values: VIDEO_ASPECT_RATIO_VALUES, json_schema_extra: { type: "media_aspect_ratio_video" } })
590
+ ], ReferenceToVideoNode.prototype, "aspect_ratio", void 0);
591
+ __decorate([
592
+ prop({ type: "str", default: "1080p", title: "Resolution", description: "Video resolution", values: VIDEO_RESOLUTION_VALUES, json_schema_extra: { type: "media_resolution_video" } })
593
+ ], ReferenceToVideoNode.prototype, "resolution", void 0);
594
+ __decorate([
595
+ prop({ type: "int", default: 5, title: "Duration", description: "Video duration in seconds", values: VIDEO_DURATION_VALUES, json_schema_extra: { type: "media_duration" } })
596
+ ], ReferenceToVideoNode.prototype, "duration", void 0);
597
+ __decorate([
598
+ prop({ type: "int", default: 0, title: "Timeout Seconds", description: "Timeout in seconds for API calls (0 = use provider default)", min: 0, max: 7200 })
599
+ ], ReferenceToVideoNode.prototype, "timeout_seconds", void 0);
600
+ export class LoadVideoFileNode extends BaseNode {
601
+ static nodeType = "nodetool.video.LoadVideoFile";
602
+ static title = "Load Video File";
603
+ static description = "Read a video file from disk.\n video, input, load, file";
604
+ static metadataOutputTypes = {
605
+ output: "video"
606
+ };
607
+ static inlineFields = [];
608
+ static inputFields = [];
609
+ async process() {
610
+ const p = filePath(String(this.path ?? "").trim());
611
+ if (!p)
612
+ throw new Error("No file path provided to Load Video File.");
613
+ const data = new Uint8Array(await fs.readFile(p));
614
+ return { output: videoRef(data, { uri: `file://${p}` }) };
615
+ }
616
+ }
617
+ __decorate([
618
+ prop({
619
+ type: "str",
620
+ default: "",
621
+ title: "Path",
622
+ description: "Path to the video file to read"
623
+ })
624
+ ], LoadVideoFileNode.prototype, "path", void 0);
625
+ /**
626
+ * Resolve the destination folder for a save node. An explicit folder wins;
627
+ * otherwise fall back to the run's workspace directory rather than the server
628
+ * process cwd (`.`), which is rarely where a user wants their output. A
629
+ * workspace with no directory (cloud storage) leaves only the cwd — the nodes
630
+ * that can write through the workspace do so via `writeSavedFile` instead.
631
+ */
632
+ function saveFolder(rawFolder, context) {
633
+ return (folderPath(rawFolder) ||
634
+ context?.workspace?.localDir ||
635
+ context?.workspaceDir ||
636
+ ".");
637
+ }
638
+ export class SaveVideoFileVideoNode extends BaseNode {
639
+ static nodeType = "nodetool.video.SaveVideoFile";
640
+ static title = "Save Video File";
641
+ static description = "Write a video file to disk.\n video, output, save, file\n\n The filename can include time and date variables:\n %Y - Year, %m - Month, %d - Day\n %H - Hour, %M - Minute, %S - Second";
642
+ static metadataOutputTypes = {
643
+ output: "video"
644
+ };
645
+ static inlineFields = ["save_to_workspace"];
646
+ static inputFields = ["video"];
647
+ async process(context) {
648
+ // dateName resolves to second granularity, so two saves in the same second
649
+ // collide — resolveSaveTarget numbers them rather than overwriting.
650
+ const bytes = await videoBytesAsync(this.video, context);
651
+ const p = await writeSavedFile({
652
+ folder: this.folder,
653
+ filename: dateName(String(this.filename || "video.mp4")),
654
+ saveToWorkspace: this.save_to_workspace,
655
+ workspace: context?.workspace,
656
+ workspaceDir: context?.workspaceDir,
657
+ bytes
658
+ });
659
+ return { output: videoRef(bytes, { uri: `file://${p}` }) };
660
+ }
661
+ }
662
+ __decorate([
663
+ prop({
664
+ type: "video",
665
+ default: defaultVideoRef(),
666
+ title: "Video",
667
+ description: "The video to save"
668
+ })
669
+ ], SaveVideoFileVideoNode.prototype, "video", void 0);
670
+ __decorate([
671
+ prop({
672
+ type: "bool",
673
+ default: true,
674
+ title: SAVE_TO_WORKSPACE_TITLE,
675
+ description: SAVE_TO_WORKSPACE_DESCRIPTION
676
+ })
677
+ ], SaveVideoFileVideoNode.prototype, "save_to_workspace", void 0);
678
+ __decorate([
679
+ prop({
680
+ type: "str",
681
+ default: "",
682
+ title: "Folder",
683
+ description: "Folder where the file will be saved",
684
+ json_schema_extra: VISIBLE_WHEN_NOT_SAVING_TO_WORKSPACE
685
+ })
686
+ ], SaveVideoFileVideoNode.prototype, "folder", void 0);
687
+ __decorate([
688
+ prop({
689
+ type: "str",
690
+ default: "",
691
+ title: "Filename",
692
+ description: "\n Name of the file to save.\n You can use time and date variables to create unique names:\n %Y - Year\n %m - Month\n %d - Day\n %H - Hour\n %M - Minute\n %S - Second\n "
693
+ })
694
+ ], SaveVideoFileVideoNode.prototype, "filename", void 0);
695
+ export class LoadVideoAssetsNode extends BaseNode {
696
+ static nodeType = "nodetool.video.LoadVideoAssets";
697
+ static title = "Load Video Folder";
698
+ static description = "Load video files from an asset folder.\n load, video, file, import";
699
+ static metadataOutputTypes = {
700
+ video: "video",
701
+ name: "str",
702
+ videos: "list",
703
+ names: "list"
704
+ };
705
+ static inlineFields = [];
706
+ static inputFields = [];
707
+ static inputMode = "buffered";
708
+ static outputCorrelation = {
709
+ video: { kind: "iteration", source: "__execution__", group: "items" },
710
+ name: { kind: "iteration", source: "__execution__", group: "items" },
711
+ videos: { kind: "single", source: "__execution__" },
712
+ names: { kind: "single", source: "__execution__" }
713
+ };
714
+ async process() {
715
+ const allVideos = [];
716
+ const allNames = [];
717
+ for await (const item of this._loadVideos()) {
718
+ allVideos.push(item.video);
719
+ allNames.push(item.name);
720
+ }
721
+ return {
722
+ video: allVideos[0] ?? null,
723
+ name: allNames[0] ?? "",
724
+ videos: allVideos,
725
+ names: allNames
726
+ };
727
+ }
728
+ async *_loadVideos() {
729
+ const folder = folderPath(this.folder);
730
+ if (!folder)
731
+ return;
732
+ let entries;
733
+ try {
734
+ entries = await fs.readdir(folder, { withFileTypes: true });
735
+ }
736
+ catch {
737
+ // folder does not exist or is not accessible
738
+ return;
739
+ }
740
+ for (const entry of entries) {
741
+ if (!entry.isFile())
742
+ continue;
743
+ const ext = path.extname(entry.name).toLowerCase();
744
+ if (![".mp4", ".mov", ".webm", ".mkv", ".avi"].includes(ext))
745
+ continue;
746
+ const full = path.join(folder, entry.name);
747
+ const data = new Uint8Array(await fs.readFile(full));
748
+ yield {
749
+ video: videoRef(data, { uri: `file://${full}` }),
750
+ name: entry.name
751
+ };
752
+ }
753
+ }
754
+ async *genProcess() {
755
+ const allVideos = [];
756
+ const allNames = [];
757
+ for await (const item of this._loadVideos()) {
758
+ allVideos.push(item.video);
759
+ allNames.push(item.name);
760
+ yield { video: item.video, name: item.name };
761
+ }
762
+ yield { videos: allVideos, names: allNames };
763
+ }
764
+ }
765
+ __decorate([
766
+ prop({
767
+ type: "folder",
768
+ default: {
769
+ type: "folder",
770
+ uri: "",
771
+ asset_id: null,
772
+ data: null,
773
+ metadata: null
774
+ },
775
+ title: "Folder",
776
+ description: "The asset folder to load the video files from."
777
+ })
778
+ ], LoadVideoAssetsNode.prototype, "folder", void 0);
779
+ export class SaveVideoNode extends BaseNode {
780
+ static nodeType = "nodetool.video.SaveVideo";
781
+ static title = "Save Video Asset";
782
+ static description = "Save a video to an asset folder.\n video, save, file, output";
783
+ static metadataOutputTypes = {
784
+ output: "video"
785
+ };
786
+ static inlineFields = [];
787
+ static inputFields = ["video"];
788
+ async process(context) {
789
+ const folder = saveFolder(this.folder, context);
790
+ const filename = dateName(String(this.name || "video.mp4"));
791
+ await fs.mkdir(path.resolve(folder), { recursive: true });
792
+ // dateName resolves to second granularity, so two saves in the same second
793
+ // collide — de-duplicate with a -1/-2/… suffix rather than overwrite.
794
+ const full = await uniqueTargetPath(path.resolve(folder, filename));
795
+ const bytes = await videoBytesAsync(this.video, context);
796
+ await fs.writeFile(full, bytes);
797
+ return { output: videoRef(bytes, { uri: `file://${full}` }) };
798
+ }
799
+ }
800
+ __decorate([
801
+ prop({
802
+ type: "video",
803
+ default: defaultVideoRef(),
804
+ title: "Video",
805
+ description: "The video to save."
806
+ })
807
+ ], SaveVideoNode.prototype, "video", void 0);
808
+ __decorate([
809
+ prop({
810
+ type: "folder",
811
+ default: {
812
+ type: "folder",
813
+ uri: "",
814
+ asset_id: null,
815
+ data: null,
816
+ metadata: null
817
+ },
818
+ title: "Folder",
819
+ description: "The asset folder to save the video in."
820
+ })
821
+ ], SaveVideoNode.prototype, "folder", void 0);
822
+ __decorate([
823
+ prop({
824
+ type: "str",
825
+ default: "%Y-%m-%d-%H-%M-%S.mp4",
826
+ title: "Name",
827
+ description: "\n Name of the output video.\n You can use time and date variables to create unique names:\n %Y - Year\n %m - Month\n %d - Day\n %H - Hour\n %M - Minute\n %S - Second\n "
828
+ })
829
+ ], SaveVideoNode.prototype, "name", void 0);
830
+ export class ForEachFrameNode extends VideoTransformNode {
831
+ static nodeType = "nodetool.video.ForEachFrame";
832
+ static title = "For Each Frame";
833
+ static description = "Extract frames from a video file with ffmpeg.\n video, frames, extract, sequence";
834
+ static metadataOutputTypes = {
835
+ frame: "image",
836
+ index: "int",
837
+ fps: "float"
838
+ };
839
+ static inlineFields = [];
840
+ static inputFields = ["video"];
841
+ static inputMode = "buffered";
842
+ static outputCorrelation = {
843
+ frame: { kind: "iteration", source: "video", group: "items" },
844
+ index: { kind: "iteration", source: "video", group: "items" },
845
+ fps: { kind: "single", source: "video" }
846
+ };
847
+ async process(context) {
848
+ // Buffered fallback: surface the first frame instead of nothing.
849
+ for await (const item of this.genProcess(context)) {
850
+ return item;
851
+ }
852
+ return {};
853
+ }
854
+ async *genProcess(context) {
855
+ const bytes = await videoBytesAsync(this.video, context);
856
+ if (bytes.length === 0)
857
+ return;
858
+ const inputFile = await withTempFile(".mp4", bytes);
859
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-frames-out-"));
860
+ try {
861
+ let fps = 24;
862
+ try {
863
+ const { stdout } = await execFfprobe([
864
+ "-v", "error",
865
+ "-select_streams", "v:0",
866
+ "-show_entries", "stream=r_frame_rate",
867
+ "-of", "csv=p=0",
868
+ inputFile.path
869
+ ]);
870
+ const parsed = parseFrameRate(stdout);
871
+ if (parsed > 0)
872
+ fps = parsed;
873
+ }
874
+ catch (error) {
875
+ // Missing ffprobe is actionable; a genuine probe failure falls back to
876
+ // 24 fps.
877
+ if (error instanceof MissingBinaryError)
878
+ throw error;
879
+ }
880
+ const start = Math.max(0, Number(this.start ?? 0));
881
+ const end = Number(this.end ?? -1);
882
+ const vfFilter = end >= 0
883
+ ? `select='between(n,${start},${end})'`
884
+ : `select='gte(n,${start})'`;
885
+ // With a bounded range, cap decoding at the number of selected frames so
886
+ // ffmpeg stops early instead of scanning to the end of the video.
887
+ const frameLimit = end >= 0 ? ["-frames:v", String(end - start + 1)] : [];
888
+ await execFfmpeg([
889
+ "-y",
890
+ "-i", inputFile.path,
891
+ "-vf", vfFilter,
892
+ "-vsync", "vfr",
893
+ ...frameLimit,
894
+ path.join(outputDir, "frame_%d.png")
895
+ ], { maxBuffer: FFMPEG_MAX_BUFFER });
896
+ const entries = await fs.readdir(outputDir);
897
+ const frameFiles = entries
898
+ .filter((f) => f.startsWith("frame_") && f.endsWith(".png"))
899
+ .sort((a, b) => {
900
+ const numA = parseInt(a.replace("frame_", "").replace(".png", ""), 10);
901
+ const numB = parseInt(b.replace("frame_", "").replace(".png", ""), 10);
902
+ return numA - numB;
903
+ });
904
+ for (let i = 0; i < frameFiles.length; i++) {
905
+ const frameData = new Uint8Array(await fs.readFile(path.join(outputDir, frameFiles[i])));
906
+ yield {
907
+ frame: {
908
+ type: "image",
909
+ data: Buffer.from(frameData).toString("base64")
910
+ },
911
+ index: start + i,
912
+ fps
913
+ };
914
+ }
915
+ }
916
+ finally {
917
+ await inputFile.cleanup();
918
+ await fs.rm(outputDir, { recursive: true, force: true });
919
+ }
920
+ }
921
+ }
922
+ __decorate([
923
+ prop({
924
+ type: "video",
925
+ default: defaultVideoRef(),
926
+ title: "Video",
927
+ description: "The input video to extract frames from."
928
+ })
929
+ ], ForEachFrameNode.prototype, "video", void 0);
930
+ __decorate([
931
+ prop({
932
+ type: "int",
933
+ default: 0,
934
+ title: "Start",
935
+ description: "The frame to start extracting from."
936
+ })
937
+ ], ForEachFrameNode.prototype, "start", void 0);
938
+ __decorate([
939
+ prop({
940
+ type: "int",
941
+ default: -1,
942
+ title: "End",
943
+ description: "The frame to stop extracting from."
944
+ })
945
+ ], ForEachFrameNode.prototype, "end", void 0);
946
+ export class FpsNode extends VideoTransformNode {
947
+ static nodeType = "nodetool.video.Fps";
948
+ static title = "Fps";
949
+ static description = "Get the frames per second (FPS) of a video file.\n video, analysis, frames, fps";
950
+ static metadataOutputTypes = {
951
+ output: "float"
952
+ };
953
+ static inlineFields = [];
954
+ static inputFields = ["video"];
955
+ async process(context) {
956
+ const bytes = await videoBytesAsync(this.video, context);
957
+ if (bytes.length === 0)
958
+ return { output: 0 };
959
+ const inputFile = await withTempFile(".mp4", bytes);
960
+ try {
961
+ const { stdout } = await execFfprobe([
962
+ "-v", "error",
963
+ "-select_streams", "v:0",
964
+ "-show_entries", "stream=r_frame_rate",
965
+ "-of", "csv=p=0",
966
+ inputFile.path
967
+ ]);
968
+ return { output: parseFrameRate(stdout) };
969
+ }
970
+ catch (error) {
971
+ // Missing ffprobe is actionable; a genuine probe failure degrades to 0.
972
+ if (error instanceof MissingBinaryError)
973
+ throw error;
974
+ return { output: 0 };
975
+ }
976
+ finally {
977
+ await inputFile.cleanup();
978
+ }
979
+ }
980
+ }
981
+ __decorate([
982
+ prop({
983
+ type: "video",
984
+ default: defaultVideoRef(),
985
+ title: "Video",
986
+ description: "The input video to analyze for FPS."
987
+ })
988
+ ], FpsNode.prototype, "video", void 0);
989
+ export class FrameToVideoNode extends VideoTransformNode {
990
+ static nodeType = "nodetool.video.FrameToVideo";
991
+ static title = "Frame To Video";
992
+ static description = "Combine a sequence of frames into a single video file.\n video, frames, combine, sequence";
993
+ static isStreamingInput = true;
994
+ static inputMode = "stream";
995
+ static outputCorrelation = {
996
+ output: { kind: "aggregate", source: "frame", collapse: "innermost" }
997
+ };
998
+ static metadataOutputTypes = {
999
+ output: "video"
1000
+ };
1001
+ static inlineFields = [];
1002
+ static inputFields = ["frame", "fps"];
1003
+ async run(inputs, outputs, _context) {
1004
+ const frames = [];
1005
+ let fps = Math.max(1, Number(this.fps ?? 30));
1006
+ for await (const [handle, item] of inputs.any()) {
1007
+ if (handle === "frame") {
1008
+ frames.push(item);
1009
+ }
1010
+ else if (handle === "fps") {
1011
+ fps = Math.max(1, Number(item ?? 30));
1012
+ }
1013
+ }
1014
+ if (frames.length === 0) {
1015
+ await outputs.emit("output", videoRef(new Uint8Array()));
1016
+ return;
1017
+ }
1018
+ try {
1019
+ const result = await combineFramesToVideo(frames, fps);
1020
+ await outputs.emit("output", videoRef(result));
1021
+ }
1022
+ catch (error) {
1023
+ throw new Error(`Combining frames into a video failed: ${error instanceof Error ? error.message : String(error)}`);
1024
+ }
1025
+ }
1026
+ async process() {
1027
+ // Legacy fallback for non-streaming usage (e.g., direct array input)
1028
+ const frames = Array.isArray(this.frame) ? this.frame : [];
1029
+ if (frames.length === 0)
1030
+ return { output: videoRef(new Uint8Array()) };
1031
+ const fps = Math.max(1, Number(this.fps ?? 30));
1032
+ try {
1033
+ const result = await combineFramesToVideo(frames, fps);
1034
+ return { output: videoRef(result) };
1035
+ }
1036
+ catch (error) {
1037
+ throw new Error(`Combining frames into a video failed: ${error instanceof Error ? error.message : String(error)}`);
1038
+ }
1039
+ }
1040
+ }
1041
+ __decorate([
1042
+ prop({
1043
+ type: "image",
1044
+ default: {
1045
+ type: "image",
1046
+ uri: "",
1047
+ asset_id: null,
1048
+ data: null,
1049
+ metadata: null
1050
+ },
1051
+ title: "Frame",
1052
+ description: "Collect input frames"
1053
+ })
1054
+ ], FrameToVideoNode.prototype, "frame", void 0);
1055
+ __decorate([
1056
+ prop({
1057
+ type: "float",
1058
+ default: 30,
1059
+ title: "Fps",
1060
+ description: "The FPS of the output video."
1061
+ })
1062
+ ], FrameToVideoNode.prototype, "fps", void 0);
1063
+ /** Probe a file's video codec/dimensions/fps and audio presence. */
1064
+ async function probeConcatMeta(file) {
1065
+ const { stdout } = await execFfprobe([
1066
+ "-v",
1067
+ "error",
1068
+ "-show_streams",
1069
+ "-show_format",
1070
+ "-of",
1071
+ "json",
1072
+ file
1073
+ ]);
1074
+ const info = JSON.parse(stdout);
1075
+ const streams = info.streams ?? [];
1076
+ const v = streams.find((s) => s.codec_type === "video");
1077
+ return {
1078
+ codec: v?.codec_name ?? "",
1079
+ width: v?.width ?? 0,
1080
+ height: v?.height ?? 0,
1081
+ fps: v?.r_frame_rate ? parseFrameRate(v.r_frame_rate) : 0,
1082
+ hasAudio: streams.some((s) => s.codec_type === "audio")
1083
+ };
1084
+ }
1085
+ /**
1086
+ * Scale + pad to a uniform frame, normalize fps/pixel format, re-encode to
1087
+ * libx264 + AAC (adding silent audio when the source has none) so mismatched
1088
+ * segments can be concatenated losslessly with `-c copy`.
1089
+ */
1090
+ async function normalizeConcatSegment(srcPath, segPath, width, height, fps, hasAudio) {
1091
+ const filter = `scale=${width}:${height}:force_original_aspect_ratio=decrease,` +
1092
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,` +
1093
+ `fps=${fps},format=yuv420p`;
1094
+ const silent = [
1095
+ "-f",
1096
+ "lavfi",
1097
+ "-i",
1098
+ "anullsrc=channel_layout=stereo:sample_rate=48000"
1099
+ ];
1100
+ await execFfmpeg([
1101
+ "-y",
1102
+ "-i",
1103
+ srcPath,
1104
+ ...(hasAudio ? [] : silent),
1105
+ "-filter_complex",
1106
+ `[0:v]${filter}[v]`,
1107
+ "-map",
1108
+ "[v]",
1109
+ "-map",
1110
+ hasAudio ? "0:a" : "1:a",
1111
+ "-c:v",
1112
+ "libx264",
1113
+ "-preset",
1114
+ "veryfast",
1115
+ "-c:a",
1116
+ "aac",
1117
+ "-ar",
1118
+ "48000",
1119
+ "-ac",
1120
+ "2",
1121
+ "-shortest",
1122
+ segPath
1123
+ ], { maxBuffer: FFMPEG_MAX_BUFFER });
1124
+ }
1125
+ export class ConcatVideoNode extends VideoTransformNode {
1126
+ static nodeType = "nodetool.video.Concat";
1127
+ static body = "content_card";
1128
+ static title = "Concatenate Video";
1129
+ static description = "Concatenate multiple video files into a single video, including audio when available. Add inputs dynamically with the “add video input” button, or wire a list of videos into a single input.\n video, concat, merge, combine, audio, +";
1130
+ static metadataOutputTypes = {
1131
+ output: "video"
1132
+ };
1133
+ static inlineFields = [];
1134
+ static inputFields = [];
1135
+ static supportsDynamicInputs = true;
1136
+ async process(context) {
1137
+ const inputValues = Array.from(this.dynamicProps.values()).flatMap((value) => normalizeVideoList(value));
1138
+ const parts = [];
1139
+ for (const input of inputValues) {
1140
+ const bytes = await videoBytesAsync(input, context);
1141
+ if (bytes.length > 0) {
1142
+ parts.push(bytes);
1143
+ }
1144
+ }
1145
+ if (parts.length === 0)
1146
+ return { output: videoRef(new Uint8Array()) };
1147
+ if (parts.length === 1)
1148
+ return { output: videoRef(parts[0]) };
1149
+ const inputs = await Promise.all(parts.map((bytes) => withTempFile(".mp4", bytes)));
1150
+ const concatDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-concat-"));
1151
+ const listPath = path.join(concatDir, "list.txt");
1152
+ const outputPath = path.join(concatDir, "output.mp4");
1153
+ try {
1154
+ // Stream-copying heterogeneous inputs (different codec/size/fps) with the
1155
+ // concat demuxer produces corrupt output. Probe every input; only take
1156
+ // the fast `-c copy` path when they all match, else normalize each to a
1157
+ // uniform segment (first input's resolution/fps) before concatenating.
1158
+ const metas = await Promise.all(inputs.map((input) => probeConcatMeta(input.path)));
1159
+ const first = metas[0];
1160
+ const uniform = metas.every((m) => m.codec === first.codec &&
1161
+ m.width === first.width &&
1162
+ m.height === first.height &&
1163
+ m.fps === first.fps &&
1164
+ m.hasAudio === first.hasAudio);
1165
+ let concatPaths;
1166
+ if (uniform) {
1167
+ concatPaths = inputs.map((input) => input.path);
1168
+ }
1169
+ else {
1170
+ const width = first.width > 0 ? first.width : 1920;
1171
+ const height = first.height > 0 ? first.height : 1080;
1172
+ const fps = first.fps > 0 ? first.fps : 30;
1173
+ concatPaths = [];
1174
+ for (const [i, input] of inputs.entries()) {
1175
+ const segPath = path.join(concatDir, `seg_${i}.mp4`);
1176
+ await normalizeConcatSegment(input.path, segPath, width, height, fps, metas[i].hasAudio);
1177
+ concatPaths.push(segPath);
1178
+ }
1179
+ }
1180
+ const listEntries = concatPaths.map((p) => `file '${p}'`).join("\n");
1181
+ await fs.writeFile(listPath, `${listEntries}\n`);
1182
+ await execFfmpeg(["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath], { maxBuffer: FFMPEG_MAX_BUFFER });
1183
+ const result = new Uint8Array(await fs.readFile(outputPath));
1184
+ return { output: videoRef(result) };
1185
+ }
1186
+ catch (error) {
1187
+ if (error instanceof MissingBinaryError)
1188
+ throw error;
1189
+ throw new Error(`Concatenating videos failed: ${error instanceof Error ? error.message : String(error)}`);
1190
+ }
1191
+ finally {
1192
+ for (const input of inputs) {
1193
+ await input.cleanup();
1194
+ }
1195
+ await fs.rm(concatDir, { recursive: true, force: true });
1196
+ }
1197
+ }
1198
+ }
1199
+ export class TrimVideoNode extends VideoTransformNode {
1200
+ static nodeType = "nodetool.video.Trim";
1201
+ static title = "Trim";
1202
+ static description = "Trim a video to a specific start and end time.\n video, trim, cut, segment";
1203
+ static metadataOutputTypes = {
1204
+ output: "video"
1205
+ };
1206
+ static inputFields = ["video"];
1207
+ async process(context) {
1208
+ const bytes = await videoBytesAsync(this.video, context);
1209
+ if (bytes.length === 0)
1210
+ return { output: videoRef(bytes) };
1211
+ const start = Math.max(0, Number(this.start_time ?? 0));
1212
+ const end = Number(this.end_time ?? -1);
1213
+ if (this.accurate) {
1214
+ // Output-side seek (after -i): ffmpeg decodes every frame up to `start`
1215
+ // and re-encodes, so the cut lands on the exact requested time instead of
1216
+ // the nearest keyframe. Slower, but frame-accurate.
1217
+ const seekArgs = ["-ss", String(start)];
1218
+ if (end >= 0) {
1219
+ seekArgs.push("-to", String(end));
1220
+ }
1221
+ const transformed = await ffmpegTransform(bytes, seekArgs);
1222
+ return { output: videoRef(transformed) };
1223
+ }
1224
+ // Input-side seek (before -i): ffmpeg jumps to the nearest keyframe instead
1225
+ // of decoding from the start, so trimming a long clip is near-instant.
1226
+ const preInputArgs = ["-ss", String(start)];
1227
+ if (end >= 0) {
1228
+ preInputArgs.push("-to", String(end));
1229
+ }
1230
+ const transformed = await ffmpegTransform(bytes, ["-c", "copy"], {
1231
+ preInputArgs
1232
+ });
1233
+ return { output: videoRef(transformed) };
1234
+ }
1235
+ }
1236
+ __decorate([
1237
+ prop({
1238
+ type: "video",
1239
+ default: defaultVideoRef(),
1240
+ title: "Video",
1241
+ description: "The input video to trim."
1242
+ })
1243
+ ], TrimVideoNode.prototype, "video", void 0);
1244
+ __decorate([
1245
+ prop({
1246
+ type: "float",
1247
+ default: 0,
1248
+ title: "Start Time",
1249
+ description: "The start time in seconds for the trimmed video."
1250
+ })
1251
+ ], TrimVideoNode.prototype, "start_time", void 0);
1252
+ __decorate([
1253
+ prop({
1254
+ type: "float",
1255
+ default: -1,
1256
+ title: "End Time",
1257
+ description: "The end time in seconds for the trimmed video. Use -1 for the end of the video."
1258
+ })
1259
+ ], TrimVideoNode.prototype, "end_time", void 0);
1260
+ __decorate([
1261
+ prop({
1262
+ type: "bool",
1263
+ default: false,
1264
+ title: "Accurate",
1265
+ description: "Re-encode for frame-exact cuts. Off (default) stream-copies and snaps to the nearest keyframe — fast, but the cut points may be off by up to one GOP."
1266
+ })
1267
+ ], TrimVideoNode.prototype, "accurate", void 0);
1268
+ export class ResizeVideoNode extends VideoTransformNode {
1269
+ static nodeType = "nodetool.video.Resize";
1270
+ static title = "Resize";
1271
+ static description = "Resize a video to a specific width and height.\n video, resize, scale, dimensions";
1272
+ static metadataOutputTypes = {
1273
+ output: "video"
1274
+ };
1275
+ async process(context) {
1276
+ const bytes = await videoBytesAsync(this.video, context);
1277
+ const width = Number(this.width ?? -1);
1278
+ const height = Number(this.height ?? -1);
1279
+ const transformed = (await ffmpegTransform(bytes, [
1280
+ "-vf",
1281
+ `scale=${width}:${height}`,
1282
+ "-c:a",
1283
+ "copy"
1284
+ ]));
1285
+ return { output: videoRef(transformed) };
1286
+ }
1287
+ }
1288
+ __decorate([
1289
+ prop({
1290
+ type: "video",
1291
+ default: defaultVideoRef(),
1292
+ title: "Video",
1293
+ description: "The input video to resize."
1294
+ })
1295
+ ], ResizeVideoNode.prototype, "video", void 0);
1296
+ __decorate([
1297
+ prop({
1298
+ type: "int",
1299
+ default: -1,
1300
+ title: "Width",
1301
+ description: "The target width. Use -1 to maintain aspect ratio."
1302
+ })
1303
+ ], ResizeVideoNode.prototype, "width", void 0);
1304
+ __decorate([
1305
+ prop({
1306
+ type: "int",
1307
+ default: -1,
1308
+ title: "Height",
1309
+ description: "The target height. Use -1 to maintain aspect ratio."
1310
+ })
1311
+ ], ResizeVideoNode.prototype, "height", void 0);
1312
+ export class RotateVideoNode extends VideoTransformNode {
1313
+ static nodeType = "nodetool.video.Rotate";
1314
+ static title = "Rotate";
1315
+ static description = "Rotate a video by a specified angle.\n video, rotate, orientation, transform";
1316
+ static metadataOutputTypes = {
1317
+ output: "video"
1318
+ };
1319
+ async process(context) {
1320
+ const bytes = await videoBytesAsync(this.video, context);
1321
+ const angle = Number(this.angle ?? 0);
1322
+ const radians = (angle * Math.PI) / 180;
1323
+ const transformed = (await ffmpegTransform(bytes, [
1324
+ "-vf",
1325
+ `rotate=${radians}:ow=rotw(${radians}):oh=roth(${radians})`,
1326
+ "-c:a",
1327
+ "copy"
1328
+ ]));
1329
+ return { output: videoRef(transformed) };
1330
+ }
1331
+ }
1332
+ __decorate([
1333
+ prop({
1334
+ type: "video",
1335
+ default: defaultVideoRef(),
1336
+ title: "Video",
1337
+ description: "The input video to rotate."
1338
+ })
1339
+ ], RotateVideoNode.prototype, "video", void 0);
1340
+ __decorate([
1341
+ prop({
1342
+ type: "float",
1343
+ default: 0,
1344
+ title: "Angle",
1345
+ description: "The angle of rotation in degrees.",
1346
+ min: -360,
1347
+ max: 360
1348
+ })
1349
+ ], RotateVideoNode.prototype, "angle", void 0);
1350
+ export class SetSpeedVideoNode extends VideoTransformNode {
1351
+ static nodeType = "nodetool.video.SetSpeed";
1352
+ static title = "Set Speed";
1353
+ static description = "Adjust the playback speed of a video.\n video, speed, tempo, time";
1354
+ static metadataOutputTypes = {
1355
+ output: "video"
1356
+ };
1357
+ async process(context) {
1358
+ const bytes = await videoBytesAsync(this.video, context);
1359
+ const speed = Math.max(0.1, Number(this.speed_factor ?? 1));
1360
+ // Build chained atempo filters for values outside 0.5-2.0 range
1361
+ // Each atempo filter is limited to 0.5-2.0, so we chain them
1362
+ const buildAtempo = (s) => {
1363
+ const parts = [];
1364
+ let remaining = s;
1365
+ if (remaining > 1) {
1366
+ while (remaining > 2) {
1367
+ parts.push("atempo=2.0");
1368
+ remaining /= 2;
1369
+ }
1370
+ parts.push(`atempo=${remaining}`);
1371
+ }
1372
+ else {
1373
+ while (remaining < 0.5) {
1374
+ parts.push("atempo=0.5");
1375
+ remaining /= 0.5;
1376
+ }
1377
+ parts.push(`atempo=${remaining}`);
1378
+ }
1379
+ return parts.join(",");
1380
+ };
1381
+ const atempoChain = buildAtempo(speed);
1382
+ // First attempt retimes audio too (fails on videos with no audio stream);
1383
+ // the video-only fallback is the terminal attempt and throws on failure.
1384
+ const transformed = (await ffmpegTransform(bytes, [
1385
+ "-filter_complex",
1386
+ `[0:v]setpts=${1 / speed}*PTS[v];[0:a]${atempoChain}[a]`,
1387
+ "-map",
1388
+ "[v]",
1389
+ "-map",
1390
+ "[a]"
1391
+ ], { allowFallback: true })) ??
1392
+ (await ffmpegTransform(bytes, ["-vf", `setpts=${1 / speed}*PTS`]));
1393
+ return { output: videoRef(transformed) };
1394
+ }
1395
+ }
1396
+ __decorate([
1397
+ prop({
1398
+ type: "video",
1399
+ default: defaultVideoRef(),
1400
+ title: "Video",
1401
+ description: "The input video to adjust speed."
1402
+ })
1403
+ ], SetSpeedVideoNode.prototype, "video", void 0);
1404
+ __decorate([
1405
+ prop({
1406
+ type: "float",
1407
+ default: 1,
1408
+ title: "Speed Factor",
1409
+ description: "The speed adjustment factor. Values > 1 speed up, < 1 slow down."
1410
+ })
1411
+ ], SetSpeedVideoNode.prototype, "speed_factor", void 0);
1412
+ export class OverlayVideoNode extends VideoTransformNode {
1413
+ static nodeType = "nodetool.video.Overlay";
1414
+ static title = "Overlay";
1415
+ static description = "Overlay one video on top of another, including audio overlay when available.\n video, overlay, composite, picture-in-picture, audio";
1416
+ static metadataOutputTypes = {
1417
+ output: "video"
1418
+ };
1419
+ async process(context) {
1420
+ const mainVideo = await videoBytesAsync(this.main_video, context);
1421
+ const overlayVideo = await videoBytesAsync(this.overlay_video, context);
1422
+ if (overlayVideo.length === 0)
1423
+ return { output: videoRef(mainVideo) };
1424
+ if (mainVideo.length === 0)
1425
+ return { output: videoRef(overlayVideo) };
1426
+ const x = Number(this.x ?? 0);
1427
+ const y = Number(this.y ?? 0);
1428
+ const scale = Math.max(0.01, Number(this.scale ?? 1));
1429
+ const vol = Math.max(0, Math.min(1, Number(this.overlay_audio_volume ?? 0.5)));
1430
+ // Try with audio mixing first, fall back to video-only overlay
1431
+ const filterWithAudio = `[1:v]scale=iw*${scale}:ih*${scale}[ov];[0:v][ov]overlay=${x}:${y}[outv];` +
1432
+ `[0:a]volume=1.0[a0];[1:a]volume=${vol}[a1];[a0][a1]amix=inputs=2:duration=first[outa]`;
1433
+ const transformed = (await ffmpegTransform(mainVideo, [
1434
+ "-filter_complex",
1435
+ filterWithAudio,
1436
+ "-map",
1437
+ "[outv]",
1438
+ "-map",
1439
+ "[outa]"
1440
+ ], {
1441
+ extraInputs: [{ suffix: ".mp4", bytes: overlayVideo }],
1442
+ allowFallback: true
1443
+ })) ??
1444
+ // Fallback: video-only overlay (one or both inputs may lack audio).
1445
+ // Terminal attempt — throws with stderr if it also fails.
1446
+ (await ffmpegTransform(mainVideo, [
1447
+ "-filter_complex",
1448
+ `[1:v]scale=iw*${scale}:ih*${scale}[ov];[0:v][ov]overlay=${x}:${y}`
1449
+ ], { extraInputs: [{ suffix: ".mp4", bytes: overlayVideo }] }));
1450
+ return { output: videoRef(transformed) };
1451
+ }
1452
+ }
1453
+ __decorate([
1454
+ prop({
1455
+ type: "video",
1456
+ default: defaultVideoRef(),
1457
+ title: "Main Video",
1458
+ description: "The main (background) video."
1459
+ })
1460
+ ], OverlayVideoNode.prototype, "main_video", void 0);
1461
+ __decorate([
1462
+ prop({
1463
+ type: "video",
1464
+ default: defaultVideoRef(),
1465
+ title: "Overlay Video",
1466
+ description: "The video to overlay on top."
1467
+ })
1468
+ ], OverlayVideoNode.prototype, "overlay_video", void 0);
1469
+ __decorate([
1470
+ prop({
1471
+ type: "int",
1472
+ default: 0,
1473
+ title: "X",
1474
+ description: "X-coordinate for overlay placement."
1475
+ })
1476
+ ], OverlayVideoNode.prototype, "x", void 0);
1477
+ __decorate([
1478
+ prop({
1479
+ type: "int",
1480
+ default: 0,
1481
+ title: "Y",
1482
+ description: "Y-coordinate for overlay placement."
1483
+ })
1484
+ ], OverlayVideoNode.prototype, "y", void 0);
1485
+ __decorate([
1486
+ prop({
1487
+ type: "float",
1488
+ default: 1,
1489
+ title: "Scale",
1490
+ description: "Scale factor for the overlay video."
1491
+ })
1492
+ ], OverlayVideoNode.prototype, "scale", void 0);
1493
+ __decorate([
1494
+ prop({
1495
+ type: "float",
1496
+ default: 0.5,
1497
+ title: "Overlay Audio Volume",
1498
+ description: "Volume of the overlay audio relative to the main audio.",
1499
+ min: 0,
1500
+ max: 1
1501
+ })
1502
+ ], OverlayVideoNode.prototype, "overlay_audio_volume", void 0);
1503
+ export class ColorBalanceVideoNode extends VideoTransformNode {
1504
+ static nodeType = "nodetool.video.ColorBalance";
1505
+ static title = "Color Balance";
1506
+ static description = "Adjust the color balance of a video.\n video, color, balance, adjustment";
1507
+ static metadataOutputTypes = {
1508
+ output: "video"
1509
+ };
1510
+ async process(context) {
1511
+ const bytes = await videoBytesAsync(this.video, context);
1512
+ // Props are 0-2 range where 1.0 = neutral; colorbalance expects -1 to 1
1513
+ const rs = Number(this.red_adjust ?? 1) - 1;
1514
+ const gs = Number(this.green_adjust ?? 1) - 1;
1515
+ const bs = Number(this.blue_adjust ?? 1) - 1;
1516
+ const transformed = (await ffmpegTransform(bytes, [
1517
+ "-vf",
1518
+ `colorbalance=rs=${rs}:gs=${gs}:bs=${bs}`,
1519
+ "-c:a",
1520
+ "copy"
1521
+ ]));
1522
+ return { output: videoRef(transformed) };
1523
+ }
1524
+ }
1525
+ __decorate([
1526
+ prop({
1527
+ type: "video",
1528
+ default: defaultVideoRef(),
1529
+ title: "Video",
1530
+ description: "The input video to adjust color balance."
1531
+ })
1532
+ ], ColorBalanceVideoNode.prototype, "video", void 0);
1533
+ __decorate([
1534
+ prop({
1535
+ type: "float",
1536
+ default: 1,
1537
+ title: "Red Adjust",
1538
+ description: "Red channel adjustment factor.",
1539
+ min: 0,
1540
+ max: 2
1541
+ })
1542
+ ], ColorBalanceVideoNode.prototype, "red_adjust", void 0);
1543
+ __decorate([
1544
+ prop({
1545
+ type: "float",
1546
+ default: 1,
1547
+ title: "Green Adjust",
1548
+ description: "Green channel adjustment factor.",
1549
+ min: 0,
1550
+ max: 2
1551
+ })
1552
+ ], ColorBalanceVideoNode.prototype, "green_adjust", void 0);
1553
+ __decorate([
1554
+ prop({
1555
+ type: "float",
1556
+ default: 1,
1557
+ title: "Blue Adjust",
1558
+ description: "Blue channel adjustment factor.",
1559
+ min: 0,
1560
+ max: 2
1561
+ })
1562
+ ], ColorBalanceVideoNode.prototype, "blue_adjust", void 0);
1563
+ export class DenoiseVideoNode extends VideoTransformNode {
1564
+ static nodeType = "nodetool.video.Denoise";
1565
+ static title = "Denoise";
1566
+ static description = "Apply noise reduction to a video.\n video, denoise, clean, enhance";
1567
+ static metadataOutputTypes = {
1568
+ output: "video"
1569
+ };
1570
+ async process(context) {
1571
+ const bytes = await videoBytesAsync(this.video, context);
1572
+ const strength = Number(this.strength ?? 5);
1573
+ const transformed = (await ffmpegTransform(bytes, [
1574
+ "-vf",
1575
+ `nlmeans=s=${strength}`,
1576
+ "-c:a",
1577
+ "copy"
1578
+ ]));
1579
+ return { output: videoRef(transformed) };
1580
+ }
1581
+ }
1582
+ __decorate([
1583
+ prop({
1584
+ type: "video",
1585
+ default: defaultVideoRef(),
1586
+ title: "Video",
1587
+ description: "The input video to denoise."
1588
+ })
1589
+ ], DenoiseVideoNode.prototype, "video", void 0);
1590
+ __decorate([
1591
+ prop({
1592
+ type: "float",
1593
+ default: 5,
1594
+ title: "Strength",
1595
+ description: "Strength of the denoising effect. Higher values mean more denoising.",
1596
+ min: 0,
1597
+ max: 20
1598
+ })
1599
+ ], DenoiseVideoNode.prototype, "strength", void 0);
1600
+ export class StabilizeVideoNode extends VideoTransformNode {
1601
+ static nodeType = "nodetool.video.Stabilize";
1602
+ static title = "Stabilize";
1603
+ static description = "Apply video stabilization to reduce camera shake and jitter.\n video, stabilize, smooth, shake-reduction";
1604
+ static metadataOutputTypes = {
1605
+ output: "video"
1606
+ };
1607
+ async process(context) {
1608
+ const bytes = await videoBytesAsync(this.video, context);
1609
+ if (bytes.length === 0)
1610
+ return { output: videoRef(bytes) };
1611
+ const smoothing = Math.max(1, Number(this.smoothing ?? 10));
1612
+ const cropBlack = Boolean(this.crop_black ?? true);
1613
+ // ffmpeg's `deshake` filter has no `smooth` option. Passing one fails the
1614
+ // whole filtergraph with "Error applying option 'smooth' to filter
1615
+ // 'deshake': Option not found", so this node could never run. `smooth`
1616
+ // belongs to vidstabtransform, which needs a libvidstab-enabled ffmpeg we
1617
+ // cannot assume is present. Map the smoothing dial onto deshake's actual
1618
+ // control — the rx/ry search range, which sets how much motion it can
1619
+ // track between frames. deshake rejects a range that is not a multiple of
1620
+ // 16 ("rx must be a multiple of 16"), so snap to the nearest legal step and
1621
+ // keep at least one: rx=0 disables the search entirely.
1622
+ const searchRange = Math.min(64, Math.max(16, Math.round(smoothing / 16) * 16));
1623
+ const deshake = `deshake=rx=${searchRange}:ry=${searchRange}`;
1624
+ if (!cropBlack) {
1625
+ const transformed = await ffmpegTransform(bytes, [
1626
+ "-vf",
1627
+ deshake,
1628
+ "-c:a",
1629
+ "copy"
1630
+ ]);
1631
+ return { output: videoRef(transformed) };
1632
+ }
1633
+ // Pass 1: run deshake + cropdetect to the null muxer and read cropdetect's
1634
+ // suggested crop from stderr (a bare `crop` filter defaults to the full
1635
+ // frame and removes nothing — cropdetect only logs, it never crops).
1636
+ const probe = await withTempFile(".mp4", bytes);
1637
+ let crop = null;
1638
+ try {
1639
+ const { stderr } = await execFfmpeg([
1640
+ "-y",
1641
+ "-i",
1642
+ probe.path,
1643
+ "-vf",
1644
+ `${deshake},cropdetect=24:16:0`,
1645
+ "-f",
1646
+ "null",
1647
+ "-"
1648
+ ], { maxBuffer: FFMPEG_MAX_BUFFER });
1649
+ crop = parseCropDetectCrop(stderr);
1650
+ }
1651
+ finally {
1652
+ await probe.cleanup();
1653
+ }
1654
+ // Pass 2: apply the detected crop, or deshake alone when none was found.
1655
+ const vf = crop ? `${deshake},crop=${crop}` : deshake;
1656
+ const transformed = await ffmpegTransform(bytes, [
1657
+ "-vf",
1658
+ vf,
1659
+ "-c:a",
1660
+ "copy"
1661
+ ]);
1662
+ return { output: videoRef(transformed) };
1663
+ }
1664
+ }
1665
+ __decorate([
1666
+ prop({
1667
+ type: "video",
1668
+ default: defaultVideoRef(),
1669
+ title: "Video",
1670
+ description: "The input video to stabilize."
1671
+ })
1672
+ ], StabilizeVideoNode.prototype, "video", void 0);
1673
+ __decorate([
1674
+ prop({
1675
+ type: "float",
1676
+ default: 10,
1677
+ title: "Smoothing",
1678
+ description: "Smoothing strength. Higher values result in smoother but potentially more cropped video.",
1679
+ min: 1,
1680
+ max: 100
1681
+ })
1682
+ ], StabilizeVideoNode.prototype, "smoothing", void 0);
1683
+ __decorate([
1684
+ prop({
1685
+ type: "bool",
1686
+ default: true,
1687
+ title: "Crop Black",
1688
+ description: "Whether to crop black borders that may appear after stabilization."
1689
+ })
1690
+ ], StabilizeVideoNode.prototype, "crop_black", void 0);
1691
+ export class SharpnessVideoNode extends VideoTransformNode {
1692
+ static nodeType = "nodetool.video.Sharpness";
1693
+ static title = "Sharpness";
1694
+ static description = "Adjust the sharpness of a video.\n video, sharpen, enhance, detail";
1695
+ static metadataOutputTypes = {
1696
+ output: "video"
1697
+ };
1698
+ async process(context) {
1699
+ const bytes = await videoBytesAsync(this.video, context);
1700
+ const lumaAmount = Number(this.luma_amount ?? 1);
1701
+ const chromaAmount = Number(this.chroma_amount ?? 0.5);
1702
+ const transformed = (await ffmpegTransform(bytes, [
1703
+ "-vf",
1704
+ `unsharp=5:5:${lumaAmount}:5:5:${chromaAmount}`,
1705
+ "-c:a",
1706
+ "copy"
1707
+ ]));
1708
+ return { output: videoRef(transformed) };
1709
+ }
1710
+ }
1711
+ __decorate([
1712
+ prop({
1713
+ type: "video",
1714
+ default: defaultVideoRef(),
1715
+ title: "Video",
1716
+ description: "The input video to sharpen."
1717
+ })
1718
+ ], SharpnessVideoNode.prototype, "video", void 0);
1719
+ __decorate([
1720
+ prop({
1721
+ type: "float",
1722
+ default: 1,
1723
+ title: "Luma Amount",
1724
+ description: "Amount of sharpening to apply to luma (brightness) channel.",
1725
+ min: 0,
1726
+ max: 3
1727
+ })
1728
+ ], SharpnessVideoNode.prototype, "luma_amount", void 0);
1729
+ __decorate([
1730
+ prop({
1731
+ type: "float",
1732
+ default: 0.5,
1733
+ title: "Chroma Amount",
1734
+ description: "Amount of sharpening to apply to chroma (color) channels.",
1735
+ min: 0,
1736
+ max: 3
1737
+ })
1738
+ ], SharpnessVideoNode.prototype, "chroma_amount", void 0);
1739
+ export class BlurVideoNode extends VideoTransformNode {
1740
+ static nodeType = "nodetool.video.Blur";
1741
+ static title = "Blur";
1742
+ static description = "Apply a blur effect to a video.\n video, blur, smooth, soften";
1743
+ static metadataOutputTypes = {
1744
+ output: "video"
1745
+ };
1746
+ async process(context) {
1747
+ const bytes = await videoBytesAsync(this.video, context);
1748
+ const radius = Number(this.strength ?? 5);
1749
+ if (radius <= 0)
1750
+ return { output: videoRef(bytes) };
1751
+ const transformed = (await ffmpegTransform(bytes, [
1752
+ "-vf",
1753
+ `boxblur=${radius}:${radius}`,
1754
+ "-c:a",
1755
+ "copy"
1756
+ ]));
1757
+ return { output: videoRef(transformed) };
1758
+ }
1759
+ }
1760
+ __decorate([
1761
+ prop({
1762
+ type: "video",
1763
+ default: defaultVideoRef(),
1764
+ title: "Video",
1765
+ description: "The input video to apply blur effect."
1766
+ })
1767
+ ], BlurVideoNode.prototype, "video", void 0);
1768
+ __decorate([
1769
+ prop({
1770
+ type: "float",
1771
+ default: 5,
1772
+ title: "Strength",
1773
+ description: "The strength of the blur effect. Higher values create a stronger blur.",
1774
+ min: 0,
1775
+ max: 20
1776
+ })
1777
+ ], BlurVideoNode.prototype, "strength", void 0);
1778
+ export class SaturationVideoNode extends VideoTransformNode {
1779
+ static nodeType = "nodetool.video.Saturation";
1780
+ static title = "Saturation";
1781
+ static description = "Adjust the color saturation of a video.\n video, saturation, color, enhance";
1782
+ static metadataOutputTypes = {
1783
+ output: "video"
1784
+ };
1785
+ async process(context) {
1786
+ const bytes = await videoBytesAsync(this.video, context);
1787
+ const saturation = Number(this.saturation ?? 1);
1788
+ const transformed = (await ffmpegTransform(bytes, [
1789
+ "-vf",
1790
+ `eq=saturation=${saturation}`,
1791
+ "-c:a",
1792
+ "copy"
1793
+ ]));
1794
+ return { output: videoRef(transformed) };
1795
+ }
1796
+ }
1797
+ __decorate([
1798
+ prop({
1799
+ type: "video",
1800
+ default: defaultVideoRef(),
1801
+ title: "Video",
1802
+ description: "The input video to adjust saturation."
1803
+ })
1804
+ ], SaturationVideoNode.prototype, "video", void 0);
1805
+ __decorate([
1806
+ prop({
1807
+ type: "float",
1808
+ default: 1,
1809
+ title: "Saturation",
1810
+ description: "Saturation level. 1.0 is original, <1 decreases saturation, >1 increases saturation.",
1811
+ min: 0,
1812
+ max: 3
1813
+ })
1814
+ ], SaturationVideoNode.prototype, "saturation", void 0);
1815
+ export class AddSubtitlesVideoNode extends VideoTransformNode {
1816
+ static nodeType = "nodetool.video.AddSubtitles";
1817
+ static title = "Add Subtitles";
1818
+ static description = "Add subtitles to a video.\n video, subtitles, text, caption";
1819
+ static metadataOutputTypes = {
1820
+ output: "video"
1821
+ };
1822
+ async process(context) {
1823
+ const bytes = await videoBytesAsync(this.video, context);
1824
+ const chunks = Array.isArray(this.chunks) ? this.chunks : [];
1825
+ if (chunks.length === 0)
1826
+ return { output: videoRef(bytes) };
1827
+ const fontSize = Math.max(1, Number(this.font_size ?? 24));
1828
+ const fontColorObj = this.font_color;
1829
+ const fontColor = String(fontColorObj?.value ?? "#FFFFFF").replace("#", "0x");
1830
+ // `||` (not `??`): the default font ref has name "", which must also
1831
+ // fall back rather than producing drawtext's font=''. Strip filtergraph-
1832
+ // significant characters \u2014 a font family name never contains them, and
1833
+ // leaving them in would let a crafted name break the -vf argument.
1834
+ const fontName = String(this.font?.name || "Sans").replace(/['\\:,;[\]%]/g, "");
1835
+ const align = String(this.align ?? "bottom");
1836
+ let yExpr;
1837
+ if (align === "top") {
1838
+ yExpr = "text_h";
1839
+ }
1840
+ else if (align === "center") {
1841
+ yExpr = "(h-text_h)/2";
1842
+ }
1843
+ else {
1844
+ // bottom
1845
+ yExpr = "h-(text_h*2)";
1846
+ }
1847
+ // Write each chunk's text to a temp file and reference it with drawtext's
1848
+ // `textfile=` option. Inline `text='...'` breaks on ordinary punctuation:
1849
+ // a comma or semicolon ends the filter, `[`/`]` are stream-label syntax,
1850
+ // and the two-level filtergraph escaping is easy to get subtly wrong. A
1851
+ // file sidesteps all of it \u2014 arbitrary subtitle text stays literal.
1852
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-subs-"));
1853
+ try {
1854
+ const drawFilters = [];
1855
+ let idx = 0;
1856
+ for (const chunk of chunks) {
1857
+ const text = String(chunk.text ?? chunk.content ?? "").trim();
1858
+ if (!text)
1859
+ continue;
1860
+ const ts = Array.isArray(chunk.timestamp) ? chunk.timestamp : [];
1861
+ const start = Number(ts[0] ?? chunk.start ?? 0);
1862
+ const end = Number(ts[1] ?? chunk.end ?? start + 5);
1863
+ const textPath = path.join(dir, `sub_${idx}.txt`);
1864
+ await fs.writeFile(textPath, text, "utf8");
1865
+ idx += 1;
1866
+ drawFilters.push(`drawtext=textfile='${textPath}':expansion=none` +
1867
+ `:x=(w-text_w)/2:y=${yExpr}` +
1868
+ `:fontcolor=${fontColor}:fontsize=${fontSize}` +
1869
+ `:font='${fontName}'` +
1870
+ `:enable='between(t,${start},${end})'`);
1871
+ }
1872
+ if (drawFilters.length === 0)
1873
+ return { output: videoRef(bytes) };
1874
+ const vf = drawFilters.join(",");
1875
+ const transformed = await ffmpegTransform(bytes, [
1876
+ "-vf",
1877
+ vf,
1878
+ "-c:a",
1879
+ "copy"
1880
+ ]);
1881
+ return { output: videoRef(transformed) };
1882
+ }
1883
+ finally {
1884
+ await fs.rm(dir, { recursive: true, force: true });
1885
+ }
1886
+ }
1887
+ }
1888
+ __decorate([
1889
+ prop({
1890
+ type: "video",
1891
+ default: defaultVideoRef(),
1892
+ title: "Video",
1893
+ description: "The input video to add subtitles to."
1894
+ })
1895
+ ], AddSubtitlesVideoNode.prototype, "video", void 0);
1896
+ __decorate([
1897
+ prop({
1898
+ type: "list[audio_chunk]",
1899
+ default: [],
1900
+ title: "Chunks",
1901
+ description: "Audio chunks to add as subtitles."
1902
+ })
1903
+ ], AddSubtitlesVideoNode.prototype, "chunks", void 0);
1904
+ __decorate([
1905
+ prop({
1906
+ type: "font",
1907
+ default: {
1908
+ type: "font",
1909
+ name: "",
1910
+ source: "system",
1911
+ url: "",
1912
+ weight: "regular"
1913
+ },
1914
+ title: "Font",
1915
+ description: "The font to use."
1916
+ })
1917
+ ], AddSubtitlesVideoNode.prototype, "font", void 0);
1918
+ __decorate([
1919
+ prop({
1920
+ type: "enum",
1921
+ default: "bottom",
1922
+ title: "Align",
1923
+ description: "Vertical alignment of subtitles.",
1924
+ values: ["top", "center", "bottom"]
1925
+ })
1926
+ ], AddSubtitlesVideoNode.prototype, "align", void 0);
1927
+ __decorate([
1928
+ prop({
1929
+ type: "int",
1930
+ default: 24,
1931
+ title: "Font Size",
1932
+ description: "The font size.",
1933
+ min: 1,
1934
+ max: 72
1935
+ })
1936
+ ], AddSubtitlesVideoNode.prototype, "font_size", void 0);
1937
+ __decorate([
1938
+ prop({
1939
+ type: "color",
1940
+ default: {
1941
+ type: "color",
1942
+ value: "#FFFFFF"
1943
+ },
1944
+ title: "Font Color",
1945
+ description: "The font color."
1946
+ })
1947
+ ], AddSubtitlesVideoNode.prototype, "font_color", void 0);
1948
+ export class ReverseVideoNode extends VideoTransformNode {
1949
+ static nodeType = "nodetool.video.Reverse";
1950
+ static title = "Reverse";
1951
+ static description = "Reverse the playback of a video.\n video, reverse, backwards, effect";
1952
+ static metadataOutputTypes = {
1953
+ output: "video"
1954
+ };
1955
+ static inputFields = ["video"];
1956
+ async process(context) {
1957
+ const bytes = await videoBytesAsync(this.video, context);
1958
+ if (bytes.length === 0)
1959
+ return { output: videoRef(bytes) };
1960
+ // Reverse audio+video first (fails without an audio stream); the video-only
1961
+ // fallback is terminal and throws on failure.
1962
+ const transformed = (await ffmpegTransform(bytes, ["-vf", "reverse", "-af", "areverse"], {
1963
+ allowFallback: true
1964
+ })) ?? (await ffmpegTransform(bytes, ["-vf", "reverse"]));
1965
+ return { output: videoRef(transformed) };
1966
+ }
1967
+ }
1968
+ __decorate([
1969
+ prop({
1970
+ type: "video",
1971
+ default: defaultVideoRef(),
1972
+ title: "Video",
1973
+ description: "The input video to reverse."
1974
+ })
1975
+ ], ReverseVideoNode.prototype, "video", void 0);
1976
+ export class TransitionVideoNode extends VideoTransformNode {
1977
+ static nodeType = "nodetool.video.Transition";
1978
+ static title = "Transition";
1979
+ static description = "Create a transition effect between two videos, including audio transition when available.\n video, transition, effect, merge, audio";
1980
+ static metadataOutputTypes = {
1981
+ output: "video"
1982
+ };
1983
+ async process(context) {
1984
+ const a = await videoBytesAsync(this.video_a, context);
1985
+ const b = await videoBytesAsync(this.video_b, context);
1986
+ if (b.length === 0)
1987
+ return { output: videoRef(a) };
1988
+ if (a.length === 0)
1989
+ return { output: videoRef(b) };
1990
+ const duration = Math.max(0.1, Number(this.duration ?? 1));
1991
+ const transitionType = String(this.transition_type ?? "fade");
1992
+ // Probe video_a duration to compute offset (transition starts at end of video_a)
1993
+ const tempA = await withTempFile(".mp4", a);
1994
+ let offsetVal;
1995
+ try {
1996
+ const aDuration = await ffprobeDuration(tempA.path);
1997
+ offsetVal = Math.max(0, aDuration - duration);
1998
+ }
1999
+ finally {
2000
+ await tempA.cleanup();
2001
+ }
2002
+ // Try with audio crossfade first, then fall back to video-only
2003
+ const filterWithAudio = `[0:v][1:v]xfade=transition=${transitionType}:duration=${duration}:offset=${offsetVal}[outv];` +
2004
+ `[0:a][1:a]acrossfade=d=${duration}[outa]`;
2005
+ const transformed = (await ffmpegTransform(a, [
2006
+ "-filter_complex",
2007
+ filterWithAudio,
2008
+ "-map",
2009
+ "[outv]",
2010
+ "-map",
2011
+ "[outa]"
2012
+ ], { extraInputs: [{ suffix: ".mp4", bytes: b }], allowFallback: true })) ??
2013
+ // Fallback: video-only xfade (inputs may lack audio). Terminal attempt —
2014
+ // throws with stderr if it also fails.
2015
+ (await ffmpegTransform(a, [
2016
+ "-filter_complex",
2017
+ `[0:v][1:v]xfade=transition=${transitionType}:duration=${duration}:offset=${offsetVal}`
2018
+ ], { extraInputs: [{ suffix: ".mp4", bytes: b }] }));
2019
+ return { output: videoRef(transformed) };
2020
+ }
2021
+ }
2022
+ __decorate([
2023
+ prop({
2024
+ type: "video",
2025
+ default: defaultVideoRef(),
2026
+ title: "Video A",
2027
+ description: "The first video in the transition."
2028
+ })
2029
+ ], TransitionVideoNode.prototype, "video_a", void 0);
2030
+ __decorate([
2031
+ prop({
2032
+ type: "video",
2033
+ default: defaultVideoRef(),
2034
+ title: "Video B",
2035
+ description: "The second video in the transition."
2036
+ })
2037
+ ], TransitionVideoNode.prototype, "video_b", void 0);
2038
+ __decorate([
2039
+ prop({
2040
+ type: "enum",
2041
+ default: "fade",
2042
+ title: "Transition Type",
2043
+ description: "Type of transition effect",
2044
+ values: [
2045
+ "fade",
2046
+ "wipeleft",
2047
+ "wiperight",
2048
+ "wipeup",
2049
+ "wipedown",
2050
+ "slideleft",
2051
+ "slideright",
2052
+ "slideup",
2053
+ "slidedown",
2054
+ "circlecrop",
2055
+ "rectcrop",
2056
+ "distance",
2057
+ "fadeblack",
2058
+ "fadewhite",
2059
+ "radial",
2060
+ "smoothleft",
2061
+ "smoothright",
2062
+ "smoothup",
2063
+ "smoothdown",
2064
+ "circleopen",
2065
+ "circleclose",
2066
+ "vertopen",
2067
+ "vertclose",
2068
+ "horzopen",
2069
+ "horzclose",
2070
+ "dissolve",
2071
+ "pixelize",
2072
+ "diagtl",
2073
+ "diagtr",
2074
+ "diagbl",
2075
+ "diagbr",
2076
+ "hlslice",
2077
+ "hrslice",
2078
+ "vuslice",
2079
+ "vdslice",
2080
+ "hblur",
2081
+ "fadegrays",
2082
+ "wipetl",
2083
+ "wipetr",
2084
+ "wipebl",
2085
+ "wipebr",
2086
+ "squeezeh",
2087
+ "squeezev",
2088
+ "zoomin",
2089
+ "fadefast",
2090
+ "fadeslow",
2091
+ "hlwind",
2092
+ "hrwind",
2093
+ "vuwind",
2094
+ "vdwind",
2095
+ "coverleft",
2096
+ "coverright",
2097
+ "coverup",
2098
+ "coverdown",
2099
+ "revealleft",
2100
+ "revealright",
2101
+ "revealup",
2102
+ "revealdown"
2103
+ ]
2104
+ })
2105
+ ], TransitionVideoNode.prototype, "transition_type", void 0);
2106
+ __decorate([
2107
+ prop({
2108
+ type: "float",
2109
+ default: 1,
2110
+ title: "Duration",
2111
+ description: "Duration of the transition effect in seconds.",
2112
+ min: 0.1,
2113
+ max: 5
2114
+ })
2115
+ ], TransitionVideoNode.prototype, "duration", void 0);
2116
+ export class AddAudioVideoNode extends VideoTransformNode {
2117
+ static nodeType = "nodetool.video.AddAudio";
2118
+ static title = "Add Audio";
2119
+ static description = "Add an audio track to a video, replacing or mixing with existing audio.\n video, audio, soundtrack, merge";
2120
+ static metadataOutputTypes = {
2121
+ output: "video"
2122
+ };
2123
+ static inputFields = ["video", "audio"];
2124
+ async process(context) {
2125
+ const v = await videoBytesAsync(this.video, context);
2126
+ const a = await audioBytesAsync(this.audio, context);
2127
+ if (v.length === 0)
2128
+ return { output: videoRef(v) };
2129
+ if (a.length === 0)
2130
+ return { output: videoRef(v) };
2131
+ const videoInput = await withTempFile(".mp4", v);
2132
+ const audioInput = await withTempFile(".wav", a);
2133
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-addaudio-out-"));
2134
+ const outputPath = path.join(outputDir, "output.mp4");
2135
+ try {
2136
+ const volume = Math.max(0, Number(this.volume ?? 1));
2137
+ const mix = Boolean(this.mix);
2138
+ // Left to the muxer the file is as long as the *longer* stream, so a
2139
+ // music model that ignores the requested duration turns a 25-second clip
2140
+ // into a four-minute one. `apad` makes the audio endless and `-shortest`
2141
+ // then ends the file with the picture, which also stops a short audio
2142
+ // track from cutting the picture off.
2143
+ const trimToVideo = String(this.output_length ?? "video") !== "longest";
2144
+ const pad = trimToVideo ? ",apad" : "";
2145
+ const buildArgs = (useMix) => {
2146
+ const args = [
2147
+ "-y",
2148
+ "-i", videoInput.path,
2149
+ "-i", audioInput.path
2150
+ ];
2151
+ if (useMix) {
2152
+ args.push("-filter_complex", `[1:a]volume=${volume}[newaud];[0:a][newaud]amix=inputs=2:duration=longest${pad}[aout]`, "-map", "0:v:0", "-map", "[aout]", "-c:v", "copy");
2153
+ }
2154
+ else if (volume !== 1 || trimToVideo) {
2155
+ args.push("-filter_complex", `[1:a]volume=${volume}${pad}[aout]`, "-map", "0:v:0", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac");
2156
+ }
2157
+ else {
2158
+ args.push("-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac");
2159
+ }
2160
+ if (trimToVideo)
2161
+ args.push("-shortest");
2162
+ args.push(outputPath);
2163
+ return args;
2164
+ };
2165
+ try {
2166
+ await execFfmpeg(buildArgs(mix), { maxBuffer: FFMPEG_MAX_BUFFER });
2167
+ }
2168
+ catch (error) {
2169
+ if (error instanceof MissingBinaryError)
2170
+ throw error;
2171
+ // Mixing requires an existing audio stream ([0:a]); videos without one
2172
+ // make ffmpeg fail. Fall back to plain replacement — the terminal
2173
+ // attempt, which throws (with stderr) if it also fails.
2174
+ if (!mix)
2175
+ throw error;
2176
+ await execFfmpeg(buildArgs(false), { maxBuffer: FFMPEG_MAX_BUFFER });
2177
+ }
2178
+ const result = new Uint8Array(await fs.readFile(outputPath));
2179
+ return { output: videoRef(result) };
2180
+ }
2181
+ finally {
2182
+ await videoInput.cleanup();
2183
+ await audioInput.cleanup();
2184
+ await fs.rm(outputDir, { recursive: true, force: true });
2185
+ }
2186
+ }
2187
+ }
2188
+ __decorate([
2189
+ prop({
2190
+ type: "video",
2191
+ default: defaultVideoRef(),
2192
+ title: "Video",
2193
+ description: "The input video to add audio to."
2194
+ })
2195
+ ], AddAudioVideoNode.prototype, "video", void 0);
2196
+ __decorate([
2197
+ prop({
2198
+ type: "audio",
2199
+ default: {
2200
+ type: "audio",
2201
+ uri: "",
2202
+ asset_id: null,
2203
+ data: null,
2204
+ metadata: null
2205
+ },
2206
+ title: "Audio",
2207
+ description: "The audio file to add to the video."
2208
+ })
2209
+ ], AddAudioVideoNode.prototype, "audio", void 0);
2210
+ __decorate([
2211
+ prop({
2212
+ type: "float",
2213
+ default: 1,
2214
+ title: "Volume",
2215
+ description: "Volume adjustment for the added audio. 1.0 is original volume.",
2216
+ min: 0,
2217
+ max: 2
2218
+ })
2219
+ ], AddAudioVideoNode.prototype, "volume", void 0);
2220
+ __decorate([
2221
+ prop({
2222
+ type: "bool",
2223
+ default: false,
2224
+ title: "Mix",
2225
+ description: "If True, mix new audio with existing. If False, replace existing audio."
2226
+ })
2227
+ ], AddAudioVideoNode.prototype, "mix", void 0);
2228
+ __decorate([
2229
+ prop({
2230
+ type: "enum",
2231
+ default: "video",
2232
+ title: "Output Length",
2233
+ description: "How long the output is. 'video' matches the picture: a longer audio track is trimmed, a shorter one padded with silence. 'longest' keeps both streams whole, so a track that outruns the picture leaves the output playing over nothing.",
2234
+ values: ["video", "longest"]
2235
+ })
2236
+ ], AddAudioVideoNode.prototype, "output_length", void 0);
2237
+ export class ChromaKeyVideoNode extends VideoTransformNode {
2238
+ static nodeType = "nodetool.video.ChromaKey";
2239
+ static title = "Chroma Key";
2240
+ static description = "Apply chroma key (green screen) effect to a video. The MP4 output has no alpha channel, so the keyed-out area renders black — composite it over a background with the Overlay node.\n video, chroma key, green screen, compositing";
2241
+ static metadataOutputTypes = {
2242
+ output: "video"
2243
+ };
2244
+ async process(context) {
2245
+ const bytes = await videoBytesAsync(this.video, context);
2246
+ const keyColorObj = this.key_color;
2247
+ const color = String(keyColorObj?.value ?? "#00FF00").replace("#", "0x");
2248
+ const similarity = Number(this.similarity ?? 0.3);
2249
+ const blend = Number(this.blend ?? 0.1);
2250
+ const transformed = (await ffmpegTransform(bytes, [
2251
+ "-vf",
2252
+ `colorkey=${color}:${similarity}:${blend}`,
2253
+ "-c:a",
2254
+ "copy"
2255
+ ]));
2256
+ return { output: videoRef(transformed) };
2257
+ }
2258
+ }
2259
+ __decorate([
2260
+ prop({
2261
+ type: "video",
2262
+ default: defaultVideoRef(),
2263
+ title: "Video",
2264
+ description: "The input video to apply chroma key effect."
2265
+ })
2266
+ ], ChromaKeyVideoNode.prototype, "video", void 0);
2267
+ __decorate([
2268
+ prop({
2269
+ type: "color",
2270
+ default: {
2271
+ type: "color",
2272
+ value: "#00FF00"
2273
+ },
2274
+ title: "Key Color",
2275
+ description: "The color to key out (e.g., '#00FF00' for green)."
2276
+ })
2277
+ ], ChromaKeyVideoNode.prototype, "key_color", void 0);
2278
+ __decorate([
2279
+ prop({
2280
+ type: "float",
2281
+ default: 0.3,
2282
+ title: "Similarity",
2283
+ description: "Similarity threshold for the key color.",
2284
+ min: 0,
2285
+ max: 1
2286
+ })
2287
+ ], ChromaKeyVideoNode.prototype, "similarity", void 0);
2288
+ __decorate([
2289
+ prop({
2290
+ type: "float",
2291
+ default: 0.1,
2292
+ title: "Blend",
2293
+ description: "Blending of the keyed area edges.",
2294
+ min: 0,
2295
+ max: 1
2296
+ })
2297
+ ], ChromaKeyVideoNode.prototype, "blend", void 0);
2298
+ export class ExtractAudioVideoNode extends VideoTransformNode {
2299
+ static nodeType = "nodetool.video.ExtractAudio";
2300
+ static title = "Extract Audio";
2301
+ static description = "Separate and extract audio track from a video file.\n video, audio, extract, separate, split";
2302
+ static metadataOutputTypes = {
2303
+ output: "audio"
2304
+ };
2305
+ static inputFields = ["video"];
2306
+ async process(context) {
2307
+ const bytes = await videoBytesAsync(this.video, context);
2308
+ if (bytes.length === 0) {
2309
+ return {
2310
+ output: {
2311
+ type: "audio",
2312
+ data: null,
2313
+ uri: "",
2314
+ asset_id: null,
2315
+ metadata: null
2316
+ }
2317
+ };
2318
+ }
2319
+ const input = await withTempFile(".mp4", bytes);
2320
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-audio-out-"));
2321
+ const outputPath = path.join(outputDir, "output.wav");
2322
+ try {
2323
+ await execFfmpeg(["-y", "-i", input.path, "-vn", "-acodec", "pcm_s16le", outputPath], { maxBuffer: FFMPEG_MAX_BUFFER });
2324
+ const audioBytes = new Uint8Array(await fs.readFile(outputPath));
2325
+ const b64 = Buffer.from(audioBytes).toString("base64");
2326
+ return {
2327
+ output: {
2328
+ type: "audio",
2329
+ data: b64,
2330
+ uri: "",
2331
+ asset_id: null,
2332
+ metadata: null
2333
+ }
2334
+ };
2335
+ }
2336
+ finally {
2337
+ await input.cleanup();
2338
+ await fs.rm(outputDir, { recursive: true, force: true });
2339
+ }
2340
+ }
2341
+ }
2342
+ __decorate([
2343
+ prop({
2344
+ type: "video",
2345
+ default: defaultVideoRef(),
2346
+ title: "Video",
2347
+ description: "The input video to separate."
2348
+ })
2349
+ ], ExtractAudioVideoNode.prototype, "video", void 0);
2350
+ export class ExtractFrameVideoNode extends VideoTransformNode {
2351
+ static nodeType = "nodetool.video.ExtractFrame";
2352
+ static title = "Extract Video Frame";
2353
+ static description = "Extract a single frame from a video at a specific time position.\n video, frame, extract, screenshot, thumbnail, capture";
2354
+ static metadataOutputTypes = {
2355
+ output: "image"
2356
+ };
2357
+ static inputFields = ["video"];
2358
+ async process(context) {
2359
+ const bytes = await videoBytesAsync(this.video, context);
2360
+ if (bytes.length === 0) {
2361
+ return { output: { type: "image", data: null } };
2362
+ }
2363
+ const inputFile = await withTempFile(".mp4", bytes);
2364
+ const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-extract-frame-"));
2365
+ const outputPath = path.join(outputDir, "frame.png");
2366
+ try {
2367
+ const time = Math.max(0, Number(this.time ?? 0));
2368
+ await execFfmpeg([
2369
+ "-y",
2370
+ "-ss", String(time),
2371
+ "-i", inputFile.path,
2372
+ "-frames:v", "1",
2373
+ "-f", "image2",
2374
+ outputPath
2375
+ ], { maxBuffer: 10 * 1024 * 1024 });
2376
+ const frameData = new Uint8Array(await fs.readFile(outputPath));
2377
+ return {
2378
+ output: {
2379
+ type: "image",
2380
+ data: Buffer.from(frameData).toString("base64")
2381
+ }
2382
+ };
2383
+ }
2384
+ catch (error) {
2385
+ // A missing binary is unrecoverable — surface it. A genuine decode
2386
+ // failure (e.g. seeking past the end) yields an empty frame.
2387
+ if (error instanceof MissingBinaryError)
2388
+ throw error;
2389
+ return { output: { type: "image", data: null } };
2390
+ }
2391
+ finally {
2392
+ await inputFile.cleanup();
2393
+ await fs.rm(outputDir, { recursive: true, force: true });
2394
+ }
2395
+ }
2396
+ }
2397
+ __decorate([
2398
+ prop({
2399
+ type: "video",
2400
+ default: defaultVideoRef(),
2401
+ title: "Video",
2402
+ description: "The input video to extract a frame from."
2403
+ })
2404
+ ], ExtractFrameVideoNode.prototype, "video", void 0);
2405
+ __decorate([
2406
+ prop({
2407
+ type: "float",
2408
+ default: 0,
2409
+ title: "Time",
2410
+ description: "Time position in seconds to extract the frame from.",
2411
+ min: 0
2412
+ })
2413
+ ], ExtractFrameVideoNode.prototype, "time", void 0);
2414
+ export class GetVideoInfoNode extends VideoTransformNode {
2415
+ static nodeType = "nodetool.video.GetVideoInfo";
2416
+ static title = "Get Video Info";
2417
+ static description = "Get metadata about a video file including duration, resolution, frame rate, and codec.\n video, info, metadata, duration, resolution, fps, codec, analysis";
2418
+ static metadataOutputTypes = {
2419
+ duration: "float",
2420
+ width: "int",
2421
+ height: "int",
2422
+ fps: "float",
2423
+ frame_count: "int",
2424
+ codec: "str",
2425
+ has_audio: "bool"
2426
+ };
2427
+ static inputFields = ["video"];
2428
+ async process(context) {
2429
+ const bytes = await videoBytesAsync(this.video, context);
2430
+ if (bytes.length === 0) {
2431
+ return {
2432
+ duration: 0,
2433
+ width: 0,
2434
+ height: 0,
2435
+ fps: 0,
2436
+ frame_count: 0,
2437
+ codec: "",
2438
+ has_audio: false
2439
+ };
2440
+ }
2441
+ const inputFile = await withTempFile(".mp4", bytes);
2442
+ try {
2443
+ const { stdout } = await execFfprobe([
2444
+ "-v", "error",
2445
+ "-show_streams",
2446
+ "-show_format",
2447
+ "-of", "json",
2448
+ inputFile.path
2449
+ ]);
2450
+ const info = JSON.parse(stdout);
2451
+ const videoStream = (info.streams ?? []).find((s) => s.codec_type === "video");
2452
+ const hasAudio = (info.streams ?? []).some((s) => s.codec_type === "audio");
2453
+ const fps = videoStream?.r_frame_rate
2454
+ ? parseFrameRate(videoStream.r_frame_rate)
2455
+ : 0;
2456
+ const duration = Number(info.format?.duration ?? 0) || 0;
2457
+ const frameCount = videoStream?.nb_frames
2458
+ ? Number(videoStream.nb_frames) || 0
2459
+ : Math.round(duration * fps);
2460
+ return {
2461
+ duration,
2462
+ width: videoStream?.width ?? 0,
2463
+ height: videoStream?.height ?? 0,
2464
+ fps,
2465
+ frame_count: frameCount,
2466
+ codec: videoStream?.codec_name ?? "",
2467
+ has_audio: hasAudio
2468
+ };
2469
+ }
2470
+ catch (error) {
2471
+ // Missing ffprobe is actionable; a genuine probe failure on corrupt
2472
+ // input degrades to the empty info result.
2473
+ if (error instanceof MissingBinaryError)
2474
+ throw error;
2475
+ return {
2476
+ duration: 0,
2477
+ width: 0,
2478
+ height: 0,
2479
+ fps: 0,
2480
+ frame_count: 0,
2481
+ codec: "",
2482
+ has_audio: false
2483
+ };
2484
+ }
2485
+ finally {
2486
+ await inputFile.cleanup();
2487
+ }
2488
+ }
2489
+ }
2490
+ __decorate([
2491
+ prop({
2492
+ type: "video",
2493
+ default: defaultVideoRef(),
2494
+ title: "Video",
2495
+ description: "The input video to analyze."
2496
+ })
2497
+ ], GetVideoInfoNode.prototype, "video", void 0);
2498
+ export class VideoToVideoNode extends BaseNode {
2499
+ static nodeType = "nodetool.video.VideoToVideo";
2500
+ static body = "content_card";
2501
+ static title = "Video To Video";
2502
+ static description = "Restyle or edit an existing video with a text prompt using any supported video provider.\n video, video-to-video, v2v, restyle, style-transfer, AI";
2503
+ static metadataOutputTypes = { output: "video" };
2504
+ static inlineFields = [];
2505
+ static inputFields = ["video", "prompt"];
2506
+ static autoSaveAsset = true;
2507
+ async process(context) {
2508
+ const bytes = await videoBytesAsync(this.video, context);
2509
+ if (bytes.length === 0)
2510
+ throw new Error("The input video is empty.");
2511
+ const { providerId, modelId } = modelConfig(this.serialize());
2512
+ if (!canUseProvider(context, providerId, modelId)) {
2513
+ throw new Error("No provider available for video-to-video generation.");
2514
+ }
2515
+ const output = coerceProviderBytes(await context.runProviderPrediction({
2516
+ provider: providerId,
2517
+ capability: "video_to_video",
2518
+ model: modelId,
2519
+ params: {
2520
+ video: bytes,
2521
+ prompt: String(this.prompt ?? ""),
2522
+ negative_prompt: this.negative_prompt,
2523
+ strength: Number(this.strength ?? 0.6)
2524
+ }
2525
+ }), "Video To Video");
2526
+ return { output: videoRef(output) };
2527
+ }
2528
+ }
2529
+ __decorate([
2530
+ prop({
2531
+ type: "video_model",
2532
+ default: {
2533
+ type: "video_model",
2534
+ provider: "fal_ai",
2535
+ id: "fal-ai/ltx-2-19b/distilled/video-to-video",
2536
+ name: "LTX Video To Video",
2537
+ path: null,
2538
+ supported_tasks: []
2539
+ },
2540
+ title: "Model",
2541
+ description: "The video-to-video model to use"
2542
+ })
2543
+ ], VideoToVideoNode.prototype, "model", void 0);
2544
+ __decorate([
2545
+ prop({
2546
+ type: "video",
2547
+ default: defaultVideoRef(),
2548
+ title: "Video",
2549
+ description: "The input video to transform"
2550
+ })
2551
+ ], VideoToVideoNode.prototype, "video", void 0);
2552
+ __decorate([
2553
+ prop({
2554
+ type: "str",
2555
+ default: "",
2556
+ title: "Prompt",
2557
+ description: "Text prompt describing the desired transformation"
2558
+ })
2559
+ ], VideoToVideoNode.prototype, "prompt", void 0);
2560
+ __decorate([
2561
+ prop({
2562
+ type: "str",
2563
+ default: "",
2564
+ title: "Negative Prompt",
2565
+ description: "Text prompt describing what to avoid"
2566
+ })
2567
+ ], VideoToVideoNode.prototype, "negative_prompt", void 0);
2568
+ __decorate([
2569
+ prop({
2570
+ type: "float",
2571
+ default: 0.6,
2572
+ title: "Strength",
2573
+ description: "How much to transform the input video",
2574
+ min: 0,
2575
+ max: 1
2576
+ })
2577
+ ], VideoToVideoNode.prototype, "strength", void 0);
2578
+ export class LipSyncNode extends BaseNode {
2579
+ static nodeType = "nodetool.video.LipSync";
2580
+ static body = "content_card";
2581
+ static title = "Lip Sync";
2582
+ static description = "Drive a face in a video to match speech in an audio track using any supported lip-sync provider.\n video, lip-sync, lipsync, talking-head, dubbing, AI";
2583
+ static metadataOutputTypes = { output: "video" };
2584
+ static inlineFields = [];
2585
+ static inputFields = ["video", "audio"];
2586
+ static autoSaveAsset = true;
2587
+ async process(context) {
2588
+ const video = await videoBytesAsync(this.video, context);
2589
+ if (video.length === 0)
2590
+ throw new Error("The input video is empty.");
2591
+ const audio = await audioBytesAsync(this.audio, context);
2592
+ if (audio.length === 0)
2593
+ throw new Error("The input audio is empty.");
2594
+ const { providerId, modelId } = modelConfig(this.serialize());
2595
+ if (!canUseProvider(context, providerId, modelId)) {
2596
+ throw new Error("No provider available for lip sync.");
2597
+ }
2598
+ const output = coerceProviderBytes(await context.runProviderPrediction({
2599
+ provider: providerId,
2600
+ capability: "lip_sync",
2601
+ model: modelId,
2602
+ params: { video, audio }
2603
+ }), "Lip Sync");
2604
+ return { output: videoRef(output) };
2605
+ }
2606
+ }
2607
+ __decorate([
2608
+ prop({
2609
+ type: "video_model",
2610
+ default: {
2611
+ type: "video_model",
2612
+ provider: "fal_ai",
2613
+ id: "fal-ai/sync-lipsync/v2",
2614
+ name: "Sync Lipsync V2",
2615
+ path: null,
2616
+ supported_tasks: []
2617
+ },
2618
+ title: "Model",
2619
+ description: "The lip-sync model to use"
2620
+ })
2621
+ ], LipSyncNode.prototype, "model", void 0);
2622
+ __decorate([
2623
+ prop({
2624
+ type: "video",
2625
+ default: defaultVideoRef(),
2626
+ title: "Video",
2627
+ description: "The input video containing the face to drive"
2628
+ })
2629
+ ], LipSyncNode.prototype, "video", void 0);
2630
+ __decorate([
2631
+ prop({
2632
+ type: "audio",
2633
+ default: { type: "audio", uri: "", asset_id: null, data: null, metadata: null },
2634
+ title: "Audio",
2635
+ description: "The audio track the mouth motion should follow"
2636
+ })
2637
+ ], LipSyncNode.prototype, "audio", void 0);
2638
+ export const VIDEO_NODES = [
2639
+ TextToVideoNode,
2640
+ ImageToVideoNode,
2641
+ ReferenceToVideoNode,
2642
+ LoadVideoFileNode,
2643
+ SaveVideoFileVideoNode,
2644
+ LoadVideoAssetsNode,
2645
+ SaveVideoNode,
2646
+ ForEachFrameNode,
2647
+ FpsNode,
2648
+ FrameToVideoNode,
2649
+ ConcatVideoNode,
2650
+ TrimVideoNode,
2651
+ ResizeVideoNode,
2652
+ RotateVideoNode,
2653
+ SetSpeedVideoNode,
2654
+ OverlayVideoNode,
2655
+ ColorBalanceVideoNode,
2656
+ DenoiseVideoNode,
2657
+ StabilizeVideoNode,
2658
+ SharpnessVideoNode,
2659
+ BlurVideoNode,
2660
+ SaturationVideoNode,
2661
+ AddSubtitlesVideoNode,
2662
+ ReverseVideoNode,
2663
+ TransitionVideoNode,
2664
+ AddAudioVideoNode,
2665
+ ChromaKeyVideoNode,
2666
+ ExtractAudioVideoNode,
2667
+ ExtractFrameVideoNode,
2668
+ GetVideoInfoNode,
2669
+ VideoToVideoNode,
2670
+ LipSyncNode
2671
+ ];
2672
+ //# sourceMappingURL=video.js.map