@shaztech/video-pipeline 1.2.0 → 1.4.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-Dqr1wx5V.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/index-Df9WKIy8.css">
12
+ <script type="module" crossorigin src="./assets/index-DK9i7gno.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.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Visual node-based video processing pipeline CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,6 +30,14 @@ 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
42
  * Validates fontFile and composes the drawtext filter string and sidecar text file path.
35
43
  * Shared by both image and video annotation helpers.
@@ -42,9 +50,15 @@ function buildDrawtextArgs(srcLabel, {
42
50
  fontFile,
43
51
  fontSize = 48,
44
52
  fontColor = 'white',
53
+ borderColor,
54
+ borderWidth,
45
55
  box = false,
46
56
  boxColor = 'black@0.5',
47
57
  padding = 20,
58
+ position = 'bottom-right',
59
+ customX = 0,
60
+ customY = 0,
61
+ startAt = 0,
48
62
  destPath,
49
63
  dryRun = false,
50
64
  }) {
@@ -67,8 +81,7 @@ function buildDrawtextArgs(srcLabel, {
67
81
 
68
82
  const escapedFontFile = escapeFilterPath(fontFile)
69
83
  const escapedTextFile = escapeFilterPath(textFile)
70
- const x = `w-tw-${padding}`
71
- const y = `h-th-${padding}`
84
+ const { x, y } = computePositionXY({ position, padding, customX, customY })
72
85
 
73
86
  let filterStr =
74
87
  `drawtext=fontfile=${escapedFontFile}` +
@@ -78,10 +91,19 @@ function buildDrawtextArgs(srcLabel, {
78
91
  `:x=${x}` +
79
92
  `:y=${y}`
80
93
 
94
+ if (borderWidth != null && borderWidth > 0) {
95
+ filterStr += `:borderw=${borderWidth}`
96
+ if (borderColor) filterStr += `:bordercolor=${borderColor}`
97
+ }
98
+
81
99
  if (box) {
82
100
  filterStr += `:box=1:boxcolor=${boxColor}:boxborderw=8`
83
101
  }
84
102
 
103
+ if (startAt > 0) {
104
+ filterStr += `:enable=gte(t\\,${startAt})`
105
+ }
106
+
85
107
  return { text, textFile, filterStr }
86
108
  }
87
109
 
@@ -97,17 +119,23 @@ function buildDrawtextArgs(srcLabel, {
97
119
  * @param {string} opts.fontFile - path to a .ttf/.otf/.ttc font file (required)
98
120
  * @param {number} [opts.fontSize=48]
99
121
  * @param {string} [opts.fontColor='white']
122
+ * @param {string} [opts.borderColor] - color of text stroke/outline (e.g. 'black')
123
+ * @param {number} [opts.borderWidth] - width of text stroke in pixels (0 = off)
100
124
  * @param {boolean} [opts.box=false] - draw a semi-transparent background box
101
125
  * @param {string} [opts.boxColor='black@0.5']
102
- * @param {number} [opts.padding=20] - px distance from right & bottom edges
126
+ * @param {number} [opts.padding=20] - px distance from edges (used by presets)
127
+ * @param {string} [opts.position='bottom-right'] - one of the 9 presets or 'custom'
128
+ * @param {number} [opts.customX=0] - x pixel offset (only when position='custom')
129
+ * @param {number} [opts.customY=0] - y pixel offset (only when position='custom')
103
130
  * @param {number} [opts.totalOffset=0] - integer added to the denominator
131
+ * @param {number} [opts.startAt=0] - (video only) seconds before label appears; ignored for images
104
132
  * @param {string} opts.destPath - absolute path to write the annotated image
105
133
  * @param {string} [opts.label] - display label for the run() spinner
106
134
  * @param {boolean} [opts.dryRun=false]
107
135
  */
108
136
  export async function annotateImageWithSequence(srcPath, opts) {
109
137
  const { destPath, label, dryRun = false, index, total } = opts
110
- const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, opts)
138
+ const { text, textFile, filterStr } = buildDrawtextArgs(srcPath, { ...opts, startAt: 0 })
111
139
 
112
140
  console.log(
113
141
  chalk.dim(` [seq-label] Annotating ${path.basename(srcPath)} `) +
@@ -188,10 +188,10 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
188
188
  if (videoSl?.enabled && !opts.dryRun) mkdirSync(path.dirname(stitchTarget), { recursive: true })
189
189
 
190
190
  // Pre-process any fixed image inputs that have sequenceLabel.enabled.
191
- // Skip if whole-video label is active (mutual exclusion enforced at runtime).
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.
192
193
  const resolvedInputs = await Promise.all(
193
194
  inputs.map(async (input) => {
194
- if (videoSl?.enabled) return input
195
195
  const sl = input.sequenceLabel
196
196
  if (!sl?.enabled || !isImage(input.value)) return input
197
197
 
@@ -209,10 +209,15 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
209
209
  fontFile: sl.fontFile,
210
210
  fontSize: sl.fontSize,
211
211
  fontColor: sl.fontColor,
212
+ borderColor: sl.borderColor,
213
+ borderWidth: sl.borderWidth,
212
214
  box: sl.box,
213
215
  boxColor: sl.boxColor,
214
216
  padding: sl.padding,
215
217
  totalOffset: sl.totalOffset,
218
+ position: sl.position,
219
+ customX: sl.customX,
220
+ customY: sl.customY,
216
221
  destPath,
217
222
  label: `${node.label ?? node.id} [annotate ${runIdx + 1}/${runCount}]`,
218
223
  dryRun: opts.dryRun,
@@ -248,10 +253,16 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
248
253
  fontFile: videoSl.fontFile,
249
254
  fontSize: videoSl.fontSize,
250
255
  fontColor: videoSl.fontColor,
256
+ borderColor: videoSl.borderColor,
257
+ borderWidth: videoSl.borderWidth,
251
258
  box: videoSl.box,
252
259
  boxColor: videoSl.boxColor,
253
260
  padding: videoSl.padding,
254
261
  totalOffset: videoSl.totalOffset,
262
+ position: videoSl.position,
263
+ customX: videoSl.customX,
264
+ customY: videoSl.customY,
265
+ startAt: videoSl.startAt,
255
266
  destPath: outputFile,
256
267
  label: `${node.label ?? node.id} [label ${runIdx + 1}/${runCount}]`,
257
268
  dryRun: opts.dryRun,
@@ -14,27 +14,40 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { readFileSync, writeFileSync, renameSync } from 'fs'
17
+ import { readFileSync, writeFileSync, renameSync, existsSync, statSync } from 'fs'
18
18
  import { execFile } from 'child_process'
19
19
  import path from 'path'
20
20
  import { Router } from 'express'
21
21
 
22
- function nativeBrowse() {
22
+ function resolveStartFolder(from) {
23
+ if (!from || typeof from !== 'string') return null
24
+ try {
25
+ if (!existsSync(from)) return null
26
+ return statSync(from).isDirectory() ? from : path.dirname(from)
27
+ } catch { return null }
28
+ }
29
+
30
+ function nativeBrowse(from) {
31
+ const start = resolveStartFolder(from)
23
32
  return new Promise((resolve) => {
24
33
  const { platform } = process
25
34
  if (platform === 'darwin') {
26
- execFile('osascript', ['-e', 'POSIX path of (choose file)'], (err, stdout) => {
35
+ const loc = start ? ` default location (POSIX file "${start.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : ''
36
+ execFile('osascript', ['-e', `POSIX path of (choose file${loc})`], (err, stdout) => {
27
37
  resolve(err ? null : stdout.trim())
28
38
  })
29
39
  } else if (platform === 'linux') {
30
- execFile('zenity', ['--file-selection'], (err, stdout) => {
40
+ const args = ['--file-selection']
41
+ if (start) args.push(`--filename=${start.endsWith('/') ? start : start + '/'}`)
42
+ execFile('zenity', args, (err, stdout) => {
31
43
  if (!err) return resolve(stdout.trim())
32
- execFile('kdialog', ['--getopenfilename', '.'], (err2, stdout2) => {
44
+ execFile('kdialog', ['--getopenfilename', start || '.'], (err2, stdout2) => {
33
45
  resolve(err2 ? null : stdout2.trim())
34
46
  })
35
47
  })
36
48
  } else if (platform === 'win32') {
37
- const ps = `Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.OpenFileDialog; if ($d.ShowDialog() -eq 'OK') { $d.FileName }`
49
+ const initLine = start ? `$d.InitialDirectory = '${start.replace(/'/g, "''")}'; ` : ''
50
+ const ps = `Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.OpenFileDialog; ${initLine}if ($d.ShowDialog() -eq 'OK') { $d.FileName }`
38
51
  execFile('powershell', ['-Command', ps], (err, stdout) => {
39
52
  resolve(err ? null : stdout.trim())
40
53
  })
@@ -44,22 +57,27 @@ function nativeBrowse() {
44
57
  })
45
58
  }
46
59
 
47
- function nativeBrowseFolder() {
60
+ function nativeBrowseFolder(from) {
61
+ const start = resolveStartFolder(from)
48
62
  return new Promise((resolve) => {
49
63
  const { platform } = process
50
64
  if (platform === 'darwin') {
51
- execFile('osascript', ['-e', 'POSIX path of (choose folder)'], (err, stdout) => {
65
+ const loc = start ? ` default location (POSIX file "${start.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : ''
66
+ execFile('osascript', ['-e', `POSIX path of (choose folder${loc})`], (err, stdout) => {
52
67
  resolve(err ? null : stdout.trim())
53
68
  })
54
69
  } else if (platform === 'linux') {
55
- execFile('zenity', ['--file-selection', '--directory'], (err, stdout) => {
70
+ const args = ['--file-selection', '--directory']
71
+ if (start) args.push(`--filename=${start.endsWith('/') ? start : start + '/'}`)
72
+ execFile('zenity', args, (err, stdout) => {
56
73
  if (!err) return resolve(stdout.trim())
57
- execFile('kdialog', ['--getexistingdirectory', '.'], (err2, stdout2) => {
74
+ execFile('kdialog', ['--getexistingdirectory', start || '.'], (err2, stdout2) => {
58
75
  resolve(err2 ? null : stdout2.trim())
59
76
  })
60
77
  })
61
78
  } else if (platform === 'win32') {
62
- const ps = `Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }`
79
+ const initLine = start ? `$d.SelectedPath = '${start.replace(/'/g, "''")}'; ` : ''
80
+ const ps = `Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; ${initLine}if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }`
63
81
  execFile('powershell', ['-Command', ps], (err, stdout) => {
64
82
  resolve(err ? null : stdout.trim())
65
83
  })
@@ -89,14 +107,16 @@ export function createRoutes(specPath, broadcast) {
89
107
  })
90
108
 
91
109
  // GET /api/browse — open a native OS file picker, return full path
92
- router.get('/api/browse', async (_req, res) => {
93
- const filePath = await nativeBrowse()
110
+ // Optional ?from=<path> opens the picker at that path's parent folder
111
+ router.get('/api/browse', async (req, res) => {
112
+ const filePath = await nativeBrowse(typeof req.query.from === 'string' ? req.query.from : null)
94
113
  res.json({ path: filePath })
95
114
  })
96
115
 
97
116
  // GET /api/browse-folder — open a native OS folder picker, return full path
98
- router.get('/api/browse-folder', async (_req, res) => {
99
- const folderPath = await nativeBrowseFolder()
117
+ // Optional ?from=<path> opens the picker at that path (or its parent if a file)
118
+ router.get('/api/browse-folder', async (req, res) => {
119
+ const folderPath = await nativeBrowseFolder(typeof req.query.from === 'string' ? req.query.from : null)
100
120
  res.json({ path: folderPath })
101
121
  })
102
122
 
@@ -64,16 +64,6 @@ 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
- }
77
67
  }
78
68
 
79
69
  for (const edge of spec.edges) {