@markdy/core 0.6.0 → 0.7.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/dist/index.d.ts +91 -4
- package/dist/index.js +724 -117
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ type AssetDef = {
|
|
|
7
7
|
value: string;
|
|
8
8
|
};
|
|
9
9
|
type ActorDef = {
|
|
10
|
-
type: "sprite" | "text" | "box" | "figure";
|
|
11
|
-
/** Constructor arguments: asset name for sprite, display text for text actors. */
|
|
10
|
+
type: "sprite" | "text" | "box" | "figure" | "caption";
|
|
11
|
+
/** Constructor arguments: asset name for sprite, display text for text/caption actors. */
|
|
12
12
|
args: string[];
|
|
13
13
|
x: number;
|
|
14
14
|
y: number;
|
|
@@ -19,6 +19,12 @@ type ActorDef = {
|
|
|
19
19
|
size?: number;
|
|
20
20
|
/** Z-index for layering control (via the `z` modifier). */
|
|
21
21
|
z?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Semantic anchor for captions (`top`, `bottom`, `center`). Absent for
|
|
24
|
+
* non-caption actors. The parser also fills `x` and `y` from the anchor
|
|
25
|
+
* so renderers that don't understand the field still place the caption.
|
|
26
|
+
*/
|
|
27
|
+
anchor?: "top" | "bottom" | "center";
|
|
22
28
|
};
|
|
23
29
|
type TimelineEvent = {
|
|
24
30
|
time: number;
|
|
@@ -26,6 +32,11 @@ type TimelineEvent = {
|
|
|
26
32
|
action: string;
|
|
27
33
|
params: Record<string, unknown>;
|
|
28
34
|
line: number;
|
|
35
|
+
/**
|
|
36
|
+
* The `scene "title" { ... }` block this event belongs to, if any.
|
|
37
|
+
* Undefined for events in the top-level scope.
|
|
38
|
+
*/
|
|
39
|
+
chapter?: string;
|
|
29
40
|
};
|
|
30
41
|
type SceneMeta = {
|
|
31
42
|
width: number;
|
|
@@ -61,6 +72,37 @@ type SequenceDef = {
|
|
|
61
72
|
paramsRaw: string;
|
|
62
73
|
}>;
|
|
63
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* A `scene "title" { ... }` block — a named grouping of timeline events.
|
|
77
|
+
* Start/end times are inclusive wall-clock seconds so renderers and
|
|
78
|
+
* tooling can highlight the active chapter without re-walking events.
|
|
79
|
+
*/
|
|
80
|
+
type Chapter = {
|
|
81
|
+
name: string;
|
|
82
|
+
startTime: number;
|
|
83
|
+
endTime: number;
|
|
84
|
+
startLine: number;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* A non-fatal parse issue. Renderers should surface these via
|
|
88
|
+
* `onWarning` so the author can fix the underlying cause; the
|
|
89
|
+
* renderer otherwise no-ops the offending statement.
|
|
90
|
+
*/
|
|
91
|
+
type ParseWarning = {
|
|
92
|
+
kind: "unknown-action" | "unknown-modifier" | "unknown-scene-key" | "unknown-camera-action" | "unknown-preset" | "import-unresolved" | "preset-mixed";
|
|
93
|
+
message: string;
|
|
94
|
+
line: number;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* An `import "path.markdy" as ns` declaration. Parsing records the
|
|
98
|
+
* intent; the host (CLI, bundler) resolves the path and may supply
|
|
99
|
+
* pre-parsed ASTs via the `parse(..., { imports })` option.
|
|
100
|
+
*/
|
|
101
|
+
type ImportDecl = {
|
|
102
|
+
path: string;
|
|
103
|
+
namespace: string;
|
|
104
|
+
line: number;
|
|
105
|
+
};
|
|
64
106
|
type SceneAST = {
|
|
65
107
|
meta: SceneMeta;
|
|
66
108
|
assets: Record<string, AssetDef>;
|
|
@@ -72,12 +114,57 @@ type SceneAST = {
|
|
|
72
114
|
seqs: Record<string, SequenceDef>;
|
|
73
115
|
/** User-defined variables — kept in AST for tooling/inspection. */
|
|
74
116
|
vars: Record<string, string>;
|
|
117
|
+
/** Named chapter blocks in author order. Empty when no chapters were used. */
|
|
118
|
+
chapters: Chapter[];
|
|
119
|
+
/** Soft parse issues. Always present; empty in the happy path. */
|
|
120
|
+
warnings: ParseWarning[];
|
|
121
|
+
/** `import` declarations in author order. Always present; empty when none were used. */
|
|
122
|
+
imports: ImportDecl[];
|
|
75
123
|
};
|
|
76
124
|
|
|
77
125
|
declare class ParseError extends Error {
|
|
78
126
|
readonly line: number;
|
|
79
127
|
constructor(message: string, line: number);
|
|
80
128
|
}
|
|
81
|
-
|
|
129
|
+
interface ParseOptions {
|
|
130
|
+
/**
|
|
131
|
+
* Pre-parsed ASTs for `import "path" as ns` declarations. The host
|
|
132
|
+
* (CLI, bundler) is responsible for reading files from disk and
|
|
133
|
+
* parsing them; the parser itself is pure.
|
|
134
|
+
*
|
|
135
|
+
* When an import's namespace is present here, its `vars`, `defs`,
|
|
136
|
+
* and `seqs` are merged into the importing AST under the
|
|
137
|
+
* `<ns>.<name>` prefix. Missing namespaces produce a soft warning.
|
|
138
|
+
*/
|
|
139
|
+
imports?: Record<string, SceneAST>;
|
|
140
|
+
/**
|
|
141
|
+
* Internal flag — distinguishes a top-level call from a recursive
|
|
142
|
+
* call used to expand a `preset`. Preset expansion bypasses the
|
|
143
|
+
* "mixed preset and other statements" warning because the expanded
|
|
144
|
+
* source is the only content.
|
|
145
|
+
*/
|
|
146
|
+
_fromPreset?: boolean;
|
|
147
|
+
}
|
|
148
|
+
declare function parse(source: string, opts?: ParseOptions): SceneAST;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* MarkdyScript built-in presets.
|
|
152
|
+
*
|
|
153
|
+
* A preset is a string template that expands at parse time into canonical
|
|
154
|
+
* MarkdyScript. The renderer never sees preset statements — by the time
|
|
155
|
+
* parsing reaches the actor/event stage, the preset has been replaced
|
|
156
|
+
* with its expansion.
|
|
157
|
+
*
|
|
158
|
+
* Design rules:
|
|
159
|
+
* 1. Each preset is self-contained — it declares its own `scene`,
|
|
160
|
+
* actors, and timeline.
|
|
161
|
+
* 2. Presets are short. The value is in `preset <name>` being a
|
|
162
|
+
* one-liner that expands to a scaffold the user can then tweak.
|
|
163
|
+
* 3. Presets share a common visual grammar so a feed of them feels
|
|
164
|
+
* like a family, not a collage.
|
|
165
|
+
*/
|
|
166
|
+
type PresetFn = (args: string[]) => string;
|
|
167
|
+
declare const PRESETS: Record<string, PresetFn>;
|
|
168
|
+
declare const PRESET_NAMES: readonly string[];
|
|
82
169
|
|
|
83
|
-
export { type ActorDef, type AssetDef, ParseError, type SceneAST, type SceneMeta, type SequenceDef, type TemplateDef, type TimelineEvent, parse };
|
|
170
|
+
export { type ActorDef, type AssetDef, type Chapter, type ImportDecl, PRESETS, PRESET_NAMES, ParseError, type ParseOptions, type ParseWarning, type PresetFn, type SceneAST, type SceneMeta, type SequenceDef, type TemplateDef, type TimelineEvent, parse };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,229 @@
|
|
|
1
|
+
// src/presets.ts
|
|
2
|
+
function quote(s) {
|
|
3
|
+
const stripped = s.replace(/^"|"$/g, "");
|
|
4
|
+
return `"${stripped.replace(/"/g, '\\"')}"`;
|
|
5
|
+
}
|
|
6
|
+
var PRESETS = {
|
|
7
|
+
meme: (args) => {
|
|
8
|
+
const top = quote(args[0] ?? "when the code works");
|
|
9
|
+
const bottom = quote(args[1] ?? "and you don't know why");
|
|
10
|
+
return `scene width=720 height=720 bg=#111
|
|
11
|
+
|
|
12
|
+
actor top = caption(${top}) at top
|
|
13
|
+
actor hero = figure(#c68642, m, \u{1F60E}) at (360, 430)
|
|
14
|
+
actor bottom = caption(${bottom}) at bottom
|
|
15
|
+
|
|
16
|
+
@0.0: top.fade_in(dur=0.4)
|
|
17
|
+
@0.4: hero.enter(from=bottom, dur=0.6)
|
|
18
|
+
@1.2: hero.face("\u{1F602}")
|
|
19
|
+
@1.8: bottom.fade_in(dur=0.4)
|
|
20
|
+
@3.2: hero.bounce(intensity=20, count=2, dur=0.8)
|
|
21
|
+
`;
|
|
22
|
+
},
|
|
23
|
+
explainer: (args) => {
|
|
24
|
+
const title = quote(args[0] ?? "how it works");
|
|
25
|
+
return `scene width=960 height=540 bg=#0d1117
|
|
26
|
+
|
|
27
|
+
actor title = caption(${title}) at top
|
|
28
|
+
actor hero = figure(#c68642, m, \u{1F60E}) at (480, 360)
|
|
29
|
+
|
|
30
|
+
scene "intro" {
|
|
31
|
+
@+0.0: title.fade_in(dur=0.5)
|
|
32
|
+
@+1.0: hero.enter(from=bottom, dur=0.5)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
scene "body" {
|
|
36
|
+
@+0.0: camera.zoom(to=1.2, dur=0.8)
|
|
37
|
+
@+1.0: hero.wave(side=right, dur=0.5)
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
},
|
|
41
|
+
reaction: (args) => {
|
|
42
|
+
const line = quote(args[0] ?? "wait, what?");
|
|
43
|
+
return `scene width=720 height=720 bg=#fff5f9
|
|
44
|
+
|
|
45
|
+
actor hero = figure(#c68642, m, \u{1F642}) at (360, 380)
|
|
46
|
+
|
|
47
|
+
@0.0: hero.enter(from=left, dur=0.4)
|
|
48
|
+
@0.5: hero.face("\u{1F633}")
|
|
49
|
+
@0.5: hero.say(${line}, dur=1.4)
|
|
50
|
+
@2.0: hero.face("\u{1F602}")
|
|
51
|
+
@2.0: hero.shake(intensity=6, dur=0.5)
|
|
52
|
+
`;
|
|
53
|
+
},
|
|
54
|
+
pov: (args) => {
|
|
55
|
+
const pov = quote(args[0] ?? "POV: you hit ship");
|
|
56
|
+
return `scene width=720 height=960 bg=#0d1117
|
|
57
|
+
|
|
58
|
+
actor label = caption(${pov}) at top
|
|
59
|
+
actor hero = figure(#c68642, m, \u{1F60E}) at (360, 600)
|
|
60
|
+
|
|
61
|
+
@0.0: label.fade_in(dur=0.4)
|
|
62
|
+
@0.4: hero.enter(from=bottom, dur=0.6)
|
|
63
|
+
@1.2: hero.pose(arm_left=70, arm_right=-70, dur=0.3)
|
|
64
|
+
@1.2: hero.face("\u{1F525}")
|
|
65
|
+
`;
|
|
66
|
+
},
|
|
67
|
+
typing: (args) => {
|
|
68
|
+
const text = quote(args[0] ?? "hello world");
|
|
69
|
+
return `scene width=800 height=300 bg=#0f1115
|
|
70
|
+
|
|
71
|
+
actor cursor = text("|") at (120, 150) size 40 opacity 0
|
|
72
|
+
actor line = text(${text}) at (140, 150) size 32 opacity 0
|
|
73
|
+
|
|
74
|
+
@0.0: cursor.fade_in(dur=0.15)
|
|
75
|
+
@+0.1: cursor.fade_out(dur=0.15)
|
|
76
|
+
@+0.0: cursor.fade_in(dur=0.15)
|
|
77
|
+
@+0.1: cursor.fade_out(dur=0.15)
|
|
78
|
+
@+0.0: cursor.fade_in(dur=0.15)
|
|
79
|
+
@+0.0: line.fade_in(dur=0.8)
|
|
80
|
+
@+0.3: cursor.fade_out(dur=0.3)
|
|
81
|
+
`;
|
|
82
|
+
},
|
|
83
|
+
terminal: (args) => {
|
|
84
|
+
const cmd = quote(args[0] ?? "$ npx markdy");
|
|
85
|
+
const output = quote(args[1] ?? "playground ready at http://localhost:4242");
|
|
86
|
+
return `scene width=960 height=420 bg=#0d1117
|
|
87
|
+
|
|
88
|
+
actor prompt = text(${cmd}) at (60, 140) size 24 opacity 0
|
|
89
|
+
actor result = text(${output}) at (60, 200) size 22 opacity 0
|
|
90
|
+
|
|
91
|
+
@0.0: prompt.fade_in(dur=0.4)
|
|
92
|
+
@1.0: result.fade_in(dur=0.4)
|
|
93
|
+
`;
|
|
94
|
+
},
|
|
95
|
+
chat_bubble: (args) => {
|
|
96
|
+
const a = quote(args[0] ?? "how do I animate this?");
|
|
97
|
+
const b = quote(args[1] ?? "ask markdy.");
|
|
98
|
+
return `scene width=800 height=400 bg=#f6f7fb
|
|
99
|
+
|
|
100
|
+
actor alice = figure(#fad4c0, f, \u{1F642}) at (180, 240)
|
|
101
|
+
actor bob = figure(#c68642, m, \u{1F642}) at (620, 240)
|
|
102
|
+
|
|
103
|
+
@0.0: alice.enter(from=left, dur=0.4)
|
|
104
|
+
@0.2: bob.enter(from=right, dur=0.4)
|
|
105
|
+
@0.8: alice.say(${a}, dur=1.8)
|
|
106
|
+
@2.6: bob.face("\u{1F60E}")
|
|
107
|
+
@2.6: bob.say(${b}, dur=1.6)
|
|
108
|
+
`;
|
|
109
|
+
},
|
|
110
|
+
vs: (args) => {
|
|
111
|
+
const left = quote(args[0] ?? "team a");
|
|
112
|
+
const right = quote(args[1] ?? "team b");
|
|
113
|
+
return `scene width=960 height=540 bg=#111
|
|
114
|
+
|
|
115
|
+
actor a = caption(${left}) at top
|
|
116
|
+
actor b = caption(${right}) at bottom
|
|
117
|
+
actor lf = figure(#c68642, m, \u{1F624}) at (260, 320)
|
|
118
|
+
actor rf = figure(#8d5524, m, \u{1F60F}) at (700, 320)
|
|
119
|
+
|
|
120
|
+
@0.0: a.fade_in(dur=0.3)
|
|
121
|
+
@0.0: b.fade_in(dur=0.3)
|
|
122
|
+
@0.3: lf.enter(from=left, dur=0.5)
|
|
123
|
+
@0.3: rf.enter(from=right, dur=0.5)
|
|
124
|
+
@1.2: lf.punch(side=right, dur=0.3)
|
|
125
|
+
@1.3: rf.shake(intensity=8, dur=0.4)
|
|
126
|
+
`;
|
|
127
|
+
},
|
|
128
|
+
tutorial_step: (args) => {
|
|
129
|
+
const n = quote(args[0] ?? "Step 1");
|
|
130
|
+
const body = quote(args[1] ?? "open your editor");
|
|
131
|
+
return `scene width=960 height=420 bg=white
|
|
132
|
+
|
|
133
|
+
actor step = caption(${n}) at top
|
|
134
|
+
actor descr = text(${body}) at (100, 220) size 32 opacity 0
|
|
135
|
+
|
|
136
|
+
@0.0: step.fade_in(dur=0.4)
|
|
137
|
+
@0.5: descr.fade_in(dur=0.4)
|
|
138
|
+
@1.2: descr.move(to=(120, 220), dur=0.5, ease=out)
|
|
139
|
+
`;
|
|
140
|
+
},
|
|
141
|
+
countdown: (args) => {
|
|
142
|
+
const to = quote(args[0] ?? "launch");
|
|
143
|
+
return `scene width=600 height=600 bg=#0d1117
|
|
144
|
+
|
|
145
|
+
actor three = text("3") at (270, 280) size 120 opacity 0
|
|
146
|
+
actor two = text("2") at (270, 280) size 120 opacity 0
|
|
147
|
+
actor one = text("1") at (270, 280) size 120 opacity 0
|
|
148
|
+
actor go = caption(${to}) at center
|
|
149
|
+
|
|
150
|
+
@0.0: three.fade_in(dur=0.2)
|
|
151
|
+
@0.9: three.fade_out(dur=0.2)
|
|
152
|
+
@0.9: two.fade_in(dur=0.2)
|
|
153
|
+
@1.8: two.fade_out(dur=0.2)
|
|
154
|
+
@1.8: one.fade_in(dur=0.2)
|
|
155
|
+
@2.7: one.fade_out(dur=0.2)
|
|
156
|
+
@2.7: go.fade_in(dur=0.4)
|
|
157
|
+
`;
|
|
158
|
+
},
|
|
159
|
+
reveal: (args) => {
|
|
160
|
+
const secret = quote(args[0] ?? "and that's the trick");
|
|
161
|
+
return `scene width=800 height=450 bg=#0d1117
|
|
162
|
+
|
|
163
|
+
actor cover = box() at (350, 175) scale 10 opacity 1
|
|
164
|
+
actor reveal = caption(${secret}) at center
|
|
165
|
+
|
|
166
|
+
@0.4: cover.fade_out(dur=0.5)
|
|
167
|
+
@0.4: reveal.fade_in(dur=0.5)
|
|
168
|
+
`;
|
|
169
|
+
},
|
|
170
|
+
glitch: (_args) => {
|
|
171
|
+
const txt = quote("GLITCH");
|
|
172
|
+
return `scene width=800 height=400 bg=#000
|
|
173
|
+
|
|
174
|
+
actor t = text(${txt}) at (260, 170) size 72
|
|
175
|
+
|
|
176
|
+
@0.0: t.fade_in(dur=0.2)
|
|
177
|
+
@0.4: t.shake(intensity=10, dur=0.3)
|
|
178
|
+
@0.8: t.shake(intensity=6, dur=0.3)
|
|
179
|
+
@1.3: t.fade_out(dur=0.3)
|
|
180
|
+
`;
|
|
181
|
+
},
|
|
182
|
+
zoom_punchline: (args) => {
|
|
183
|
+
const line = quote(args[0] ?? "the punchline");
|
|
184
|
+
return `scene width=800 height=450 bg=#0d1117
|
|
185
|
+
|
|
186
|
+
actor line = caption(${line}) at center
|
|
187
|
+
|
|
188
|
+
@0.0: line.fade_in(dur=0.4)
|
|
189
|
+
@0.6: camera.zoom(to=1.4, dur=0.8, ease=out)
|
|
190
|
+
@1.6: camera.shake(intensity=6, dur=0.4)
|
|
191
|
+
`;
|
|
192
|
+
},
|
|
193
|
+
before_after: (args) => {
|
|
194
|
+
const before = quote(args[0] ?? "before");
|
|
195
|
+
const after = quote(args[1] ?? "after");
|
|
196
|
+
return `scene width=960 height=420 bg=#f6f7fb
|
|
197
|
+
|
|
198
|
+
actor a = caption(${before}) at top
|
|
199
|
+
actor b = caption(${after}) at bottom
|
|
200
|
+
actor dude = figure(#c68642, m, \u{1F635}) at (480, 240)
|
|
201
|
+
|
|
202
|
+
@0.0: a.fade_in(dur=0.3)
|
|
203
|
+
@0.0: dude.enter(from=left, dur=0.5)
|
|
204
|
+
@1.4: dude.face("\u{1F60E}")
|
|
205
|
+
@1.4: b.fade_in(dur=0.3)
|
|
206
|
+
`;
|
|
207
|
+
},
|
|
208
|
+
tier_list: (_args) => {
|
|
209
|
+
return `scene width=960 height=540 bg=#111
|
|
210
|
+
|
|
211
|
+
actor s = text("S") at (60, 90) size 72 opacity 0
|
|
212
|
+
actor a = text("A") at (60, 200) size 72 opacity 0
|
|
213
|
+
actor b = text("B") at (60, 310) size 72 opacity 0
|
|
214
|
+
actor c = text("C") at (60, 420) size 72 opacity 0
|
|
215
|
+
actor title = caption("tier list") at top
|
|
216
|
+
|
|
217
|
+
@0.0: title.fade_in(dur=0.3)
|
|
218
|
+
@0.3: s.fade_in(dur=0.2)
|
|
219
|
+
@0.6: a.fade_in(dur=0.2)
|
|
220
|
+
@0.9: b.fade_in(dur=0.2)
|
|
221
|
+
@1.2: c.fade_in(dur=0.2)
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
var PRESET_NAMES = Object.keys(PRESETS);
|
|
226
|
+
|
|
1
227
|
// src/parser.ts
|
|
2
228
|
var ParseError = class extends Error {
|
|
3
229
|
constructor(message, line) {
|
|
@@ -37,9 +263,15 @@ function stripComment(line) {
|
|
|
37
263
|
function splitByComma(s) {
|
|
38
264
|
const parts = [];
|
|
39
265
|
let depth = 0;
|
|
266
|
+
let inString = false;
|
|
40
267
|
let start = 0;
|
|
41
268
|
for (let i = 0; i < s.length; i++) {
|
|
42
269
|
const ch = s[i];
|
|
270
|
+
if (ch === '"') {
|
|
271
|
+
inString = !inString;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (inString) continue;
|
|
43
275
|
if (ch === "(") depth++;
|
|
44
276
|
else if (ch === ")") depth--;
|
|
45
277
|
else if (ch === "," && depth === 0) {
|
|
@@ -93,32 +325,124 @@ function parseActionParams(action, raw) {
|
|
|
93
325
|
}
|
|
94
326
|
return params;
|
|
95
327
|
}
|
|
96
|
-
|
|
328
|
+
var UNIVERSAL_ACTIONS = /* @__PURE__ */ new Set([
|
|
329
|
+
"enter",
|
|
330
|
+
"exit",
|
|
331
|
+
"move",
|
|
332
|
+
"fade_in",
|
|
333
|
+
"fade_out",
|
|
334
|
+
"scale",
|
|
335
|
+
"rotate",
|
|
336
|
+
"shake",
|
|
337
|
+
"say",
|
|
338
|
+
"throw",
|
|
339
|
+
"play"
|
|
340
|
+
]);
|
|
341
|
+
var FIGURE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
|
|
342
|
+
"punch",
|
|
343
|
+
"kick",
|
|
344
|
+
"wave",
|
|
345
|
+
"nod",
|
|
346
|
+
"jump",
|
|
347
|
+
"bounce",
|
|
348
|
+
"face",
|
|
349
|
+
"rotate_part",
|
|
350
|
+
"pose"
|
|
351
|
+
]);
|
|
352
|
+
var CAMERA_ACTIONS = /* @__PURE__ */ new Set(["pan", "zoom", "shake"]);
|
|
353
|
+
function isKnownAction(actorType, action) {
|
|
354
|
+
if (actorType === "camera") return CAMERA_ACTIONS.has(action);
|
|
355
|
+
if (UNIVERSAL_ACTIONS.has(action)) return true;
|
|
356
|
+
return FIGURE_ONLY_ACTIONS.has(action);
|
|
357
|
+
}
|
|
358
|
+
var MODIFIER_KEYS = /* @__PURE__ */ new Set(["scale", "rotate", "opacity", "size", "z"]);
|
|
359
|
+
function parseSpaceModifiers(raw, lineNum, warnings) {
|
|
97
360
|
const result = {};
|
|
98
361
|
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
99
362
|
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
100
363
|
const key = tokens[i];
|
|
101
364
|
const val = Number(tokens[i + 1]);
|
|
102
365
|
if (Number.isNaN(val)) continue;
|
|
103
|
-
if (key
|
|
104
|
-
|
|
105
|
-
else
|
|
106
|
-
|
|
107
|
-
|
|
366
|
+
if (MODIFIER_KEYS.has(key)) {
|
|
367
|
+
result[key] = val;
|
|
368
|
+
} else {
|
|
369
|
+
warnings.push({
|
|
370
|
+
kind: "unknown-modifier",
|
|
371
|
+
message: `unknown modifier "${key}" \u2014 ignored`,
|
|
372
|
+
line: lineNum
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
function parseWithModifiers(raw, lineNum, warnings) {
|
|
379
|
+
const result = {};
|
|
380
|
+
for (const token of splitByComma(raw)) {
|
|
381
|
+
const t = token.trim();
|
|
382
|
+
if (!t) continue;
|
|
383
|
+
const eqIdx = t.indexOf("=");
|
|
384
|
+
if (eqIdx === -1) {
|
|
385
|
+
warnings.push({
|
|
386
|
+
kind: "unknown-modifier",
|
|
387
|
+
message: `expected "key=value" in with-clause, got "${t}"`,
|
|
388
|
+
line: lineNum
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const key = t.slice(0, eqIdx).trim();
|
|
393
|
+
const val = Number(t.slice(eqIdx + 1).trim());
|
|
394
|
+
if (Number.isNaN(val)) {
|
|
395
|
+
warnings.push({
|
|
396
|
+
kind: "unknown-modifier",
|
|
397
|
+
message: `modifier "${key}" needs a numeric value \u2014 ignored`,
|
|
398
|
+
line: lineNum
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (MODIFIER_KEYS.has(key)) {
|
|
403
|
+
result[key] = val;
|
|
404
|
+
} else {
|
|
405
|
+
warnings.push({
|
|
406
|
+
kind: "unknown-modifier",
|
|
407
|
+
message: `unknown modifier "${key}" \u2014 ignored`,
|
|
408
|
+
line: lineNum
|
|
409
|
+
});
|
|
410
|
+
}
|
|
108
411
|
}
|
|
109
412
|
return result;
|
|
110
413
|
}
|
|
414
|
+
function parseActorTrailer(trailerRaw, lineNum, warnings) {
|
|
415
|
+
const trimmed = trailerRaw.trim();
|
|
416
|
+
if (!trimmed) return {};
|
|
417
|
+
const withMatch = /(^|\s)with(\s|$)/.exec(trimmed);
|
|
418
|
+
if (!withMatch) {
|
|
419
|
+
return parseSpaceModifiers(trimmed, lineNum, warnings);
|
|
420
|
+
}
|
|
421
|
+
const before = trimmed.slice(0, withMatch.index).trim();
|
|
422
|
+
const after = trimmed.slice(withMatch.index + withMatch[0].length).trim();
|
|
423
|
+
const fromSpace = before ? parseSpaceModifiers(before, lineNum, warnings) : {};
|
|
424
|
+
const fromWith = after ? parseWithModifiers(after, lineNum, warnings) : {};
|
|
425
|
+
return { ...fromSpace, ...fromWith };
|
|
426
|
+
}
|
|
111
427
|
var ASSET_RE = /^asset\s+(\w+)\s*=\s*(image|icon)\("([^"]+)"\)$/;
|
|
112
|
-
var BUILTIN_ACTOR_TYPES = /* @__PURE__ */ new Set(["sprite", "text", "box", "figure"]);
|
|
113
|
-
var
|
|
114
|
-
var
|
|
428
|
+
var BUILTIN_ACTOR_TYPES = /* @__PURE__ */ new Set(["sprite", "text", "box", "figure", "caption"]);
|
|
429
|
+
var ACTOR_NUM_POS_RE = /^actor\s+(\w+)\s*=\s*([\w.]+)\(([^)]*)\)\s+at\s+\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)(.*)$/;
|
|
430
|
+
var ACTOR_ANCHOR_POS_RE = /^actor\s+(\w+)\s*=\s*([\w.]+)\(([^)]*)\)\s+at\s+(top|bottom|center)\b(.*)$/;
|
|
431
|
+
var EVENT_RE = /^@([\d.]+):\s+(\w+)\.(!?\w+)\((.*)\)$/;
|
|
432
|
+
var REL_EVENT_RE = /^@\+([\d.]+):\s+(\w+)\.(!?\w+)\((.*)\)$/;
|
|
115
433
|
var VAR_RE = /^var\s+(\w+)\s*=\s*(.+)$/;
|
|
116
434
|
var DEF_HEADER_RE = /^def\s+(\w+)\(([^)]*)\)\s*\{$/;
|
|
117
|
-
var DEF_BODY_RE = /^\s*(sprite|text|box|figure)\(([^)]*)\)\s*$/;
|
|
435
|
+
var DEF_BODY_RE = /^\s*(sprite|text|box|figure|caption)\(([^)]*)\)\s*$/;
|
|
118
436
|
var SEQ_HEADER_RE = /^seq\s+(\w+)(?:\(([^)]*)\))?\s*\{$/;
|
|
119
|
-
var SEQ_EVENT_RE = /^@\+([\d.]+):\s+\$\.(
|
|
437
|
+
var SEQ_EVENT_RE = /^@\+([\d.]+):\s+\$\.(!?\w+)\((.*)\)$/;
|
|
438
|
+
var CHAPTER_HEADER_RE = /^scene\s+"([^"]+)"\s*\{$/;
|
|
439
|
+
var IMPORT_RE = /^import\s+"([^"]+)"\s+as\s+(\w+)\s*$/;
|
|
440
|
+
var PRESET_RE = /^preset\s+(\w+)(?:\s*\((.*)\))?\s*$/;
|
|
120
441
|
function interpolate(s, vars) {
|
|
121
|
-
return s.replace(
|
|
442
|
+
return s.replace(
|
|
443
|
+
/\\?\$\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g,
|
|
444
|
+
(_, name) => vars[name] ?? `\${${name}}`
|
|
445
|
+
);
|
|
122
446
|
}
|
|
123
447
|
var DEFAULTS = {
|
|
124
448
|
width: 800,
|
|
@@ -126,6 +450,10 @@ var DEFAULTS = {
|
|
|
126
450
|
fps: 30,
|
|
127
451
|
bg: "white"
|
|
128
452
|
};
|
|
453
|
+
var CAPTION_TOP_Y_FRAC = 0.12;
|
|
454
|
+
var CAPTION_BOTTOM_Y_FRAC = 0.88;
|
|
455
|
+
var CAPTION_CENTER_Y_FRAC = 0.5;
|
|
456
|
+
var KNOWN_SCENE_KEYS = /* @__PURE__ */ new Set(["width", "height", "fps", "bg", "duration"]);
|
|
129
457
|
function validateMoveTarget(action, params, meta, actor, line) {
|
|
130
458
|
if (action !== "move") return;
|
|
131
459
|
const to = params.to;
|
|
@@ -138,7 +466,11 @@ function validateMoveTarget(action, params, meta, actor, line) {
|
|
|
138
466
|
);
|
|
139
467
|
}
|
|
140
468
|
}
|
|
141
|
-
function parse(source) {
|
|
469
|
+
function parse(source, opts = {}) {
|
|
470
|
+
if (!opts._fromPreset) {
|
|
471
|
+
const expansion = tryExpandSolePreset(source);
|
|
472
|
+
if (expansion) return parse(expansion, { ...opts, _fromPreset: true });
|
|
473
|
+
}
|
|
142
474
|
const ast = {
|
|
143
475
|
meta: { ...DEFAULTS },
|
|
144
476
|
assets: {},
|
|
@@ -146,13 +478,18 @@ function parse(source) {
|
|
|
146
478
|
events: [],
|
|
147
479
|
defs: {},
|
|
148
480
|
seqs: {},
|
|
149
|
-
vars: {}
|
|
481
|
+
vars: {},
|
|
482
|
+
chapters: [],
|
|
483
|
+
warnings: [],
|
|
484
|
+
imports: []
|
|
150
485
|
};
|
|
151
486
|
let sceneFound = false;
|
|
152
487
|
const lines = source.split(/\r?\n/);
|
|
153
488
|
let inDef = null;
|
|
154
489
|
let defNeedsClose = false;
|
|
155
490
|
let inSeq = null;
|
|
491
|
+
let inChapter = null;
|
|
492
|
+
const topScope = { name: "", prevEnd: 0 };
|
|
156
493
|
for (let i = 0; i < lines.length; i++) {
|
|
157
494
|
const lineNum = i + 1;
|
|
158
495
|
const rawUntouched = lines[i].trim();
|
|
@@ -182,6 +519,19 @@ function parse(source) {
|
|
|
182
519
|
inSeq = null;
|
|
183
520
|
continue;
|
|
184
521
|
}
|
|
522
|
+
if (inChapter) {
|
|
523
|
+
const endTime = inChapter.scope.prevEnd;
|
|
524
|
+
const startTime = inChapter.earliestEventTime === Infinity ? inChapter.openedAt : Math.min(inChapter.openedAt, inChapter.earliestEventTime);
|
|
525
|
+
ast.chapters.push({
|
|
526
|
+
name: inChapter.name,
|
|
527
|
+
startLine: inChapter.startLine,
|
|
528
|
+
startTime,
|
|
529
|
+
endTime
|
|
530
|
+
});
|
|
531
|
+
if (endTime > topScope.prevEnd) topScope.prevEnd = endTime;
|
|
532
|
+
inChapter = null;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
185
535
|
throw new ParseError("Unexpected '}'", lineNum);
|
|
186
536
|
}
|
|
187
537
|
if (inDef) {
|
|
@@ -207,11 +557,35 @@ function parse(source) {
|
|
|
207
557
|
throw new ParseError(`Invalid seq event (expected "@+offset: $.action(params)"): ${raw}`, lineNum);
|
|
208
558
|
}
|
|
209
559
|
const [, offsetStr, action, paramsRaw] = sm;
|
|
210
|
-
inSeq.events.push({
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
560
|
+
inSeq.events.push({ offset: Number(offsetStr), action, paramsRaw });
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (raw.startsWith("import ")) {
|
|
564
|
+
const im = IMPORT_RE.exec(raw);
|
|
565
|
+
if (!im) {
|
|
566
|
+
throw new ParseError(`Invalid import declaration: ${raw}`, lineNum);
|
|
567
|
+
}
|
|
568
|
+
const [, path, namespace] = im;
|
|
569
|
+
const decl = { path, namespace, line: lineNum };
|
|
570
|
+
ast.imports.push(decl);
|
|
571
|
+
const resolved = opts.imports?.[namespace];
|
|
572
|
+
if (!resolved) {
|
|
573
|
+
ast.warnings.push({
|
|
574
|
+
kind: "import-unresolved",
|
|
575
|
+
message: `import "${path}" as ${namespace} \u2014 no pre-parsed AST supplied by host`,
|
|
576
|
+
line: lineNum
|
|
577
|
+
});
|
|
578
|
+
} else {
|
|
579
|
+
for (const [k, v] of Object.entries(resolved.vars)) {
|
|
580
|
+
ast.vars[`${namespace}.${k}`] = v;
|
|
581
|
+
}
|
|
582
|
+
for (const [k, v] of Object.entries(resolved.defs)) {
|
|
583
|
+
ast.defs[`${namespace}.${k}`] = v;
|
|
584
|
+
}
|
|
585
|
+
for (const [k, v] of Object.entries(resolved.seqs)) {
|
|
586
|
+
ast.seqs[`${namespace}.${k}`] = v;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
215
589
|
continue;
|
|
216
590
|
}
|
|
217
591
|
if (raw.startsWith("def ")) {
|
|
@@ -235,6 +609,30 @@ function parse(source) {
|
|
|
235
609
|
continue;
|
|
236
610
|
}
|
|
237
611
|
if (/^scene(\s|$)/.test(raw)) {
|
|
612
|
+
const chm = CHAPTER_HEADER_RE.exec(raw);
|
|
613
|
+
if (chm) {
|
|
614
|
+
if (inChapter) {
|
|
615
|
+
throw new ParseError(
|
|
616
|
+
`Nested chapters are not supported; close chapter "${inChapter.name}" first`,
|
|
617
|
+
lineNum
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
const chapterName = chm[1];
|
|
621
|
+
inChapter = {
|
|
622
|
+
name: chapterName,
|
|
623
|
+
startLine: lineNum,
|
|
624
|
+
openedAt: topScope.prevEnd,
|
|
625
|
+
earliestEventTime: Infinity,
|
|
626
|
+
scope: { name: chapterName, prevEnd: topScope.prevEnd }
|
|
627
|
+
};
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (inChapter) {
|
|
631
|
+
throw new ParseError(
|
|
632
|
+
`scene header inside chapter "${inChapter.name}" is not allowed; close the chapter first or use 'scene "title" { ... }' for a nested section`,
|
|
633
|
+
lineNum
|
|
634
|
+
);
|
|
635
|
+
}
|
|
238
636
|
if (sceneFound) {
|
|
239
637
|
throw new ParseError("Duplicate scene declaration", lineNum);
|
|
240
638
|
}
|
|
@@ -257,7 +655,12 @@ function parse(source) {
|
|
|
257
655
|
ast.meta.duration = Number(val);
|
|
258
656
|
break;
|
|
259
657
|
default:
|
|
260
|
-
|
|
658
|
+
if (KNOWN_SCENE_KEYS.has(key)) break;
|
|
659
|
+
ast.warnings.push({
|
|
660
|
+
kind: "unknown-scene-key",
|
|
661
|
+
message: `unknown scene property "${key}" \u2014 ignored`,
|
|
662
|
+
line: lineNum
|
|
663
|
+
});
|
|
261
664
|
}
|
|
262
665
|
}
|
|
263
666
|
continue;
|
|
@@ -271,107 +674,31 @@ function parse(source) {
|
|
|
271
674
|
ast.assets[name] = { type, value };
|
|
272
675
|
continue;
|
|
273
676
|
}
|
|
274
|
-
if (raw.startsWith("
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
resolvedArgs = argsRaw.trim() ? splitByComma(argsRaw).map((a) => {
|
|
285
|
-
const t = a.trim();
|
|
286
|
-
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
287
|
-
}) : [];
|
|
288
|
-
} else if (ast.defs[typeName]) {
|
|
289
|
-
const tmpl = ast.defs[typeName];
|
|
290
|
-
const callArgs = argsRaw.trim() ? splitByComma(argsRaw).map((a) => {
|
|
291
|
-
const t = a.trim();
|
|
292
|
-
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
293
|
-
}) : [];
|
|
294
|
-
const localVars = {};
|
|
295
|
-
for (let pi = 0; pi < tmpl.params.length; pi++) {
|
|
296
|
-
localVars[tmpl.params[pi]] = callArgs[pi] ?? "";
|
|
297
|
-
}
|
|
298
|
-
resolvedType = tmpl.actorType;
|
|
299
|
-
resolvedArgs = tmpl.bodyArgs.map((a) => interpolate(a, localVars));
|
|
677
|
+
if (raw.startsWith("preset ")) {
|
|
678
|
+
const pm = PRESET_RE.exec(raw);
|
|
679
|
+
const maybeName = pm?.[1];
|
|
680
|
+
if (maybeName && !PRESETS[maybeName]) {
|
|
681
|
+
const nameList = Object.keys(PRESETS).sort().join(", ");
|
|
682
|
+
ast.warnings.push({
|
|
683
|
+
kind: "unknown-preset",
|
|
684
|
+
message: `unknown preset "${maybeName}" \u2014 available: ${nameList}`,
|
|
685
|
+
line: lineNum
|
|
686
|
+
});
|
|
300
687
|
} else {
|
|
301
|
-
|
|
688
|
+
ast.warnings.push({
|
|
689
|
+
kind: "preset-mixed",
|
|
690
|
+
message: "`preset` is a whole-file shorthand; mid-file presets are ignored",
|
|
691
|
+
line: lineNum
|
|
692
|
+
});
|
|
302
693
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
throw new ParseError(
|
|
308
|
-
`Actor "${name}" position (${x}, ${y}) is outside scene bounds (0\u2013${ast.meta.width}, 0\u2013${ast.meta.height})`,
|
|
309
|
-
lineNum
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
ast.actors[name] = {
|
|
313
|
-
type: resolvedType,
|
|
314
|
-
args: resolvedArgs,
|
|
315
|
-
x,
|
|
316
|
-
y,
|
|
317
|
-
...modifiers
|
|
318
|
-
};
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (raw.startsWith("actor ")) {
|
|
697
|
+
parseActorLine(raw, lineNum, ast);
|
|
319
698
|
continue;
|
|
320
699
|
}
|
|
321
700
|
if (raw.startsWith("@")) {
|
|
322
|
-
|
|
323
|
-
if (!m) {
|
|
324
|
-
throw new ParseError(`Invalid event: ${raw}`, lineNum);
|
|
325
|
-
}
|
|
326
|
-
const [, timeStr, actor, action, paramsRaw] = m;
|
|
327
|
-
const time = Number(timeStr);
|
|
328
|
-
if (Number.isNaN(time)) {
|
|
329
|
-
throw new ParseError(`Invalid time value: ${timeStr}`, lineNum);
|
|
330
|
-
}
|
|
331
|
-
if (!ast.actors[actor]) {
|
|
332
|
-
throw new ParseError(`Unknown actor: "${actor}"`, lineNum);
|
|
333
|
-
}
|
|
334
|
-
if (action === "play") {
|
|
335
|
-
const playParts = splitByComma(paramsRaw);
|
|
336
|
-
const seqName = playParts[0]?.trim();
|
|
337
|
-
if (!seqName || !ast.seqs[seqName]) {
|
|
338
|
-
throw new ParseError(`Unknown sequence: "${seqName}"`, lineNum);
|
|
339
|
-
}
|
|
340
|
-
const seq = ast.seqs[seqName];
|
|
341
|
-
const playVars = {};
|
|
342
|
-
for (let pi = 1; pi < playParts.length; pi++) {
|
|
343
|
-
const eqIdx = playParts[pi].indexOf("=");
|
|
344
|
-
if (eqIdx !== -1) {
|
|
345
|
-
const k = playParts[pi].slice(0, eqIdx).trim();
|
|
346
|
-
const v = playParts[pi].slice(eqIdx + 1).trim();
|
|
347
|
-
playVars[k] = v;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
let posIdx = 0;
|
|
351
|
-
for (let pi = 1; pi < playParts.length; pi++) {
|
|
352
|
-
if (!playParts[pi].includes("=") && posIdx < seq.params.length) {
|
|
353
|
-
playVars[seq.params[posIdx]] = playParts[pi].trim();
|
|
354
|
-
posIdx++;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
for (const sev of seq.events) {
|
|
358
|
-
const expandedParams = interpolate(sev.paramsRaw, playVars);
|
|
359
|
-
const absTime = Math.round((time + sev.offset) * 1e3) / 1e3;
|
|
360
|
-
const params2 = parseActionParams(sev.action, expandedParams);
|
|
361
|
-
validateMoveTarget(sev.action, params2, ast.meta, actor, lineNum);
|
|
362
|
-
ast.events.push({
|
|
363
|
-
time: absTime,
|
|
364
|
-
actor,
|
|
365
|
-
action: sev.action,
|
|
366
|
-
params: params2,
|
|
367
|
-
line: lineNum
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
const params = parseActionParams(action, paramsRaw);
|
|
373
|
-
validateMoveTarget(action, params, ast.meta, actor, lineNum);
|
|
374
|
-
ast.events.push({ time, actor, action, params, line: lineNum });
|
|
701
|
+
parseEventLine(raw, lineNum, ast, inChapter, topScope);
|
|
375
702
|
continue;
|
|
376
703
|
}
|
|
377
704
|
throw new ParseError(`Unrecognized statement: ${raw}`, lineNum);
|
|
@@ -385,17 +712,297 @@ function parse(source) {
|
|
|
385
712
|
if (inSeq) {
|
|
386
713
|
throw new ParseError(`Unclosed seq block "${inSeq.name}"`, inSeq.startLine);
|
|
387
714
|
}
|
|
715
|
+
if (inChapter) {
|
|
716
|
+
throw new ParseError(`Unclosed chapter "${inChapter.name}"`, inChapter.startLine);
|
|
717
|
+
}
|
|
388
718
|
if (ast.meta.duration === void 0) {
|
|
389
719
|
let maxEnd = 0;
|
|
390
720
|
for (const ev of ast.events) {
|
|
391
721
|
const dur = typeof ev.params.dur === "number" ? ev.params.dur : 0;
|
|
392
722
|
maxEnd = Math.max(maxEnd, ev.time + dur);
|
|
393
723
|
}
|
|
394
|
-
if (maxEnd > 0) ast.meta.duration = maxEnd;
|
|
724
|
+
if (maxEnd > 0) ast.meta.duration = round3(maxEnd);
|
|
395
725
|
}
|
|
396
726
|
return ast;
|
|
397
727
|
}
|
|
728
|
+
function parseActorCallArgs(argsRaw) {
|
|
729
|
+
if (!argsRaw.trim()) return [];
|
|
730
|
+
return splitByComma(argsRaw).map((a) => {
|
|
731
|
+
const t = a.trim();
|
|
732
|
+
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function parseActorLine(raw, lineNum, ast) {
|
|
736
|
+
const amAnchor = ACTOR_ANCHOR_POS_RE.exec(raw);
|
|
737
|
+
const amNum = amAnchor ? null : ACTOR_NUM_POS_RE.exec(raw);
|
|
738
|
+
if (!amAnchor && !amNum) {
|
|
739
|
+
throw new ParseError(`Invalid actor declaration: ${raw}`, lineNum);
|
|
740
|
+
}
|
|
741
|
+
let name;
|
|
742
|
+
let typeName;
|
|
743
|
+
let argsRaw;
|
|
744
|
+
let x;
|
|
745
|
+
let y;
|
|
746
|
+
let anchor;
|
|
747
|
+
let trailer;
|
|
748
|
+
if (amAnchor) {
|
|
749
|
+
const [, nm, tn, ar, an, tr] = amAnchor;
|
|
750
|
+
name = nm;
|
|
751
|
+
typeName = tn;
|
|
752
|
+
argsRaw = ar;
|
|
753
|
+
anchor = an;
|
|
754
|
+
trailer = tr;
|
|
755
|
+
x = ast.meta.width / 2;
|
|
756
|
+
switch (anchor) {
|
|
757
|
+
case "top":
|
|
758
|
+
y = Math.round(ast.meta.height * CAPTION_TOP_Y_FRAC);
|
|
759
|
+
break;
|
|
760
|
+
case "bottom":
|
|
761
|
+
y = Math.round(ast.meta.height * CAPTION_BOTTOM_Y_FRAC);
|
|
762
|
+
break;
|
|
763
|
+
default:
|
|
764
|
+
y = Math.round(ast.meta.height * CAPTION_CENTER_Y_FRAC);
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
const [, nm, tn, ar, xs, ys, tr] = amNum;
|
|
769
|
+
name = nm;
|
|
770
|
+
typeName = tn;
|
|
771
|
+
argsRaw = ar;
|
|
772
|
+
x = Number(xs);
|
|
773
|
+
y = Number(ys);
|
|
774
|
+
trailer = tr;
|
|
775
|
+
}
|
|
776
|
+
if (name === "camera") {
|
|
777
|
+
throw new ParseError(
|
|
778
|
+
`"camera" is a reserved actor name; drop this declaration and use camera.pan/zoom/shake directly`,
|
|
779
|
+
lineNum
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
const rawArgs = parseActorCallArgs(argsRaw);
|
|
783
|
+
let resolvedType;
|
|
784
|
+
let resolvedArgs;
|
|
785
|
+
if (BUILTIN_ACTOR_TYPES.has(typeName)) {
|
|
786
|
+
resolvedType = typeName;
|
|
787
|
+
resolvedArgs = rawArgs;
|
|
788
|
+
} else if (ast.defs[typeName]) {
|
|
789
|
+
const tmpl = ast.defs[typeName];
|
|
790
|
+
const localVars = {};
|
|
791
|
+
for (let pi = 0; pi < tmpl.params.length; pi++) {
|
|
792
|
+
localVars[tmpl.params[pi]] = rawArgs[pi] ?? "";
|
|
793
|
+
}
|
|
794
|
+
resolvedType = tmpl.actorType;
|
|
795
|
+
resolvedArgs = tmpl.bodyArgs.map((a) => interpolate(a, localVars));
|
|
796
|
+
} else {
|
|
797
|
+
throw new ParseError(`Unknown actor type or template: "${typeName}"`, lineNum);
|
|
798
|
+
}
|
|
799
|
+
const modifiers = parseActorTrailer(trailer, lineNum, ast.warnings);
|
|
800
|
+
if (anchor && resolvedType !== "caption") {
|
|
801
|
+
throw new ParseError(
|
|
802
|
+
`anchor syntax "at ${anchor}" only applies to caption actors; got ${typeName}`,
|
|
803
|
+
lineNum
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (resolvedType === "caption" && !anchor) {
|
|
807
|
+
throw new ParseError(
|
|
808
|
+
`Caption actors require anchor syntax (\`at top | bottom | center\`); got numeric position (${x}, ${y}) for "${name}"`,
|
|
809
|
+
lineNum
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
if (!anchor && (x < 0 || x > ast.meta.width || y < 0 || y > ast.meta.height)) {
|
|
813
|
+
throw new ParseError(
|
|
814
|
+
`Actor "${name}" position (${x}, ${y}) is outside scene bounds (0\u2013${ast.meta.width}, 0\u2013${ast.meta.height})`,
|
|
815
|
+
lineNum
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
ast.actors[name] = {
|
|
819
|
+
type: resolvedType,
|
|
820
|
+
args: resolvedArgs,
|
|
821
|
+
x,
|
|
822
|
+
y,
|
|
823
|
+
...modifiers,
|
|
824
|
+
...anchor ? { anchor } : {}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function parseEventLine(raw, lineNum, ast, inChapter, topScope) {
|
|
828
|
+
const scope = inChapter?.scope ?? topScope;
|
|
829
|
+
const recordEventTime = (t) => {
|
|
830
|
+
if (inChapter && t < inChapter.earliestEventTime) {
|
|
831
|
+
inChapter.earliestEventTime = t;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const rel = REL_EVENT_RE.exec(raw);
|
|
835
|
+
const abs = rel ? null : EVENT_RE.exec(raw);
|
|
836
|
+
if (!rel && !abs) {
|
|
837
|
+
throw new ParseError(`Invalid event: ${raw}`, lineNum);
|
|
838
|
+
}
|
|
839
|
+
let time;
|
|
840
|
+
let actor;
|
|
841
|
+
let actionToken;
|
|
842
|
+
let paramsRaw;
|
|
843
|
+
if (rel) {
|
|
844
|
+
const [, offsetStr, act, action2, pr] = rel;
|
|
845
|
+
const offset = Number(offsetStr);
|
|
846
|
+
if (Number.isNaN(offset)) {
|
|
847
|
+
throw new ParseError(`Invalid @+offset value: ${offsetStr}`, lineNum);
|
|
848
|
+
}
|
|
849
|
+
time = round3(scope.prevEnd + offset);
|
|
850
|
+
actor = act;
|
|
851
|
+
actionToken = action2;
|
|
852
|
+
paramsRaw = pr;
|
|
853
|
+
} else {
|
|
854
|
+
const [, timeStr, act, action2, pr] = abs;
|
|
855
|
+
const t = Number(timeStr);
|
|
856
|
+
if (Number.isNaN(t)) {
|
|
857
|
+
throw new ParseError(`Invalid time value: ${timeStr}`, lineNum);
|
|
858
|
+
}
|
|
859
|
+
time = t;
|
|
860
|
+
actor = act;
|
|
861
|
+
actionToken = action2;
|
|
862
|
+
paramsRaw = pr;
|
|
863
|
+
}
|
|
864
|
+
const mustUnderstand = actionToken.startsWith("!");
|
|
865
|
+
const action = mustUnderstand ? actionToken.slice(1) : actionToken;
|
|
866
|
+
if (actor === "camera") {
|
|
867
|
+
if (!CAMERA_ACTIONS.has(action)) {
|
|
868
|
+
if (mustUnderstand) {
|
|
869
|
+
throw new ParseError(`Unknown camera action "${action}"`, lineNum);
|
|
870
|
+
}
|
|
871
|
+
ast.warnings.push({
|
|
872
|
+
kind: "unknown-camera-action",
|
|
873
|
+
message: `unknown camera action "${action}" \u2014 renderer will no-op`,
|
|
874
|
+
line: lineNum
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
const params2 = parseActionParams(action, paramsRaw);
|
|
878
|
+
recordEventTime(time);
|
|
879
|
+
pushEvent(ast, scope, {
|
|
880
|
+
time,
|
|
881
|
+
actor: "camera",
|
|
882
|
+
action,
|
|
883
|
+
params: params2,
|
|
884
|
+
line: lineNum,
|
|
885
|
+
...inChapter ? { chapter: inChapter.name } : {}
|
|
886
|
+
});
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const actorDef = ast.actors[actor];
|
|
890
|
+
if (!actorDef) {
|
|
891
|
+
throw new ParseError(`Unknown actor: "${actor}"`, lineNum);
|
|
892
|
+
}
|
|
893
|
+
if (action === "play") {
|
|
894
|
+
const playParts = splitByComma(paramsRaw);
|
|
895
|
+
const seqName = playParts[0]?.trim();
|
|
896
|
+
if (!seqName || !ast.seqs[seqName]) {
|
|
897
|
+
throw new ParseError(`Unknown sequence: "${seqName}"`, lineNum);
|
|
898
|
+
}
|
|
899
|
+
const seq = ast.seqs[seqName];
|
|
900
|
+
const playVars = {};
|
|
901
|
+
for (let pi = 1; pi < playParts.length; pi++) {
|
|
902
|
+
const eqIdx = playParts[pi].indexOf("=");
|
|
903
|
+
if (eqIdx !== -1) {
|
|
904
|
+
const k = playParts[pi].slice(0, eqIdx).trim();
|
|
905
|
+
const v = playParts[pi].slice(eqIdx + 1).trim();
|
|
906
|
+
playVars[k] = v;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
let posIdx = 0;
|
|
910
|
+
for (let pi = 1; pi < playParts.length; pi++) {
|
|
911
|
+
if (!playParts[pi].includes("=") && posIdx < seq.params.length) {
|
|
912
|
+
playVars[seq.params[posIdx]] = playParts[pi].trim();
|
|
913
|
+
posIdx++;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const sev of seq.events) {
|
|
917
|
+
const expandedParams = interpolate(sev.paramsRaw, playVars);
|
|
918
|
+
const absTime = round3(time + sev.offset);
|
|
919
|
+
const sevMustUnderstand = sev.action.startsWith("!");
|
|
920
|
+
const sevAction = sevMustUnderstand ? sev.action.slice(1) : sev.action;
|
|
921
|
+
validateActionForActor(sevAction, actorDef, sevMustUnderstand, lineNum, ast.warnings);
|
|
922
|
+
const params2 = parseActionParams(sevAction, expandedParams);
|
|
923
|
+
validateMoveTarget(sevAction, params2, ast.meta, actor, lineNum);
|
|
924
|
+
recordEventTime(absTime);
|
|
925
|
+
pushEvent(ast, scope, {
|
|
926
|
+
time: absTime,
|
|
927
|
+
actor,
|
|
928
|
+
action: sevAction,
|
|
929
|
+
params: params2,
|
|
930
|
+
line: lineNum,
|
|
931
|
+
...inChapter ? { chapter: inChapter.name } : {}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
validateActionForActor(action, actorDef, mustUnderstand, lineNum, ast.warnings);
|
|
937
|
+
const params = parseActionParams(action, paramsRaw);
|
|
938
|
+
validateMoveTarget(action, params, ast.meta, actor, lineNum);
|
|
939
|
+
recordEventTime(time);
|
|
940
|
+
pushEvent(ast, scope, {
|
|
941
|
+
time,
|
|
942
|
+
actor,
|
|
943
|
+
action,
|
|
944
|
+
params,
|
|
945
|
+
line: lineNum,
|
|
946
|
+
...inChapter ? { chapter: inChapter.name } : {}
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function pushEvent(ast, scope, ev) {
|
|
950
|
+
ast.events.push(ev);
|
|
951
|
+
const dur = typeof ev.params.dur === "number" ? ev.params.dur : 0;
|
|
952
|
+
const endTime = round3(ev.time + dur);
|
|
953
|
+
if (endTime > scope.prevEnd) scope.prevEnd = endTime;
|
|
954
|
+
}
|
|
955
|
+
function validateActionForActor(action, actorDef, mustUnderstand, lineNum, warnings) {
|
|
956
|
+
if (FIGURE_ONLY_ACTIONS.has(action)) {
|
|
957
|
+
if (actorDef.type !== "figure") {
|
|
958
|
+
throw new ParseError(
|
|
959
|
+
`action "${action}" is figure-only; actor type is "${actorDef.type}"`,
|
|
960
|
+
lineNum
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (isKnownAction(actorDef.type, action)) return;
|
|
966
|
+
if (mustUnderstand) {
|
|
967
|
+
throw new ParseError(`Unknown action "${action}" (must-understand form)`, lineNum);
|
|
968
|
+
}
|
|
969
|
+
warnings.push({
|
|
970
|
+
kind: "unknown-action",
|
|
971
|
+
message: `unknown action "${action}" on ${actorDef.type} actor \u2014 renderer will no-op`,
|
|
972
|
+
line: lineNum
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
function tryExpandSolePreset(source) {
|
|
976
|
+
const lines = source.split(/\r?\n/);
|
|
977
|
+
let presetLine = null;
|
|
978
|
+
for (const raw of lines) {
|
|
979
|
+
const stripped = stripComment(raw).trim();
|
|
980
|
+
if (!stripped) continue;
|
|
981
|
+
if (stripped.startsWith("preset ")) {
|
|
982
|
+
if (presetLine) return null;
|
|
983
|
+
presetLine = stripped;
|
|
984
|
+
} else {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (!presetLine) return null;
|
|
989
|
+
const pm = PRESET_RE.exec(presetLine);
|
|
990
|
+
if (!pm) return null;
|
|
991
|
+
const [, name, argsRaw] = pm;
|
|
992
|
+
const fn = PRESETS[name];
|
|
993
|
+
if (!fn) return null;
|
|
994
|
+
const args = argsRaw ? splitByComma(argsRaw).map((a) => {
|
|
995
|
+
const t = a.trim();
|
|
996
|
+
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
997
|
+
}) : [];
|
|
998
|
+
return fn(args);
|
|
999
|
+
}
|
|
1000
|
+
function round3(n) {
|
|
1001
|
+
return Math.round(n * 1e3) / 1e3;
|
|
1002
|
+
}
|
|
398
1003
|
export {
|
|
1004
|
+
PRESETS,
|
|
1005
|
+
PRESET_NAMES,
|
|
399
1006
|
ParseError,
|
|
400
1007
|
parse
|
|
401
1008
|
};
|