@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.
- package/README.md +45 -0
- package/dist/editor/assets/{index-Df9WKIy8.css → index-CUxLpKni.css} +1 -1
- package/dist/editor/assets/index-yrb6jZba.js +71 -0
- package/dist/editor/index.html +2 -2
- package/package.json +1 -1
- package/src/executor/nodeHandlers/imageAnnotate.js +120 -44
- package/src/executor/nodeHandlers/video-cutter.js +11 -2
- package/src/executor/nodeHandlers/video-stitcher.js +40 -3
- package/src/executor/runner.js +3 -2
- package/dist/editor/assets/index-BqNsIbxc.js +0 -71
package/dist/editor/index.html
CHANGED
|
@@ -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-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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: "${
|
|
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
|
-
|
|
79
|
-
const text = prefix ? `${prefix} ${index}/${
|
|
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 =
|
|
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',
|
|
119
|
-
'-loglevel', 'warning',
|
|
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)
|
|
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
|
-
|
|
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',
|
|
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
|
|
package/src/executor/runner.js
CHANGED
|
@@ -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('
|
|
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
|
})
|