@shaztech/video-pipeline 1.0.4 → 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-K7ALkYj6.css → index-Df9WKIy8.css} +1 -1
- package/dist/editor/assets/index-Dqr1wx5V.js +71 -0
- package/dist/editor/index.html +2 -2
- package/package.json +1 -1
- package/src/executor/nodeHandlers/imageAnnotate.js +188 -0
- package/src/executor/nodeHandlers/video-cutter.js +11 -2
- package/src/executor/nodeHandlers/video-stitcher.js +95 -10
- package/src/executor/runner.js +3 -2
- package/src/spec/schema.js +10 -0
- package/dist/editor/assets/index-DyAG6YEF.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-Dqr1wx5V.js"></script>
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Df9WKIy8.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Shazron Abdullah
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'path'
|
|
18
|
+
import { existsSync, writeFileSync, unlinkSync } from 'fs'
|
|
19
|
+
import chalk from 'chalk'
|
|
20
|
+
import { run } from '../runner.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escapes a file path for use inside an ffmpeg filter string value.
|
|
24
|
+
* In ffmpeg filter syntax the following characters must be escaped with a backslash:
|
|
25
|
+
* \ : '
|
|
26
|
+
* (The path is embedded as a value in drawtext=fontfile=...:textfile=...)
|
|
27
|
+
*/
|
|
28
|
+
function escapeFilterPath(p) {
|
|
29
|
+
// Replace backslashes first, then colons, then single-quotes
|
|
30
|
+
return p.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates fontFile and composes the drawtext filter string and sidecar text file path.
|
|
35
|
+
* Shared by both image and video annotation helpers.
|
|
36
|
+
*/
|
|
37
|
+
function buildDrawtextArgs(srcLabel, {
|
|
38
|
+
index,
|
|
39
|
+
total,
|
|
40
|
+
totalOffset = 0,
|
|
41
|
+
prefix,
|
|
42
|
+
fontFile,
|
|
43
|
+
fontSize = 48,
|
|
44
|
+
fontColor = 'white',
|
|
45
|
+
box = false,
|
|
46
|
+
boxColor = 'black@0.5',
|
|
47
|
+
padding = 20,
|
|
48
|
+
destPath,
|
|
49
|
+
dryRun = false,
|
|
50
|
+
}) {
|
|
51
|
+
if (!fontFile) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`sequenceLabel: fontFile is required but was not provided (item: "${srcLabel}")`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!dryRun && !existsSync(fontFile)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`sequenceLabel: fontFile not found: ${fontFile}`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const effectiveTotal = total + totalOffset
|
|
64
|
+
const text = prefix ? `${prefix} ${index}/${effectiveTotal}` : `${index}/${effectiveTotal}`
|
|
65
|
+
|
|
66
|
+
const textFile = `${destPath}.txt`
|
|
67
|
+
|
|
68
|
+
const escapedFontFile = escapeFilterPath(fontFile)
|
|
69
|
+
const escapedTextFile = escapeFilterPath(textFile)
|
|
70
|
+
const x = `w-tw-${padding}`
|
|
71
|
+
const y = `h-th-${padding}`
|
|
72
|
+
|
|
73
|
+
let filterStr =
|
|
74
|
+
`drawtext=fontfile=${escapedFontFile}` +
|
|
75
|
+
`:textfile=${escapedTextFile}` +
|
|
76
|
+
`:fontsize=${fontSize}` +
|
|
77
|
+
`:fontcolor=${fontColor}` +
|
|
78
|
+
`:x=${x}` +
|
|
79
|
+
`:y=${y}`
|
|
80
|
+
|
|
81
|
+
if (box) {
|
|
82
|
+
filterStr += `:box=1:boxcolor=${boxColor}:boxborderw=8`
|
|
83
|
+
}
|
|
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
|
+
|
|
125
|
+
const argv = [
|
|
126
|
+
'-nostdin',
|
|
127
|
+
'-loglevel', 'warning',
|
|
128
|
+
'-y',
|
|
129
|
+
'-i', srcPath,
|
|
130
|
+
'-vf', filterStr,
|
|
131
|
+
'-frames:v', '1',
|
|
132
|
+
destPath,
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
await run('ffmpeg', argv, {
|
|
136
|
+
label: label ?? `annotate ${index}/${total}`,
|
|
137
|
+
dryRun,
|
|
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
|
+
}
|
|
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,6 +18,13 @@ 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, annotateVideoWithSequence } from './imageAnnotate.js'
|
|
22
|
+
|
|
23
|
+
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp|tiff?|avif|svg)$/i
|
|
24
|
+
|
|
25
|
+
function isImage(filePath) {
|
|
26
|
+
return IMAGE_EXTS.test(filePath)
|
|
27
|
+
}
|
|
21
28
|
|
|
22
29
|
function expandPath(p) {
|
|
23
30
|
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
|
|
@@ -25,7 +32,8 @@ function expandPath(p) {
|
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* Builds the list of per-run inputs from inputOrder + edge outputs.
|
|
28
|
-
* Returns
|
|
35
|
+
* Returns { runs, runCount } where each run is:
|
|
36
|
+
* { inputs: Array<{ value: string, imageDuration?: number, sequenceLabel?: object }>, name: string }
|
|
29
37
|
*
|
|
30
38
|
* When an edge item expands to N files, N runs are produced (one per edge file),
|
|
31
39
|
* with fixed items applied to every run. The output filename equals the basename
|
|
@@ -51,8 +59,8 @@ function buildRuns(config, incomingEdges, context, nodeId) {
|
|
|
51
59
|
// No edge connections — single run with all fixed inputs
|
|
52
60
|
const inputs = config.inputOrder
|
|
53
61
|
.filter((i) => i.type === 'fixed' && i.value)
|
|
54
|
-
.map((i) =>
|
|
55
|
-
return [{ inputs, name: 'output.mp4' }]
|
|
62
|
+
.map((i) => ({ value: i.value, imageDuration: i.imageDuration, sequenceLabel: i.sequenceLabel }))
|
|
63
|
+
return { runs: [{ inputs, name: 'output.mp4' }], runCount: 1 }
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
// Number of runs = max file count across all edge items
|
|
@@ -75,12 +83,14 @@ function buildRuns(config, incomingEdges, context, nodeId) {
|
|
|
75
83
|
const inputs = []
|
|
76
84
|
for (const item of config.inputOrder) {
|
|
77
85
|
if (item.type === 'fixed') {
|
|
78
|
-
if (item.value)
|
|
86
|
+
if (item.value) {
|
|
87
|
+
inputs.push({ value: item.value, imageDuration: item.imageDuration, sequenceLabel: item.sequenceLabel })
|
|
88
|
+
}
|
|
79
89
|
} else if (item.type === 'edge') {
|
|
80
90
|
const files = edgeOutputs.get(item.nodeId) ?? []
|
|
81
91
|
// Use file i; clamp to last available if this edge has fewer files
|
|
82
92
|
const file = files[Math.min(i, files.length - 1)]
|
|
83
|
-
if (file) inputs.push(file)
|
|
93
|
+
if (file) inputs.push({ value: file })
|
|
84
94
|
}
|
|
85
95
|
}
|
|
86
96
|
const pivotFile = pivotFiles[i]
|
|
@@ -93,7 +103,7 @@ function buildRuns(config, incomingEdges, context, nodeId) {
|
|
|
93
103
|
: `output_${String(i + 1).padStart(3, '0')}.mp4`
|
|
94
104
|
runs.push({ inputs, name })
|
|
95
105
|
}
|
|
96
|
-
return runs
|
|
106
|
+
return { runs, runCount }
|
|
97
107
|
} else {
|
|
98
108
|
// Legacy: fixed inputs first, then all edge outputs — single run
|
|
99
109
|
const fixedInputs = Array.isArray(config.inputs) ? config.inputs.filter(Boolean) : []
|
|
@@ -107,10 +117,20 @@ function buildRuns(config, incomingEdges, context, nodeId) {
|
|
|
107
117
|
}
|
|
108
118
|
variableInputs.push(...(sourceCtx.outputs ?? []))
|
|
109
119
|
}
|
|
110
|
-
|
|
120
|
+
const inputs = [...fixedInputs, ...variableInputs].map((v) => ({ value: v }))
|
|
121
|
+
return { runs: [{ inputs, name: 'output.mp4' }], runCount: 1 }
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Flattens an input object to the CLI string expected by video-stitcher:
|
|
127
|
+
* - image with duration → "path:duration"
|
|
128
|
+
* - otherwise → "path"
|
|
129
|
+
*/
|
|
130
|
+
function flattenInput({ value, imageDuration }) {
|
|
131
|
+
return imageDuration != null ? `${value}:${imageDuration}` : value
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
/**
|
|
115
135
|
* Builds argv and executes video-stitcher for a node.
|
|
116
136
|
* Produces one output file per edge segment (N inputs → N outputs),
|
|
@@ -135,11 +155,13 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
|
|
|
135
155
|
|
|
136
156
|
if (!opts.dryRun) mkdirSync(outputDir, { recursive: true })
|
|
137
157
|
|
|
138
|
-
const runs = buildRuns(config, incomingEdges, context, node.id)
|
|
158
|
+
const { runs, runCount } = buildRuns(config, incomingEdges, context, node.id)
|
|
139
159
|
|
|
140
160
|
const outputFiles = []
|
|
141
161
|
|
|
142
|
-
for (
|
|
162
|
+
for (let runIdx = 0; runIdx < runs.length; runIdx++) {
|
|
163
|
+
const { inputs, name } = runs[runIdx]
|
|
164
|
+
|
|
143
165
|
if (inputs.length < 2) {
|
|
144
166
|
throw new Error(
|
|
145
167
|
`Node "${node.id}" (video-stitcher): at least 2 inputs required for "${name}", got ${inputs.length}`
|
|
@@ -156,7 +178,52 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
|
|
|
156
178
|
)
|
|
157
179
|
}
|
|
158
180
|
|
|
159
|
-
const
|
|
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).
|
|
192
|
+
const resolvedInputs = await Promise.all(
|
|
193
|
+
inputs.map(async (input) => {
|
|
194
|
+
if (videoSl?.enabled) return input
|
|
195
|
+
const sl = input.sequenceLabel
|
|
196
|
+
if (!sl?.enabled || !isImage(input.value)) return input
|
|
197
|
+
|
|
198
|
+
const annotDir = path.join(tempRoot, node.id, 'annotated')
|
|
199
|
+
if (!opts.dryRun) mkdirSync(annotDir, { recursive: true })
|
|
200
|
+
|
|
201
|
+
const ext = path.extname(input.value)
|
|
202
|
+
const base = path.basename(input.value, ext)
|
|
203
|
+
const destPath = path.join(annotDir, `${runIdx + 1}_${base}${ext}`)
|
|
204
|
+
|
|
205
|
+
await annotateImageWithSequence(input.value, {
|
|
206
|
+
index: runIdx + 1,
|
|
207
|
+
total: runCount,
|
|
208
|
+
prefix: sl.prefix,
|
|
209
|
+
fontFile: sl.fontFile,
|
|
210
|
+
fontSize: sl.fontSize,
|
|
211
|
+
fontColor: sl.fontColor,
|
|
212
|
+
box: sl.box,
|
|
213
|
+
boxColor: sl.boxColor,
|
|
214
|
+
padding: sl.padding,
|
|
215
|
+
totalOffset: sl.totalOffset,
|
|
216
|
+
destPath,
|
|
217
|
+
label: `${node.label ?? node.id} [annotate ${runIdx + 1}/${runCount}]`,
|
|
218
|
+
dryRun: opts.dryRun,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return { ...input, value: destPath }
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const cliInputs = resolvedInputs.map(flattenInput)
|
|
226
|
+
const argv = [...cliInputs, '-o', stitchTarget]
|
|
160
227
|
|
|
161
228
|
if (config.imageDuration != null && config.imageDuration !== 1) {
|
|
162
229
|
argv.push('-d', String(config.imageDuration))
|
|
@@ -173,6 +240,24 @@ export async function handleVideoStitcher(node, context, tempRoot, incomingEdges
|
|
|
173
240
|
dryRun: opts.dryRun
|
|
174
241
|
})
|
|
175
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
|
+
|
|
176
261
|
outputFiles.push(outputFile)
|
|
177
262
|
}
|
|
178
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) {
|