@shaztech/video-pipeline 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,8 +9,8 @@
9
9
  html, body, #root { height: 100%; width: 100%; overflow: hidden; }
10
10
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; }
11
11
  </style>
12
- <script type="module" crossorigin src="./assets/index-DyAG6YEF.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/index-K7ALkYj6.css">
12
+ <script type="module" crossorigin src="./assets/index-Dqr1wx5V.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/index-Df9WKIy8.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaztech/video-pipeline",
3
- "version": "1.0.4",
3
+ "version": "1.2.0",
4
4
  "description": "Visual node-based video processing pipeline CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,188 @@
1
+ /*
2
+ * Copyright 2026 Shazron Abdullah
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import path from 'path'
18
+ import { existsSync, writeFileSync, unlinkSync } from 'fs'
19
+ import chalk from 'chalk'
20
+ import { run } from '../runner.js'
21
+
22
+ /**
23
+ * Escapes a file path for use inside an ffmpeg filter string value.
24
+ * In ffmpeg filter syntax the following characters must be escaped with a backslash:
25
+ * \ : '
26
+ * (The path is embedded as a value in drawtext=fontfile=...:textfile=...)
27
+ */
28
+ function escapeFilterPath(p) {
29
+ // Replace backslashes first, then colons, then single-quotes
30
+ return p.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'")
31
+ }
32
+
33
+ /**
34
+ * Validates fontFile and composes the drawtext filter string and sidecar text file path.
35
+ * Shared by both image and video annotation helpers.
36
+ */
37
+ function buildDrawtextArgs(srcLabel, {
38
+ index,
39
+ total,
40
+ totalOffset = 0,
41
+ prefix,
42
+ fontFile,
43
+ fontSize = 48,
44
+ fontColor = 'white',
45
+ box = false,
46
+ boxColor = 'black@0.5',
47
+ padding = 20,
48
+ destPath,
49
+ dryRun = false,
50
+ }) {
51
+ if (!fontFile) {
52
+ throw new Error(
53
+ `sequenceLabel: fontFile is required but was not provided (item: "${srcLabel}")`
54
+ )
55
+ }
56
+
57
+ if (!dryRun && !existsSync(fontFile)) {
58
+ throw new Error(
59
+ `sequenceLabel: fontFile not found: ${fontFile}`
60
+ )
61
+ }
62
+
63
+ const effectiveTotal = total + totalOffset
64
+ const text = prefix ? `${prefix} ${index}/${effectiveTotal}` : `${index}/${effectiveTotal}`
65
+
66
+ const textFile = `${destPath}.txt`
67
+
68
+ const escapedFontFile = escapeFilterPath(fontFile)
69
+ const escapedTextFile = escapeFilterPath(textFile)
70
+ const x = `w-tw-${padding}`
71
+ const y = `h-th-${padding}`
72
+
73
+ let filterStr =
74
+ `drawtext=fontfile=${escapedFontFile}` +
75
+ `:textfile=${escapedTextFile}` +
76
+ `:fontsize=${fontSize}` +
77
+ `:fontcolor=${fontColor}` +
78
+ `:x=${x}` +
79
+ `:y=${y}`
80
+
81
+ if (box) {
82
+ filterStr += `:box=1:boxcolor=${boxColor}:boxborderw=8`
83
+ }
84
+
85
+ return { text, textFile, filterStr }
86
+ }
87
+
88
+ /**
89
+ * Burns a sequence label (e.g. "scene 3/10") into the bottom-right corner of
90
+ * an image using ffmpeg drawtext.
91
+ *
92
+ * @param {string} srcPath - absolute path to the source image
93
+ * @param {object} opts
94
+ * @param {number} opts.index - 1-based sequence index
95
+ * @param {number} opts.total - total item count
96
+ * @param {string} [opts.prefix] - optional prefix, e.g. "scene" → "scene 3/10"
97
+ * @param {string} opts.fontFile - path to a .ttf/.otf/.ttc font file (required)
98
+ * @param {number} [opts.fontSize=48]
99
+ * @param {string} [opts.fontColor='white']
100
+ * @param {boolean} [opts.box=false] - draw a semi-transparent background box
101
+ * @param {string} [opts.boxColor='black@0.5']
102
+ * @param {number} [opts.padding=20] - px distance from right & bottom edges
103
+ * @param {number} [opts.totalOffset=0] - integer added to the denominator
104
+ * @param {string} opts.destPath - absolute path to write the annotated image
105
+ * @param {string} [opts.label] - display label for the run() spinner
106
+ * @param {boolean} [opts.dryRun=false]
107
+ */
108
+ export async function annotateImageWithSequence(srcPath, opts) {
109
+ const { destPath, label, dryRun = false, index, total } = opts
110
+ const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, opts)
111
+
112
+ console.log(
113
+ chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
114
+ chalk.cyan(`"${text}"`) +
115
+ chalk.dim(` → ${path.basename(destPath)}`)
116
+ )
117
+
118
+ if (!dryRun) {
119
+ console.log(chalk.dim(` [seq-label] text file: ${textFile}`))
120
+ writeFileSync(textFile, text, 'utf8')
121
+ console.log(chalk.dim(` [seq-label] font file: ${opts.fontFile}`))
122
+ console.log(chalk.dim(` [seq-label] dest path: ${destPath}`))
123
+ }
124
+
125
+ const argv = [
126
+ '-nostdin',
127
+ '-loglevel', 'warning',
128
+ '-y',
129
+ '-i', srcPath,
130
+ '-vf', filterStr,
131
+ '-frames:v', '1',
132
+ destPath,
133
+ ]
134
+
135
+ await run('ffmpeg', argv, {
136
+ label: label ?? `annotate ${index}/${total}`,
137
+ dryRun,
138
+ })
139
+
140
+ if (!dryRun) {
141
+ try { unlinkSync(textFile) } catch {}
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Burns a sequence label (e.g. "scene 3/10") into the bottom-right corner of
147
+ * a video file using ffmpeg drawtext. Used for the whole-output-video label on
148
+ * a stitcher node. Audio is stream-copied to avoid re-encoding.
149
+ *
150
+ * @param {string} srcPath - absolute path to the source video
151
+ * @param {object} opts - same signature as annotateImageWithSequence
152
+ */
153
+ export async function annotateVideoWithSequence(srcPath, opts) {
154
+ const { destPath, label, dryRun = false, index, total } = opts
155
+ const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, opts)
156
+
157
+ console.log(
158
+ chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
159
+ chalk.cyan(`"${text}"`) +
160
+ chalk.dim(` → ${path.basename(destPath)}`)
161
+ )
162
+
163
+ if (!dryRun) {
164
+ console.log(chalk.dim(` [seq-label] text file: ${textFile}`))
165
+ writeFileSync(textFile, text, 'utf8')
166
+ console.log(chalk.dim(` [seq-label] font file: ${opts.fontFile}`))
167
+ console.log(chalk.dim(` [seq-label] dest path: ${destPath}`))
168
+ }
169
+
170
+ const argv = [
171
+ '-nostdin',
172
+ '-loglevel', 'warning',
173
+ '-y',
174
+ '-i', srcPath,
175
+ '-vf', filterStr,
176
+ '-c:a', 'copy',
177
+ destPath,
178
+ ]
179
+
180
+ await run('ffmpeg', argv, {
181
+ label: label ?? `annotate ${index}/${total}`,
182
+ dryRun,
183
+ })
184
+
185
+ if (!dryRun) {
186
+ try { unlinkSync(textFile) } catch {}
187
+ }
188
+ }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import os from 'os'
18
18
  import path from 'path'
19
- import { mkdirSync, rmSync, readdirSync, copyFileSync } from 'fs'
19
+ import { mkdirSync, rmSync, readdirSync, copyFileSync, existsSync } from 'fs'
20
20
  import { glob } from 'glob'
21
21
  import { run } from '../runner.js'
22
22
 
@@ -84,7 +84,16 @@ export async function handleVideoCutter(node, context, _tempRoot, opts = {}) {
84
84
  : baseOutputDir
85
85
 
86
86
  if (!opts.dryRun) {
87
- if (opts.overwrite) rmSync(outputDir, { recursive: true, force: true })
87
+ if (opts.overwrite) {
88
+ rmSync(outputDir, { recursive: true, force: true })
89
+ } else if (existsSync(outputDir)) {
90
+ const existing = await glob('**/*.mp4', { cwd: outputDir })
91
+ if (existing.length > 0) {
92
+ throw new Error(
93
+ `Node "${node.id}" (video-cutter): output already exists at ${outputDir}\nUse --overwrite to re-cut.`
94
+ )
95
+ }
96
+ }
88
97
  mkdirSync(outputDir, { recursive: true })
89
98
  }
90
99
 
@@ -18,6 +18,13 @@ import os from 'os'
18
18
  import path from 'path'
19
19
  import { mkdirSync, existsSync, copyFileSync } from 'fs'
20
20
  import { run } from '../runner.js'
21
+ import { annotateImageWithSequence, annotateVideoWithSequence } from './imageAnnotate.js'
22
+
23
+ const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp|tiff?|avif|svg)$/i
24
+
25
+ function isImage(filePath) {
26
+ return IMAGE_EXTS.test(filePath)
27
+ }
21
28
 
22
29
  function expandPath(p) {
23
30
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
@@ -25,7 +32,8 @@ function expandPath(p) {
25
32
 
26
33
  /**
27
34
  * Builds the list of per-run inputs from inputOrder + edge outputs.
28
- * Returns an array of { inputs: string[], name: string } one entry per output file.
35
+ * Returns { runs, runCount } where each run is:
36
+ * { inputs: Array<{ value: string, imageDuration?: number, sequenceLabel?: object }>, name: string }
29
37
  *
30
38
  * When an edge item expands to N files, N runs are produced (one per edge file),
31
39
  * with fixed items applied to every run. The output filename equals the basename
@@ -51,8 +59,8 @@ function buildRuns(config, incomingEdges, context, nodeId) {
51
59
  // No edge connections — single run with all fixed inputs
52
60
  const inputs = config.inputOrder
53
61
  .filter((i) => i.type === 'fixed' && i.value)
54
- .map((i) => i.imageDuration != null ? `${i.value}:${i.imageDuration}` : i.value)
55
- return [{ inputs, name: 'output.mp4' }]
62
+ .map((i) => ({ value: i.value, imageDuration: i.imageDuration, sequenceLabel: i.sequenceLabel }))
63
+ return { runs: [{ inputs, name: 'output.mp4' }], runCount: 1 }
56
64
  }
57
65
 
58
66
  // Number of runs = max file count across all edge items
@@ -75,12 +83,14 @@ function buildRuns(config, incomingEdges, context, nodeId) {
75
83
  const inputs = []
76
84
  for (const item of config.inputOrder) {
77
85
  if (item.type === 'fixed') {
78
- if (item.value) inputs.push(item.imageDuration != null ? `${item.value}:${item.imageDuration}` : item.value)
86
+ if (item.value) {
87
+ inputs.push({ value: item.value, imageDuration: item.imageDuration, sequenceLabel: item.sequenceLabel })
88
+ }
79
89
  } else if (item.type === 'edge') {
80
90
  const files = edgeOutputs.get(item.nodeId) ?? []
81
91
  // Use file i; clamp to last available if this edge has fewer files
82
92
  const file = files[Math.min(i, files.length - 1)]
83
- if (file) inputs.push(file)
93
+ if (file) inputs.push({ value: file })
84
94
  }
85
95
  }
86
96
  const pivotFile = pivotFiles[i]
@@ -93,7 +103,7 @@ function buildRuns(config, incomingEdges, context, nodeId) {
93
103
  : `output_${String(i + 1).padStart(3, '0')}.mp4`
94
104
  runs.push({ inputs, name })
95
105
  }
96
- return runs
106
+ return { runs, runCount }
97
107
  } else {
98
108
  // Legacy: fixed inputs first, then all edge outputs — single run
99
109
  const fixedInputs = Array.isArray(config.inputs) ? config.inputs.filter(Boolean) : []
@@ -107,10 +117,20 @@ function buildRuns(config, incomingEdges, context, nodeId) {
107
117
  }
108
118
  variableInputs.push(...(sourceCtx.outputs ?? []))
109
119
  }
110
- return [{ inputs: [...fixedInputs, ...variableInputs], name: 'output.mp4' }]
120
+ const inputs = [...fixedInputs, ...variableInputs].map((v) => ({ value: v }))
121
+ return { runs: [{ inputs, name: 'output.mp4' }], runCount: 1 }
111
122
  }
112
123
  }
113
124
 
125
+ /**
126
+ * Flattens an input object to the CLI string expected by video-stitcher:
127
+ * - image with duration → "path:duration"
128
+ * - otherwise → "path"
129
+ */
130
+ function flattenInput({ value, imageDuration }) {
131
+ return imageDuration != null ? `${value}:${imageDuration}` : value
132
+ }
133
+
114
134
  /**
115
135
  * Builds argv and executes video-stitcher for a node.
116
136
  * Produces one output file per edge segment (N inputs → N outputs),
@@ -135,11 +155,13 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
135
155
 
136
156
  if (!opts.dryRun) mkdirSync(outputDir, { recursive: true })
137
157
 
138
- const runs = buildRuns(config, incomingEdges, context, node.id)
158
+ const { runs, runCount } = buildRuns(config, incomingEdges, context, node.id)
139
159
 
140
160
  const outputFiles = []
141
161
 
142
- for (const { inputs, name } of runs) {
162
+ for (let runIdx = 0; runIdx < runs.length; runIdx++) {
163
+ const { inputs, name } = runs[runIdx]
164
+
143
165
  if (inputs.length < 2) {
144
166
  throw new Error(
145
167
  `Node "${node.id}" (video-stitcher): at least 2 inputs required for "${name}", got ${inputs.length}`
@@ -156,7 +178,52 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
156
178
  )
157
179
  }
158
180
 
159
- const argv = [...inputs, '-o', outputFile]
181
+ const videoSl = config.sequenceLabel
182
+
183
+ // When whole-video sequence label is enabled, stitch to a temp path first so
184
+ // we can run the drawtext pass on it to produce the final outputFile.
185
+ const stitchTarget = videoSl?.enabled
186
+ ? path.join(tempRoot, node.id, 'prelabel', name)
187
+ : outputFile
188
+ if (videoSl?.enabled && !opts.dryRun) mkdirSync(path.dirname(stitchTarget), { recursive: true })
189
+
190
+ // Pre-process any fixed image inputs that have sequenceLabel.enabled.
191
+ // Skip if whole-video label is active (mutual exclusion enforced at runtime).
192
+ const resolvedInputs = await Promise.all(
193
+ inputs.map(async (input) => {
194
+ if (videoSl?.enabled) return input
195
+ const sl = input.sequenceLabel
196
+ if (!sl?.enabled || !isImage(input.value)) return input
197
+
198
+ const annotDir = path.join(tempRoot, node.id, 'annotated')
199
+ if (!opts.dryRun) mkdirSync(annotDir, { recursive: true })
200
+
201
+ const ext = path.extname(input.value)
202
+ const base = path.basename(input.value, ext)
203
+ const destPath = path.join(annotDir, `${runIdx + 1}_${base}${ext}`)
204
+
205
+ await annotateImageWithSequence(input.value, {
206
+ index: runIdx + 1,
207
+ total: runCount,
208
+ prefix: sl.prefix,
209
+ fontFile: sl.fontFile,
210
+ fontSize: sl.fontSize,
211
+ fontColor: sl.fontColor,
212
+ box: sl.box,
213
+ boxColor: sl.boxColor,
214
+ padding: sl.padding,
215
+ totalOffset: sl.totalOffset,
216
+ destPath,
217
+ label: `${node.label ?? node.id} [annotate ${runIdx + 1}/${runCount}]`,
218
+ dryRun: opts.dryRun,
219
+ })
220
+
221
+ return { ...input, value: destPath }
222
+ })
223
+ )
224
+
225
+ const cliInputs = resolvedInputs.map(flattenInput)
226
+ const argv = [...cliInputs, '-o', stitchTarget]
160
227
 
161
228
  if (config.imageDuration != null && config.imageDuration !== 1) {
162
229
  argv.push('-d', String(config.imageDuration))
@@ -173,6 +240,24 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
173
240
  dryRun: opts.dryRun
174
241
  })
175
242
 
243
+ if (videoSl?.enabled) {
244
+ await annotateVideoWithSequence(stitchTarget, {
245
+ index: runIdx + 1,
246
+ total: runCount,
247
+ prefix: videoSl.prefix,
248
+ fontFile: videoSl.fontFile,
249
+ fontSize: videoSl.fontSize,
250
+ fontColor: videoSl.fontColor,
251
+ box: videoSl.box,
252
+ boxColor: videoSl.boxColor,
253
+ padding: videoSl.padding,
254
+ totalOffset: videoSl.totalOffset,
255
+ destPath: outputFile,
256
+ label: `${node.label ?? node.id} [label ${runIdx + 1}/${runCount}]`,
257
+ dryRun: opts.dryRun,
258
+ })
259
+ }
260
+
176
261
  outputFiles.push(outputFile)
177
262
  }
178
263
 
@@ -63,9 +63,10 @@ export async function run(bin, args, opts = {}) {
63
63
  const proc = spawn(resolved, args, { stdio: ['pipe', 'inherit', 'inherit'] })
64
64
  proc.stdin.write(opts.input)
65
65
  proc.stdin.end()
66
- proc.on('close', (code) => {
66
+ proc.on('exit', (code, signal) => {
67
+ proc.stdin.destroy()
67
68
  if (code === 0) resolve()
68
- else reject(new Error(`${bin} exited with code ${code}`))
69
+ else reject(new Error(`${bin} exited with code ${code ?? `signal ${signal}`}`))
69
70
  })
70
71
  proc.on('error', reject)
71
72
  })
@@ -64,6 +64,16 @@ export function validateSpec(spec) {
64
64
  if (!node.config || typeof node.config !== 'object') {
65
65
  errors.push(`Node "${node.id}" missing config object`)
66
66
  }
67
+
68
+ if (node.type === 'video-stitcher' && node.config) {
69
+ const videoSlEnabled = node.config.sequenceLabel?.enabled === true
70
+ const imageSlEnabled = (node.config.inputOrder ?? []).some((i) => i.sequenceLabel?.enabled === true)
71
+ if (videoSlEnabled && imageSlEnabled) {
72
+ errors.push(
73
+ `Node "${node.id}" (video-stitcher): config.sequenceLabel.enabled and per-image sequenceLabel.enabled are mutually exclusive`
74
+ )
75
+ }
76
+ }
67
77
  }
68
78
 
69
79
  for (const edge of spec.edges) {