@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.
@@ -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
+ }