@shaztech/video-pipeline 1.1.1 → 1.3.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-BqNsIbxc.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/index-Df9WKIy8.css">
12
+ <script type="module" crossorigin src="./assets/index-yrb6jZba.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CUxLpKni.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.1.1",
3
+ "version": "1.3.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
 
@@ -30,28 +30,22 @@ function escapeFilterPath(p) {
30
30
  return p.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'")
31
31
  }
32
32
 
33
+ function computePositionXY({ position = 'bottom-right', padding = 20, customX = 0, customY = 0 }) {
34
+ if (position === 'custom') return { x: String(customX), y: String(customY) }
35
+ const H = { left: String(padding), center: '(w-tw)/2', right: `w-tw-${padding}` }
36
+ const V = { top: String(padding), center: '(h-th)/2', bottom: `h-th-${padding}` }
37
+ const parts = position.split('-')
38
+ return { x: H[parts[1] ?? 'center'], y: V[parts[0]] }
39
+ }
40
+
33
41
  /**
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]
42
+ * Validates fontFile and composes the drawtext filter string and sidecar text file path.
43
+ * Shared by both image and video annotation helpers.
51
44
  */
52
- export async function annotateImageWithSequence(srcPath, {
45
+ function buildDrawtextArgs(srcLabel, {
53
46
  index,
54
47
  total,
48
+ totalOffset = 0,
55
49
  prefix,
56
50
  fontFile,
57
51
  fontSize = 48,
@@ -59,13 +53,16 @@ export async function annotateImageWithSequence(srcPath, {
59
53
  box = false,
60
54
  boxColor = 'black@0.5',
61
55
  padding = 20,
56
+ position = 'bottom-right',
57
+ customX = 0,
58
+ customY = 0,
59
+ startAt = 0,
62
60
  destPath,
63
- label,
64
61
  dryRun = false,
65
62
  }) {
66
63
  if (!fontFile) {
67
64
  throw new Error(
68
- `sequenceLabel: fontFile is required but was not provided (item: "${srcPath}")`
65
+ `sequenceLabel: fontFile is required but was not provided (item: "${srcLabel}")`
69
66
  )
70
67
  }
71
68
 
@@ -75,32 +72,14 @@ export async function annotateImageWithSequence(srcPath, {
75
72
  )
76
73
  }
77
74
 
78
- // Compose label text
79
- const text = prefix ? `${prefix} ${index}/${total}` : `${index}/${total}`
75
+ const effectiveTotal = total + totalOffset
76
+ const text = prefix ? `${prefix} ${index}/${effectiveTotal}` : `${index}/${effectiveTotal}`
80
77
 
81
- console.log(
82
- chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
83
- chalk.cyan(`"${text}"`) +
84
- chalk.dim(` → ${path.basename(destPath)}`)
85
- )
86
-
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
78
  const textFile = `${destPath}.txt`
90
79
 
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
80
  const escapedFontFile = escapeFilterPath(fontFile)
101
81
  const escapedTextFile = escapeFilterPath(textFile)
102
- const x = `w-tw-${padding}`
103
- const y = `h-th-${padding}`
82
+ const { x, y } = computePositionXY({ position, padding, customX, customY })
104
83
 
105
84
  let filterStr =
106
85
  `drawtext=fontfile=${escapedFontFile}` +
@@ -114,9 +93,57 @@ export async function annotateImageWithSequence(srcPath, {
114
93
  filterStr += `:box=1:boxcolor=${boxColor}:boxborderw=8`
115
94
  }
116
95
 
96
+ if (startAt > 0) {
97
+ filterStr += `:enable=gte(t\\,${startAt})`
98
+ }
99
+
100
+ return { text, textFile, filterStr }
101
+ }
102
+
103
+ /**
104
+ * Burns a sequence label (e.g. "scene 3/10") into the bottom-right corner of
105
+ * an image using ffmpeg drawtext.
106
+ *
107
+ * @param {string} srcPath - absolute path to the source image
108
+ * @param {object} opts
109
+ * @param {number} opts.index - 1-based sequence index
110
+ * @param {number} opts.total - total item count
111
+ * @param {string} [opts.prefix] - optional prefix, e.g. "scene" → "scene 3/10"
112
+ * @param {string} opts.fontFile - path to a .ttf/.otf/.ttc font file (required)
113
+ * @param {number} [opts.fontSize=48]
114
+ * @param {string} [opts.fontColor='white']
115
+ * @param {boolean} [opts.box=false] - draw a semi-transparent background box
116
+ * @param {string} [opts.boxColor='black@0.5']
117
+ * @param {number} [opts.padding=20] - px distance from edges (used by presets)
118
+ * @param {string} [opts.position='bottom-right'] - one of the 9 presets or 'custom'
119
+ * @param {number} [opts.customX=0] - x pixel offset (only when position='custom')
120
+ * @param {number} [opts.customY=0] - y pixel offset (only when position='custom')
121
+ * @param {number} [opts.totalOffset=0] - integer added to the denominator
122
+ * @param {number} [opts.startAt=0] - (video only) seconds before label appears; ignored for images
123
+ * @param {string} opts.destPath - absolute path to write the annotated image
124
+ * @param {string} [opts.label] - display label for the run() spinner
125
+ * @param {boolean} [opts.dryRun=false]
126
+ */
127
+ export async function annotateImageWithSequence(srcPath, opts) {
128
+ const { destPath, label, dryRun = false, index, total } = opts
129
+ const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, { ...opts, startAt: 0 })
130
+
131
+ console.log(
132
+ chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
133
+ chalk.cyan(`"${text}"`) +
134
+ chalk.dim(` → ${path.basename(destPath)}`)
135
+ )
136
+
137
+ if (!dryRun) {
138
+ console.log(chalk.dim(` [seq-label] text file: ${textFile}`))
139
+ writeFileSync(textFile, text, 'utf8')
140
+ console.log(chalk.dim(` [seq-label] font file: ${opts.fontFile}`))
141
+ console.log(chalk.dim(` [seq-label] dest path: ${destPath}`))
142
+ }
143
+
117
144
  const argv = [
118
- '-nostdin', // prevent ffmpeg from reading stdin (avoids hangs in pipelines)
119
- '-loglevel', 'warning', // suppress verbose progress output
145
+ '-nostdin',
146
+ '-loglevel', 'warning',
120
147
  '-y',
121
148
  '-i', srcPath,
122
149
  '-vf', filterStr,
@@ -128,4 +155,53 @@ export async function annotateImageWithSequence(srcPath, {
128
155
  label: label ?? `annotate ${index}/${total}`,
129
156
  dryRun,
130
157
  })
158
+
159
+ if (!dryRun) {
160
+ try { unlinkSync(textFile) } catch {}
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Burns a sequence label (e.g. "scene 3/10") into the bottom-right corner of
166
+ * a video file using ffmpeg drawtext. Used for the whole-output-video label on
167
+ * a stitcher node. Audio is stream-copied to avoid re-encoding.
168
+ *
169
+ * @param {string} srcPath - absolute path to the source video
170
+ * @param {object} opts - same signature as annotateImageWithSequence
171
+ */
172
+ export async function annotateVideoWithSequence(srcPath, opts) {
173
+ const { destPath, label, dryRun = false, index, total } = opts
174
+ const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, opts)
175
+
176
+ console.log(
177
+ chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
178
+ chalk.cyan(`"${text}"`) +
179
+ chalk.dim(` → ${path.basename(destPath)}`)
180
+ )
181
+
182
+ if (!dryRun) {
183
+ console.log(chalk.dim(` [seq-label] text file: ${textFile}`))
184
+ writeFileSync(textFile, text, 'utf8')
185
+ console.log(chalk.dim(` [seq-label] font file: ${opts.fontFile}`))
186
+ console.log(chalk.dim(` [seq-label] dest path: ${destPath}`))
187
+ }
188
+
189
+ const argv = [
190
+ '-nostdin',
191
+ '-loglevel', 'warning',
192
+ '-y',
193
+ '-i', srcPath,
194
+ '-vf', filterStr,
195
+ '-c:a', 'copy',
196
+ destPath,
197
+ ]
198
+
199
+ await run('ffmpeg', argv, {
200
+ label: label ?? `annotate ${index}/${total}`,
201
+ dryRun,
202
+ })
203
+
204
+ if (!dryRun) {
205
+ try { unlinkSync(textFile) } catch {}
206
+ }
131
207
  }
@@ -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,7 +178,18 @@ 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
+ // Per-image and whole-video labels can both be active — per-image annotations
192
+ // get baked into frames and the whole-video pass then draws on top.
182
193
  const resolvedInputs = await Promise.all(
183
194
  inputs.map(async (input) => {
184
195
  const sl = input.sequenceLabel
@@ -201,6 +212,10 @@ 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,
216
+ position: sl.position,
217
+ customX: sl.customX,
218
+ customY: sl.customY,
204
219
  destPath,
205
220
  label: `${node.label ?? node.id} [annotate ${runIdx + 1}/${runCount}]`,
206
221
  dryRun: opts.dryRun,
@@ -211,7 +226,7 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
211
226
  )
212
227
 
213
228
  const cliInputs = resolvedInputs.map(flattenInput)
214
- const argv = [...cliInputs, '-o', outputFile]
229
+ const argv = [...cliInputs, '-o', stitchTarget]
215
230
 
216
231
  if (config.imageDuration != null && config.imageDuration !== 1) {
217
232
  argv.push('-d', String(config.imageDuration))
@@ -228,6 +243,28 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
228
243
  dryRun: opts.dryRun
229
244
  })
230
245
 
246
+ if (videoSl?.enabled) {
247
+ await annotateVideoWithSequence(stitchTarget, {
248
+ index: runIdx + 1,
249
+ total: runCount,
250
+ prefix: videoSl.prefix,
251
+ fontFile: videoSl.fontFile,
252
+ fontSize: videoSl.fontSize,
253
+ fontColor: videoSl.fontColor,
254
+ box: videoSl.box,
255
+ boxColor: videoSl.boxColor,
256
+ padding: videoSl.padding,
257
+ totalOffset: videoSl.totalOffset,
258
+ position: videoSl.position,
259
+ customX: videoSl.customX,
260
+ customY: videoSl.customY,
261
+ startAt: videoSl.startAt,
262
+ destPath: outputFile,
263
+ label: `${node.label ?? node.id} [label ${runIdx + 1}/${runCount}]`,
264
+ dryRun: opts.dryRun,
265
+ })
266
+ }
267
+
231
268
  outputFiles.push(outputFile)
232
269
  }
233
270
 
@@ -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
  })