@hajimism/dek 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/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { VoiceDict } from "./cue.ts";
|
|
6
|
+
import { deckPaths } from "./deck-paths.ts";
|
|
7
|
+
import { DekError } from "./error.ts";
|
|
8
|
+
import { cacheDir, deckProjectRoot } from "./path.ts";
|
|
9
|
+
import { readDeckFile } from "./resolve.ts";
|
|
10
|
+
import { writeInside } from "./safe-fs.ts";
|
|
11
|
+
import type { Deck } from "./schema.ts";
|
|
12
|
+
import { lastStop, type Position, resolveStop } from "./step.ts";
|
|
13
|
+
import {
|
|
14
|
+
type BeatTiming,
|
|
15
|
+
DEFAULT_LEAD_MS,
|
|
16
|
+
DEFAULT_PAUSE,
|
|
17
|
+
type PauseConfig,
|
|
18
|
+
type Timeline,
|
|
19
|
+
type Utterance,
|
|
20
|
+
} from "./timeline.ts";
|
|
21
|
+
import { parseTomlWith } from "./zod.ts";
|
|
22
|
+
|
|
23
|
+
export type VoiceSettings = {
|
|
24
|
+
engine: string;
|
|
25
|
+
speaker: string;
|
|
26
|
+
speed: number;
|
|
27
|
+
pause: PauseConfig;
|
|
28
|
+
lead: number;
|
|
29
|
+
/** Keyed like the URL hash: `slug`, `slug/beat-id`, or `slug/2`. */
|
|
30
|
+
beats: Record<string, BeatTiming>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type VoiceResolved = {
|
|
34
|
+
styleId: number;
|
|
35
|
+
engineVersion: string;
|
|
36
|
+
speaker: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const VoiceToml = z.object({
|
|
40
|
+
// A name, with a port on this machine, or a URL: "voicevox:80@host" must not pass for local.
|
|
41
|
+
engine: z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^(?:https?:\/\/\S+|[a-z][a-z0-9-]*(?::\d{1,5})?)$/i, {
|
|
44
|
+
message: 'an engine name like "voicevox", a name and port like "voicevox:50021", or a URL',
|
|
45
|
+
})
|
|
46
|
+
.default("voicevox"),
|
|
47
|
+
speaker: z.string(),
|
|
48
|
+
speed: z.number().default(1),
|
|
49
|
+
pause: z
|
|
50
|
+
.object({
|
|
51
|
+
sentence: z.number().optional(),
|
|
52
|
+
beat: z.number().optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
lead: z.number().min(0).default(DEFAULT_LEAD_MS),
|
|
56
|
+
beats: z
|
|
57
|
+
.record(
|
|
58
|
+
z.string(),
|
|
59
|
+
z.object({ lead: z.number().min(0).optional(), pause: z.number().min(0).optional() }),
|
|
60
|
+
)
|
|
61
|
+
.default({}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Each word, a table of its reading. */
|
|
65
|
+
const DictToml = z.record(
|
|
66
|
+
z.string(),
|
|
67
|
+
z.object({
|
|
68
|
+
kana: z.string(),
|
|
69
|
+
accent: z.number().optional(),
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export function hasVoice(deckDir: string): boolean {
|
|
74
|
+
return existsSync(deckPaths(deckDir).voiceToml);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How to give an existing deck a voice; every command that needs one and finds none says this.
|
|
79
|
+
* `dekc new` copies dek.toml `[voice]` only into decks it creates, so it is not the fix here.
|
|
80
|
+
*/
|
|
81
|
+
export const VOICE_SETUP_HINT =
|
|
82
|
+
'create voice/voice.toml in the deck with a speaker, like speaker = "ずんだもん/ノーマル" (engine defaults to voicevox); see https://hajimism.github.io/dek/guide/voice.html#setup';
|
|
83
|
+
|
|
84
|
+
export function voiceMissingError(deckDir: string): DekError {
|
|
85
|
+
return new DekError("voice.toml not found", {
|
|
86
|
+
path: deckPaths(deckDir).voiceToml,
|
|
87
|
+
hint: VOICE_SETUP_HINT,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function voiceCacheDir(deckDir: string): string {
|
|
92
|
+
return cacheDir(deckDir, "voice");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function voiceCacheFile(deckDir: string, file: string): string {
|
|
96
|
+
return join(voiceCacheDir(deckDir), file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function loadVoiceSettings(deckDir: string): VoiceSettings {
|
|
100
|
+
const path = deckPaths(deckDir).voiceToml;
|
|
101
|
+
const source = readDeckFile(deckDir, path);
|
|
102
|
+
if (source === undefined) {
|
|
103
|
+
throw voiceMissingError(deckDir);
|
|
104
|
+
}
|
|
105
|
+
const data = parseTomlWith(VoiceToml, source, {
|
|
106
|
+
label: "voice.toml",
|
|
107
|
+
anchor: "voice-voice-toml",
|
|
108
|
+
path,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
engine: data.engine,
|
|
112
|
+
speaker: data.speaker,
|
|
113
|
+
speed: data.speed,
|
|
114
|
+
pause: {
|
|
115
|
+
sentence: data.pause?.sentence ?? DEFAULT_PAUSE.sentence,
|
|
116
|
+
beat: data.pause?.beat ?? DEFAULT_PAUSE.beat,
|
|
117
|
+
},
|
|
118
|
+
lead: data.lead,
|
|
119
|
+
beats: data.beats,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Maps voice.toml beat keys onto cue positions. A slide key frames the slide:
|
|
125
|
+
* its `lead` runs into the slide's arrival and its `pause` follows the last beat.
|
|
126
|
+
* A beat key refines its own beat. Keys that match nothing are returned in
|
|
127
|
+
* `unknown` so lint can report them.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveBeatTiming(
|
|
130
|
+
deck: Deck,
|
|
131
|
+
settings: Pick<VoiceSettings, "lead" | "beats">,
|
|
132
|
+
): { timing: (position: Position) => BeatTiming; unknown: string[] } {
|
|
133
|
+
const bySlide = new Map<number, BeatTiming>();
|
|
134
|
+
const byBeat = new Map<string, BeatTiming>();
|
|
135
|
+
const unknown: string[] = [];
|
|
136
|
+
for (const [key, value] of Object.entries(settings.beats)) {
|
|
137
|
+
const position = beatKeyPosition(deck, key);
|
|
138
|
+
if (!position) {
|
|
139
|
+
unknown.push(key);
|
|
140
|
+
} else if (key.includes("/")) {
|
|
141
|
+
byBeat.set(`${position.slideIndex}/${position.beatIndex}`, value);
|
|
142
|
+
} else {
|
|
143
|
+
bySlide.set(position.slideIndex, value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
timing: (position) => {
|
|
148
|
+
const slide = bySlide.get(position.slideIndex);
|
|
149
|
+
const last = lastStop(deck.sections[position.slideIndex]?.beats ?? []);
|
|
150
|
+
const merged = {
|
|
151
|
+
lead: position.beatIndex === 0 ? (slide?.lead ?? settings.lead) : settings.lead,
|
|
152
|
+
pause: position.beatIndex === last ? slide?.pause : undefined,
|
|
153
|
+
...byBeat.get(`${position.slideIndex}/${position.beatIndex}`),
|
|
154
|
+
};
|
|
155
|
+
return Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
|
|
156
|
+
},
|
|
157
|
+
unknown,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function beatKeyPosition(deck: Deck, key: string): Position | undefined {
|
|
162
|
+
const [slug, beat, ...rest] = key.split("/");
|
|
163
|
+
const slideIndex = deck.sections.findIndex((section) => section.slug === slug);
|
|
164
|
+
const section = deck.sections[slideIndex];
|
|
165
|
+
if (!section || rest.length > 0) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (beat === undefined) {
|
|
169
|
+
return { slideIndex, beatIndex: 0 };
|
|
170
|
+
}
|
|
171
|
+
const beatIndex = resolveStop(section.beats, beat);
|
|
172
|
+
return beatIndex === undefined ? undefined : { slideIndex, beatIndex };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function loadVoiceDict(deckDir: string): VoiceDict {
|
|
176
|
+
const path = deckPaths(deckDir).voiceDict;
|
|
177
|
+
const source = readDeckFile(deckDir, path);
|
|
178
|
+
if (source === undefined) {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
return parseTomlWith(DictToml, source, { label: "dict.toml", anchor: "voice-dict-toml", path });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function writeVoiceDict(deckDir: string, dict: VoiceDict): string {
|
|
185
|
+
const path = deckPaths(deckDir).voiceDict;
|
|
186
|
+
const body = Object.entries(dict)
|
|
187
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
188
|
+
.map(([key, entry]) => {
|
|
189
|
+
const accent = entry.accent !== undefined ? `\naccent = ${entry.accent}` : "";
|
|
190
|
+
return `[${escapeTomlKey(key)}]\nkana = ${JSON.stringify(entry.kana)}${accent}\n`;
|
|
191
|
+
})
|
|
192
|
+
.join("\n");
|
|
193
|
+
writeInside(path, body || "# voice dictionary\n", deckProjectRoot(deckDir));
|
|
194
|
+
return path;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function formatVoiceToml(settings: {
|
|
198
|
+
engine: string;
|
|
199
|
+
speaker: string;
|
|
200
|
+
speed?: number;
|
|
201
|
+
}): string {
|
|
202
|
+
return `engine = ${JSON.stringify(settings.engine)}
|
|
203
|
+
speaker = ${JSON.stringify(settings.speaker)}
|
|
204
|
+
speed = ${settings.speed ?? 1}
|
|
205
|
+
pause = { sentence = ${DEFAULT_PAUSE.sentence}, beat = ${DEFAULT_PAUSE.beat} }
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const UtteranceSchema = z.object({
|
|
210
|
+
text: z.string(),
|
|
211
|
+
kana: z.string(),
|
|
212
|
+
durationMs: z.number(),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const TimelineSchema = z.object({
|
|
216
|
+
audio: z.string(),
|
|
217
|
+
durationMs: z.number(),
|
|
218
|
+
beats: z.array(
|
|
219
|
+
z.object({
|
|
220
|
+
position: z.object({
|
|
221
|
+
slideIndex: z.number(),
|
|
222
|
+
beatIndex: z.number(),
|
|
223
|
+
}),
|
|
224
|
+
start: z.number(),
|
|
225
|
+
end: z.number(),
|
|
226
|
+
sentences: z.array(
|
|
227
|
+
z.object({
|
|
228
|
+
text: z.string(),
|
|
229
|
+
kana: z.string(),
|
|
230
|
+
start: z.number(),
|
|
231
|
+
end: z.number(),
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
lead: z.number().optional(),
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export function parseUtteranceJson(text: string): Utterance | undefined {
|
|
240
|
+
try {
|
|
241
|
+
const result = UtteranceSchema.safeParse(JSON.parse(text));
|
|
242
|
+
if (result.success) {
|
|
243
|
+
return result.data;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function parseTimelineJson(text: string, path?: string): Timeline {
|
|
252
|
+
try {
|
|
253
|
+
const result = TimelineSchema.safeParse(JSON.parse(text));
|
|
254
|
+
if (result.success) {
|
|
255
|
+
return result.data;
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new DekError("invalid timeline.json", {
|
|
259
|
+
path,
|
|
260
|
+
hint: "run `dekc voice`",
|
|
261
|
+
cause: error,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
throw new DekError("invalid timeline.json", {
|
|
265
|
+
path,
|
|
266
|
+
hint: "run `dekc voice`",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function loadCachedTimeline(deckDir: string): Timeline | undefined {
|
|
271
|
+
const path = voiceCacheFile(deckDir, "timeline.json");
|
|
272
|
+
if (!existsSync(path)) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return parseTimelineJson(readFileSync(path, "utf8"), path);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function tryLoadCachedTimeline(deckDir: string): Timeline | undefined {
|
|
279
|
+
try {
|
|
280
|
+
return loadCachedTimeline(deckDir);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error instanceof DekError) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function resolveTimelineAudio(
|
|
290
|
+
timeline: Pick<Timeline, "audio">,
|
|
291
|
+
timelinePath: string,
|
|
292
|
+
): string {
|
|
293
|
+
// Only a name: the audio sits beside its timeline, and a path in the file, which a repository
|
|
294
|
+
// can commit, never sends ffmpeg to read something elsewhere.
|
|
295
|
+
return join(dirname(timelinePath), basename(timeline.audio || "audio.wav"));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function writeResolved(deckDir: string, resolved: VoiceResolved): string {
|
|
299
|
+
const path = join(voiceCacheDir(deckDir), "resolved.json");
|
|
300
|
+
writeInside(path, `${JSON.stringify(resolved, null, 2)}\n`, deckProjectRoot(deckDir));
|
|
301
|
+
return path;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function utteranceHash(input: {
|
|
305
|
+
text: string;
|
|
306
|
+
engine: string;
|
|
307
|
+
engineVersion: string;
|
|
308
|
+
styleId: number;
|
|
309
|
+
speed: number;
|
|
310
|
+
}): string {
|
|
311
|
+
return createHash("sha256")
|
|
312
|
+
.update(
|
|
313
|
+
`${input.text}\0${input.engine}\0${input.engineVersion}\0${input.styleId}\0${input.speed}`,
|
|
314
|
+
)
|
|
315
|
+
.digest("hex")
|
|
316
|
+
.slice(0, 16);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function escapeTomlKey(key: string): string {
|
|
320
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
|
|
321
|
+
}
|
package/src/core/zod.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { DekError } from "./error.ts";
|
|
3
|
+
|
|
4
|
+
const REFERENCE = "https://hajimism.github.io/dek/reference/config.html";
|
|
5
|
+
|
|
6
|
+
type ConfigAnchor = "dek-toml" | "frontmatter" | "voice-voice-toml" | "voice-dict-toml";
|
|
7
|
+
|
|
8
|
+
/** Where the keys of a config file are documented, as a hint. */
|
|
9
|
+
export function configHint(anchor: ConfigAnchor): string {
|
|
10
|
+
return `see ${REFERENCE}#${anchor}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A parser's syntax error in its own words, without the class name or banner Bun puts in front.
|
|
15
|
+
* Bun's parsers say no line of the file: the error's own `line` is where JavaScript called them.
|
|
16
|
+
*/
|
|
17
|
+
export function parseFailure(error: unknown): string {
|
|
18
|
+
return (error instanceof Error ? error.message : String(error)).replace(
|
|
19
|
+
/^(?:SyntaxError: )?(?:TOML Parse error: )?/,
|
|
20
|
+
"",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatZodIssues(error: z.ZodError): string {
|
|
25
|
+
return error.issues
|
|
26
|
+
.map((issue) => {
|
|
27
|
+
const path = issue.path.join(".");
|
|
28
|
+
// "expected string, received undefined" says less than "required".
|
|
29
|
+
const message =
|
|
30
|
+
issue.code === "invalid_type" && /received undefined$/.test(issue.message)
|
|
31
|
+
? "required"
|
|
32
|
+
: issue.message;
|
|
33
|
+
return path ? `${path}: ${message}` : message;
|
|
34
|
+
})
|
|
35
|
+
.join("; ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A TOML file read through `schema`: a syntax error names the file, a schema issue names the key,
|
|
40
|
+
* and both point at where the file's keys are documented.
|
|
41
|
+
*/
|
|
42
|
+
export function parseTomlWith<S extends z.ZodType>(
|
|
43
|
+
schema: S,
|
|
44
|
+
source: string,
|
|
45
|
+
where: { label: string; anchor: ConfigAnchor; path?: string },
|
|
46
|
+
): z.output<S> {
|
|
47
|
+
const hint = configHint(where.anchor);
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = Bun.TOML.parse(source);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new DekError(`invalid ${where.label}: ${parseFailure(error)}`, {
|
|
53
|
+
path: where.path,
|
|
54
|
+
cause: error,
|
|
55
|
+
hint,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const result = schema.safeParse(parsed ?? {});
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
throw new DekError(formatZodIssues(result.error), { path: where.path, hint });
|
|
61
|
+
}
|
|
62
|
+
return result.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The keys of `value` that a strict `schema` does not know, each as its full path. Parsing with
|
|
67
|
+
* the lenient schema drops them without a word; this is how lint hears of them.
|
|
68
|
+
*/
|
|
69
|
+
export function unrecognizedKeys(schema: z.ZodType, value: unknown): string[][] {
|
|
70
|
+
const result = schema.safeParse(value);
|
|
71
|
+
if (result.success) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return result.error.issues.flatMap((issue) =>
|
|
75
|
+
issue.code === "unrecognized_keys"
|
|
76
|
+
? issue.keys.map((key) => [...issue.path.map(String), key])
|
|
77
|
+
: [],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { isLiveEvent } from "../core/live-protocol.ts";
|
|
4
|
+
import { PAGE_ID, readPageConfig } from "../core/page.ts";
|
|
5
|
+
import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
|
|
6
|
+
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
7
|
+
import type { Position } from "../core/step.ts";
|
|
8
|
+
import { createDeckControl } from "./deck-control.ts";
|
|
9
|
+
import { bindInput } from "./input.ts";
|
|
10
|
+
import { createLaserView, type LaserView } from "./laser-view.ts";
|
|
11
|
+
import { applyLiveEvent, hydrateLiveEvent } from "./live.ts";
|
|
12
|
+
import { documentLiveHost } from "./live-host.ts";
|
|
13
|
+
import { createMarksView } from "./marks-view.ts";
|
|
14
|
+
import { createNavigator } from "./navigator.ts";
|
|
15
|
+
import { createPresenterView } from "./presenter-view.ts";
|
|
16
|
+
import { createRailView } from "./rail-view.ts";
|
|
17
|
+
import { createRehearseController, fetchRehearsal } from "./rehearse.ts";
|
|
18
|
+
import { deckChannelName, deckUrl, liveTokenQuery } from "./routes.ts";
|
|
19
|
+
import { createStage } from "./stage.ts";
|
|
20
|
+
|
|
21
|
+
const dataEl = document.getElementById(PAGE_ID.data);
|
|
22
|
+
if (dataEl?.textContent) {
|
|
23
|
+
const page = readPageConfig(document.body);
|
|
24
|
+
const slides = JSON.parse(dataEl.textContent) as PresenterSlide[];
|
|
25
|
+
const deck = deckStops(slides);
|
|
26
|
+
const stage = createStage({ slides, deck, videoMode: page.mode === "video" });
|
|
27
|
+
const presenter = createPresenterView({ slides, deck, stage, mode: page.mode });
|
|
28
|
+
|
|
29
|
+
function fitAll(): void {
|
|
30
|
+
stage.fit();
|
|
31
|
+
presenter.fit();
|
|
32
|
+
rail.fit();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Draw every view at `pos`. */
|
|
36
|
+
function render(pos: Position): void {
|
|
37
|
+
stage.render(pos);
|
|
38
|
+
presenter.render(pos);
|
|
39
|
+
rail.markCurrent(pos.slideIndex);
|
|
40
|
+
marks?.render(pos);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The first key or move takes the key hint away. Removing it also drops its animation, which
|
|
45
|
+
* the first move would otherwise wait on before it settles.
|
|
46
|
+
*/
|
|
47
|
+
function dismissKeyHint(): void {
|
|
48
|
+
document.getElementById(PAGE_ID.hint)?.remove();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rehearse = createRehearseController({
|
|
52
|
+
load: () =>
|
|
53
|
+
fetchRehearsal(location.pathname, {
|
|
54
|
+
fetch: (input, init) => fetch(input, init),
|
|
55
|
+
audio: (src) => new Audio(src),
|
|
56
|
+
}),
|
|
57
|
+
go: (position, origin) => nav.go(position, origin),
|
|
58
|
+
current: () => nav.target(),
|
|
59
|
+
});
|
|
60
|
+
const control = createDeckControl({ go: (next, origin) => nav.go(next, origin), rehearse });
|
|
61
|
+
|
|
62
|
+
// A video shows the talk as narrated; nobody points at it.
|
|
63
|
+
const laser: LaserView | undefined =
|
|
64
|
+
page.mode === "video"
|
|
65
|
+
? undefined
|
|
66
|
+
: createLaserView({
|
|
67
|
+
current: () => nav.position().slideIndex,
|
|
68
|
+
send: (pointer) => nav.point(pointer),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Only the dev server has a socket to follow; a built file on a static host must not dial one.
|
|
72
|
+
const socketUrl = page.live
|
|
73
|
+
? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${deckUrl(location.pathname, { kind: "socket" })}${liveTokenQuery(page.liveToken)}`
|
|
74
|
+
: undefined;
|
|
75
|
+
const nav = createNavigator({
|
|
76
|
+
deck,
|
|
77
|
+
channelName: deckChannelName(
|
|
78
|
+
page.deck,
|
|
79
|
+
deck.map((slide) => slide.slug),
|
|
80
|
+
),
|
|
81
|
+
...(socketUrl ? { socketUrl } : {}),
|
|
82
|
+
present: stage.present,
|
|
83
|
+
hurry: stage.hurry,
|
|
84
|
+
beforeMove: dismissKeyHint,
|
|
85
|
+
onMove: (from, to) => {
|
|
86
|
+
render(to);
|
|
87
|
+
if (from.slideIndex !== to.slideIndex) {
|
|
88
|
+
stage.announce(to);
|
|
89
|
+
laser?.sync();
|
|
90
|
+
}
|
|
91
|
+
// A hashchange echoing this same position must not cut a running animation short.
|
|
92
|
+
if (!positionsEqual(from, to)) {
|
|
93
|
+
stage.showMove(from, to);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
afterMove: presenter.startClock,
|
|
97
|
+
route: (next) => control.request(next, "remote"),
|
|
98
|
+
...(laser ? { onPointer: laser.receive } : {}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const marks = createMarksView({
|
|
102
|
+
url: `${deckUrl(location.pathname, { kind: "marks" })}${liveTokenQuery(page.liveToken)}`,
|
|
103
|
+
current: () => nav.position(),
|
|
104
|
+
fetch: (input, init) => fetch(input, init),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const rail = createRailView({
|
|
108
|
+
slides,
|
|
109
|
+
stage,
|
|
110
|
+
onPick: (slideIndex) => control.request({ slideIndex, beatIndex: 0 }, "local"),
|
|
111
|
+
onResize: fitAll,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function setPresenterOpen(open: boolean): void {
|
|
115
|
+
presenter.setOpen(open);
|
|
116
|
+
fitAll();
|
|
117
|
+
render(nav.position());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
bindInput({
|
|
121
|
+
onKey: dismissKeyHint,
|
|
122
|
+
togglePresenter: () => {
|
|
123
|
+
if (!presenter.available()) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
setPresenterOpen(!presenter.isOpen());
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
toggleFullscreen: () => {
|
|
130
|
+
if (document.fullscreenElement) {
|
|
131
|
+
void document.exitFullscreen?.().catch(() => undefined);
|
|
132
|
+
} else {
|
|
133
|
+
void document.documentElement.requestFullscreen?.().catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
toggleRail: () => {
|
|
137
|
+
if (!rail.available() || presenter.isOpen()) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
rail.setOpen(!rail.isOpen());
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
togglePlay: control.togglePlay,
|
|
144
|
+
toggleLaser: () => laser?.toggle() ?? false,
|
|
145
|
+
toggleMark: () => marks?.toggle() ?? false,
|
|
146
|
+
pointing: () => laser?.isOn() ?? false,
|
|
147
|
+
move: (move) => control.request(moveTarget(move, nav.target(), deck), "local"),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
window.addEventListener("resize", fitAll);
|
|
151
|
+
window.addEventListener("beforeprint", () => stage.setPrinting(true, nav.position()));
|
|
152
|
+
window.addEventListener("afterprint", () => stage.setPrinting(false, nav.position()));
|
|
153
|
+
matchMedia("print").addEventListener?.("change", (event) =>
|
|
154
|
+
stage.setPrinting(event.matches, nav.position()),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
rail.fill();
|
|
158
|
+
fitAll();
|
|
159
|
+
render(nav.position());
|
|
160
|
+
// A hash that named a beat past the slide's last now says where the deck opened.
|
|
161
|
+
nav.writeHash("replace");
|
|
162
|
+
stage.showMotion(nav.position(), page.mode === "video" ? "hold" : "final");
|
|
163
|
+
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekGo"]
|
|
164
|
+
window["dekGo"] = (next) => nav.go(next, "capture");
|
|
165
|
+
// The video recorder seeks slide scripts the way it seeks Web Animations.
|
|
166
|
+
// biome-ignore lint/complexity/useLiteralKeys: video recorder looks up window["dekMotion"]
|
|
167
|
+
window["dekMotion"] = stage.motion;
|
|
168
|
+
|
|
169
|
+
const rehearseMode = new URLSearchParams(location.search).has("rehearse");
|
|
170
|
+
if (rehearseMode) {
|
|
171
|
+
void rehearse.load();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const liveHost = documentLiveHost();
|
|
175
|
+
// Keep a string key so minify does not rename the hook liveReloadScript calls.
|
|
176
|
+
// biome-ignore lint/complexity/useLiteralKeys: liveReloadScript looks up window["dekLive"]
|
|
177
|
+
window["dekLive"] = async (raw: unknown) => {
|
|
178
|
+
if (!isLiveEvent(raw)) {
|
|
179
|
+
location.reload();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (raw.type === "timeline") {
|
|
183
|
+
if (rehearseMode) {
|
|
184
|
+
await rehearse.load();
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const event = await hydrateLiveEvent(raw, location.pathname);
|
|
189
|
+
if (!event) {
|
|
190
|
+
location.reload();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (applyLiveEvent(event, liveHost).reload) {
|
|
194
|
+
location.reload();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Whatever changed, drawing the beat again puts it back as it stood.
|
|
198
|
+
const pos = nav.position();
|
|
199
|
+
rail.fill();
|
|
200
|
+
render(pos);
|
|
201
|
+
stage.showMotion(pos, "final");
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A copy of a slide to look at, never to use: a rail thumbnail or the presenter's next preview.
|
|
5
|
+
* It shows as the stage would (theme CSS reveals steps only on `.is-current`), takes no ids or
|
|
6
|
+
* morph names from the real slide, loads no media, and is inert and hidden from assistive tech,
|
|
7
|
+
* so focus and the accessibility tree meet each slide once, on the stage.
|
|
8
|
+
*/
|
|
9
|
+
export function visualClone(source: HTMLElement): HTMLElement | undefined {
|
|
10
|
+
const clone = source.cloneNode(true);
|
|
11
|
+
if (!(clone instanceof HTMLElement)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
clone.classList.add("is-current");
|
|
15
|
+
for (const el of [clone, ...clone.querySelectorAll<HTMLElement>("[id], [data-morph]")]) {
|
|
16
|
+
el.removeAttribute("id");
|
|
17
|
+
el.style.removeProperty("view-transition-name");
|
|
18
|
+
}
|
|
19
|
+
for (const el of clone.querySelectorAll("video, audio, iframe, object, embed")) {
|
|
20
|
+
el.removeAttribute("src");
|
|
21
|
+
el.removeAttribute("srcdoc");
|
|
22
|
+
el.replaceChildren();
|
|
23
|
+
}
|
|
24
|
+
clone.setAttribute("inert", "");
|
|
25
|
+
clone.setAttribute("aria-hidden", "true");
|
|
26
|
+
return clone;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The deck's size on screen, or the default stage before it is laid out. */
|
|
30
|
+
export function deckSize(deckEl: { offsetWidth: number; offsetHeight: number }): {
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
} {
|
|
34
|
+
return { width: deckEl.offsetWidth || 1280, height: deckEl.offsetHeight || 720 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A box the deck's size holding `clone`, so a copy of a slide lays out as the slide does before
|
|
39
|
+
* it is scaled down to a thumbnail or a preview.
|
|
40
|
+
*/
|
|
41
|
+
export function stillFrame(
|
|
42
|
+
className: string,
|
|
43
|
+
clone: HTMLElement,
|
|
44
|
+
size: { width: number; height: number },
|
|
45
|
+
): HTMLDivElement {
|
|
46
|
+
const frame = document.createElement("div");
|
|
47
|
+
frame.className = className;
|
|
48
|
+
frame.style.width = `${size.width}px`;
|
|
49
|
+
frame.style.height = `${size.height}px`;
|
|
50
|
+
frame.append(clone);
|
|
51
|
+
return frame;
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Position } from "../core/step.ts";
|
|
2
|
+
import type { GoOrigin } from "./navigator.ts";
|
|
3
|
+
import type { RehearseController } from "./rehearse.ts";
|
|
4
|
+
|
|
5
|
+
/** Where a move someone asked for came from: a key or a click on this page, or a peer. */
|
|
6
|
+
export type RequestOrigin = Extract<GoOrigin, "local" | "remote">;
|
|
7
|
+
|
|
8
|
+
export type DeckControl = {
|
|
9
|
+
/** Move the deck to `next`; nothing at either end of the talk. */
|
|
10
|
+
request(next: Position | null, origin: RequestOrigin): void;
|
|
11
|
+
/** Play or pause the rehearsal; false when none is loaded, so the key is left alone. */
|
|
12
|
+
togglePlay(): boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Who a move goes through. While a rehearsal is loaded its clock owns the deck, so a move goes
|
|
17
|
+
* through the clock and the clock moves with it; otherwise the navigator takes it directly.
|
|
18
|
+
*/
|
|
19
|
+
export function createDeckControl(options: {
|
|
20
|
+
go: (next: Position, origin: RequestOrigin) => Promise<void>;
|
|
21
|
+
rehearse: Pick<RehearseController, "ready" | "move" | "toggle">;
|
|
22
|
+
}): DeckControl {
|
|
23
|
+
const { go, rehearse } = options;
|
|
24
|
+
return {
|
|
25
|
+
request(next, origin) {
|
|
26
|
+
if (!next) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (rehearse.ready()) {
|
|
30
|
+
rehearse.move(next, origin);
|
|
31
|
+
} else {
|
|
32
|
+
void go(next, origin);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
togglePlay() {
|
|
36
|
+
if (!rehearse.ready()) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
rehearse.toggle();
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|