@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.
- package/README.md +41 -0
- package/dist/editor/assets/index-Dqr1wx5V.js +71 -0
- package/dist/editor/index.html +1 -1
- package/package.json +1 -1
- package/src/executor/nodeHandlers/imageAnnotate.js +99 -42
- package/src/executor/nodeHandlers/video-cutter.js +11 -2
- package/src/executor/nodeHandlers/video-stitcher.js +33 -3
- package/src/executor/runner.js +3 -2
- package/src/spec/schema.js +10 -0
- package/dist/editor/assets/index-BqNsIbxc.js +0 -71
package/dist/editor/index.html
CHANGED
|
@@ -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-
|
|
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
|
@@ -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
|
-
*
|
|
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]
|
|
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
|
-
|
|
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: "${
|
|
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
|
-
|
|
79
|
-
const text = prefix ? `${prefix} ${index}/${
|
|
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',
|
|
119
|
-
'-loglevel', 'warning',
|
|
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)
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
})
|
package/src/spec/schema.js
CHANGED
|
@@ -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) {
|