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 +215 -0
- package/index.js +187 -0
- package/package.json +45 -0
- package/src/lexer.js +113 -0
- package/src/parser.js +149 -0
- package/src/serializer.js +54 -0
- package/src/validator.js +141 -0
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 }
|
package/src/validator.js
ADDED
|
@@ -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 }
|