@markdy/core 0.5.8 → 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 +93 -4
- package/dist/index.js +724 -116
- 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;
|
|
@@ -17,6 +17,14 @@ type ActorDef = {
|
|
|
17
17
|
opacity?: number;
|
|
18
18
|
/** Font size in pixels; applies to text actors (via the `size` modifier). */
|
|
19
19
|
size?: number;
|
|
20
|
+
/** Z-index for layering control (via the `z` modifier). */
|
|
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";
|
|
20
28
|
};
|
|
21
29
|
type TimelineEvent = {
|
|
22
30
|
time: number;
|
|
@@ -24,6 +32,11 @@ type TimelineEvent = {
|
|
|
24
32
|
action: string;
|
|
25
33
|
params: Record<string, unknown>;
|
|
26
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;
|
|
27
40
|
};
|
|
28
41
|
type SceneMeta = {
|
|
29
42
|
width: number;
|
|
@@ -59,6 +72,37 @@ type SequenceDef = {
|
|
|
59
72
|
paramsRaw: string;
|
|
60
73
|
}>;
|
|
61
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
|
+
};
|
|
62
106
|
type SceneAST = {
|
|
63
107
|
meta: SceneMeta;
|
|
64
108
|
assets: Record<string, AssetDef>;
|
|
@@ -70,12 +114,57 @@ type SceneAST = {
|
|
|
70
114
|
seqs: Record<string, SequenceDef>;
|
|
71
115
|
/** User-defined variables — kept in AST for tooling/inspection. */
|
|
72
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[];
|
|
73
123
|
};
|
|
74
124
|
|
|
75
125
|
declare class ParseError extends Error {
|
|
76
126
|
readonly line: number;
|
|
77
127
|
constructor(message: string, line: number);
|
|
78
128
|
}
|
|
79
|
-
|
|
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[];
|
|
80
169
|
|
|
81
|
-
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,31 +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
|
-
|
|
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
|
+
}
|
|
107
411
|
}
|
|
108
412
|
return result;
|
|
109
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
|
+
}
|
|
110
427
|
var ASSET_RE = /^asset\s+(\w+)\s*=\s*(image|icon)\("([^"]+)"\)$/;
|
|
111
|
-
var BUILTIN_ACTOR_TYPES = /* @__PURE__ */ new Set(["sprite", "text", "box", "figure"]);
|
|
112
|
-
var
|
|
113
|
-
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+)\((.*)\)$/;
|
|
114
433
|
var VAR_RE = /^var\s+(\w+)\s*=\s*(.+)$/;
|
|
115
434
|
var DEF_HEADER_RE = /^def\s+(\w+)\(([^)]*)\)\s*\{$/;
|
|
116
|
-
var DEF_BODY_RE = /^\s*(sprite|text|box|figure)\(([^)]*)\)\s*$/;
|
|
435
|
+
var DEF_BODY_RE = /^\s*(sprite|text|box|figure|caption)\(([^)]*)\)\s*$/;
|
|
117
436
|
var SEQ_HEADER_RE = /^seq\s+(\w+)(?:\(([^)]*)\))?\s*\{$/;
|
|
118
|
-
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*$/;
|
|
119
441
|
function interpolate(s, vars) {
|
|
120
|
-
return s.replace(
|
|
442
|
+
return s.replace(
|
|
443
|
+
/\\?\$\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g,
|
|
444
|
+
(_, name) => vars[name] ?? `\${${name}}`
|
|
445
|
+
);
|
|
121
446
|
}
|
|
122
447
|
var DEFAULTS = {
|
|
123
448
|
width: 800,
|
|
@@ -125,6 +450,10 @@ var DEFAULTS = {
|
|
|
125
450
|
fps: 30,
|
|
126
451
|
bg: "white"
|
|
127
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"]);
|
|
128
457
|
function validateMoveTarget(action, params, meta, actor, line) {
|
|
129
458
|
if (action !== "move") return;
|
|
130
459
|
const to = params.to;
|
|
@@ -137,7 +466,11 @@ function validateMoveTarget(action, params, meta, actor, line) {
|
|
|
137
466
|
);
|
|
138
467
|
}
|
|
139
468
|
}
|
|
140
|
-
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
|
+
}
|
|
141
474
|
const ast = {
|
|
142
475
|
meta: { ...DEFAULTS },
|
|
143
476
|
assets: {},
|
|
@@ -145,13 +478,18 @@ function parse(source) {
|
|
|
145
478
|
events: [],
|
|
146
479
|
defs: {},
|
|
147
480
|
seqs: {},
|
|
148
|
-
vars: {}
|
|
481
|
+
vars: {},
|
|
482
|
+
chapters: [],
|
|
483
|
+
warnings: [],
|
|
484
|
+
imports: []
|
|
149
485
|
};
|
|
150
486
|
let sceneFound = false;
|
|
151
487
|
const lines = source.split(/\r?\n/);
|
|
152
488
|
let inDef = null;
|
|
153
489
|
let defNeedsClose = false;
|
|
154
490
|
let inSeq = null;
|
|
491
|
+
let inChapter = null;
|
|
492
|
+
const topScope = { name: "", prevEnd: 0 };
|
|
155
493
|
for (let i = 0; i < lines.length; i++) {
|
|
156
494
|
const lineNum = i + 1;
|
|
157
495
|
const rawUntouched = lines[i].trim();
|
|
@@ -181,6 +519,19 @@ function parse(source) {
|
|
|
181
519
|
inSeq = null;
|
|
182
520
|
continue;
|
|
183
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
|
+
}
|
|
184
535
|
throw new ParseError("Unexpected '}'", lineNum);
|
|
185
536
|
}
|
|
186
537
|
if (inDef) {
|
|
@@ -206,11 +557,35 @@ function parse(source) {
|
|
|
206
557
|
throw new ParseError(`Invalid seq event (expected "@+offset: $.action(params)"): ${raw}`, lineNum);
|
|
207
558
|
}
|
|
208
559
|
const [, offsetStr, action, paramsRaw] = sm;
|
|
209
|
-
inSeq.events.push({
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
}
|
|
214
589
|
continue;
|
|
215
590
|
}
|
|
216
591
|
if (raw.startsWith("def ")) {
|
|
@@ -234,6 +609,30 @@ function parse(source) {
|
|
|
234
609
|
continue;
|
|
235
610
|
}
|
|
236
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
|
+
}
|
|
237
636
|
if (sceneFound) {
|
|
238
637
|
throw new ParseError("Duplicate scene declaration", lineNum);
|
|
239
638
|
}
|
|
@@ -256,7 +655,12 @@ function parse(source) {
|
|
|
256
655
|
ast.meta.duration = Number(val);
|
|
257
656
|
break;
|
|
258
657
|
default:
|
|
259
|
-
|
|
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
|
+
});
|
|
260
664
|
}
|
|
261
665
|
}
|
|
262
666
|
continue;
|
|
@@ -270,107 +674,31 @@ function parse(source) {
|
|
|
270
674
|
ast.assets[name] = { type, value };
|
|
271
675
|
continue;
|
|
272
676
|
}
|
|
273
|
-
if (raw.startsWith("
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
resolvedArgs = argsRaw.trim() ? splitByComma(argsRaw).map((a) => {
|
|
284
|
-
const t = a.trim();
|
|
285
|
-
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
286
|
-
}) : [];
|
|
287
|
-
} else if (ast.defs[typeName]) {
|
|
288
|
-
const tmpl = ast.defs[typeName];
|
|
289
|
-
const callArgs = argsRaw.trim() ? splitByComma(argsRaw).map((a) => {
|
|
290
|
-
const t = a.trim();
|
|
291
|
-
return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
|
|
292
|
-
}) : [];
|
|
293
|
-
const localVars = {};
|
|
294
|
-
for (let pi = 0; pi < tmpl.params.length; pi++) {
|
|
295
|
-
localVars[tmpl.params[pi]] = callArgs[pi] ?? "";
|
|
296
|
-
}
|
|
297
|
-
resolvedType = tmpl.actorType;
|
|
298
|
-
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
|
+
});
|
|
299
687
|
} else {
|
|
300
|
-
|
|
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
|
+
});
|
|
301
693
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
throw new ParseError(
|
|
307
|
-
`Actor "${name}" position (${x}, ${y}) is outside scene bounds (0\u2013${ast.meta.width}, 0\u2013${ast.meta.height})`,
|
|
308
|
-
lineNum
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
ast.actors[name] = {
|
|
312
|
-
type: resolvedType,
|
|
313
|
-
args: resolvedArgs,
|
|
314
|
-
x,
|
|
315
|
-
y,
|
|
316
|
-
...modifiers
|
|
317
|
-
};
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (raw.startsWith("actor ")) {
|
|
697
|
+
parseActorLine(raw, lineNum, ast);
|
|
318
698
|
continue;
|
|
319
699
|
}
|
|
320
700
|
if (raw.startsWith("@")) {
|
|
321
|
-
|
|
322
|
-
if (!m) {
|
|
323
|
-
throw new ParseError(`Invalid event: ${raw}`, lineNum);
|
|
324
|
-
}
|
|
325
|
-
const [, timeStr, actor, action, paramsRaw] = m;
|
|
326
|
-
const time = Number(timeStr);
|
|
327
|
-
if (Number.isNaN(time)) {
|
|
328
|
-
throw new ParseError(`Invalid time value: ${timeStr}`, lineNum);
|
|
329
|
-
}
|
|
330
|
-
if (!ast.actors[actor]) {
|
|
331
|
-
throw new ParseError(`Unknown actor: "${actor}"`, lineNum);
|
|
332
|
-
}
|
|
333
|
-
if (action === "play") {
|
|
334
|
-
const playParts = splitByComma(paramsRaw);
|
|
335
|
-
const seqName = playParts[0]?.trim();
|
|
336
|
-
if (!seqName || !ast.seqs[seqName]) {
|
|
337
|
-
throw new ParseError(`Unknown sequence: "${seqName}"`, lineNum);
|
|
338
|
-
}
|
|
339
|
-
const seq = ast.seqs[seqName];
|
|
340
|
-
const playVars = {};
|
|
341
|
-
for (let pi = 1; pi < playParts.length; pi++) {
|
|
342
|
-
const eqIdx = playParts[pi].indexOf("=");
|
|
343
|
-
if (eqIdx !== -1) {
|
|
344
|
-
const k = playParts[pi].slice(0, eqIdx).trim();
|
|
345
|
-
const v = playParts[pi].slice(eqIdx + 1).trim();
|
|
346
|
-
playVars[k] = v;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
let posIdx = 0;
|
|
350
|
-
for (let pi = 1; pi < playParts.length; pi++) {
|
|
351
|
-
if (!playParts[pi].includes("=") && posIdx < seq.params.length) {
|
|
352
|
-
playVars[seq.params[posIdx]] = playParts[pi].trim();
|
|
353
|
-
posIdx++;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
for (const sev of seq.events) {
|
|
357
|
-
const expandedParams = interpolate(sev.paramsRaw, playVars);
|
|
358
|
-
const absTime = Math.round((time + sev.offset) * 1e3) / 1e3;
|
|
359
|
-
const params2 = parseActionParams(sev.action, expandedParams);
|
|
360
|
-
validateMoveTarget(sev.action, params2, ast.meta, actor, lineNum);
|
|
361
|
-
ast.events.push({
|
|
362
|
-
time: absTime,
|
|
363
|
-
actor,
|
|
364
|
-
action: sev.action,
|
|
365
|
-
params: params2,
|
|
366
|
-
line: lineNum
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
const params = parseActionParams(action, paramsRaw);
|
|
372
|
-
validateMoveTarget(action, params, ast.meta, actor, lineNum);
|
|
373
|
-
ast.events.push({ time, actor, action, params, line: lineNum });
|
|
701
|
+
parseEventLine(raw, lineNum, ast, inChapter, topScope);
|
|
374
702
|
continue;
|
|
375
703
|
}
|
|
376
704
|
throw new ParseError(`Unrecognized statement: ${raw}`, lineNum);
|
|
@@ -384,17 +712,297 @@ function parse(source) {
|
|
|
384
712
|
if (inSeq) {
|
|
385
713
|
throw new ParseError(`Unclosed seq block "${inSeq.name}"`, inSeq.startLine);
|
|
386
714
|
}
|
|
715
|
+
if (inChapter) {
|
|
716
|
+
throw new ParseError(`Unclosed chapter "${inChapter.name}"`, inChapter.startLine);
|
|
717
|
+
}
|
|
387
718
|
if (ast.meta.duration === void 0) {
|
|
388
719
|
let maxEnd = 0;
|
|
389
720
|
for (const ev of ast.events) {
|
|
390
721
|
const dur = typeof ev.params.dur === "number" ? ev.params.dur : 0;
|
|
391
722
|
maxEnd = Math.max(maxEnd, ev.time + dur);
|
|
392
723
|
}
|
|
393
|
-
if (maxEnd > 0) ast.meta.duration = maxEnd;
|
|
724
|
+
if (maxEnd > 0) ast.meta.duration = round3(maxEnd);
|
|
394
725
|
}
|
|
395
726
|
return ast;
|
|
396
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
|
+
}
|
|
397
1003
|
export {
|
|
1004
|
+
PRESETS,
|
|
1005
|
+
PRESET_NAMES,
|
|
398
1006
|
ParseError,
|
|
399
1007
|
parse
|
|
400
1008
|
};
|