@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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,175 @@
1
+ .slide {
2
+ --fg: #f5f5f5;
3
+ --bg: #111;
4
+ --accent: #f5f5f5;
5
+ --muted: #888;
6
+ --font-title: sans-serif;
7
+ --font-body: sans-serif;
8
+ --size-title: 2.5rem;
9
+ --size-body: 1.5rem;
10
+ --size-caption: 1rem;
11
+ --gap: 2rem;
12
+ --pad: 64px 80px;
13
+ --radius: 4px;
14
+ --step-transition: 0.3s ease;
15
+ box-sizing: border-box;
16
+ width: 100%;
17
+ height: 100%;
18
+ overflow: hidden;
19
+ padding: var(--pad);
20
+ background: var(--bg);
21
+ color: var(--fg);
22
+ font-family: var(--font-body);
23
+ }
24
+
25
+ .slide .slide-title {
26
+ margin: 0 0 calc(var(--gap) * 0.75);
27
+ font-family: var(--font-title);
28
+ font-size: var(--size-title);
29
+ line-height: 1.2;
30
+ }
31
+
32
+ /* @layout title
33
+ <section class="slide" data-layout="title">
34
+ <h2 class="slide-title">The talk in one line</h2>
35
+ </section>
36
+ */
37
+ .slide[data-layout="title"] {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ text-align: center;
42
+ }
43
+
44
+ /* @layout default
45
+ <section class="slide" data-layout="default">
46
+ <h2 class="slide-title">A heading</h2>
47
+ <ul>
48
+ <li data-step="1">One point per beat</li>
49
+ <li data-step="2">The next point</li>
50
+ </ul>
51
+ </section>
52
+ */
53
+ .slide[data-layout="default"] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ }
57
+
58
+ /* @layout two-col
59
+ <section class="slide" data-layout="two-col">
60
+ <div class="col">
61
+ <h2 class="slide-title">Left: the claim</h2>
62
+ </div>
63
+ <div class="col">
64
+ <p class="node">Right: the evidence</p>
65
+ </div>
66
+ </section>
67
+ */
68
+ .slide[data-layout="two-col"] {
69
+ display: grid;
70
+ grid-template-columns: 1fr 1fr;
71
+ gap: var(--gap);
72
+ align-items: center;
73
+ }
74
+
75
+ /* @layout full-bleed
76
+ <section class="slide" data-layout="full-bleed">
77
+ <img class="figure" src="assets/image.jpg" alt="">
78
+ </section>
79
+ */
80
+ .slide[data-layout="full-bleed"] {
81
+ padding: 0;
82
+ }
83
+
84
+ /* @layout quote
85
+ <section class="slide" data-layout="quote">
86
+ <p>One sentence worth remembering.</p>
87
+ </section>
88
+ */
89
+ .slide[data-layout="quote"] {
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ font-size: calc(var(--size-body) * 4 / 3);
94
+ font-style: italic;
95
+ }
96
+
97
+ .slide ul {
98
+ margin: 0;
99
+ padding-left: 1.25em;
100
+ font-size: var(--size-body);
101
+ line-height: 1.6;
102
+ }
103
+
104
+ .slide .col {
105
+ min-width: 0;
106
+ }
107
+
108
+ .slide .node {
109
+ margin: calc(var(--gap) * 0.2) 0;
110
+ padding: calc(var(--gap) * 0.3) calc(var(--gap) * 0.4);
111
+ border: thin solid var(--muted);
112
+ border-radius: var(--radius);
113
+ }
114
+
115
+ .slide .node-parent {
116
+ border-color: var(--accent);
117
+ }
118
+
119
+ .slide .figure {
120
+ display: block;
121
+ max-width: 100%;
122
+ }
123
+
124
+ .slide .figure-small {
125
+ max-width: 40%;
126
+ }
127
+
128
+ .slide.is-current [data-step] {
129
+ opacity: 0;
130
+ transform: translateY(0.5em);
131
+ transition: var(--step-transition);
132
+ }
133
+
134
+ .slide.is-current [data-step].is-shown {
135
+ opacity: 1;
136
+ transform: none;
137
+ }
138
+
139
+ @keyframes fade-out {
140
+ from {
141
+ opacity: 1;
142
+ }
143
+ to {
144
+ opacity: 0;
145
+ }
146
+ }
147
+
148
+ @keyframes fade-in {
149
+ from {
150
+ opacity: 0;
151
+ }
152
+ to {
153
+ opacity: 1;
154
+ }
155
+ }
156
+
157
+ ::view-transition-old(slide) {
158
+ animation: fade-out var(--step-transition);
159
+ }
160
+
161
+ ::view-transition-new(slide) {
162
+ animation: fade-in var(--step-transition);
163
+ }
164
+
165
+ @media (prefers-reduced-motion: reduce) {
166
+ .slide.is-current [data-step] {
167
+ transition: none;
168
+ animation: none;
169
+ }
170
+
171
+ ::view-transition-old(slide),
172
+ ::view-transition-new(slide) {
173
+ animation: none;
174
+ }
175
+ }
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DekError } from "../core/error.ts";
4
+ import { cacheDir, type DistOptions, distDir, distFile } from "../core/path.ts";
5
+ import {
6
+ asResolvedDeck,
7
+ type Project,
8
+ type ProjectDeck,
9
+ type ResolvedDeck,
10
+ requireSection,
11
+ } from "../core/resolve.ts";
12
+ import { dropLinks, outputDir, outputPath, replaceFile } from "../core/safe-fs.ts";
13
+ import { logicalSize } from "../core/size.ts";
14
+ import { sliceTimeline, slideTimeRange, type Timeline } from "../core/timeline.ts";
15
+ import { videoDocument } from "../core/video-document.ts";
16
+ import {
17
+ hasVoice,
18
+ loadCachedTimeline,
19
+ loadVoiceSettings,
20
+ resolveTimelineAudio,
21
+ voiceCacheFile,
22
+ voiceMissingError,
23
+ } from "../core/voice.ts";
24
+ import { playerScript } from "../runtime/player.ts";
25
+ import { sliceWav } from "../voice/wav.ts";
26
+ import { muxVideo } from "./mux.ts";
27
+ import { defaultVideoRunner, type VideoRunner } from "./recorder.ts";
28
+ import { writeVideoSidecars } from "./sidecar.ts";
29
+
30
+ export type BakeVideoResult = {
31
+ out: string;
32
+ vtt?: string;
33
+ chapters?: string;
34
+ credits?: string;
35
+ };
36
+
37
+ export type BakeVideoOptions = DistOptions & {
38
+ slug?: string;
39
+ fps?: number;
40
+ runner?: VideoRunner;
41
+ };
42
+
43
+ export async function bakeVideo(dir: string, options?: BakeVideoOptions): Promise<BakeVideoResult>;
44
+ export async function bakeVideo(
45
+ source: ResolvedDeck,
46
+ options?: BakeVideoOptions,
47
+ ): Promise<BakeVideoResult>;
48
+ export async function bakeVideo(
49
+ input: string | ResolvedDeck,
50
+ options: BakeVideoOptions = {},
51
+ ): Promise<BakeVideoResult> {
52
+ const { project, deck } = asResolvedDeck(input);
53
+ return bakeProjectDeck(project, deck, options);
54
+ }
55
+
56
+ async function bakeProjectDeck(
57
+ project: Project,
58
+ deck: ProjectDeck,
59
+ options: BakeVideoOptions = {},
60
+ ): Promise<BakeVideoResult> {
61
+ if (!hasVoice(deck.dir)) {
62
+ throw voiceMissingError(deck.dir);
63
+ }
64
+ const timelinePath = voiceCacheFile(deck.dir, "timeline.json");
65
+ const loaded = loadCachedTimeline(deck.dir);
66
+ if (!loaded) {
67
+ throw new DekError("Timeline not found", {
68
+ path: timelinePath,
69
+ hint: "run `dekc voice`",
70
+ });
71
+ }
72
+ let timeline = { ...loaded, audio: resolveTimelineAudio(loaded, timelinePath) };
73
+ const fps = options.fps ?? 30;
74
+ const size = logicalSize(deck.deck.ratio);
75
+ const html = videoDocument({ project, deck }, await playerScript());
76
+
77
+ const videoCache = cacheDir(deck.dir, "video");
78
+ const outDir = options.slug ? videoCache : join(videoCache, "_full");
79
+ // Chromium writes frames here by name; a link planted at one would take the frame elsewhere.
80
+ dropLinks(outputDir(outDir, project.root));
81
+
82
+ if (options.slug) {
83
+ const slideIndex = deck.deck.sections.findIndex((section) => section.slug === options.slug);
84
+ if (slideIndex < 0) {
85
+ requireSection(deck, options.slug);
86
+ }
87
+ timeline = sliceTimelineAudio(timeline, slideIndex, join(outDir, `${options.slug}.wav`));
88
+ }
89
+
90
+ const runner = options.runner ?? defaultVideoRunner;
91
+ const captured = await runner({
92
+ html,
93
+ timeline,
94
+ fps,
95
+ viewport: size,
96
+ outDir,
97
+ ...(options.slug ? { slug: options.slug } : {}),
98
+ });
99
+
100
+ const out = options.slug
101
+ ? join(videoCache, `${options.slug}.mp4`)
102
+ : distFile(project, deck, "mp4", options);
103
+ await muxVideo({
104
+ frames: captured.frames,
105
+ audioPath: timeline.audio,
106
+ outPath: outputPath(out, project.root),
107
+ });
108
+
109
+ if (options.slug) {
110
+ return { out };
111
+ }
112
+
113
+ const settings = loadVoiceSettings(deck.dir);
114
+ const titles = deck.deck.sections.map((section, index) => {
115
+ const beat = timeline.beats.find((entry) => entry.position.slideIndex === index);
116
+ return { slug: section.slug, title: section.title, startMs: beat?.start ?? 0 };
117
+ });
118
+ const stem = join(distDir(project, deck, options), deck.name);
119
+ const sidecars = writeVideoSidecars({
120
+ timeline,
121
+ titles,
122
+ speaker: settings.speaker,
123
+ engine: settings.engine,
124
+ stem,
125
+ });
126
+ return { out, ...sidecars };
127
+ }
128
+
129
+ export function sliceTimelineAudio(
130
+ timeline: Timeline,
131
+ slideIndex: number,
132
+ slicedAudioPath: string,
133
+ ): Timeline {
134
+ const range = slideTimeRange(timeline, slideIndex);
135
+ if (!range) {
136
+ throw new DekError(`no timeline beats for slide ${slideIndex}`, {
137
+ hint: "run `dekc voice`",
138
+ });
139
+ }
140
+ if (!existsSync(timeline.audio)) {
141
+ throw new DekError("audio not found", {
142
+ path: timeline.audio,
143
+ hint: "run `dekc voice`",
144
+ });
145
+ }
146
+ const sliced = sliceTimeline(timeline, slideIndex);
147
+ replaceFile(
148
+ slicedAudioPath,
149
+ sliceWav(readFileSync(timeline.audio), range.start, sliced.durationMs),
150
+ );
151
+ return { ...sliced, audio: slicedAudioPath };
152
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { DekError } from "../core/error.ts";
5
+ import { runPiped, workerCommand } from "../core/spawn.ts";
6
+ import type { VideoFrame } from "./recorder.ts";
7
+
8
+ export function ffmpegResolved(): boolean {
9
+ if (process.env.DEK_FFMPEG) {
10
+ return true;
11
+ }
12
+ try {
13
+ const result = Bun.spawnSync(["ffmpeg", "-version"], { stdout: "pipe", stderr: "pipe" });
14
+ return result.exitCode === 0;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function ffmpegCommand(args: string[]): string[] {
21
+ const bin = process.env.DEK_FFMPEG;
22
+ if (bin) {
23
+ return workerCommand(bin, args);
24
+ }
25
+ return ["ffmpeg", ...args];
26
+ }
27
+
28
+ /** An encode of mostly held frames runs well inside this, even for a long talk. */
29
+ const FFMPEG_TIMEOUT_MS = 30 * 60 * 1000;
30
+
31
+ export async function muxVideo(options: {
32
+ frames: VideoFrame[];
33
+ audioPath: string;
34
+ outPath: string;
35
+ /** A hung ffmpeg is stopped after this; 30 minutes when left out. */
36
+ timeoutMs?: number;
37
+ }): Promise<string> {
38
+ if (!ffmpegResolved()) {
39
+ throw new DekError("ffmpeg not found", {
40
+ hint: "install ffmpeg and ensure it is on PATH",
41
+ });
42
+ }
43
+ if (options.frames.length === 0) {
44
+ throw new DekError("no frames to mux", { hint: "run `dekc voice` then `dekc video`" });
45
+ }
46
+ const dir = mkdtempSync(join(tmpdir(), "dek-mux-"));
47
+ try {
48
+ const listPath = join(dir, "frames.txt");
49
+ const lines: string[] = [];
50
+ // ffmpeg reads the list line by line; a path with a line break in it would add directives.
51
+ const broken = options.frames.find((frame) => /[\r\n]/.test(frame.path));
52
+ if (broken) {
53
+ throw new DekError("a frame path holds a line break", {
54
+ path: broken.path,
55
+ hint: "rename the folder so its name has no line break",
56
+ });
57
+ }
58
+ for (const frame of options.frames) {
59
+ lines.push(`file '${frame.path.replace(/'/g, "'\\''")}'`);
60
+ lines.push(`duration ${(frame.durationMs / 1000).toFixed(3)}`);
61
+ }
62
+ const last = options.frames[options.frames.length - 1];
63
+ if (last) {
64
+ lines.push(`file '${last.path.replace(/'/g, "'\\''")}'`);
65
+ }
66
+ writeFileSync(listPath, `${lines.join("\n")}\n`);
67
+
68
+ const args = [
69
+ "-y",
70
+ "-f",
71
+ "concat",
72
+ "-safe",
73
+ "0",
74
+ "-i",
75
+ listPath,
76
+ "-i",
77
+ options.audioPath,
78
+ "-vf",
79
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2",
80
+ "-c:v",
81
+ "libx264",
82
+ "-pix_fmt",
83
+ "yuv420p",
84
+ "-c:a",
85
+ "aac",
86
+ "-ar",
87
+ "48000",
88
+ "-shortest",
89
+ options.outPath,
90
+ ];
91
+ const { stderr, exitCode } = await runPiped(ffmpegCommand(args), {
92
+ label: "ffmpeg failed",
93
+ hint: "check ffmpeg output",
94
+ timeoutMs: options.timeoutMs ?? FFMPEG_TIMEOUT_MS,
95
+ });
96
+ if (exitCode !== 0) {
97
+ throw new DekError("ffmpeg failed", { hint: stderr.slice(0, 200) || "check ffmpeg output" });
98
+ }
99
+ return options.outPath;
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ }
@@ -0,0 +1,115 @@
1
+ import { DekError } from "../core/error.ts";
2
+ import { moduleFilePath } from "../core/path.ts";
3
+ import { playwrightResolved } from "../core/playwright.ts";
4
+ import { runJsonWorker, workerCommand } from "../core/spawn.ts";
5
+ import type { Position } from "../core/step.ts";
6
+ import { playbackSchedule, type Timeline } from "../core/timeline.ts";
7
+
8
+ export type VideoFrame = {
9
+ path: string;
10
+ durationMs: number;
11
+ kind?: "animation" | "hold";
12
+ };
13
+
14
+ export type VideoCaptureRequest = {
15
+ html: string;
16
+ timeline: Timeline;
17
+ fps: number;
18
+ viewport: { width: number; height: number };
19
+ outDir: string;
20
+ slug?: string;
21
+ };
22
+
23
+ export type VideoCaptureResponse = {
24
+ frames: VideoFrame[];
25
+ };
26
+
27
+ export type VideoRunner = (request: VideoCaptureRequest) => Promise<VideoCaptureResponse>;
28
+
29
+ /** A go the video plays, and how long it shows: until the next go, the first from the start. */
30
+ type PlannedGo = {
31
+ at: number;
32
+ position: Position;
33
+ durationMs: number;
34
+ };
35
+
36
+ export function frameStops(animationMs: number, fps: number): number[] {
37
+ if (animationMs <= 0) {
38
+ return [];
39
+ }
40
+ const frameMs = 1000 / Math.max(1, fps);
41
+ const count = Math.max(1, Math.round(animationMs / frameMs));
42
+ return Array.from({ length: count }, (_, i) =>
43
+ i === count - 1 ? animationMs : ((i + 1) * animationMs) / count,
44
+ );
45
+ }
46
+
47
+ export function holdMs(beatMs: number, animationMs: number): number {
48
+ return Math.max(1, beatMs - animationMs);
49
+ }
50
+
51
+ /** Every go of the talk, each shown until the next; together they cover the whole audio. */
52
+ export function planCapture(timeline: Timeline): PlannedGo[] {
53
+ const gos = playbackSchedule(timeline);
54
+ return gos.map((go, index) => ({
55
+ ...go,
56
+ durationMs: Math.max(
57
+ 1,
58
+ (gos[index + 1]?.at ?? timeline.durationMs) - (index === 0 ? 0 : go.at),
59
+ ),
60
+ }));
61
+ }
62
+
63
+ export async function defaultVideoRunner(
64
+ request: VideoCaptureRequest,
65
+ options: { timeoutMs?: number } = {},
66
+ ): Promise<VideoCaptureResponse> {
67
+ const bin = process.env.DEK_VIDEO;
68
+ if (bin) {
69
+ return spawnVideoRunner(bin, request, options);
70
+ }
71
+ if (playwrightResolved()) {
72
+ return spawnVideoRunner(workerPath(), request, options);
73
+ }
74
+ throw new DekError("video capture failed", {
75
+ hint: "install Playwright or set DEK_VIDEO",
76
+ });
77
+ }
78
+
79
+ function workerPath(): string {
80
+ return moduleFilePath(new URL("./worker.ts", import.meta.url));
81
+ }
82
+
83
+ function spawnVideoRunner(
84
+ bin: string,
85
+ request: VideoCaptureRequest,
86
+ options: { timeoutMs?: number },
87
+ ): Promise<VideoCaptureResponse> {
88
+ return runJsonWorker(workerCommand(bin), request, parseVideoResponse, {
89
+ label: "video capture failed",
90
+ hint: "install Playwright or set DEK_VIDEO",
91
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
92
+ });
93
+ }
94
+
95
+ /** The video worker's JSON, or null when its frames are not each a path and how long it shows. */
96
+ export function parseVideoResponse(text: string): VideoCaptureResponse | null {
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(text);
100
+ } catch {
101
+ return null;
102
+ }
103
+ const frames = (parsed as { frames?: unknown } | null)?.frames;
104
+ return Array.isArray(frames) && frames.every(isVideoFrame) ? { frames } : null;
105
+ }
106
+
107
+ function isVideoFrame(value: unknown): value is VideoFrame {
108
+ const { path, durationMs, kind } = (value ?? {}) as Record<string, unknown>;
109
+ return (
110
+ typeof path === "string" &&
111
+ typeof durationMs === "number" &&
112
+ Number.isFinite(durationMs) &&
113
+ (kind === undefined || kind === "animation" || kind === "hold")
114
+ );
115
+ }
@@ -0,0 +1,60 @@
1
+ import { replaceFile } from "../core/safe-fs.ts";
2
+ import type { Timeline } from "../core/timeline.ts";
3
+
4
+ export function writeVideoSidecars(options: {
5
+ timeline: Timeline;
6
+ titles: Array<{ slug: string; title: string; startMs: number }>;
7
+ speaker: string;
8
+ engine: string;
9
+ stem: string;
10
+ }): { vtt: string; chapters: string; credits: string } {
11
+ const vtt = `${options.stem}.vtt`;
12
+ const chapters = `${options.stem}.chapters.txt`;
13
+ const credits = `${options.stem}.credits.txt`;
14
+ replaceFile(vtt, renderVtt(options.timeline));
15
+ replaceFile(chapters, renderChapters(options.titles));
16
+ replaceFile(credits, `${options.engine}:${options.speaker}\n`);
17
+ return { vtt, chapters, credits };
18
+ }
19
+
20
+ function renderVtt(timeline: Timeline): string {
21
+ const cues: string[] = ["WEBVTT", ""];
22
+ let index = 1;
23
+ for (const beat of timeline.beats) {
24
+ for (const sentence of beat.sentences) {
25
+ cues.push(
26
+ String(index),
27
+ `${formatVtt(sentence.start)} --> ${formatVtt(sentence.end)}`,
28
+ escapeVtt(sentence.text),
29
+ "",
30
+ );
31
+ index += 1;
32
+ }
33
+ }
34
+ return `${cues.join("\n")}\n`;
35
+ }
36
+
37
+ function escapeVtt(text: string): string {
38
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/\s+/g, " ").trim();
39
+ }
40
+
41
+ function renderChapters(titles: Array<{ title: string; startMs: number }>): string {
42
+ return titles
43
+ .map((entry) => `${formatClock(entry.startMs)} ${entry.title}`)
44
+ .join("\n")
45
+ .concat("\n");
46
+ }
47
+
48
+ function formatVtt(ms: number): string {
49
+ const total = Math.max(0, ms) / 1000;
50
+ const hours = Math.floor(total / 3600);
51
+ const minutes = Math.floor((total % 3600) / 60);
52
+ const seconds = total % 60;
53
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${seconds.toFixed(3).padStart(6, "0")}`;
54
+ }
55
+
56
+ function formatClock(ms: number): string {
57
+ const seconds = Math.floor(ms / 1000);
58
+ const minutes = Math.floor(seconds / 60);
59
+ return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
60
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { Browser } from "playwright";
5
+ import { captureGo, loadVideoDoc } from "../core/capture-go.ts";
6
+ import { runPlaywrightWorker } from "../core/playwright.ts";
7
+ import {
8
+ frameStops,
9
+ holdMs,
10
+ planCapture,
11
+ type VideoCaptureRequest,
12
+ type VideoCaptureResponse,
13
+ type VideoFrame,
14
+ } from "./recorder.ts";
15
+
16
+ await runPlaywrightWorker(capture);
17
+
18
+ /** Records every beat of the request into `outDir`. */
19
+ async function capture(
20
+ browser: Browser,
21
+ request: VideoCaptureRequest,
22
+ ): Promise<VideoCaptureResponse> {
23
+ mkdirSync(request.outDir, { recursive: true });
24
+ const page = await browser.newPage({
25
+ viewport: { width: request.viewport.width, height: request.viewport.height },
26
+ });
27
+ await loadVideoDoc(page, request.html);
28
+
29
+ const frames: VideoFrame[] = [];
30
+ for (const go of planCapture(request.timeline)) {
31
+ let previous = 0;
32
+ // Only what this go started moves; slide scripts are held at t=0 by the player in video mode.
33
+ await captureGo(
34
+ page,
35
+ go.position,
36
+ (span) => frameStops(span, request.fps),
37
+ async (ms, end) => {
38
+ const path = join(request.outDir, `frame-${String(frames.length).padStart(4, "0")}.png`);
39
+ await page.screenshot({ path, type: "png" });
40
+ frames.push(
41
+ end
42
+ ? { path, durationMs: holdMs(go.durationMs, ms), kind: "hold" }
43
+ : { path, durationMs: ms - previous, kind: "animation" },
44
+ );
45
+ previous = ms;
46
+ },
47
+ );
48
+ }
49
+
50
+ return { frames };
51
+ }