@shaztech/video-pipeline 1.0.1
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/bin.js +19 -0
- package/dist/editor/assets/index-DV6clFYC.js +71 -0
- package/dist/editor/assets/index-K7ALkYj6.css +1 -0
- package/dist/editor/index.html +18 -0
- package/package.json +42 -0
- package/src/cli.js +35 -0
- package/src/commands/create.js +43 -0
- package/src/commands/edit.js +57 -0
- package/src/commands/run.js +64 -0
- package/src/commands/validate.js +79 -0
- package/src/executor/index.js +148 -0
- package/src/executor/nodeHandlers/input-file.js +41 -0
- package/src/executor/nodeHandlers/input-folder.js +55 -0
- package/src/executor/nodeHandlers/video-cutter.js +145 -0
- package/src/executor/nodeHandlers/video-stitcher.js +190 -0
- package/src/executor/runner.js +75 -0
- package/src/executor/topoSort.js +61 -0
- package/src/server/index.js +81 -0
- package/src/server/routes.js +123 -0
- package/src/spec/defaultSpec.js +24 -0
- package/src/spec/schema.js +80 -0
|
@@ -0,0 +1,145 @@
|
|
|
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 os from 'os'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { mkdirSync, rmSync, readdirSync, copyFileSync } from 'fs'
|
|
20
|
+
import { glob } from 'glob'
|
|
21
|
+
import { run } from '../runner.js'
|
|
22
|
+
|
|
23
|
+
function expandPath(p) {
|
|
24
|
+
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds argv and executes video-cutter for a node.
|
|
29
|
+
* After execution, globs the output directory for segment files
|
|
30
|
+
* and stores them in context[nodeId].outputs (sorted).
|
|
31
|
+
*
|
|
32
|
+
* Input files come from (in priority order):
|
|
33
|
+
* 1. Connected input-file / input-folder nodes (opts.inputFiles)
|
|
34
|
+
* 2. config.input (legacy / backward-compat)
|
|
35
|
+
*
|
|
36
|
+
* When multiple input files are supplied each is cut into its own
|
|
37
|
+
* sub-directory of the output dir; all resulting segments are pooled
|
|
38
|
+
* into context[nodeId].outputs.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} node - the spec node
|
|
41
|
+
* @param {Map} context - runtime context keyed by nodeId
|
|
42
|
+
* @param {string} _tempRoot - base temp directory (unused)
|
|
43
|
+
* @param {object} opts - { dryRun, overwrite, outputFolderPaths, inputFiles }
|
|
44
|
+
*/
|
|
45
|
+
export async function handleVideoCutter(node, context, _tempRoot, opts = {}) {
|
|
46
|
+
const { config } = node
|
|
47
|
+
|
|
48
|
+
// Resolve input file(s)
|
|
49
|
+
const inputFiles = opts.inputFiles?.length > 0
|
|
50
|
+
? opts.inputFiles.map(expandPath).filter(Boolean)
|
|
51
|
+
: config.input ? [expandPath(config.input)] : []
|
|
52
|
+
|
|
53
|
+
if (inputFiles.length === 0) {
|
|
54
|
+
if (opts.dryRun) {
|
|
55
|
+
// dry-run with no input: emit a placeholder so downstream nodes can print
|
|
56
|
+
const outputDir = path.join(os.tmpdir(), 'video-pipeline', node.id)
|
|
57
|
+
const segCount = config.segments ?? 1
|
|
58
|
+
const dummyOutputs = Array.from({ length: segCount }, (_, i) =>
|
|
59
|
+
path.join(outputDir, `segment_${String(i + 1).padStart(3, '0')}.mp4`)
|
|
60
|
+
)
|
|
61
|
+
context.set(node.id, { outputDir, outputs: dummyOutputs })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Node "${node.id}" (video-cutter): no input file configured — connect an InputFile or InputFolder node`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Output folder paths from connected OutputFolder nodes
|
|
68
|
+
const outputFolderPaths = (opts.outputFolderPaths ?? []).map(expandPath).filter(Boolean)
|
|
69
|
+
|
|
70
|
+
const allSegments = []
|
|
71
|
+
const multiInput = inputFiles.length > 1
|
|
72
|
+
|
|
73
|
+
// Determine base output dir (same for all input files)
|
|
74
|
+
const baseOutputDir = outputFolderPaths.length > 0
|
|
75
|
+
? outputFolderPaths[0]
|
|
76
|
+
: config.output
|
|
77
|
+
? expandPath(config.output)
|
|
78
|
+
: path.join(path.dirname(inputFiles[0]), 'cutter-output')
|
|
79
|
+
|
|
80
|
+
for (const inputFile of inputFiles) {
|
|
81
|
+
// Each input file gets its own sub-directory when processing multiple files
|
|
82
|
+
const outputDir = multiInput
|
|
83
|
+
? path.join(baseOutputDir, path.basename(inputFile, path.extname(inputFile)))
|
|
84
|
+
: baseOutputDir
|
|
85
|
+
|
|
86
|
+
if (!opts.dryRun) {
|
|
87
|
+
if (opts.overwrite) rmSync(outputDir, { recursive: true, force: true })
|
|
88
|
+
mkdirSync(outputDir, { recursive: true })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const argv = ['-i', inputFile, '-o', outputDir]
|
|
92
|
+
|
|
93
|
+
if (config.segments != null) {
|
|
94
|
+
argv.push('-n', String(config.segments))
|
|
95
|
+
} else if (config.duration != null) {
|
|
96
|
+
argv.push('-d', String(config.duration))
|
|
97
|
+
} else if (config.sceneDetect != null) {
|
|
98
|
+
argv.push('--scene-detect', config.sceneDetect === true ? '' : String(config.sceneDetect))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (config.verify) argv.push('--verify')
|
|
102
|
+
if (config.reEncode) argv.push('--re-encode')
|
|
103
|
+
|
|
104
|
+
await run('video-cutter', argv.filter(Boolean), {
|
|
105
|
+
label: node.label ?? node.id,
|
|
106
|
+
dryRun: opts.dryRun,
|
|
107
|
+
input: 'y\n'
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (opts.dryRun) {
|
|
111
|
+
const segCount = config.segments ?? 1
|
|
112
|
+
for (let i = 0; i < segCount; i++) {
|
|
113
|
+
allSegments.push(path.join(outputDir, `segment_${String(i + 1).padStart(3, '0')}.mp4`))
|
|
114
|
+
}
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const files = (await glob('**/*.mp4', { cwd: outputDir, absolute: true })).sort()
|
|
119
|
+
|
|
120
|
+
if (files.length === 0) {
|
|
121
|
+
let contents = []
|
|
122
|
+
try { contents = readdirSync(outputDir) } catch {}
|
|
123
|
+
const detail = contents.length > 0
|
|
124
|
+
? `Output directory contains: ${contents.join(', ')}`
|
|
125
|
+
: 'Output directory is empty'
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Node "${node.id}" (video-cutter): produced no output segments\n ${detail}\n Input: ${inputFile}`
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
allSegments.push(...files)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Copy all segments to any additional output-folder paths
|
|
135
|
+
const additionalDirs = outputFolderPaths.slice(1)
|
|
136
|
+
for (const dir of additionalDirs) {
|
|
137
|
+
console.log(` Copying ${allSegments.length} segment(s) → ${dir}`)
|
|
138
|
+
mkdirSync(dir, { recursive: true })
|
|
139
|
+
for (const file of allSegments) {
|
|
140
|
+
copyFileSync(file, path.join(dir, path.basename(file)))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
context.set(node.id, { outputDir: baseOutputDir, outputs: allSegments })
|
|
145
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
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 os from 'os'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { mkdirSync, existsSync, copyFileSync } from 'fs'
|
|
20
|
+
import { run } from '../runner.js'
|
|
21
|
+
|
|
22
|
+
function expandPath(p) {
|
|
23
|
+
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the list of per-run inputs from inputOrder + edge outputs.
|
|
28
|
+
* Returns an array of { inputs: string[], name: string } — one entry per output file.
|
|
29
|
+
*
|
|
30
|
+
* When an edge item expands to N files, N runs are produced (one per edge file),
|
|
31
|
+
* with fixed items applied to every run. The output filename equals the basename
|
|
32
|
+
* of the corresponding edge file.
|
|
33
|
+
*/
|
|
34
|
+
function buildRuns(config, incomingEdges, context, nodeId) {
|
|
35
|
+
// Collect edge outputs keyed by source nodeId
|
|
36
|
+
const edgeOutputs = new Map()
|
|
37
|
+
for (const edge of incomingEdges) {
|
|
38
|
+
const sourceCtx = context.get(edge.source)
|
|
39
|
+
if (!sourceCtx) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Node "${nodeId}" (video-stitcher): upstream node "${edge.source}" has no outputs in context`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
edgeOutputs.set(edge.source, sourceCtx.outputs ?? [])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(config.inputOrder) && config.inputOrder.length > 0) {
|
|
48
|
+
const edgeItems = config.inputOrder.filter((i) => i.type === 'edge')
|
|
49
|
+
|
|
50
|
+
if (edgeItems.length === 0) {
|
|
51
|
+
// No edge connections — single run with all fixed inputs
|
|
52
|
+
const inputs = config.inputOrder
|
|
53
|
+
.filter((i) => i.type === 'fixed' && i.value)
|
|
54
|
+
.map((i) => i.imageDuration != null ? `${i.value}:${i.imageDuration}` : i.value)
|
|
55
|
+
return [{ inputs, name: 'output.mp4' }]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Number of runs = max file count across all edge items
|
|
59
|
+
let runCount = 1
|
|
60
|
+
for (const item of edgeItems) {
|
|
61
|
+
const files = edgeOutputs.get(item.nodeId) ?? []
|
|
62
|
+
if (files.length > runCount) runCount = files.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const runs = []
|
|
66
|
+
const pivotFiles = edgeOutputs.get(edgeItems[0].nodeId) ?? []
|
|
67
|
+
|
|
68
|
+
// When an InputFolder feeds multiple files into the cutter, each file's segments
|
|
69
|
+
// land in a named subfolder (e.g. cutter-output/movie1/seg_01.mp4).
|
|
70
|
+
// Detect this by checking whether the pivot files span more than one parent dir.
|
|
71
|
+
const pivotParents = new Set(pivotFiles.map((f) => path.dirname(f)))
|
|
72
|
+
const multiSource = pivotParents.size > 1
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < runCount; i++) {
|
|
75
|
+
const inputs = []
|
|
76
|
+
for (const item of config.inputOrder) {
|
|
77
|
+
if (item.type === 'fixed') {
|
|
78
|
+
if (item.value) inputs.push(item.imageDuration != null ? `${item.value}:${item.imageDuration}` : item.value)
|
|
79
|
+
} else if (item.type === 'edge') {
|
|
80
|
+
const files = edgeOutputs.get(item.nodeId) ?? []
|
|
81
|
+
// Use file i; clamp to last available if this edge has fewer files
|
|
82
|
+
const file = files[Math.min(i, files.length - 1)]
|
|
83
|
+
if (file) inputs.push(file)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const pivotFile = pivotFiles[i]
|
|
87
|
+
// Mirror the source subfolder name in the output so files from different input
|
|
88
|
+
// sources don't collide (e.g. stitch-output/movie1/seg_01.mp4).
|
|
89
|
+
const name = pivotFile
|
|
90
|
+
? multiSource
|
|
91
|
+
? path.join(path.basename(path.dirname(pivotFile)), path.basename(pivotFile))
|
|
92
|
+
: path.basename(pivotFile)
|
|
93
|
+
: `output_${String(i + 1).padStart(3, '0')}.mp4`
|
|
94
|
+
runs.push({ inputs, name })
|
|
95
|
+
}
|
|
96
|
+
return runs
|
|
97
|
+
} else {
|
|
98
|
+
// Legacy: fixed inputs first, then all edge outputs — single run
|
|
99
|
+
const fixedInputs = Array.isArray(config.inputs) ? config.inputs.filter(Boolean) : []
|
|
100
|
+
const variableInputs = []
|
|
101
|
+
for (const edge of incomingEdges) {
|
|
102
|
+
const sourceCtx = context.get(edge.source)
|
|
103
|
+
if (!sourceCtx) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Node "${nodeId}" (video-stitcher): upstream node "${edge.source}" has no outputs in context`
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
variableInputs.push(...(sourceCtx.outputs ?? []))
|
|
109
|
+
}
|
|
110
|
+
return [{ inputs: [...fixedInputs, ...variableInputs], name: 'output.mp4' }]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds argv and executes video-stitcher for a node.
|
|
116
|
+
* Produces one output file per edge segment (N inputs → N outputs),
|
|
117
|
+
* all written into the configured output folder.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} node - the spec node
|
|
120
|
+
* @param {Map} context - runtime context keyed by nodeId
|
|
121
|
+
* @param {string} tempRoot - base temp directory for this run
|
|
122
|
+
* @param {object[]} incomingEdges - edges where edge.target === node.id
|
|
123
|
+
* @param {object} opts - { dryRun, overwrite }
|
|
124
|
+
*/
|
|
125
|
+
export async function handleVideoStitcher(node, context, tempRoot, incomingEdges, opts = {}) {
|
|
126
|
+
const { config } = node
|
|
127
|
+
|
|
128
|
+
// Determine output directory: connected output-folder node > config.output > temp
|
|
129
|
+
const outputFolderPaths = (opts.outputFolderPaths ?? []).map(expandPath).filter(Boolean)
|
|
130
|
+
const outputDir = outputFolderPaths.length > 0
|
|
131
|
+
? outputFolderPaths[0]
|
|
132
|
+
: config.output
|
|
133
|
+
? expandPath(config.output)
|
|
134
|
+
: path.join(tempRoot, node.id)
|
|
135
|
+
|
|
136
|
+
if (!opts.dryRun) mkdirSync(outputDir, { recursive: true })
|
|
137
|
+
|
|
138
|
+
const runs = buildRuns(config, incomingEdges, context, node.id)
|
|
139
|
+
|
|
140
|
+
const outputFiles = []
|
|
141
|
+
|
|
142
|
+
for (const { inputs, name } of runs) {
|
|
143
|
+
if (inputs.length < 2) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Node "${node.id}" (video-stitcher): at least 2 inputs required for "${name}", got ${inputs.length}`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const outputFile = path.join(outputDir, name)
|
|
150
|
+
|
|
151
|
+
if (!opts.dryRun) mkdirSync(path.dirname(outputFile), { recursive: true })
|
|
152
|
+
|
|
153
|
+
if (!opts.dryRun && !opts.overwrite && existsSync(outputFile)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Output file already exists: ${outputFile}\nUse --overwrite to replace it.`
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const argv = [...inputs, '-o', outputFile]
|
|
160
|
+
|
|
161
|
+
if (config.imageDuration != null && config.imageDuration !== 1) {
|
|
162
|
+
argv.push('-d', String(config.imageDuration))
|
|
163
|
+
}
|
|
164
|
+
if (config.bgAudio) {
|
|
165
|
+
argv.push('--bg-audio', config.bgAudio)
|
|
166
|
+
}
|
|
167
|
+
if (config.bgAudioVolume != null && config.bgAudioVolume !== 1.0) {
|
|
168
|
+
argv.push('--bg-audio-volume', String(config.bgAudioVolume))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await run('video-stitcher', argv, {
|
|
172
|
+
label: node.label ?? node.id,
|
|
173
|
+
dryRun: opts.dryRun
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
outputFiles.push(outputFile)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Copy outputs to any additional output-folder paths
|
|
180
|
+
const additionalDirs = outputFolderPaths.slice(1)
|
|
181
|
+
for (const dir of additionalDirs) {
|
|
182
|
+
console.log(` Copying ${outputFiles.length} output(s) → ${dir}`)
|
|
183
|
+
mkdirSync(dir, { recursive: true })
|
|
184
|
+
for (const file of outputFiles) {
|
|
185
|
+
copyFileSync(file, path.join(dir, path.basename(file)))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
context.set(node.id, { outputDir, outputs: outputFiles })
|
|
190
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { execa } from 'execa'
|
|
18
|
+
import { spawn } from 'child_process'
|
|
19
|
+
import { fileURLToPath } from 'url'
|
|
20
|
+
import path from 'path'
|
|
21
|
+
import { existsSync, realpathSync } from 'fs'
|
|
22
|
+
import chalk from 'chalk'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a CLI binary from the workspace node_modules/.bin,
|
|
26
|
+
* falling back to global PATH.
|
|
27
|
+
*/
|
|
28
|
+
function resolveBin(name) {
|
|
29
|
+
// Walk up from this file to find node_modules/.bin
|
|
30
|
+
const here = fileURLToPath(import.meta.url)
|
|
31
|
+
let dir = path.dirname(here)
|
|
32
|
+
for (let i = 0; i < 8; i++) {
|
|
33
|
+
const candidate = path.join(dir, 'node_modules', '.bin', name)
|
|
34
|
+
if (existsSync(candidate)) {
|
|
35
|
+
try { return realpathSync(candidate) } catch { return candidate }
|
|
36
|
+
}
|
|
37
|
+
dir = path.dirname(dir)
|
|
38
|
+
}
|
|
39
|
+
// Fall back to name on PATH
|
|
40
|
+
return name
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Runs a CLI tool with the given args, streaming output.
|
|
45
|
+
* @param {string} bin - binary name (e.g. 'video-cutter')
|
|
46
|
+
* @param {string[]} args - argv array
|
|
47
|
+
* @param {{ label?: string, dryRun?: boolean }} opts
|
|
48
|
+
*/
|
|
49
|
+
export async function run(bin, args, opts = {}) {
|
|
50
|
+
const resolved = resolveBin(bin)
|
|
51
|
+
const label = opts.label ?? bin
|
|
52
|
+
|
|
53
|
+
if (opts.dryRun) {
|
|
54
|
+
console.log(chalk.dim(`[dry-run] ${resolved} ${args.join(' ')}`))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(chalk.cyan(`▶ ${label}`) + chalk.dim(` $ ${bin} ${args.join(' ')}`))
|
|
59
|
+
|
|
60
|
+
if (opts.input !== undefined) {
|
|
61
|
+
// Use native spawn so stdin can be piped while stdout/stderr use real inherited file descriptors
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
const proc = spawn(resolved, args, { stdio: ['pipe', 'inherit', 'inherit'] })
|
|
64
|
+
proc.stdin.write(opts.input)
|
|
65
|
+
proc.stdin.end()
|
|
66
|
+
proc.on('close', (code) => {
|
|
67
|
+
if (code === 0) resolve()
|
|
68
|
+
else reject(new Error(`${bin} exited with code ${code}`))
|
|
69
|
+
})
|
|
70
|
+
proc.on('error', reject)
|
|
71
|
+
})
|
|
72
|
+
} else {
|
|
73
|
+
await execa(resolved, args, { stdio: 'inherit' })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* Kahn's topological sort algorithm.
|
|
19
|
+
* Returns an array of levels, where each level is an array of node IDs
|
|
20
|
+
* that can be executed in parallel (all their dependencies are in prior levels).
|
|
21
|
+
* Throws if a cycle is detected.
|
|
22
|
+
*/
|
|
23
|
+
export function topoSort(nodes, edges) {
|
|
24
|
+
const nodeIds = new Set(nodes.map((n) => n.id))
|
|
25
|
+
|
|
26
|
+
// Build adjacency: source -> [targets]
|
|
27
|
+
const outEdges = new Map()
|
|
28
|
+
const inDegree = new Map()
|
|
29
|
+
|
|
30
|
+
for (const id of nodeIds) {
|
|
31
|
+
outEdges.set(id, [])
|
|
32
|
+
inDegree.set(id, 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const edge of edges) {
|
|
36
|
+
outEdges.get(edge.source)?.push(edge.target)
|
|
37
|
+
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const levels = []
|
|
41
|
+
let remaining = new Set(nodeIds)
|
|
42
|
+
|
|
43
|
+
while (remaining.size > 0) {
|
|
44
|
+
const level = [...remaining].filter((id) => inDegree.get(id) === 0)
|
|
45
|
+
|
|
46
|
+
if (level.length === 0) {
|
|
47
|
+
throw new Error('Pipeline has a cycle — cannot execute')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
levels.push(level)
|
|
51
|
+
|
|
52
|
+
for (const id of level) {
|
|
53
|
+
remaining.delete(id)
|
|
54
|
+
for (const target of outEdges.get(id) ?? []) {
|
|
55
|
+
inDegree.set(target, inDegree.get(target) - 1)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return levels
|
|
61
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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 http from 'http'
|
|
18
|
+
import net from 'net'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import { fileURLToPath } from 'url'
|
|
21
|
+
import express from 'express'
|
|
22
|
+
import { WebSocketServer } from 'ws'
|
|
23
|
+
import { createRoutes } from './routes.js'
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const EDITOR_DIST = path.resolve(__dirname, '../../dist/editor')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds an available TCP port starting from `start`.
|
|
30
|
+
*/
|
|
31
|
+
function findFreePort(start = 3847) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const server = net.createServer()
|
|
34
|
+
server.listen(start, () => {
|
|
35
|
+
const { port } = server.address()
|
|
36
|
+
server.close(() => resolve(port))
|
|
37
|
+
})
|
|
38
|
+
server.on('error', () => findFreePort(start + 1).then(resolve).catch(reject))
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Starts the editor Express + WebSocket server.
|
|
44
|
+
* @param {string} specPath - absolute path to the spec JSON file
|
|
45
|
+
* @param {{ port?: number }} opts
|
|
46
|
+
* @returns {Promise<{ url: string, server: http.Server, wss: WebSocketServer }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function startEditorServer(specPath, opts = {}) {
|
|
49
|
+
const port = opts.port ?? (await findFreePort())
|
|
50
|
+
|
|
51
|
+
const app = express()
|
|
52
|
+
const httpServer = http.createServer(app)
|
|
53
|
+
const wss = new WebSocketServer({ server: httpServer })
|
|
54
|
+
|
|
55
|
+
function broadcast(data) {
|
|
56
|
+
const msg = JSON.stringify(data)
|
|
57
|
+
for (const client of wss.clients) {
|
|
58
|
+
if (client.readyState === 1 /* OPEN */) {
|
|
59
|
+
client.send(msg)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
app.use(express.json({ limit: '10mb' }))
|
|
65
|
+
app.use(createRoutes(specPath, broadcast))
|
|
66
|
+
app.use(express.static(EDITOR_DIST))
|
|
67
|
+
|
|
68
|
+
// Fallback for SPA routing
|
|
69
|
+
app.get('/*path', (_req, res) => {
|
|
70
|
+
res.sendFile(path.join(EDITOR_DIST, 'index.html'))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
wss.on('connection', (ws) => {
|
|
74
|
+
ws.send(JSON.stringify({ type: 'ready', specPath }))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await new Promise((resolve) => httpServer.listen(port, resolve))
|
|
78
|
+
|
|
79
|
+
const url = `http://localhost:${port}`
|
|
80
|
+
return { url, server: httpServer, wss }
|
|
81
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { readFileSync, writeFileSync, renameSync } from 'fs'
|
|
18
|
+
import { execFile } from 'child_process'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import { Router } from 'express'
|
|
21
|
+
|
|
22
|
+
function nativeBrowse() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const { platform } = process
|
|
25
|
+
if (platform === 'darwin') {
|
|
26
|
+
execFile('osascript', ['-e', 'POSIX path of (choose file)'], (err, stdout) => {
|
|
27
|
+
resolve(err ? null : stdout.trim())
|
|
28
|
+
})
|
|
29
|
+
} else if (platform === 'linux') {
|
|
30
|
+
execFile('zenity', ['--file-selection'], (err, stdout) => {
|
|
31
|
+
if (!err) return resolve(stdout.trim())
|
|
32
|
+
execFile('kdialog', ['--getopenfilename', '.'], (err2, stdout2) => {
|
|
33
|
+
resolve(err2 ? null : stdout2.trim())
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
} 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 }`
|
|
38
|
+
execFile('powershell', ['-Command', ps], (err, stdout) => {
|
|
39
|
+
resolve(err ? null : stdout.trim())
|
|
40
|
+
})
|
|
41
|
+
} else {
|
|
42
|
+
resolve(null)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nativeBrowseFolder() {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const { platform } = process
|
|
50
|
+
if (platform === 'darwin') {
|
|
51
|
+
execFile('osascript', ['-e', 'POSIX path of (choose folder)'], (err, stdout) => {
|
|
52
|
+
resolve(err ? null : stdout.trim())
|
|
53
|
+
})
|
|
54
|
+
} else if (platform === 'linux') {
|
|
55
|
+
execFile('zenity', ['--file-selection', '--directory'], (err, stdout) => {
|
|
56
|
+
if (!err) return resolve(stdout.trim())
|
|
57
|
+
execFile('kdialog', ['--getexistingdirectory', '.'], (err2, stdout2) => {
|
|
58
|
+
resolve(err2 ? null : stdout2.trim())
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
} 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 }`
|
|
63
|
+
execFile('powershell', ['-Command', ps], (err, stdout) => {
|
|
64
|
+
resolve(err ? null : stdout.trim())
|
|
65
|
+
})
|
|
66
|
+
} else {
|
|
67
|
+
resolve(null)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates Express router for the editor API.
|
|
74
|
+
* @param {string} specPath - absolute path to the spec JSON file
|
|
75
|
+
* @param {function} broadcast - WebSocket broadcast function
|
|
76
|
+
*/
|
|
77
|
+
export function createRoutes(specPath, broadcast) {
|
|
78
|
+
const router = Router()
|
|
79
|
+
|
|
80
|
+
// GET /api/spec — return current spec from disk
|
|
81
|
+
router.get('/api/spec', (_req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(specPath, 'utf8')
|
|
84
|
+
const spec = JSON.parse(content)
|
|
85
|
+
res.json(spec)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
res.status(500).json({ error: err.message })
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// 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()
|
|
94
|
+
res.json({ path: filePath })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// 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()
|
|
100
|
+
res.json({ path: folderPath })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// POST /api/spec — atomically write spec to disk
|
|
104
|
+
router.post('/api/spec', (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
const spec = req.body
|
|
107
|
+
if (!spec || typeof spec !== 'object') {
|
|
108
|
+
return res.status(400).json({ error: 'Request body must be a JSON object' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tmp = `${specPath}.tmp`
|
|
112
|
+
writeFileSync(tmp, JSON.stringify(spec, null, 2) + '\n', 'utf8')
|
|
113
|
+
renameSync(tmp, specPath)
|
|
114
|
+
|
|
115
|
+
broadcast({ type: 'saved', timestamp: Date.now() })
|
|
116
|
+
res.json({ ok: true })
|
|
117
|
+
} catch (err) {
|
|
118
|
+
res.status(500).json({ error: err.message })
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return router
|
|
123
|
+
}
|