@shaztech/video-pipeline 1.1.1 → 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,7 +9,7 @@
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-BqNsIbxc.js"></script>
12
+ <script type="module" crossorigin src="./assets/index-Dqr1wx5V.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-Df9WKIy8.css">
14
14
  </head>
15
15
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaztech/video-pipeline",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Visual node-based video processing pipeline CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import path from 'path'
18
- import { existsSync, writeFileSync } from 'fs'
18
+ import { existsSync, writeFileSync, unlinkSync } from 'fs'
19
19
  import chalk from 'chalk'
20
20
  import { run } from '../runner.js'
21
21
 
@@ -31,27 +31,13 @@ function escapeFilterPath(p) {
31
31
  }
32
32
 
33
33
  /**
34
- * Burns a sequence label (e.g. "scene 3/10") into the bottom-right corner of
35
- * an image using ffmpeg drawtext.
36
- *
37
- * @param {string} srcPath - absolute path to the source image
38
- * @param {object} opts
39
- * @param {number} opts.index - 1-based sequence index
40
- * @param {number} opts.total - total item count
41
- * @param {string} [opts.prefix] - optional prefix, e.g. "scene" → "scene 3/10"
42
- * @param {string} opts.fontFile - path to a .ttf/.otf/.ttc font file (required)
43
- * @param {number} [opts.fontSize=48]
44
- * @param {string} [opts.fontColor='white']
45
- * @param {boolean} [opts.box=false] - draw a semi-transparent background box
46
- * @param {string} [opts.boxColor='black@0.5']
47
- * @param {number} [opts.padding=20] - px distance from right & bottom edges
48
- * @param {string} opts.destPath - absolute path to write the annotated image
49
- * @param {string} [opts.label] - display label for the run() spinner
50
- * @param {boolean} [opts.dryRun=false]
34
+ * Validates fontFile and composes the drawtext filter string and sidecar text file path.
35
+ * Shared by both image and video annotation helpers.
51
36
  */
52
- export async function annotateImageWithSequence(srcPath, {
37
+ function buildDrawtextArgs(srcLabel, {
53
38
  index,
54
39
  total,
40
+ totalOffset = 0,
55
41
  prefix,
56
42
  fontFile,
57
43
  fontSize = 48,
@@ -60,12 +46,11 @@ export async function annotateImageWithSequence(srcPath, {
60
46
  boxColor = 'black@0.5',
61
47
  padding = 20,
62
48
  destPath,
63
- label,
64
49
  dryRun = false,
65
50
  }) {
66
51
  if (!fontFile) {
67
52
  throw new Error(
68
- `sequenceLabel: fontFile is required but was not provided (item: "${srcPath}")`
53
+ `sequenceLabel: fontFile is required but was not provided (item: "${srcLabel}")`
69
54
  )
70
55
  }
71
56
 
@@ -75,28 +60,11 @@ export async function annotateImageWithSequence(srcPath, {
75
60
  )
76
61
  }
77
62
 
78
- // Compose label text
79
- const text = prefix ? `${prefix} ${index}/${total}` : `${index}/${total}`
80
-
81
- console.log(
82
- chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
83
- chalk.cyan(`"${text}"`) +
84
- chalk.dim(` → ${path.basename(destPath)}`)
85
- )
63
+ const effectiveTotal = total + totalOffset
64
+ const text = prefix ? `${prefix} ${index}/${effectiveTotal}` : `${index}/${effectiveTotal}`
86
65
 
87
- // Write text to a sidecar file so we don't have to worry about ffmpeg
88
- // filter-string escaping of the text itself (handles apostrophes, colons, etc.)
89
66
  const textFile = `${destPath}.txt`
90
67
 
91
- if (!dryRun) {
92
- console.log(chalk.dim(` [seq-label] text file: ${textFile}`))
93
- writeFileSync(textFile, text, 'utf8')
94
- console.log(chalk.dim(` [seq-label] font file: ${fontFile}`))
95
- console.log(chalk.dim(` [seq-label] dest path: ${destPath}`))
96
- }
97
-
98
- // Build drawtext filter. x/y place the text padding px from right/bottom edges.
99
- // w, h, tw, th are built-in ffmpeg drawtext variables.
100
68
  const escapedFontFile = escapeFilterPath(fontFile)
101
69
  const escapedTextFile = escapeFilterPath(textFile)
102
70
  const x = `w-tw-${padding}`
@@ -114,9 +82,49 @@ export async function annotateImageWithSequence(srcPath, {
114
82
  filterStr += `:box=1:boxcolor=${boxColor}:boxborderw=8`
115
83
  }
116
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
+
117
125
  const argv = [
118
- '-nostdin', // prevent ffmpeg from reading stdin (avoids hangs in pipelines)
119
- '-loglevel', 'warning', // suppress verbose progress output
126
+ '-nostdin',
127
+ '-loglevel', 'warning',
120
128
  '-y',
121
129
  '-i', srcPath,
122
130
  '-vf', filterStr,
@@ -128,4 +136,53 @@ export async function annotateImageWithSequence(srcPath, {
128
136
  label: label ?? `annotate ${index}/${total}`,
129
137
  dryRun,
130
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
+ }
131
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,7 +18,7 @@ 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 } from './imageAnnotate.js'
21
+ import { annotateImageWithSequence, annotateVideoWithSequence } from './imageAnnotate.js'
22
22
 
23
23
  const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp|tiff?|avif|svg)$/i
24
24
 
@@ -178,9 +178,20 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
178
178
  )
179
179
  }
180
180
 
181
- // Pre-process any fixed image inputs that have sequenceLabel.enabled
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).
182
192
  const resolvedInputs = await Promise.all(
183
193
  inputs.map(async (input) => {
194
+ if (videoSl?.enabled) return input
184
195
  const sl = input.sequenceLabel
185
196
  if (!sl?.enabled || !isImage(input.value)) return input
186
197
 
@@ -201,6 +212,7 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
201
212
  box: sl.box,
202
213
  boxColor: sl.boxColor,
203
214
  padding: sl.padding,
215
+ totalOffset: sl.totalOffset,
204
216
  destPath,
205
217
  label: `${node.label ?? node.id} [annotate ${runIdx + 1}/${runCount}]`,
206
218
  dryRun: opts.dryRun,
@@ -211,7 +223,7 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
211
223
  )
212
224
 
213
225
  const cliInputs = resolvedInputs.map(flattenInput)
214
- const argv = [...cliInputs, '-o', outputFile]
226
+ const argv = [...cliInputs, '-o', stitchTarget]
215
227
 
216
228
  if (config.imageDuration != null && config.imageDuration !== 1) {
217
229
  argv.push('-d', String(config.imageDuration))
@@ -228,6 +240,24 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
228
240
  dryRun: opts.dryRun
229
241
  })
230
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
+
231
261
  outputFiles.push(outputFile)
232
262
  }
233
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) {