vstml-parser 0.1.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 ADDED
@@ -0,0 +1,215 @@
1
+ # vstml-parser
2
+
3
+ > Official parser for **VSTML** — Video Speech Text Markup Language
4
+
5
+ VSTML is an open markup language for AI-powered video editing. It gives AI a structured language to read, write, and edit video using timestamps from Whisper STT and metadata from FFmpeg — without requiring any vision model or ML training.
6
+
7
+ ```
8
+ npm install vstml-parser
9
+ ```
10
+
11
+ ---
12
+
13
+ ## What is VSTML?
14
+
15
+ VSTML is to video editing what HTML is to web pages. It describes a video's structure, speech, and edit operations in a format that both humans and AI can read and write.
16
+
17
+ ```
18
+ [vstml version="0.1" mode="edit"]
19
+ [timeline duration="45s" fps="30"]
20
+ [scene id="s1" start="0s" end="14s"]
21
+ [clip id="c1" src="intro.mp4"]
22
+ [delete clip="c1" from="3.1s" to="4.9s"]
23
+ [delete clip="c1" from="5.9s" to="6.3s"]
24
+ [caption at="0.8s" duration="7s"]Hey everyone welcome back[/caption]
25
+ [/scene]
26
+ [transition type="crossdissolve" duration="0.5s" between="c1,c2"]
27
+ [/timeline]
28
+ [/vstml]
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Quick Start
34
+
35
+ ```javascript
36
+ const {
37
+ parse,
38
+ parseAndValidate,
39
+ stringify,
40
+ query,
41
+ getTranscript,
42
+ getEditOperations
43
+ } = require('vstml-parser')
44
+
45
+ // Parse a VSTML string into an AST
46
+ const ast = parse(vstmlString)
47
+
48
+ // Parse and validate in one step
49
+ const { ast, valid, errors, warnings } = parseAndValidate(vstmlString)
50
+
51
+ // Find all nodes of a specific tag
52
+ const scenes = query(ast, 'scene')
53
+ const cuts = query(ast, 'cut')
54
+ const silences = query(ast, 'silence')
55
+
56
+ // Extract the full transcript from word-level timestamps
57
+ const { text, words } = getTranscript(ast)
58
+
59
+ // Get all edit operations sorted by timestamp
60
+ const ops = getEditOperations(ast)
61
+
62
+ // Convert AST back to VSTML text
63
+ const output = stringify(ast)
64
+ ```
65
+
66
+ ---
67
+
68
+ ## API
69
+
70
+ ### `parse(input)`
71
+ Parses a VSTML string and returns an AST.
72
+
73
+ ```javascript
74
+ const ast = parse('[cut clip="c1" at="4.2s"]')
75
+ // Returns: { nodeType: 'document', children: [...] }
76
+ ```
77
+
78
+ ### `parseAndValidate(input)`
79
+ Parses and validates a VSTML string against the v0.1 spec.
80
+
81
+ ```javascript
82
+ const { ast, valid, errors, warnings } = parseAndValidate(input)
83
+ if (!valid) {
84
+ console.error(errors)
85
+ }
86
+ ```
87
+
88
+ ### `stringify(ast)`
89
+ Converts an AST back into formatted VSTML text.
90
+
91
+ ```javascript
92
+ const vstmlText = stringify(ast)
93
+ ```
94
+
95
+ ### `query(ast, tagName)`
96
+ Find all elements matching a tag name anywhere in the document.
97
+
98
+ ```javascript
99
+ const words = query(ast, 'word')
100
+ const effects = query(ast, 'effect')
101
+ ```
102
+
103
+ ### `getTranscript(ast)`
104
+ Extract the full spoken transcript from `[word]` tags.
105
+
106
+ ```javascript
107
+ const { text, words } = getTranscript(ast)
108
+ // text: "Hey everyone welcome back"
109
+ // words: [{ word: "Hey", timestamp: "0.5s", seconds: 0.5 }, ...]
110
+ ```
111
+
112
+ ### `getEditOperations(ast)`
113
+ Get all edit operations (cut, trim, delete, etc.) sorted by timestamp.
114
+
115
+ ```javascript
116
+ const ops = getEditOperations(ast)
117
+ // [{ operation: "trim", attributes: {...}, timestamp: "0s", seconds: 0 }, ...]
118
+ ```
119
+
120
+ ---
121
+
122
+ ## AST Structure
123
+
124
+ Every node has a `nodeType`:
125
+
126
+ ```javascript
127
+ // Element node
128
+ {
129
+ nodeType: 'element',
130
+ tag: 'scene',
131
+ attributes: { id: 's1', start: '0s', end: '14s' },
132
+ children: [...]
133
+ }
134
+
135
+ // Text node
136
+ {
137
+ nodeType: 'text',
138
+ value: 'Hello World'
139
+ }
140
+
141
+ // Document root
142
+ {
143
+ nodeType: 'document',
144
+ children: [...]
145
+ }
146
+ ```
147
+
148
+ ---
149
+
150
+ ## VSTML Modes
151
+
152
+ VSTML files operate in two modes:
153
+
154
+ **`analysis`** — output of the Whisper STT + FFmpeg scraper pipeline. Describes what is in a video.
155
+
156
+ **`edit`** — output of the AI editor. Describes what changes to make.
157
+
158
+ ```
159
+ [vstml version="0.1" mode="analysis"] ← describes the video
160
+ [vstml version="0.1" mode="edit"] ← describes the edits
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Validation
166
+
167
+ The validator checks:
168
+ - Required attributes per tag
169
+ - Timestamp format (must be like `"4.2s"` or `"10s"`)
170
+ - Valid mode values (`"analysis"` or `"edit"`)
171
+ - Known effect and transition types
172
+ - Speed value format (`"1.5x"`)
173
+ - maxpass must be a number
174
+
175
+ ```javascript
176
+ const { valid, errors, warnings } = parseAndValidate(input)
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Running Tests
182
+
183
+ ```bash
184
+ npm test
185
+ ```
186
+
187
+ ---
188
+
189
+ ## VSTML Spec
190
+
191
+ Full tag reference and specification:
192
+ 👉 [VSTML v0.1 Specification](https://github.com/YOUR_USERNAME/vstml-parser/blob/main/VSTML_SPEC_v0.1.md)
193
+
194
+ ---
195
+
196
+ ## Why VSTML?
197
+
198
+ | Feature | VSTML | EDL | FCPXML |
199
+ |---------|-------|-----|--------|
200
+ | AI-readable | ✅ | ❌ | ❌ |
201
+ | Human-readable | ✅ | Partial | ❌ |
202
+ | Speech timestamps | ✅ | ❌ | ❌ |
203
+ | Recursive AI loop | ✅ | ❌ | ❌ |
204
+ | App independent | ✅ | Partial | ❌ |
205
+ | No ML required | ✅ | ✅ | ✅ |
206
+
207
+ ---
208
+
209
+ ## License
210
+
211
+ MIT — free to use, implement, and build on.
212
+
213
+ ---
214
+
215
+ *VSTML is an open specification. Anyone may implement a VSTML parser or editor.*
package/index.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * vstml-parser
3
+ * Official parser for VSTML — Video Speech Text Markup Language
4
+ *
5
+ * VSTML is an open markup language for AI-powered video editing.
6
+ * It gives AI a structured language to read, write, and edit video
7
+ * using timestamps from Whisper STT and metadata from FFmpeg —
8
+ * without requiring any vision model or ML training.
9
+ *
10
+ * @version 0.1.0
11
+ * @license MIT
12
+ */
13
+
14
+ const { Lexer } = require('./src/lexer')
15
+ const { Parser } = require('./src/parser')
16
+ const { validate } = require('./src/validator')
17
+ const { serialize } = require('./src/serializer')
18
+
19
+ /**
20
+ * Parse a VSTML string into an AST
21
+ * @param {string} input - Raw VSTML text
22
+ * @returns {object} AST document node
23
+ *
24
+ * @example
25
+ * const { parse } = require('vstml-parser')
26
+ * const ast = parse('[scene id="s1" start="0s" end="10s"][/scene]')
27
+ */
28
+ function parse(input) {
29
+ if (typeof input !== 'string') {
30
+ throw new TypeError('vstml-parser: input must be a string')
31
+ }
32
+ const lexer = new Lexer(input)
33
+ const tokens = lexer.tokenize()
34
+ const parser = new Parser(tokens)
35
+ return parser.parse()
36
+ }
37
+
38
+ /**
39
+ * Parse and validate a VSTML string
40
+ * @param {string} input - Raw VSTML text
41
+ * @returns {{ ast: object, valid: boolean, errors: string[], warnings: string[] }}
42
+ *
43
+ * @example
44
+ * const { parseAndValidate } = require('vstml-parser')
45
+ * const result = parseAndValidate(vstmlString)
46
+ * if (!result.valid) console.error(result.errors)
47
+ */
48
+ function parseAndValidate(input) {
49
+ const ast = parse(input)
50
+ const { valid, errors, warnings } = validate(ast)
51
+ return { ast, valid, errors, warnings }
52
+ }
53
+
54
+ /**
55
+ * Convert a VSTML AST back into a formatted VSTML string
56
+ * @param {object} ast - AST produced by parse()
57
+ * @returns {string} Formatted VSTML text
58
+ *
59
+ * @example
60
+ * const { parse, stringify } = require('vstml-parser')
61
+ * const ast = parse(input)
62
+ * const output = stringify(ast)
63
+ */
64
+ function stringify(ast) {
65
+ return serialize(ast)
66
+ }
67
+
68
+ /**
69
+ * Query the AST for all elements matching a tag name
70
+ * @param {object} ast - AST produced by parse()
71
+ * @param {string} tagName - Tag to search for e.g. "cut", "scene", "word"
72
+ * @returns {object[]} Array of matching AST nodes
73
+ *
74
+ * @example
75
+ * const cuts = query(ast, 'cut')
76
+ * const silences = query(ast, 'silence')
77
+ */
78
+ function query(ast, tagName) {
79
+ const results = []
80
+
81
+ function walk(node) {
82
+ if (!node) return
83
+ if (node.nodeType === 'element' && node.tag === tagName) {
84
+ results.push(node)
85
+ }
86
+ for (const child of node.children || []) {
87
+ walk(child)
88
+ }
89
+ }
90
+
91
+ walk(ast)
92
+ return results
93
+ }
94
+
95
+ /**
96
+ * Get all timestamps from a VSTML document
97
+ * Useful for building a timeline view or verifying timing
98
+ * @param {object} ast - AST produced by parse()
99
+ * @returns {object[]} Array of { tag, timestamp, attributes }
100
+ */
101
+ function getTimestamps(ast) {
102
+ const results = []
103
+ const TS_ATTRS = ['start', 'end', 'at', 'from', 'to']
104
+
105
+ function walk(node) {
106
+ if (!node) return
107
+ if (node.nodeType === 'element') {
108
+ for (const attr of TS_ATTRS) {
109
+ if (node.attributes && node.attributes[attr]) {
110
+ results.push({
111
+ tag: node.tag,
112
+ attribute: attr,
113
+ timestamp: node.attributes[attr],
114
+ seconds: parseFloat(node.attributes[attr]),
115
+ attributes: node.attributes,
116
+ })
117
+ }
118
+ }
119
+ }
120
+ for (const child of node.children || []) {
121
+ walk(child)
122
+ }
123
+ }
124
+
125
+ walk(ast)
126
+ return results.sort((a, b) => a.seconds - b.seconds)
127
+ }
128
+
129
+ /**
130
+ * Extract the full transcript from a VSTML analysis document
131
+ * Built from [word] tags with their timestamps
132
+ * @param {object} ast - AST produced by parse()
133
+ * @returns {{ text: string, words: object[] }}
134
+ */
135
+ function getTranscript(ast) {
136
+ const words = query(ast, 'word').map(node => ({
137
+ word: node.children.find(c => c.nodeType === 'text')?.value || '',
138
+ timestamp: node.attributes?.t,
139
+ seconds: parseFloat(node.attributes?.t),
140
+ }))
141
+
142
+ return {
143
+ text: words.map(w => w.word).join(' '),
144
+ words,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Get all edit operations from an edit-mode VSTML document
150
+ * Returns cuts, deletes, trims, etc. sorted by timestamp
151
+ * @param {object} ast - AST produced by parse()
152
+ * @returns {object[]} Sorted array of edit operation nodes
153
+ */
154
+ function getEditOperations(ast) {
155
+ const EDIT_TAGS = ['cut', 'trim', 'delete', 'split', 'merge', 'speed', 'reverse', 'reorder']
156
+ const ops = []
157
+
158
+ for (const tag of EDIT_TAGS) {
159
+ const nodes = query(ast, tag)
160
+ for (const node of nodes) {
161
+ const ts = node.attributes?.at || node.attributes?.from || node.attributes?.start || '0s'
162
+ ops.push({
163
+ operation: tag,
164
+ attributes: node.attributes,
165
+ timestamp: ts,
166
+ seconds: parseFloat(ts),
167
+ })
168
+ }
169
+ }
170
+
171
+ return ops.sort((a, b) => a.seconds - b.seconds)
172
+ }
173
+
174
+ module.exports = {
175
+ parse,
176
+ parseAndValidate,
177
+ stringify,
178
+ query,
179
+ getTimestamps,
180
+ getTranscript,
181
+ getEditOperations,
182
+ // Also export internals for advanced use
183
+ Lexer,
184
+ Parser,
185
+ validate,
186
+ serialize,
187
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "vstml-parser",
3
+ "version": "0.1.0",
4
+ "description": "Official parser for VSTML — Video Speech Text Markup Language. A structured markup language for AI-powered video editing.",
5
+ "keywords": [
6
+ "vstml",
7
+ "video",
8
+ "markup",
9
+ "language",
10
+ "parser",
11
+ "ai",
12
+ "editing",
13
+ "speech",
14
+ "whisper",
15
+ "ffmpeg",
16
+ "timeline"
17
+ ],
18
+ "homepage": "https://github.com/ADIBOYZ32/vstml-parser#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/ADIBOYZ32/vstml-parser/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ADIBOYZ32/vstml-parser.git"
25
+ },
26
+ "license": "MIT",
27
+ "author": "ADIBOYZ32",
28
+ "type": "commonjs",
29
+ "main": "index.js",
30
+ "directories": {
31
+ "test": "test"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "src/",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "test": "node test/parser.test.js"
41
+ },
42
+ "engines": {
43
+ "node": ">=14.0.0"
44
+ }
45
+ }
package/src/lexer.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * VSTML Lexer
3
+ * Converts raw VSTML text into a flat list of tokens
4
+ */
5
+
6
+ const TOKEN_TYPES = {
7
+ TAG_OPEN_START: 'TAG_OPEN_START', // [tagname
8
+ TAG_CLOSE_START: 'TAG_CLOSE_START', // [/tagname
9
+ TAG_END: 'TAG_END', // ]
10
+ ATTR_KEY: 'ATTR_KEY', // key
11
+ ATTR_VALUE: 'ATTR_VALUE', // "value"
12
+ TEXT: 'TEXT', // text content between tags
13
+ EOF: 'EOF',
14
+ }
15
+
16
+ class Lexer {
17
+ constructor(input) {
18
+ this.input = input
19
+ this.pos = 0
20
+ this.tokens = []
21
+ }
22
+
23
+ peek() {
24
+ return this.input[this.pos] || null
25
+ }
26
+
27
+ consume() {
28
+ return this.input[this.pos++] || null
29
+ }
30
+
31
+ skipWhitespace() {
32
+ while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
33
+ this.pos++
34
+ }
35
+ }
36
+
37
+ readUntil(chars) {
38
+ let result = ''
39
+ while (this.pos < this.input.length && !chars.includes(this.input[this.pos])) {
40
+ result += this.consume()
41
+ }
42
+ return result
43
+ }
44
+
45
+ readString() {
46
+ this.consume() // opening quote
47
+ let result = ''
48
+ while (this.pos < this.input.length && this.input[this.pos] !== '"') {
49
+ result += this.consume()
50
+ }
51
+ this.consume() // closing quote
52
+ return result
53
+ }
54
+
55
+ tokenize() {
56
+ while (this.pos < this.input.length) {
57
+ this.skipWhitespace()
58
+ if (this.pos >= this.input.length) break
59
+
60
+ const ch = this.peek()
61
+
62
+ if (ch === '[') {
63
+ this.consume() // [
64
+ const isClosing = this.peek() === '/'
65
+ if (isClosing) this.consume() // /
66
+
67
+ const tagName = this.readUntil([' ', ']', '\n', '\r', '\t']).trim()
68
+
69
+ if (isClosing) {
70
+ this.tokens.push({ type: TOKEN_TYPES.TAG_CLOSE_START, value: tagName })
71
+ } else {
72
+ this.tokens.push({ type: TOKEN_TYPES.TAG_OPEN_START, value: tagName })
73
+ }
74
+
75
+ // Parse attributes inside the tag
76
+ while (this.pos < this.input.length && this.peek() !== ']') {
77
+ this.skipWhitespace()
78
+ if (this.peek() === ']') break
79
+
80
+ // Read attribute key
81
+ const key = this.readUntil(['=', ']', ' ']).trim()
82
+ if (!key) { this.consume(); continue }
83
+
84
+ if (this.peek() === '=') {
85
+ this.consume() // =
86
+ if (this.peek() === '"') {
87
+ const value = this.readString()
88
+ this.tokens.push({ type: TOKEN_TYPES.ATTR_KEY, value: key })
89
+ this.tokens.push({ type: TOKEN_TYPES.ATTR_VALUE, value: value })
90
+ }
91
+ }
92
+ }
93
+
94
+ if (this.peek() === ']') {
95
+ this.consume() // ]
96
+ this.tokens.push({ type: TOKEN_TYPES.TAG_END, value: ']' })
97
+ }
98
+
99
+ } else {
100
+ // Text content
101
+ const text = this.readUntil(['[']).trim()
102
+ if (text) {
103
+ this.tokens.push({ type: TOKEN_TYPES.TEXT, value: text })
104
+ }
105
+ }
106
+ }
107
+
108
+ this.tokens.push({ type: TOKEN_TYPES.EOF, value: null })
109
+ return this.tokens
110
+ }
111
+ }
112
+
113
+ module.exports = { Lexer, TOKEN_TYPES }
package/src/parser.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * VSTML Parser
3
+ * Converts a flat token list into a structured AST (Abstract Syntax Tree)
4
+ */
5
+
6
+ const { Lexer, TOKEN_TYPES } = require('./lexer')
7
+
8
+ // Set of tags that are always self-closing (never have children)
9
+ const SELF_CLOSING_TAGS = new Set([
10
+ 'cut', 'trim', 'delete', 'split', 'merge', 'speed', 'reverse', 'reorder',
11
+ 'clip', 'audio', 'image', 'marker', 'silence', 'filler', 'audiospike',
12
+ 'voiceover', 'subtitle', 'transition', 'effect', 'overlay', 'broll', 'pip',
13
+ 'correction', 'approved', 'maxpass',
14
+ ])
15
+
16
+ class Parser {
17
+ constructor(tokens) {
18
+ this.tokens = tokens
19
+ this.pos = 0
20
+ }
21
+
22
+ peek() {
23
+ return this.tokens[this.pos] || { type: TOKEN_TYPES.EOF, value: null }
24
+ }
25
+
26
+ consume() {
27
+ return this.tokens[this.pos++] || { type: TOKEN_TYPES.EOF, value: null }
28
+ }
29
+
30
+ // Collect attributes from the token stream until TAG_END
31
+ collectAttributes() {
32
+ const attrs = {}
33
+ while (
34
+ this.peek().type !== TOKEN_TYPES.TAG_END &&
35
+ this.peek().type !== TOKEN_TYPES.EOF
36
+ ) {
37
+ if (this.peek().type === TOKEN_TYPES.ATTR_KEY) {
38
+ const key = this.consume().value
39
+ if (this.peek().type === TOKEN_TYPES.ATTR_VALUE) {
40
+ attrs[key] = this.consume().value
41
+ }
42
+ } else {
43
+ this.consume()
44
+ }
45
+ }
46
+ if (this.peek().type === TOKEN_TYPES.TAG_END) {
47
+ this.consume() // consume ]
48
+ }
49
+ return attrs
50
+ }
51
+
52
+ // Parse a single node (tag or text)
53
+ parseNode() {
54
+ const token = this.peek()
55
+
56
+ if (token.type === TOKEN_TYPES.EOF) return null
57
+
58
+ // Closing tag — signal to parent to stop
59
+ if (token.type === TOKEN_TYPES.TAG_CLOSE_START) return null
60
+
61
+ // Text node
62
+ if (token.type === TOKEN_TYPES.TEXT) {
63
+ this.consume()
64
+ return { nodeType: 'text', value: token.value }
65
+ }
66
+
67
+ // Opening tag
68
+ if (token.type === TOKEN_TYPES.TAG_OPEN_START) {
69
+ this.consume() // consume tag name token
70
+ const tagName = token.value
71
+ const attributes = this.collectAttributes()
72
+
73
+ const node = {
74
+ nodeType: 'element',
75
+ tag: tagName,
76
+ attributes,
77
+ children: [],
78
+ }
79
+
80
+ // Self-closing tags never consume children
81
+ if (SELF_CLOSING_TAGS.has(tagName)) {
82
+ return node
83
+ }
84
+
85
+ // Parse children until we hit the matching closing tag
86
+ while (this.pos < this.tokens.length) {
87
+ const next = this.peek()
88
+
89
+ if (next.type === TOKEN_TYPES.EOF) break
90
+
91
+ // Check for matching closing tag
92
+ if (
93
+ next.type === TOKEN_TYPES.TAG_CLOSE_START &&
94
+ next.value === tagName
95
+ ) {
96
+ this.consume() // consume closing tag name
97
+ // consume TAG_END for closing tag
98
+ if (this.peek().type === TOKEN_TYPES.TAG_END) this.consume()
99
+ break
100
+ }
101
+
102
+ // Non-matching closing tag means we're done with this node's children
103
+ if (next.type === TOKEN_TYPES.TAG_CLOSE_START) break
104
+
105
+ // Skip stray TAG_END tokens inside children
106
+ if (next.type === TOKEN_TYPES.TAG_END) {
107
+ this.consume()
108
+ continue
109
+ }
110
+
111
+ const child = this.parseNode()
112
+ if (child) node.children.push(child)
113
+ else break
114
+ }
115
+
116
+ return node
117
+ }
118
+
119
+ // Skip unknown tokens
120
+ this.consume()
121
+ return null
122
+ }
123
+
124
+ // Parse the full document
125
+ parse() {
126
+ const root = {
127
+ nodeType: 'document',
128
+ children: [],
129
+ }
130
+
131
+ while (this.peek().type !== TOKEN_TYPES.EOF) {
132
+ // Skip stray TAG_END tokens at document level
133
+ if (this.peek().type === TOKEN_TYPES.TAG_END) {
134
+ this.consume()
135
+ continue
136
+ }
137
+ const node = this.parseNode()
138
+ if (node) root.children.push(node)
139
+ else if (this.peek().type !== TOKEN_TYPES.EOF) {
140
+ // Consume to avoid infinite loop on unrecognized tokens
141
+ this.consume()
142
+ }
143
+ }
144
+
145
+ return root
146
+ }
147
+ }
148
+
149
+ module.exports = { Parser }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * VSTML Serializer
3
+ * Converts a VSTML AST back into formatted VSTML text
4
+ * Useful for AI that generates AST objects and needs to output .vstml files
5
+ */
6
+
7
+ function serializeNode(node, indent = 0) {
8
+ const pad = ' '.repeat(indent)
9
+
10
+ if (node.nodeType === 'text') {
11
+ return `${pad}${node.value}`
12
+ }
13
+
14
+ if (node.nodeType === 'element') {
15
+ const attrs = Object.entries(node.attributes || {})
16
+ .map(([k, v]) => `${k}="${v}"`)
17
+ .join(' ')
18
+
19
+ const attrStr = attrs ? ` ${attrs}` : ''
20
+ const openTag = `${pad}[${node.tag}${attrStr}]`
21
+
22
+ if (!node.children || node.children.length === 0) {
23
+ return openTag
24
+ }
25
+
26
+ // Check if all children are text nodes (inline)
27
+ const allText = node.children.every(c => c.nodeType === 'text')
28
+ if (allText) {
29
+ const text = node.children.map(c => c.value).join(' ')
30
+ return `${openTag}${text}[/${node.tag}]`
31
+ }
32
+
33
+ // Block children
34
+ const childLines = node.children
35
+ .map(child => serializeNode(child, indent + 1))
36
+ .join('\n')
37
+
38
+ return `${openTag}\n${childLines}\n${pad}[/${node.tag}]`
39
+ }
40
+
41
+ if (node.nodeType === 'document') {
42
+ return node.children
43
+ .map(child => serializeNode(child, indent))
44
+ .join('\n')
45
+ }
46
+
47
+ return ''
48
+ }
49
+
50
+ function serialize(ast) {
51
+ return serializeNode(ast)
52
+ }
53
+
54
+ module.exports = { serialize }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * VSTML Validator
3
+ * Validates a parsed VSTML AST against the spec rules
4
+ * Returns a list of errors and warnings
5
+ */
6
+
7
+ const VALID_TAGS = new Set([
8
+ // Document
9
+ 'vstml', 'timeline',
10
+ // Media
11
+ 'clip', 'audio', 'image',
12
+ // Structure
13
+ 'scene', 'chapter', 'marker',
14
+ // Speech
15
+ 'speech', 'word', 'silence', 'filler', 'audiospike', 'voiceover',
16
+ // Edit operations
17
+ 'cut', 'trim', 'delete', 'split', 'merge', 'speed', 'reverse', 'reorder',
18
+ // Text
19
+ 'text', 'caption', 'title', 'subtitle',
20
+ // Transitions
21
+ 'transition',
22
+ // Effects
23
+ 'effect',
24
+ // Overlay
25
+ 'overlay', 'broll', 'pip',
26
+ // Recursive loop
27
+ 'review', 'issue', 'correction', 'approved', 'maxpass',
28
+ ])
29
+
30
+ const REQUIRED_ATTRS = {
31
+ vstml: ['version', 'mode'],
32
+ timeline: ['duration', 'fps'],
33
+ clip: ['id', 'src'],
34
+ audio: ['id', 'src'],
35
+ scene: ['id', 'start', 'end'],
36
+ speech: ['start', 'end'],
37
+ word: ['t'],
38
+ silence: ['start', 'end'],
39
+ filler: ['word', 't'],
40
+ audiospike: ['at'],
41
+ cut: ['clip'],
42
+ trim: ['clip', 'from', 'to'],
43
+ delete: ['clip', 'from', 'to'],
44
+ split: ['clip', 'at'],
45
+ text: ['start', 'end'],
46
+ caption: ['at', 'duration'],
47
+ transition: ['type'],
48
+ effect: ['type'],
49
+ review: ['pass', 'goal'],
50
+ issue: ['at', 'type'],
51
+ correction: ['for_issue', 'action'],
52
+ approved: ['pass'],
53
+ maxpass: ['value'],
54
+ }
55
+
56
+ const TIMESTAMP_REGEX = /^\d+(\.\d+)?s$/
57
+
58
+ function validateTimestamp(value, tag, attr) {
59
+ if (value && !TIMESTAMP_REGEX.test(value)) {
60
+ return `[${tag}] attribute "${attr}" value "${value}" is not a valid timestamp. Use format like "4.2s" or "10s".`
61
+ }
62
+ return null
63
+ }
64
+
65
+ function validateNode(node, errors, warnings, context = {}) {
66
+ if (node.nodeType !== 'element') return
67
+
68
+ const tag = node.tag
69
+ const attrs = node.attributes || {}
70
+
71
+ // Check tag is known
72
+ if (!VALID_TAGS.has(tag)) {
73
+ warnings.push(`Unknown tag "[${tag}]" — not in VSTML v0.1 spec. It will be ignored by parsers.`)
74
+ }
75
+
76
+ // Check required attributes
77
+ const required = REQUIRED_ATTRS[tag] || []
78
+ for (const req of required) {
79
+ if (!attrs[req]) {
80
+ errors.push(`[${tag}] is missing required attribute "${req}".`)
81
+ }
82
+ }
83
+
84
+ // Timestamp validation for common attributes
85
+ const tsAttrs = ['start', 'end', 'at', 'from', 'to', 'duration']
86
+ for (const tsAttr of tsAttrs) {
87
+ if (attrs[tsAttr]) {
88
+ const err = validateTimestamp(attrs[tsAttr], tag, tsAttr)
89
+ if (err) errors.push(err)
90
+ }
91
+ }
92
+
93
+ // Rule: vstml must have valid mode
94
+ if (tag === 'vstml' && attrs.mode && !['analysis', 'edit'].includes(attrs.mode)) {
95
+ errors.push(`[vstml] mode must be "analysis" or "edit", got "${attrs.mode}".`)
96
+ }
97
+
98
+ // Rule: effect must have known type
99
+ const EFFECT_TYPES = ['colorgrade', 'blur', 'zoom', 'brightness', 'contrast', 'saturation', 'eq', 'compression', 'normalize']
100
+ if (tag === 'effect' && attrs.type && !EFFECT_TYPES.includes(attrs.type)) {
101
+ warnings.push(`[effect] type "${attrs.type}" is not a standard VSTML effect type.`)
102
+ }
103
+
104
+ // Rule: transition must have known type
105
+ const TRANSITION_TYPES = ['cut', 'fade', 'crossdissolve', 'wipe', 'zoom', 'flash']
106
+ if (tag === 'transition' && attrs.type && !TRANSITION_TYPES.includes(attrs.type)) {
107
+ warnings.push(`[transition] type "${attrs.type}" is not a standard VSTML transition type.`)
108
+ }
109
+
110
+ // Rule: maxpass value should be a number
111
+ if (tag === 'maxpass' && attrs.value && isNaN(Number(attrs.value))) {
112
+ errors.push(`[maxpass] value must be a number, got "${attrs.value}".`)
113
+ }
114
+
115
+ // Rule: speed value format
116
+ if (tag === 'speed' && attrs.value && !/^\d+(\.\d+)?x$/.test(attrs.value)) {
117
+ errors.push(`[speed] value must be in format like "1.5x" or "0.5x", got "${attrs.value}".`)
118
+ }
119
+
120
+ // Recurse into children
121
+ for (const child of node.children || []) {
122
+ validateNode(child, errors, warnings, context)
123
+ }
124
+ }
125
+
126
+ function validate(ast) {
127
+ const errors = []
128
+ const warnings = []
129
+
130
+ for (const child of ast.children || []) {
131
+ validateNode(child, errors, warnings)
132
+ }
133
+
134
+ return {
135
+ valid: errors.length === 0,
136
+ errors,
137
+ warnings,
138
+ }
139
+ }
140
+
141
+ module.exports = { validate }