@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.
- package/README.md +58 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes/ffmpeg-helpers.d.ts +77 -0
- package/dist/nodes/ffmpeg-helpers.d.ts.map +1 -0
- package/dist/nodes/ffmpeg-helpers.js +220 -0
- package/dist/nodes/ffmpeg-helpers.js.map +1 -0
- package/dist/nodes/lib-video-download.d.ts +29 -0
- package/dist/nodes/lib-video-download.d.ts.map +1 -0
- package/dist/nodes/lib-video-download.js +353 -0
- package/dist/nodes/lib-video-download.js.map +1 -0
- package/dist/nodes/model3d/analysis.d.ts +16 -0
- package/dist/nodes/model3d/analysis.d.ts.map +1 -0
- package/dist/nodes/model3d/analysis.js +38 -0
- package/dist/nodes/model3d/analysis.js.map +1 -0
- package/dist/nodes/model3d/base.d.ts +28 -0
- package/dist/nodes/model3d/base.d.ts.map +1 -0
- package/dist/nodes/model3d/base.js +20 -0
- package/dist/nodes/model3d/base.js.map +1 -0
- package/dist/nodes/model3d/boolean-ops.d.ts +2 -0
- package/dist/nodes/model3d/boolean-ops.d.ts.map +1 -0
- package/dist/nodes/model3d/boolean-ops.js +172 -0
- package/dist/nodes/model3d/boolean-ops.js.map +1 -0
- package/dist/nodes/model3d/cleanup.d.ts +56 -0
- package/dist/nodes/model3d/cleanup.d.ts.map +1 -0
- package/dist/nodes/model3d/cleanup.js +144 -0
- package/dist/nodes/model3d/cleanup.js.map +1 -0
- package/dist/nodes/model3d/convert.d.ts +27 -0
- package/dist/nodes/model3d/convert.d.ts.map +1 -0
- package/dist/nodes/model3d/convert.js +66 -0
- package/dist/nodes/model3d/convert.js.map +1 -0
- package/dist/nodes/model3d/defaults.d.ts +34 -0
- package/dist/nodes/model3d/defaults.d.ts.map +1 -0
- package/dist/nodes/model3d/defaults.js +28 -0
- package/dist/nodes/model3d/defaults.js.map +1 -0
- package/dist/nodes/model3d/document-ops.d.ts +4 -0
- package/dist/nodes/model3d/document-ops.d.ts.map +1 -0
- package/dist/nodes/model3d/document-ops.js +40 -0
- package/dist/nodes/model3d/document-ops.js.map +1 -0
- package/dist/nodes/model3d/generation.d.ts +56 -0
- package/dist/nodes/model3d/generation.d.ts.map +1 -0
- package/dist/nodes/model3d/generation.js +203 -0
- package/dist/nodes/model3d/generation.js.map +1 -0
- package/dist/nodes/model3d/glb.d.ts +29 -0
- package/dist/nodes/model3d/glb.d.ts.map +1 -0
- package/dist/nodes/model3d/glb.js +255 -0
- package/dist/nodes/model3d/glb.js.map +1 -0
- package/dist/nodes/model3d/index.d.ts +22 -0
- package/dist/nodes/model3d/index.d.ts.map +1 -0
- package/dist/nodes/model3d/index.js +41 -0
- package/dist/nodes/model3d/index.js.map +1 -0
- package/dist/nodes/model3d/io.d.ts +81 -0
- package/dist/nodes/model3d/io.d.ts.map +1 -0
- package/dist/nodes/model3d/io.js +162 -0
- package/dist/nodes/model3d/io.js.map +1 -0
- package/dist/nodes/model3d/mesh-ops.d.ts +40 -0
- package/dist/nodes/model3d/mesh-ops.d.ts.map +1 -0
- package/dist/nodes/model3d/mesh-ops.js +775 -0
- package/dist/nodes/model3d/mesh-ops.js.map +1 -0
- package/dist/nodes/model3d/nodes.d.ts +2 -0
- package/dist/nodes/model3d/nodes.d.ts.map +1 -0
- package/dist/nodes/model3d/nodes.js +2 -0
- package/dist/nodes/model3d/nodes.js.map +1 -0
- package/dist/nodes/model3d/ops.d.ts +44 -0
- package/dist/nodes/model3d/ops.d.ts.map +1 -0
- package/dist/nodes/model3d/ops.js +142 -0
- package/dist/nodes/model3d/ops.js.map +1 -0
- package/dist/nodes/model3d/render.d.ts +51 -0
- package/dist/nodes/model3d/render.d.ts.map +1 -0
- package/dist/nodes/model3d/render.js +124 -0
- package/dist/nodes/model3d/render.js.map +1 -0
- package/dist/nodes/model3d/render3d-core.d.ts +177 -0
- package/dist/nodes/model3d/render3d-core.d.ts.map +1 -0
- package/dist/nodes/model3d/render3d-core.js +366 -0
- package/dist/nodes/model3d/render3d-core.js.map +1 -0
- package/dist/nodes/model3d/render3d-headless.d.ts +75 -0
- package/dist/nodes/model3d/render3d-headless.d.ts.map +1 -0
- package/dist/nodes/model3d/render3d-headless.js +190 -0
- package/dist/nodes/model3d/render3d-headless.js.map +1 -0
- package/dist/nodes/model3d/render3d-page.d.ts +16 -0
- package/dist/nodes/model3d/render3d-page.d.ts.map +1 -0
- package/dist/nodes/model3d/render3d-page.js +51 -0
- package/dist/nodes/model3d/render3d-page.js.map +1 -0
- package/dist/nodes/model3d/transforms.d.ts +56 -0
- package/dist/nodes/model3d/transforms.d.ts.map +1 -0
- package/dist/nodes/model3d/transforms.js +148 -0
- package/dist/nodes/model3d/transforms.js.map +1 -0
- package/dist/nodes/model3d/types.d.ts +82 -0
- package/dist/nodes/model3d/types.d.ts.map +1 -0
- package/dist/nodes/model3d/types.js +2 -0
- package/dist/nodes/model3d/types.js.map +1 -0
- package/dist/nodes/model3d/utils.d.ts +46 -0
- package/dist/nodes/model3d/utils.d.ts.map +1 -0
- package/dist/nodes/model3d/utils.js +190 -0
- package/dist/nodes/model3d/utils.js.map +1 -0
- package/dist/nodes/model3d.d.ts +2 -0
- package/dist/nodes/model3d.d.ts.map +1 -0
- package/dist/nodes/model3d.js +2 -0
- package/dist/nodes/model3d.js.map +1 -0
- package/dist/nodes/placeholder-values.d.ts +10 -0
- package/dist/nodes/placeholder-values.d.ts.map +1 -0
- package/dist/nodes/placeholder-values.js +19 -0
- package/dist/nodes/placeholder-values.js.map +1 -0
- package/dist/nodes/script.d.ts +161 -0
- package/dist/nodes/script.d.ts.map +1 -0
- package/dist/nodes/script.js +673 -0
- package/dist/nodes/script.js.map +1 -0
- package/dist/nodes/storyboard.d.ts +269 -0
- package/dist/nodes/storyboard.d.ts.map +1 -0
- package/dist/nodes/storyboard.js +1017 -0
- package/dist/nodes/storyboard.js.map +1 -0
- package/dist/nodes/timeline/assetFiles.d.ts +19 -0
- package/dist/nodes/timeline/assetFiles.d.ts.map +1 -0
- package/dist/nodes/timeline/assetFiles.js +52 -0
- package/dist/nodes/timeline/assetFiles.js.map +1 -0
- package/dist/nodes/timeline/compositeRender.d.ts +60 -0
- package/dist/nodes/timeline/compositeRender.d.ts.map +1 -0
- package/dist/nodes/timeline/compositeRender.js +508 -0
- package/dist/nodes/timeline/compositeRender.js.map +1 -0
- package/dist/nodes/timeline/contactSheet.d.ts +43 -0
- package/dist/nodes/timeline/contactSheet.d.ts.map +1 -0
- package/dist/nodes/timeline/contactSheet.js +78 -0
- package/dist/nodes/timeline/contactSheet.js.map +1 -0
- package/dist/nodes/timeline/outputFormats.d.ts +69 -0
- package/dist/nodes/timeline/outputFormats.d.ts.map +1 -0
- package/dist/nodes/timeline/outputFormats.js +173 -0
- package/dist/nodes/timeline/outputFormats.js.map +1 -0
- package/dist/nodes/timeline/pngSequence.d.ts +44 -0
- package/dist/nodes/timeline/pngSequence.d.ts.map +1 -0
- package/dist/nodes/timeline/pngSequence.js +172 -0
- package/dist/nodes/timeline/pngSequence.js.map +1 -0
- package/dist/nodes/timeline/rasterizers.d.ts +47 -0
- package/dist/nodes/timeline/rasterizers.d.ts.map +1 -0
- package/dist/nodes/timeline/rasterizers.js +103 -0
- package/dist/nodes/timeline/rasterizers.js.map +1 -0
- package/dist/nodes/timeline/rawFrames.d.ts +149 -0
- package/dist/nodes/timeline/rawFrames.d.ts.map +1 -0
- package/dist/nodes/timeline/rawFrames.js +598 -0
- package/dist/nodes/timeline/rawFrames.js.map +1 -0
- package/dist/nodes/timeline.d.ts +206 -0
- package/dist/nodes/timeline.d.ts.map +1 -0
- package/dist/nodes/timeline.js +1395 -0
- package/dist/nodes/timeline.js.map +1 -0
- package/dist/nodes/video.d.ts +705 -0
- package/dist/nodes/video.d.ts.map +1 -0
- package/dist/nodes/video.js +2672 -0
- package/dist/nodes/video.js.map +1 -0
- package/dist/render3d-page.js +4118 -0
- package/package.json +95 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script nodes — operate on persisted scripts (the script editor's documents)
|
|
3
|
+
* referenced by the `script` type. A script owns its text; audio is derived.
|
|
4
|
+
*
|
|
5
|
+
* - LoadScript reads a script's text and metadata for downstream LLM/text nodes.
|
|
6
|
+
* - VoiceScript batch-synthesizes every draft/stale line with its cast voice,
|
|
7
|
+
* appending a take per line and persisting the script.
|
|
8
|
+
* - ScriptToTimeline assembles the current takes into a voiceover sequence,
|
|
9
|
+
* the headless mirror of the editor's "Send to timeline".
|
|
10
|
+
* - WriteScript writes a new script from a brief through the same authoring
|
|
11
|
+
* rules the script surface and the `write_script` capability use.
|
|
12
|
+
* - FillScript derives a new script from a template by filling its `{{key}}`
|
|
13
|
+
* placeholders. Both create a row; neither writes the script it read.
|
|
14
|
+
*/
|
|
15
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
16
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
17
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
18
|
+
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;
|
|
19
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
20
|
+
};
|
|
21
|
+
import { BaseNode, prop } from "@nodetool-ai/node-sdk";
|
|
22
|
+
import { SCRIPT_TOOL_DESCRIPTION, SCRIPT_TOOL_NAME, SCRIPT_WRITER_SYSTEM_PROMPT, buildScriptSchema, buildScriptWriterPrompt, fallbackScript, fillScript, parseWrittenScript } from "@nodetool-ai/protocol";
|
|
23
|
+
import { generateStructured, resolveEntities } from "@nodetool-ai/runtime";
|
|
24
|
+
import { concatBytes, encodePcm16Wav } from "@nodetool-ai/audio-nodes";
|
|
25
|
+
import { assembleSubtitleCues, buildScriptTimeline, currentTake, effectiveVoice, formatSubtitles, makeSequence, needsVoicing, scriptLines, PLACEHOLDER_LINE_MS } from "@nodetool-ai/timeline";
|
|
26
|
+
import { tagAsNode, tagAsServer } from "@nodetool-ai/nodes-utils";
|
|
27
|
+
import { promises as fs } from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import { ffprobeDuration } from "./ffmpeg-helpers.js";
|
|
32
|
+
import { stringValues } from "./placeholder-values.js";
|
|
33
|
+
import { isPositiveNumber, isString } from "@nodetool-ai/node-sdk";
|
|
34
|
+
const scriptRefDefault = { type: "script", id: null, data: null };
|
|
35
|
+
async function loadScript(ref, context) {
|
|
36
|
+
const scriptRef = (ref ?? {});
|
|
37
|
+
if (!scriptRef.id) {
|
|
38
|
+
throw new Error("Script input is empty — connect a Constant Script node and pick a script");
|
|
39
|
+
}
|
|
40
|
+
if (!context) {
|
|
41
|
+
throw new Error("Script nodes require a processing context");
|
|
42
|
+
}
|
|
43
|
+
const script = (await context.getScript(scriptRef.id));
|
|
44
|
+
if (!script) {
|
|
45
|
+
throw new Error(`Script not found: ${scriptRef.id}`);
|
|
46
|
+
}
|
|
47
|
+
return script;
|
|
48
|
+
}
|
|
49
|
+
const allLines = (doc) => scriptLines(doc.sections);
|
|
50
|
+
export class LoadScriptNode extends BaseNode {
|
|
51
|
+
static nodeType = "nodetool.script.LoadScript";
|
|
52
|
+
static title = "Load Script";
|
|
53
|
+
static description = "Read a persisted script's text and metadata.\n script, text, voiceover, narration, load\n\n Use cases:\n - Feed a script's text into an LLM or text node\n - Inspect line count and cast before voicing\n - Branch a workflow on a script's contents";
|
|
54
|
+
static metadataOutputTypes = {
|
|
55
|
+
text: "str",
|
|
56
|
+
lines: "list[str]",
|
|
57
|
+
name: "str",
|
|
58
|
+
line_count: "int"
|
|
59
|
+
};
|
|
60
|
+
static inlineFields = ["script"];
|
|
61
|
+
static inputFields = ["script"];
|
|
62
|
+
async process(context) {
|
|
63
|
+
const script = await loadScript(this.script, context);
|
|
64
|
+
const lines = allLines(script.document).map((line) => line.text);
|
|
65
|
+
return {
|
|
66
|
+
text: lines.join("\n"),
|
|
67
|
+
lines,
|
|
68
|
+
name: script.name,
|
|
69
|
+
line_count: lines.length
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
__decorate([
|
|
74
|
+
prop({
|
|
75
|
+
type: "script",
|
|
76
|
+
default: scriptRefDefault,
|
|
77
|
+
title: "Script",
|
|
78
|
+
description: "The script to read."
|
|
79
|
+
})
|
|
80
|
+
], LoadScriptNode.prototype, "script", void 0);
|
|
81
|
+
export class VoiceScriptNode extends BaseNode {
|
|
82
|
+
static nodeType = "nodetool.script.VoiceScript";
|
|
83
|
+
static title = "Voice Script";
|
|
84
|
+
static description = "Synthesize speech for every draft or stale line of a script, using each line's cast voice, and save the takes back onto the script. Lines already up to date, or with no text or no voice, are skipped.\n script, voiceover, tts, narration, batch\n\n Use cases:\n - Voice an LLM-written script in one step\n - Re-voice lines whose text or voice changed\n - Produce narration assets for timeline assembly";
|
|
85
|
+
static requiredRuntimes = ["ffmpeg"];
|
|
86
|
+
static metadataOutputTypes = {
|
|
87
|
+
output: "script",
|
|
88
|
+
voiced_count: "int"
|
|
89
|
+
};
|
|
90
|
+
static inlineFields = ["script"];
|
|
91
|
+
static inputFields = ["script"];
|
|
92
|
+
async process(context) {
|
|
93
|
+
if (!context) {
|
|
94
|
+
throw new Error("VoiceScript requires a processing context");
|
|
95
|
+
}
|
|
96
|
+
const script = await loadScript(this.script, context);
|
|
97
|
+
const doc = script.document;
|
|
98
|
+
const cast = doc.cast ?? [];
|
|
99
|
+
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "nodetool-voice-script-"));
|
|
100
|
+
let voiced = 0;
|
|
101
|
+
try {
|
|
102
|
+
for (const line of allLines(doc)) {
|
|
103
|
+
const text = line.text.trim();
|
|
104
|
+
if (!text)
|
|
105
|
+
continue;
|
|
106
|
+
const voice = effectiveVoice(line, cast);
|
|
107
|
+
if (!voice)
|
|
108
|
+
continue;
|
|
109
|
+
if (!needsVoicing(line, voice))
|
|
110
|
+
continue;
|
|
111
|
+
const synth = await synthesizeLine(context, text, voice, this.speed);
|
|
112
|
+
if (!synth)
|
|
113
|
+
continue;
|
|
114
|
+
const durationMs = await probeDurationMs(synth.bytes, workDir, voiced);
|
|
115
|
+
const asset = (await context.createAsset({
|
|
116
|
+
name: `${script.name || "script"}-line-${voiced + 1}`,
|
|
117
|
+
contentType: synth.contentType,
|
|
118
|
+
content: synth.bytes
|
|
119
|
+
}));
|
|
120
|
+
const take = {
|
|
121
|
+
id: `take_${randomUUID()}`,
|
|
122
|
+
assetId: asset.id,
|
|
123
|
+
durationMs,
|
|
124
|
+
words: [],
|
|
125
|
+
textSnapshot: line.text,
|
|
126
|
+
voiceSnapshot: voice,
|
|
127
|
+
createdAt: new Date().toISOString()
|
|
128
|
+
};
|
|
129
|
+
line.takes = [...line.takes, take];
|
|
130
|
+
line.currentTakeId = take.id;
|
|
131
|
+
voiced += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
if (voiced > 0) {
|
|
138
|
+
const saved = (await context.updateScript(script.id, {
|
|
139
|
+
document: doc,
|
|
140
|
+
baseUpdatedAt: script.updatedAt
|
|
141
|
+
}));
|
|
142
|
+
if (!saved) {
|
|
143
|
+
throw new Error("VoiceScript: failed to save the script (it was modified concurrently)");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
output: { type: "script", id: script.id },
|
|
148
|
+
voiced_count: voiced
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
__decorate([
|
|
153
|
+
prop({
|
|
154
|
+
type: "script",
|
|
155
|
+
default: scriptRefDefault,
|
|
156
|
+
title: "Script",
|
|
157
|
+
description: "The script whose lines to voice."
|
|
158
|
+
})
|
|
159
|
+
], VoiceScriptNode.prototype, "script", void 0);
|
|
160
|
+
__decorate([
|
|
161
|
+
prop({
|
|
162
|
+
type: "float",
|
|
163
|
+
default: 1,
|
|
164
|
+
title: "Speed",
|
|
165
|
+
description: "Speech speed multiplier passed to the TTS provider.",
|
|
166
|
+
min: 0.25,
|
|
167
|
+
max: 4
|
|
168
|
+
})
|
|
169
|
+
], VoiceScriptNode.prototype, "speed", void 0);
|
|
170
|
+
export class ScriptToTimelineNode extends BaseNode {
|
|
171
|
+
static nodeType = "nodetool.script.ScriptToTimeline";
|
|
172
|
+
static title = "Script To Timeline";
|
|
173
|
+
static description = "Assemble a script's current takes into a voiceover timeline — one audio clip per voiced line, laid end to end with the authored pauses, each linked back to its script line. Updates the linked timeline in place when the script already has one. Voice the script first.\n script, timeline, voiceover, assemble, sequence\n\n Use cases:\n - Turn a voiced script into an editable sequence\n - Build a narration track for a video edit\n - Round-trip re-voiced lines into an existing timeline";
|
|
174
|
+
static metadataOutputTypes = {
|
|
175
|
+
output: "timeline"
|
|
176
|
+
};
|
|
177
|
+
static inlineFields = ["script"];
|
|
178
|
+
static inputFields = ["script"];
|
|
179
|
+
async process(context) {
|
|
180
|
+
if (!context) {
|
|
181
|
+
throw new Error("ScriptToTimeline requires a processing context");
|
|
182
|
+
}
|
|
183
|
+
const script = await loadScript(this.script, context);
|
|
184
|
+
const doc = script.document;
|
|
185
|
+
const name = script.name?.trim() || "Script voiceover";
|
|
186
|
+
// Same mapping the editor's "Send to timeline" and the headless
|
|
187
|
+
// assemble_script_timeline tool use.
|
|
188
|
+
const { tracks, clips, durationMs: cursorMs } = buildScriptTimeline({
|
|
189
|
+
scriptId: script.id,
|
|
190
|
+
cast: doc.cast ?? [],
|
|
191
|
+
sections: doc.sections
|
|
192
|
+
});
|
|
193
|
+
if (clips.length === 0) {
|
|
194
|
+
throw new Error("ScriptToTimeline: no voiced lines to assemble — voice the script first");
|
|
195
|
+
}
|
|
196
|
+
const existingId = script.timelineId;
|
|
197
|
+
let seq;
|
|
198
|
+
if (existingId) {
|
|
199
|
+
// Re-assemble: replace this script's voiceover track/clips, keep any
|
|
200
|
+
// other tracks and clips the editor added.
|
|
201
|
+
const existing = (await context.getTimelineSequence(existingId));
|
|
202
|
+
if (!existing) {
|
|
203
|
+
throw new Error(`Linked timeline not found: ${existingId}`);
|
|
204
|
+
}
|
|
205
|
+
const foreignClips = existing.clips.filter((c) => c.scriptId !== script.id);
|
|
206
|
+
const thisScriptTrackIds = new Set(existing.clips
|
|
207
|
+
.filter((c) => c.scriptId === script.id)
|
|
208
|
+
.map((c) => c.trackId));
|
|
209
|
+
const foreignTrackIds = new Set(foreignClips.map((c) => c.trackId));
|
|
210
|
+
const foreignTracks = existing.tracks.filter((t) => foreignTrackIds.has(t.id) || !thisScriptTrackIds.has(t.id));
|
|
211
|
+
seq = {
|
|
212
|
+
...existing,
|
|
213
|
+
tracks: [...tracks, ...foreignTracks],
|
|
214
|
+
clips: [...clips, ...foreignClips]
|
|
215
|
+
};
|
|
216
|
+
seq.durationMs = seq.clips.reduce((end, c) => Math.max(end, c.startMs + c.durationMs), 0);
|
|
217
|
+
const saved = (await context.updateTimelineSequence(existingId, seq));
|
|
218
|
+
if (!saved) {
|
|
219
|
+
throw new Error("ScriptToTimeline: failed to update the linked timeline");
|
|
220
|
+
}
|
|
221
|
+
return { output: { type: "timeline", id: saved.id } };
|
|
222
|
+
}
|
|
223
|
+
seq = makeSequence({ name, projectId: script.projectId, tracks, clips });
|
|
224
|
+
seq.durationMs = cursorMs;
|
|
225
|
+
const saved = (await context.createTimelineSequence(seq));
|
|
226
|
+
if (!saved) {
|
|
227
|
+
throw new Error("ScriptToTimeline: failed to create the timeline");
|
|
228
|
+
}
|
|
229
|
+
// Link the script to the new sequence (best-effort — a CAS miss doesn't
|
|
230
|
+
// invalidate the timeline we just wrote).
|
|
231
|
+
try {
|
|
232
|
+
await context.updateScript(script.id, { timelineId: saved.id });
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
console.warn("ScriptToTimeline: failed to link script to timeline", error);
|
|
236
|
+
}
|
|
237
|
+
return { output: { type: "timeline", id: saved.id } };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
__decorate([
|
|
241
|
+
prop({
|
|
242
|
+
type: "script",
|
|
243
|
+
default: scriptRefDefault,
|
|
244
|
+
title: "Script",
|
|
245
|
+
description: "The voiced script to assemble."
|
|
246
|
+
})
|
|
247
|
+
], ScriptToTimelineNode.prototype, "script", void 0);
|
|
248
|
+
export class ScriptToSubtitlesNode extends BaseNode {
|
|
249
|
+
static nodeType = "nodetool.script.ScriptToSubtitles";
|
|
250
|
+
static title = "Script To Subtitles";
|
|
251
|
+
static description = "Export a voiced script as SRT or WebVTT subtitles, straight from each current take's word timings — one cue per line (or per word), laid out end to end with the authored pauses. Voice the script first; unvoiced lines are skipped.\n script, subtitles, srt, vtt, captions, export\n\n Use cases:\n - Produce a subtitle sidecar for a voiced narration\n - Generate word-timed captions from take timings\n - Feed subtitles into a burn-in or upload step";
|
|
252
|
+
static metadataOutputTypes = {
|
|
253
|
+
subtitles: "str",
|
|
254
|
+
cue_count: "int"
|
|
255
|
+
};
|
|
256
|
+
static inlineFields = ["script", "format", "granularity"];
|
|
257
|
+
static inputFields = ["script"];
|
|
258
|
+
async process(context) {
|
|
259
|
+
const script = await loadScript(this.script, context);
|
|
260
|
+
const doc = script.document;
|
|
261
|
+
const entries = [];
|
|
262
|
+
for (const line of allLines(doc)) {
|
|
263
|
+
const take = currentTake(line);
|
|
264
|
+
if (!take || !take.assetId)
|
|
265
|
+
continue;
|
|
266
|
+
const text = line.text.trim();
|
|
267
|
+
if (!text)
|
|
268
|
+
continue;
|
|
269
|
+
entries.push({
|
|
270
|
+
text: line.text,
|
|
271
|
+
durationMs: take.durationMs > 0 ? take.durationMs : PLACEHOLDER_LINE_MS,
|
|
272
|
+
words: take.words,
|
|
273
|
+
pauseAfterMs: line.pauseAfterMs
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (entries.length === 0) {
|
|
277
|
+
throw new Error("ScriptToSubtitles: no voiced lines to export — voice the script first");
|
|
278
|
+
}
|
|
279
|
+
const cues = assembleSubtitleCues(entries, {
|
|
280
|
+
granularity: this.granularity
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
subtitles: formatSubtitles(cues, this.format),
|
|
284
|
+
cue_count: cues.length
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
__decorate([
|
|
289
|
+
prop({
|
|
290
|
+
type: "script",
|
|
291
|
+
default: scriptRefDefault,
|
|
292
|
+
title: "Script",
|
|
293
|
+
description: "The voiced script to export subtitles from."
|
|
294
|
+
})
|
|
295
|
+
], ScriptToSubtitlesNode.prototype, "script", void 0);
|
|
296
|
+
__decorate([
|
|
297
|
+
prop({
|
|
298
|
+
type: "enum",
|
|
299
|
+
default: "srt",
|
|
300
|
+
title: "Format",
|
|
301
|
+
description: "Subtitle format: SubRip (.srt) or WebVTT (.vtt).",
|
|
302
|
+
values: ["srt", "vtt"]
|
|
303
|
+
})
|
|
304
|
+
], ScriptToSubtitlesNode.prototype, "format", void 0);
|
|
305
|
+
__decorate([
|
|
306
|
+
prop({
|
|
307
|
+
type: "enum",
|
|
308
|
+
default: "line",
|
|
309
|
+
title: "Granularity",
|
|
310
|
+
description: "One cue per line (whole line text) or per word (using take word timings).",
|
|
311
|
+
values: ["line", "word"]
|
|
312
|
+
})
|
|
313
|
+
], ScriptToSubtitlesNode.prototype, "granularity", void 0);
|
|
314
|
+
// ── Template derivations (design docs/graph-resources/design.md § 4.3) ──
|
|
315
|
+
const EMPTY_LANGUAGE_MODEL = {
|
|
316
|
+
type: "language_model",
|
|
317
|
+
provider: "empty",
|
|
318
|
+
id: "",
|
|
319
|
+
name: "",
|
|
320
|
+
path: null,
|
|
321
|
+
supported_tasks: []
|
|
322
|
+
};
|
|
323
|
+
/** Tokens the writer is allowed for one script — the capability's own cap. */
|
|
324
|
+
const MAX_WRITER_TOKENS = 8192;
|
|
325
|
+
/** Length the writer aims at when a graph does not say. The middle choice. */
|
|
326
|
+
const DEFAULT_SCRIPT_SECONDS = 60;
|
|
327
|
+
/**
|
|
328
|
+
* The brief the writer reads: the graph's brief, plus the cast a graph handed
|
|
329
|
+
* in. Without the names the model invents its own, and no speaker could be
|
|
330
|
+
* bound to the entity that is meant to voice it.
|
|
331
|
+
*/
|
|
332
|
+
function briefWithCast(brief, cast) {
|
|
333
|
+
if (cast.length === 0)
|
|
334
|
+
return brief;
|
|
335
|
+
const roster = cast
|
|
336
|
+
.map((entity) => entity.descriptor.trim()
|
|
337
|
+
? `${entity.name} — ${entity.descriptor.trim()}`
|
|
338
|
+
: entity.name)
|
|
339
|
+
.join("; ");
|
|
340
|
+
return `${brief.trim()}\n\nCast: ${roster}. Use these names for the speakers.`;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Bind each written speaker to a cast entity: by name where the writer used
|
|
344
|
+
* one, then positionally for whatever is left over. A format's speaker is
|
|
345
|
+
* "Narrator" whatever the entity is called, so a name-only rule would leave the
|
|
346
|
+
* one narrator of a voiceover script unvoiced.
|
|
347
|
+
*/
|
|
348
|
+
function bindCastToSpeakers(written, cast, voice) {
|
|
349
|
+
const byName = new Map(cast.map((entity) => [entity.name.trim().toLowerCase(), entity]));
|
|
350
|
+
const taken = new Set();
|
|
351
|
+
const bound = written.cast.map((speaker) => {
|
|
352
|
+
const match = byName.get(speaker.name.trim().toLowerCase());
|
|
353
|
+
if (match && !taken.has(match.id)) {
|
|
354
|
+
taken.add(match.id);
|
|
355
|
+
return { speaker, entity: match };
|
|
356
|
+
}
|
|
357
|
+
return { speaker, entity: null };
|
|
358
|
+
});
|
|
359
|
+
const spare = cast.filter((entity) => !taken.has(entity.id));
|
|
360
|
+
for (const entry of bound) {
|
|
361
|
+
if (entry.entity || spare.length === 0)
|
|
362
|
+
continue;
|
|
363
|
+
entry.entity = spare.shift() ?? null;
|
|
364
|
+
}
|
|
365
|
+
return bound.map(({ speaker, entity }) => {
|
|
366
|
+
const member = { id: speaker.id, name: speaker.name };
|
|
367
|
+
if (!entity)
|
|
368
|
+
return member;
|
|
369
|
+
member.entityId = entity.id;
|
|
370
|
+
// A binding is provider, model and voice together or nothing at all —
|
|
371
|
+
// half a binding would look voiceable and synthesize silence.
|
|
372
|
+
const voiceId = entity.voice_id ?? "";
|
|
373
|
+
if (voiceId && voice.provider && voice.model) {
|
|
374
|
+
member.voice = { ...voice, voice: voiceId };
|
|
375
|
+
}
|
|
376
|
+
return member;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
export class WriteScriptNode extends BaseNode {
|
|
380
|
+
static nodeType = "nodetool.script.WriteScript";
|
|
381
|
+
static title = "Write Script";
|
|
382
|
+
static description = "Write a new script from a brief, using the same writer rules the script editor and the agent use, and save it as a new script. Cast entities become speakers, each carrying its entity link and its voice.\n script, write, brief, narration, cast\n\n Use cases:\n - Turn a per-row brief into a script inside a batch\n - Write one script per language from the same brief\n - Author narration for a board without opening the editor";
|
|
383
|
+
static metadataOutputTypes = {
|
|
384
|
+
script: "script",
|
|
385
|
+
line_count: "int"
|
|
386
|
+
};
|
|
387
|
+
static inlineFields = ["brief", "format", "language", "pace"];
|
|
388
|
+
static inputFields = ["brief", "cast"];
|
|
389
|
+
async process(context) {
|
|
390
|
+
if (!context) {
|
|
391
|
+
throw new Error("WriteScript requires a processing context");
|
|
392
|
+
}
|
|
393
|
+
const brief = this.brief?.trim() ?? "";
|
|
394
|
+
if (!brief) {
|
|
395
|
+
throw new Error("WriteScript: brief is empty — there is nothing to write");
|
|
396
|
+
}
|
|
397
|
+
const providerId = this.model?.provider ?? "";
|
|
398
|
+
const modelId = this.model?.id ?? "";
|
|
399
|
+
if (!providerId || !modelId) {
|
|
400
|
+
throw new Error("WriteScript: pick a model — the writer needs a provider and a model id");
|
|
401
|
+
}
|
|
402
|
+
const cast = await resolveEntities(this.cast, context);
|
|
403
|
+
const input = {
|
|
404
|
+
brief: briefWithCast(brief, cast),
|
|
405
|
+
format: this.format ?? "",
|
|
406
|
+
lengthSeconds: Number(this.length_seconds) || DEFAULT_SCRIPT_SECONDS,
|
|
407
|
+
pace: this.pace
|
|
408
|
+
};
|
|
409
|
+
if (this.language?.trim()) {
|
|
410
|
+
input.language = this.language.trim();
|
|
411
|
+
}
|
|
412
|
+
const provider = await context.getProvider(providerId);
|
|
413
|
+
const answer = await generateStructured(provider, {
|
|
414
|
+
model: modelId,
|
|
415
|
+
maxTokens: MAX_WRITER_TOKENS,
|
|
416
|
+
messages: [
|
|
417
|
+
{ role: "system", content: SCRIPT_WRITER_SYSTEM_PROMPT },
|
|
418
|
+
{ role: "user", content: buildScriptWriterPrompt(input) }
|
|
419
|
+
],
|
|
420
|
+
toolName: SCRIPT_TOOL_NAME,
|
|
421
|
+
toolDescription: SCRIPT_TOOL_DESCRIPTION,
|
|
422
|
+
schema: buildScriptSchema()
|
|
423
|
+
});
|
|
424
|
+
const parseOptions = { idPrefix: `w${Date.now().toString(36)}` };
|
|
425
|
+
const parsed = parseWrittenScript(answer, parseOptions);
|
|
426
|
+
// A provider with no tool support — the fake one included — falls back to
|
|
427
|
+
// the brief split into lines, the rule the writer capability applies too.
|
|
428
|
+
const written = parsed.sections.length > 0 ? parsed : fallbackScript(input, parseOptions);
|
|
429
|
+
const document = {
|
|
430
|
+
cast: bindCastToSpeakers(written, cast, {
|
|
431
|
+
provider: this.voice_provider?.trim() ?? "",
|
|
432
|
+
model: this.voice_model?.trim() ?? ""
|
|
433
|
+
}),
|
|
434
|
+
sections: written.sections.map((section) => ({
|
|
435
|
+
id: section.id,
|
|
436
|
+
title: section.title,
|
|
437
|
+
lines: section.lines.map((line) => {
|
|
438
|
+
const saved = {
|
|
439
|
+
id: line.id,
|
|
440
|
+
speakerId: line.speakerId,
|
|
441
|
+
text: line.text,
|
|
442
|
+
takes: []
|
|
443
|
+
};
|
|
444
|
+
if (line.direction !== undefined) {
|
|
445
|
+
saved.direction = line.direction;
|
|
446
|
+
}
|
|
447
|
+
return saved;
|
|
448
|
+
})
|
|
449
|
+
}))
|
|
450
|
+
};
|
|
451
|
+
const saved = (await context.createScript({
|
|
452
|
+
name: this.name?.trim() || defaultScriptName(brief),
|
|
453
|
+
document
|
|
454
|
+
}));
|
|
455
|
+
if (!saved) {
|
|
456
|
+
throw new Error("WriteScript: failed to create the script");
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
script: { type: "script", id: saved.id },
|
|
460
|
+
line_count: allLines(document).length
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
__decorate([
|
|
465
|
+
prop({
|
|
466
|
+
type: "language_model",
|
|
467
|
+
default: EMPTY_LANGUAGE_MODEL,
|
|
468
|
+
title: "Model",
|
|
469
|
+
description: "Model that writes the script."
|
|
470
|
+
})
|
|
471
|
+
], WriteScriptNode.prototype, "model", void 0);
|
|
472
|
+
__decorate([
|
|
473
|
+
prop({
|
|
474
|
+
type: "str",
|
|
475
|
+
default: "",
|
|
476
|
+
title: "Brief",
|
|
477
|
+
description: "What the script is about."
|
|
478
|
+
})
|
|
479
|
+
], WriteScriptNode.prototype, "brief", void 0);
|
|
480
|
+
__decorate([
|
|
481
|
+
prop({
|
|
482
|
+
type: "enum",
|
|
483
|
+
default: "voiceover",
|
|
484
|
+
title: "Format",
|
|
485
|
+
description: "Which format's cast and sections the writer works to.",
|
|
486
|
+
values: ["voiceover", "dialogue", "interview", "ad-read", "tutorial"]
|
|
487
|
+
})
|
|
488
|
+
], WriteScriptNode.prototype, "format", void 0);
|
|
489
|
+
__decorate([
|
|
490
|
+
prop({
|
|
491
|
+
type: "list[entity]",
|
|
492
|
+
default: [],
|
|
493
|
+
title: "Cast",
|
|
494
|
+
description: "Entities that speak. Each becomes a speaker linked to the entity, voiced with its voice id."
|
|
495
|
+
})
|
|
496
|
+
], WriteScriptNode.prototype, "cast", void 0);
|
|
497
|
+
__decorate([
|
|
498
|
+
prop({
|
|
499
|
+
type: "str",
|
|
500
|
+
default: "",
|
|
501
|
+
title: "Language",
|
|
502
|
+
description: "Language to write in. Empty writes in the brief's language."
|
|
503
|
+
})
|
|
504
|
+
], WriteScriptNode.prototype, "language", void 0);
|
|
505
|
+
__decorate([
|
|
506
|
+
prop({
|
|
507
|
+
type: "enum",
|
|
508
|
+
default: "normal",
|
|
509
|
+
title: "Pace",
|
|
510
|
+
description: "Reading pace the length target is computed at.",
|
|
511
|
+
values: ["slow", "normal", "fast"]
|
|
512
|
+
})
|
|
513
|
+
], WriteScriptNode.prototype, "pace", void 0);
|
|
514
|
+
__decorate([
|
|
515
|
+
prop({
|
|
516
|
+
type: "int",
|
|
517
|
+
default: DEFAULT_SCRIPT_SECONDS,
|
|
518
|
+
title: "Length Seconds",
|
|
519
|
+
description: "How long the finished read should take.",
|
|
520
|
+
min: 5,
|
|
521
|
+
max: 1800
|
|
522
|
+
})
|
|
523
|
+
], WriteScriptNode.prototype, "length_seconds", void 0);
|
|
524
|
+
__decorate([
|
|
525
|
+
prop({
|
|
526
|
+
type: "str",
|
|
527
|
+
default: "",
|
|
528
|
+
title: "Voice Provider",
|
|
529
|
+
description: "TTS provider for the cast's voices. Needed with Voice Model for a speaker's voice id to be voiceable."
|
|
530
|
+
})
|
|
531
|
+
], WriteScriptNode.prototype, "voice_provider", void 0);
|
|
532
|
+
__decorate([
|
|
533
|
+
prop({
|
|
534
|
+
type: "str",
|
|
535
|
+
default: "",
|
|
536
|
+
title: "Voice Model",
|
|
537
|
+
description: "TTS model for the cast's voices."
|
|
538
|
+
})
|
|
539
|
+
], WriteScriptNode.prototype, "voice_model", void 0);
|
|
540
|
+
__decorate([
|
|
541
|
+
prop({
|
|
542
|
+
type: "str",
|
|
543
|
+
default: "",
|
|
544
|
+
title: "Name",
|
|
545
|
+
description: "Name for the new script. Defaults to the brief's first words."
|
|
546
|
+
})
|
|
547
|
+
], WriteScriptNode.prototype, "name", void 0);
|
|
548
|
+
/** A script name from the brief's first words, when the graph gave none. */
|
|
549
|
+
function defaultScriptName(brief) {
|
|
550
|
+
const words = brief.trim().split(/\s+/).slice(0, 8).join(" ");
|
|
551
|
+
return words.length > 0 ? words.slice(0, 80) : "Untitled script";
|
|
552
|
+
}
|
|
553
|
+
export class FillScriptNode extends BaseNode {
|
|
554
|
+
static nodeType = "nodetool.script.FillScript";
|
|
555
|
+
static title = "Fill Script";
|
|
556
|
+
static description = "Fill a template script's {{key}} placeholders from a value bag and save the result as a new script. The template is never written. A key with no value stays in the text and is reported, and every take is kept, so a later Voice Script pays only for the lines whose text moved.\n script, template, fill, placeholder, batch\n\n Use cases:\n - Produce one script per row of a product feed\n - Localize a template by swapping its values\n - Re-run a batch and re-voice only what changed";
|
|
557
|
+
static metadataOutputTypes = {
|
|
558
|
+
script: "script",
|
|
559
|
+
filled: "list[str]",
|
|
560
|
+
unresolved: "list[str]"
|
|
561
|
+
};
|
|
562
|
+
static inlineFields = ["script"];
|
|
563
|
+
static inputFields = ["script", "values"];
|
|
564
|
+
async process(context) {
|
|
565
|
+
if (!context) {
|
|
566
|
+
throw new Error("FillScript requires a processing context");
|
|
567
|
+
}
|
|
568
|
+
const script = await loadScript(this.script, context);
|
|
569
|
+
const result = fillScript(script.document, stringValues(this.values));
|
|
570
|
+
const saved = (await context.createScript({
|
|
571
|
+
name: this.name?.trim() || script.name,
|
|
572
|
+
projectId: script.projectId,
|
|
573
|
+
document: { ...result.document, templateId: script.id }
|
|
574
|
+
}));
|
|
575
|
+
if (!saved) {
|
|
576
|
+
throw new Error("FillScript: failed to create the filled script");
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
script: { type: "script", id: saved.id },
|
|
580
|
+
filled: result.filled,
|
|
581
|
+
unresolved: result.unresolved
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
__decorate([
|
|
586
|
+
prop({
|
|
587
|
+
type: "script",
|
|
588
|
+
default: scriptRefDefault,
|
|
589
|
+
title: "Script",
|
|
590
|
+
description: "The template script to fill."
|
|
591
|
+
})
|
|
592
|
+
], FillScriptNode.prototype, "script", void 0);
|
|
593
|
+
__decorate([
|
|
594
|
+
prop({
|
|
595
|
+
type: "dict",
|
|
596
|
+
default: {},
|
|
597
|
+
title: "Values",
|
|
598
|
+
description: "Value per placeholder key, e.g. {name: \"Aero 9\"}."
|
|
599
|
+
})
|
|
600
|
+
], FillScriptNode.prototype, "values", void 0);
|
|
601
|
+
__decorate([
|
|
602
|
+
prop({
|
|
603
|
+
type: "str",
|
|
604
|
+
default: "",
|
|
605
|
+
title: "Name",
|
|
606
|
+
description: "Name for the new script. Defaults to the template's name."
|
|
607
|
+
})
|
|
608
|
+
], FillScriptNode.prototype, "name", void 0);
|
|
609
|
+
async function synthesizeLine(context, text, voice, speed) {
|
|
610
|
+
const provider = voice.provider;
|
|
611
|
+
const model = voice.model;
|
|
612
|
+
if (!provider || !model)
|
|
613
|
+
return null;
|
|
614
|
+
const params = { text, voice: voice.voice, speed };
|
|
615
|
+
// Providers that stream raw PCM (OpenAI, ElevenLabs…) are wrapped into a WAV.
|
|
616
|
+
// Providers that return an encoded audio file (FAL, KIE) go through the
|
|
617
|
+
// encoded path.
|
|
618
|
+
if (await context.providerSupportsStreamingTTS(provider)) {
|
|
619
|
+
const chunks = [];
|
|
620
|
+
let sampleRate = 24000;
|
|
621
|
+
for await (const item of context.streamProviderPrediction({
|
|
622
|
+
provider,
|
|
623
|
+
capability: "text_to_speech",
|
|
624
|
+
model,
|
|
625
|
+
params
|
|
626
|
+
})) {
|
|
627
|
+
const piece = item;
|
|
628
|
+
if (isPositiveNumber(piece.sampleRate)) {
|
|
629
|
+
sampleRate = piece.sampleRate;
|
|
630
|
+
}
|
|
631
|
+
if (piece.samples instanceof Int16Array) {
|
|
632
|
+
chunks.push(new Uint8Array(piece.samples.buffer.slice(piece.samples.byteOffset, piece.samples.byteOffset + piece.samples.byteLength)));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const wav = encodePcm16Wav(concatBytes(chunks), sampleRate, 1);
|
|
636
|
+
if (wav.length === 0)
|
|
637
|
+
return null;
|
|
638
|
+
return { bytes: wav, contentType: "audio/wav" };
|
|
639
|
+
}
|
|
640
|
+
const encoded = await context.textToSpeechEncoded({
|
|
641
|
+
provider,
|
|
642
|
+
capability: "text_to_speech",
|
|
643
|
+
model,
|
|
644
|
+
params
|
|
645
|
+
});
|
|
646
|
+
if (!encoded?.data || encoded.data.length === 0)
|
|
647
|
+
return null;
|
|
648
|
+
return {
|
|
649
|
+
bytes: encoded.data,
|
|
650
|
+
contentType: isString(encoded.mimeType) ? encoded.mimeType : "audio/mpeg"
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
async function probeDurationMs(bytes, workDir, index) {
|
|
654
|
+
const probePath = path.join(workDir, `line_${index}`);
|
|
655
|
+
try {
|
|
656
|
+
await fs.writeFile(probePath, bytes);
|
|
657
|
+
const seconds = await ffprobeDuration(probePath);
|
|
658
|
+
return seconds > 0 ? Math.round(seconds * 1000) : PLACEHOLDER_LINE_MS;
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return PLACEHOLDER_LINE_MS;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
export const SCRIPT_NODES = tagAsNode([
|
|
665
|
+
LoadScriptNode,
|
|
666
|
+
VoiceScriptNode,
|
|
667
|
+
ScriptToTimelineNode,
|
|
668
|
+
ScriptToSubtitlesNode,
|
|
669
|
+
// Tagged first, so `tagAsNode` leaves them alone: neither node shells out to
|
|
670
|
+
// ffmpeg or writes a temp file, and both need the model interfaces.
|
|
671
|
+
...tagAsServer([WriteScriptNode, FillScriptNode])
|
|
672
|
+
]);
|
|
673
|
+
//# sourceMappingURL=script.js.map
|