@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,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Section / beat ids. Digits-only values collide with numeric `data-step`. */
|
|
4
|
+
export const Id = z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^[a-z0-9-]+$/)
|
|
7
|
+
.refine((s) => /[a-z]/.test(s));
|
|
8
|
+
|
|
9
|
+
export type Id = z.infer<typeof Id>;
|
|
10
|
+
|
|
11
|
+
export const Beat = z.object({
|
|
12
|
+
id: Id.optional(),
|
|
13
|
+
title: z.string(),
|
|
14
|
+
body: z.string(),
|
|
15
|
+
line: z.number(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type Beat = z.infer<typeof Beat>;
|
|
19
|
+
|
|
20
|
+
export const Section = z.object({
|
|
21
|
+
slug: Id,
|
|
22
|
+
title: z.string(),
|
|
23
|
+
body: z.string(),
|
|
24
|
+
beats: z.array(Beat),
|
|
25
|
+
line: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type Section = z.infer<typeof Section>;
|
|
29
|
+
|
|
30
|
+
const Lang = z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/, "write a BCP 47 tag, like en or ja");
|
|
33
|
+
|
|
34
|
+
/** The page language when there is no script to read it from, such as the project index. */
|
|
35
|
+
export const FALLBACK_LANG = "en";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The language a script is written in, read from its writing system: kana means Japanese even
|
|
39
|
+
* among kanji, then Hangul, then Han. Anything else is taken as English. The frontmatter `lang`
|
|
40
|
+
* overrides it for every other language.
|
|
41
|
+
*/
|
|
42
|
+
export function inferLang(text: string): string {
|
|
43
|
+
if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
|
|
44
|
+
return "ja";
|
|
45
|
+
}
|
|
46
|
+
if (/\p{Script=Hangul}/u.test(text)) {
|
|
47
|
+
return "ko";
|
|
48
|
+
}
|
|
49
|
+
if (/\p{Script=Han}/u.test(text)) {
|
|
50
|
+
return "zh";
|
|
51
|
+
}
|
|
52
|
+
return "en";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Frontmatter fields written to `.dek/schema.json` for yaml-language-server. */
|
|
56
|
+
export const Frontmatter = z.object({
|
|
57
|
+
title: z.string(),
|
|
58
|
+
description: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("One or two sentences a shared link shows under the title."),
|
|
62
|
+
event: z.string().optional(),
|
|
63
|
+
date: z.string().date("write a date, like 2026-04-18").optional(),
|
|
64
|
+
duration: z
|
|
65
|
+
.string("write minutes, like 10m")
|
|
66
|
+
.regex(/^\d+m$/, "write minutes, like 10m")
|
|
67
|
+
.optional(),
|
|
68
|
+
ratio: z.enum(["16:9", "4:3"]).default("16:9"),
|
|
69
|
+
lang: Lang.optional().describe(
|
|
70
|
+
"BCP 47 tag for the page. Omit it to follow the script: kana → ja, Hangul → ko, Han → zh, else en.",
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export type Frontmatter = z.infer<typeof Frontmatter>;
|
|
75
|
+
|
|
76
|
+
export const Deck = Frontmatter.extend({
|
|
77
|
+
lang: Lang,
|
|
78
|
+
sections: z.array(Section).min(1, "add a ## heading; each one becomes a slide"),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type Deck = z.infer<typeof Deck>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a script may write, not what parsing returns: a field with a default is optional. Unknown
|
|
85
|
+
* keys stay flagged, as DEK008 flags them, although parsing drops them rather than failing.
|
|
86
|
+
*/
|
|
87
|
+
export function frontmatterJsonSchema(): unknown {
|
|
88
|
+
return { ...z.toJSONSchema(Frontmatter, { io: "input" }), additionalProperties: false };
|
|
89
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { splitLines } from "./lines.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line of a script's body, and whether it is literal text no heading can start: `fenced` when
|
|
5
|
+
* that is because it belongs to a fenced code block, whose text the slide shows as written.
|
|
6
|
+
*/
|
|
7
|
+
export type ScriptLine = { text: string; literal: boolean; fenced: boolean };
|
|
8
|
+
|
|
9
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The body's lines, each marked literal when it is part of a fenced code block, fences included,
|
|
13
|
+
* or lies inside an HTML comment. A `##` there is text the slide shows or the author hid, never a
|
|
14
|
+
* slide or a beat. Fences follow CommonMark: three or more backticks or tildes, indented at most
|
|
15
|
+
* three spaces, closed by a run of the same character at least as long.
|
|
16
|
+
*/
|
|
17
|
+
export function scriptLines(body: string): ScriptLine[] {
|
|
18
|
+
let fence: { char: string; length: number } | undefined;
|
|
19
|
+
let comment = false;
|
|
20
|
+
return splitLines(body).map((text) => {
|
|
21
|
+
if (fence) {
|
|
22
|
+
const close = new RegExp(`^ {0,3}${fence.char === "`" ? "`" : "~"}{${fence.length},}\\s*$`);
|
|
23
|
+
if (close.test(text)) {
|
|
24
|
+
fence = undefined;
|
|
25
|
+
}
|
|
26
|
+
return { text, literal: true, fenced: true };
|
|
27
|
+
}
|
|
28
|
+
const literal = comment;
|
|
29
|
+
const open = comment ? undefined : FENCE_OPEN_RE.exec(text);
|
|
30
|
+
// A backtick fence's info string may not hold a backtick; such a line is inline code.
|
|
31
|
+
if (open?.[1] && !(open[1].startsWith("`") && open[2]?.includes("`"))) {
|
|
32
|
+
fence = { char: open[1].charAt(0), length: open[1].length };
|
|
33
|
+
return { text, literal: true, fenced: true };
|
|
34
|
+
}
|
|
35
|
+
comment = commentOpenAfter(text, comment);
|
|
36
|
+
return { text, literal, fenced: false };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether an HTML comment is still open after `text`, given whether one was open before it. */
|
|
41
|
+
function commentOpenAfter(text: string, open: boolean): boolean {
|
|
42
|
+
let inside = open;
|
|
43
|
+
let at = 0;
|
|
44
|
+
while (at < text.length) {
|
|
45
|
+
const next = text.indexOf(inside ? "-->" : "<!--", at);
|
|
46
|
+
if (next < 0) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
at = next + (inside ? 3 : 4);
|
|
50
|
+
inside = !inside;
|
|
51
|
+
}
|
|
52
|
+
return inside;
|
|
53
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { escapeAttr, escapeHtml } from "./escape.ts";
|
|
2
|
+
import type { Size } from "./size.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Contact sheets: many captures on one image, so an agent reads a deck, or a slide's motion, in
|
|
6
|
+
* one look instead of one file per frame. A sheet is laid out here, as boxes at pixel positions,
|
|
7
|
+
* and drawn by a browser from the HTML `sheetHtml` writes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The longest edge a vision model reads without scaling the image down. */
|
|
11
|
+
export const SHEET_MAX_EDGE = 1568;
|
|
12
|
+
/** The most pixels a vision model reads without scaling the image down. */
|
|
13
|
+
export const SHEET_MAX_PIXELS = 1_150_000;
|
|
14
|
+
/** Part of every cache key for a sheet: a change to how sheets look makes new ones. */
|
|
15
|
+
export const SHEET_LAYOUT_VERSION = "1";
|
|
16
|
+
|
|
17
|
+
export type SheetCell = { image: string; label: string };
|
|
18
|
+
type SheetRow = { label?: string; cells: SheetCell[] };
|
|
19
|
+
export type SheetSpec = {
|
|
20
|
+
path: string;
|
|
21
|
+
title: string;
|
|
22
|
+
tile: Size;
|
|
23
|
+
columns: number;
|
|
24
|
+
rows: SheetRow[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SheetBox = {
|
|
28
|
+
kind: "title" | "label" | "image" | "caption";
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
text?: string;
|
|
34
|
+
image?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One capture of a slide in motion, `ms` into the go that began its beat. */
|
|
38
|
+
export type MotionFrame = { ms: number; path: string; end?: true };
|
|
39
|
+
export type MotionBeat = { label: string; frames: MotionFrame[] };
|
|
40
|
+
|
|
41
|
+
type SheetOptions = { slide: Size; title: string; dir: string };
|
|
42
|
+
|
|
43
|
+
const PAD = 16;
|
|
44
|
+
const GAP = 12;
|
|
45
|
+
const TITLE = 32;
|
|
46
|
+
const LABEL = 24;
|
|
47
|
+
const CAPTION = 20;
|
|
48
|
+
/** Below this a tile shows too little to judge a slide by; a long deck takes more sheets instead. */
|
|
49
|
+
const MIN_TILE_WIDTH = 240;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every slide as a tile, in order. The tiles are as large as they can be with the whole deck on one
|
|
53
|
+
* sheet a vision model reads as drawn; a deck too long for that goes on over more sheets.
|
|
54
|
+
*/
|
|
55
|
+
export function overviewSheets(
|
|
56
|
+
cells: SheetCell[],
|
|
57
|
+
options: SheetOptions & {
|
|
58
|
+
/** Fixes the column count instead of choosing it. */
|
|
59
|
+
columns?: number;
|
|
60
|
+
},
|
|
61
|
+
): SheetSpec[] {
|
|
62
|
+
if (cells.length === 0) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const columns = options.columns ?? overviewColumns(cells.length, options.slide);
|
|
66
|
+
const tile = tileFor(columns, options.slide);
|
|
67
|
+
const rows = chunk(cells, columns).map((row) => ({ cells: row }));
|
|
68
|
+
const pages = paginate(rows, columns, tile);
|
|
69
|
+
let first = 1;
|
|
70
|
+
return pages.map((page, index) => {
|
|
71
|
+
const count = page.reduce((sum, row) => sum + row.cells.length, 0);
|
|
72
|
+
const title = `${options.title} · slides ${first}–${first + count - 1} of ${cells.length}`;
|
|
73
|
+
first += count;
|
|
74
|
+
return { path: sheetPath(options.dir, index), title, tile, columns, rows: page };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A row for each beat, its frames left to right and its settled end last, captioned with the
|
|
80
|
+
* moment each shows. A beat that does not move is one frame, captioned as such.
|
|
81
|
+
*/
|
|
82
|
+
export function motionSheets(beats: MotionBeat[], options: SheetOptions): SheetSpec[] {
|
|
83
|
+
const columns = Math.max(1, ...beats.map((beat) => beat.frames.length));
|
|
84
|
+
const tile = tileFor(columns, options.slide);
|
|
85
|
+
const rows = beats.map((beat) => ({
|
|
86
|
+
label: beat.label,
|
|
87
|
+
cells: beat.frames.map((frame) => ({ image: frame.path, label: frameCaption(frame, beat) })),
|
|
88
|
+
}));
|
|
89
|
+
const pages = paginate(rows, columns, tile);
|
|
90
|
+
return pages.map((page, index) => ({
|
|
91
|
+
path: sheetPath(options.dir, index),
|
|
92
|
+
title: pages.length > 1 ? `${options.title} · ${index + 1}/${pages.length}` : options.title,
|
|
93
|
+
tile,
|
|
94
|
+
columns,
|
|
95
|
+
rows: page,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where each title, row label, image, and caption goes, and how large the sheet is. */
|
|
100
|
+
export function sheetLayout(spec: SheetSpec): { size: Size; boxes: SheetBox[] } {
|
|
101
|
+
const width = PAD * 2 + spec.columns * spec.tile.width + (spec.columns - 1) * GAP;
|
|
102
|
+
const inner = width - PAD * 2;
|
|
103
|
+
const boxes: SheetBox[] = [
|
|
104
|
+
{ kind: "title", x: PAD, y: PAD, width: inner, height: TITLE, text: spec.title },
|
|
105
|
+
];
|
|
106
|
+
let y = PAD + TITLE;
|
|
107
|
+
spec.rows.forEach((row, index) => {
|
|
108
|
+
if (index > 0) {
|
|
109
|
+
y += GAP;
|
|
110
|
+
}
|
|
111
|
+
if (row.label !== undefined) {
|
|
112
|
+
boxes.push({ kind: "label", x: PAD, y, width: inner, height: LABEL, text: row.label });
|
|
113
|
+
y += LABEL;
|
|
114
|
+
}
|
|
115
|
+
row.cells.forEach((cell, column) => {
|
|
116
|
+
const x = PAD + column * (spec.tile.width + GAP);
|
|
117
|
+
boxes.push({ kind: "image", x, y, ...spec.tile, image: cell.image });
|
|
118
|
+
boxes.push({
|
|
119
|
+
kind: "caption",
|
|
120
|
+
x,
|
|
121
|
+
y: y + spec.tile.height,
|
|
122
|
+
width: spec.tile.width,
|
|
123
|
+
height: CAPTION,
|
|
124
|
+
text: cell.label,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
y += spec.tile.height + CAPTION;
|
|
128
|
+
});
|
|
129
|
+
return { size: { width, height: y + PAD }, boxes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The sheet as a page to screenshot at its own size; `src` turns an image path into a URL. */
|
|
133
|
+
export function sheetHtml(spec: SheetSpec, src: (image: string) => string): string {
|
|
134
|
+
const { size, boxes } = sheetLayout(spec);
|
|
135
|
+
const drawn = boxes
|
|
136
|
+
.map((box) => {
|
|
137
|
+
const at = `left:${box.x}px;top:${box.y}px;width:${box.width}px;height:${box.height}px`;
|
|
138
|
+
return box.kind === "image"
|
|
139
|
+
? `<img class="image" style="${at}" src="${escapeAttr(src(box.image ?? ""))}" alt="">`
|
|
140
|
+
: `<div class="${box.kind}" style="${at}">${escapeHtml(box.text ?? "")}</div>`;
|
|
141
|
+
})
|
|
142
|
+
.join("\n");
|
|
143
|
+
return `<!doctype html>
|
|
144
|
+
<html><head><meta charset="utf-8"><style>
|
|
145
|
+
html, body { margin: 0; background: #262626; }
|
|
146
|
+
.sheet { position: relative; overflow: hidden; }
|
|
147
|
+
.sheet > * { position: absolute; box-sizing: border-box; }
|
|
148
|
+
.title, .label, .caption { color: #d4d4d4; font: 12px/20px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
149
|
+
.title { font-size: 13px; color: #fafafa; }
|
|
150
|
+
.label { line-height: 22px; color: #a3a3a3; }
|
|
151
|
+
.caption { padding-top: 2px; }
|
|
152
|
+
.image { display: block; outline: 1px solid #5c5c5c; }
|
|
153
|
+
</style></head><body><div class="sheet" style="width:${size.width}px;height:${size.height}px">
|
|
154
|
+
${drawn}
|
|
155
|
+
</div></body></html>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The fewest columns, so the largest tiles, that fit the whole deck on one sheet. */
|
|
159
|
+
function overviewColumns(count: number, slide: Size): number {
|
|
160
|
+
for (let columns = 1; columns <= count; columns++) {
|
|
161
|
+
const tile = tileFor(columns, slide);
|
|
162
|
+
if (tile.width < MIN_TILE_WIDTH) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const rows = chunk(Array.from({ length: count }), columns).map(() => ({ cells: [] }));
|
|
166
|
+
if (fits(rows, columns, tile)) {
|
|
167
|
+
return columns;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return Math.max(1, Math.floor((SHEET_MAX_EDGE - PAD * 2 + GAP) / (MIN_TILE_WIDTH + GAP)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The tile for `columns` across the widest sheet, never larger than the slide itself. */
|
|
174
|
+
function tileFor(columns: number, slide: Size): Size {
|
|
175
|
+
const across = Math.floor((SHEET_MAX_EDGE - PAD * 2 - (columns - 1) * GAP) / columns);
|
|
176
|
+
const width = Math.min(slide.width, across);
|
|
177
|
+
return { width, height: Math.round((width * slide.height) / slide.width) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Rows split over as few sheets as keep each one within what a vision model reads as drawn. */
|
|
181
|
+
function paginate(rows: SheetRow[], columns: number, tile: Size): SheetRow[][] {
|
|
182
|
+
const pages: SheetRow[][] = [];
|
|
183
|
+
let page: SheetRow[] = [];
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
if (page.length > 0 && !fits([...page, row], columns, tile)) {
|
|
186
|
+
pages.push(page);
|
|
187
|
+
page = [];
|
|
188
|
+
}
|
|
189
|
+
page.push(row);
|
|
190
|
+
}
|
|
191
|
+
if (page.length > 0) {
|
|
192
|
+
pages.push(page);
|
|
193
|
+
}
|
|
194
|
+
return pages;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fits(rows: SheetRow[], columns: number, tile: Size): boolean {
|
|
198
|
+
const { size } = sheetLayout({ path: "", title: "", tile, columns, rows });
|
|
199
|
+
return (
|
|
200
|
+
size.width <= SHEET_MAX_EDGE &&
|
|
201
|
+
size.height <= SHEET_MAX_EDGE &&
|
|
202
|
+
size.width * size.height <= SHEET_MAX_PIXELS
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function frameCaption(frame: MotionFrame, beat: MotionBeat): string {
|
|
207
|
+
if (!frame.end) {
|
|
208
|
+
return `${Math.round(frame.ms)} ms`;
|
|
209
|
+
}
|
|
210
|
+
return beat.frames.length === 1 ? "no motion" : `end · ${Math.round(frame.ms)} ms`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sheetPath(dir: string, index: number): string {
|
|
214
|
+
return `${dir}/sheet-${index + 1}.png`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
218
|
+
const chunks: T[][] = [];
|
|
219
|
+
for (let i = 0; i < items.length; i += size) {
|
|
220
|
+
chunks.push(items.slice(i, i + size));
|
|
221
|
+
}
|
|
222
|
+
return chunks;
|
|
223
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DekError } from "../error.ts";
|
|
2
|
+
import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
|
|
3
|
+
import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
|
|
4
|
+
import { morphEntry } from "../shot-cache.ts";
|
|
5
|
+
import { logicalSize } from "../size.ts";
|
|
6
|
+
import { lastStop, stepKey } from "../step.ts";
|
|
7
|
+
import { videoDocument } from "../video-document.ts";
|
|
8
|
+
import type { ShotFile } from "./still.ts";
|
|
9
|
+
|
|
10
|
+
export type ShotMorphOptions = {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
/** Where in the transition, 0..1; `parseMorphAt` reads it from the command line. */
|
|
14
|
+
at: number;
|
|
15
|
+
/** The player runtime to embed; `playerScript()` from src/runtime. */
|
|
16
|
+
playerScript: string;
|
|
17
|
+
runner?: PlaywrightRunner;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** `--at` as a number between 0 and 1; 0.5 when left out. */
|
|
21
|
+
export function parseMorphAt(value: string | undefined): number {
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
return 0.5;
|
|
24
|
+
}
|
|
25
|
+
// Number("") is 0: an empty value would pass for the start of the transition.
|
|
26
|
+
return requireMorphAt(value.trim() === "" ? Number.NaN : Number(value), value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireMorphAt(at: number, written: string = String(at)): number {
|
|
30
|
+
if (!Number.isFinite(at) || at < 0 || at > 1) {
|
|
31
|
+
throw new DekError(`invalid --at "${written}"`, {
|
|
32
|
+
hint: "use a number between 0 and 1, e.g. --at 0.5",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return at;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Screenshot the view transition from the last beat of `from` into beat 0 of
|
|
40
|
+
* `to`, frozen at `at`. The page is the video document (all slides plus the
|
|
41
|
+
* player runtime), so morphs and theme transitions run exactly as in `dekc video`.
|
|
42
|
+
*/
|
|
43
|
+
export async function shotMorph(dir: string, options: ShotMorphOptions): Promise<ShotFile[]>;
|
|
44
|
+
export async function shotMorph(
|
|
45
|
+
source: ResolvedDeck,
|
|
46
|
+
options: ShotMorphOptions,
|
|
47
|
+
): Promise<ShotFile[]>;
|
|
48
|
+
export async function shotMorph(
|
|
49
|
+
input: string | ResolvedDeck,
|
|
50
|
+
options: ShotMorphOptions,
|
|
51
|
+
): Promise<ShotFile[]> {
|
|
52
|
+
const resolved = asResolvedDeck(input);
|
|
53
|
+
const { deck } = resolved;
|
|
54
|
+
const at = requireMorphAt(options.at);
|
|
55
|
+
const fromSection = requireSection(deck, options.from);
|
|
56
|
+
const toSection = requireSection(deck, options.to);
|
|
57
|
+
const from = {
|
|
58
|
+
slideIndex: deck.deck.sections.indexOf(fromSection),
|
|
59
|
+
beatIndex: lastStop(fromSection.beats),
|
|
60
|
+
};
|
|
61
|
+
const to = { slideIndex: deck.deck.sections.indexOf(toSection), beatIndex: 0 };
|
|
62
|
+
const step = stepKey(fromSection.beats, from.beatIndex);
|
|
63
|
+
|
|
64
|
+
const html = videoDocument(resolved, options.playerScript);
|
|
65
|
+
const entry = morphEntry(deck.dir, options.from, options.to, at, html);
|
|
66
|
+
if (!entry.cached) {
|
|
67
|
+
await requirePlaywright(
|
|
68
|
+
{
|
|
69
|
+
kind: "morph",
|
|
70
|
+
viewport: logicalSize(deck.deck.ratio),
|
|
71
|
+
html,
|
|
72
|
+
screenshotPath: entry.path,
|
|
73
|
+
morph: { from, to, at },
|
|
74
|
+
},
|
|
75
|
+
options.runner,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
entry.commit();
|
|
79
|
+
return [{ slug: options.from, step, path: entry.path, to: options.to, at }];
|
|
80
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import pkg from "../../../package.json";
|
|
4
|
+
import { DekError } from "../error.ts";
|
|
5
|
+
import { deckProjectRoot } from "../path.ts";
|
|
6
|
+
import { type PlaywrightRunner, requirePlaywright } from "../playwright.ts";
|
|
7
|
+
import { deckStops, retreat } from "../position.ts";
|
|
8
|
+
import { asResolvedDeck, type ResolvedDeck, requireSection } from "../resolve.ts";
|
|
9
|
+
import { dropLinks, isCachedFile, outputDir, replaceFile } from "../safe-fs.ts";
|
|
10
|
+
import type { Section } from "../schema.ts";
|
|
11
|
+
import { type MotionFrame, SHEET_LAYOUT_VERSION } from "../sheet.ts";
|
|
12
|
+
import { bundleEntry } from "../shot-cache.ts";
|
|
13
|
+
import { logicalSize } from "../size.ts";
|
|
14
|
+
import { beatAt, type Position, stepKey, stopCount } from "../step.ts";
|
|
15
|
+
import { videoDocument } from "../video-document.ts";
|
|
16
|
+
import { resolveBeat } from "./still.ts";
|
|
17
|
+
|
|
18
|
+
export type ShotMotionOptions = {
|
|
19
|
+
slug: string;
|
|
20
|
+
/** One beat alone, played from the beat before it; every beat when left out. */
|
|
21
|
+
step?: string;
|
|
22
|
+
/** The player runtime to embed; `playerScript()` from src/runtime. */
|
|
23
|
+
playerScript: string;
|
|
24
|
+
runner?: PlaywrightRunner;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ShotMotion = {
|
|
28
|
+
slug: string;
|
|
29
|
+
/** The contact sheets: a row per beat, its moments left to right, its settled end last. */
|
|
30
|
+
sheets: string[];
|
|
31
|
+
beats: Array<{ step: string; frames: MotionFrame[] }>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Where each go is held before its end, as a share of how long it runs. */
|
|
35
|
+
const MOTION_FRACTIONS = [0, 0.25, 0.5, 0.75];
|
|
36
|
+
|
|
37
|
+
type MotionPlan = {
|
|
38
|
+
/** Where the talk stands before the first beat plays; none at the start of the talk. */
|
|
39
|
+
from?: Position;
|
|
40
|
+
/** Each beat to play, in order, with its sheet row label and its step key. */
|
|
41
|
+
beats: Array<{ position: Position; label: string; step: string }>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which beats of `section` a motion plays, and from where: every beat from the slide before
|
|
46
|
+
* (or the start of the talk), or with `step` that beat alone, from the beat before it.
|
|
47
|
+
*/
|
|
48
|
+
export function planMotion(section: Section, sections: Section[], step?: string): MotionPlan {
|
|
49
|
+
const slideIndex = sections.indexOf(section);
|
|
50
|
+
const indices =
|
|
51
|
+
step === undefined
|
|
52
|
+
? Array.from({ length: stopCount(section.beats) }, (_, i) => i)
|
|
53
|
+
: [resolveBeat(section, step).index];
|
|
54
|
+
const first = indices[0] ?? 0;
|
|
55
|
+
const from = retreat({ slideIndex, beatIndex: first }, deckStops(sections));
|
|
56
|
+
const previous = sections[slideIndex - 1];
|
|
57
|
+
const origin =
|
|
58
|
+
first > 1
|
|
59
|
+
? `from beat ${first - 1}`
|
|
60
|
+
: first === 1
|
|
61
|
+
? "from arrival"
|
|
62
|
+
: previous
|
|
63
|
+
? `from ${previous.slug}`
|
|
64
|
+
: "from the start";
|
|
65
|
+
return {
|
|
66
|
+
...(from ? { from } : {}),
|
|
67
|
+
beats: indices.map((beatIndex, i) => ({
|
|
68
|
+
position: { slideIndex, beatIndex },
|
|
69
|
+
label: [stopLabel(section, beatIndex), i === 0 ? origin : undefined]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(" · "),
|
|
72
|
+
step: stepKey(section.beats, beatIndex),
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A slide's beats in motion, each played the way the talk reaches it: the first from the slide
|
|
79
|
+
* before (or the beat before, with a step), each after from the one it follows. Every go is held
|
|
80
|
+
* at a few moments of everything it starts, the view transition, the slide's CSS animations and
|
|
81
|
+
* transitions, and its script, then ended as `dekc shot` ends it. The page is the video document,
|
|
82
|
+
* so this is the motion the talk and `dekc video` show.
|
|
83
|
+
*/
|
|
84
|
+
export async function shotMotion(dir: string, options: ShotMotionOptions): Promise<ShotMotion>;
|
|
85
|
+
export async function shotMotion(
|
|
86
|
+
source: ResolvedDeck,
|
|
87
|
+
options: ShotMotionOptions,
|
|
88
|
+
): Promise<ShotMotion>;
|
|
89
|
+
export async function shotMotion(
|
|
90
|
+
input: string | ResolvedDeck,
|
|
91
|
+
options: ShotMotionOptions,
|
|
92
|
+
): Promise<ShotMotion> {
|
|
93
|
+
const resolved = asResolvedDeck(input);
|
|
94
|
+
const { deck } = resolved;
|
|
95
|
+
const section = requireSection(deck, options.slug);
|
|
96
|
+
const plan = planMotion(section, deck.deck.sections, options.step);
|
|
97
|
+
const beats = plan.beats.map(({ position, label }) => ({ position, label }));
|
|
98
|
+
const first = plan.beats[0]?.position.beatIndex ?? 0;
|
|
99
|
+
|
|
100
|
+
const html = videoDocument(resolved, options.playerScript);
|
|
101
|
+
const title = `${section.slug} · motion`;
|
|
102
|
+
// The page holds the player, but not the worker code that holds and seeks it; the version does.
|
|
103
|
+
const key = `${JSON.stringify([pkg.version, SHEET_LAYOUT_VERSION, MOTION_FRACTIONS, plan.from, beats, title])}\0${html}`;
|
|
104
|
+
// Each step keeps its own entry, so runs of different steps neither evict nor race each other.
|
|
105
|
+
const capture = options.step === undefined ? [section.slug] : [section.slug, String(first)];
|
|
106
|
+
const bundle = bundleEntry(deck.dir, "motion", capture, key);
|
|
107
|
+
const manifest = join(bundle.dir, "motion.json");
|
|
108
|
+
const cached = readMotionManifest(manifest, bundle.dir);
|
|
109
|
+
if (cached) {
|
|
110
|
+
bundle.commit();
|
|
111
|
+
return cached;
|
|
112
|
+
}
|
|
113
|
+
// Chromium writes the frames and sheets by name, so no link may stand where one goes.
|
|
114
|
+
dropLinks(bundle.dir);
|
|
115
|
+
dropLinks(outputDir(join(bundle.dir, "frames"), deckProjectRoot(deck.dir)));
|
|
116
|
+
|
|
117
|
+
const response = await requirePlaywright(
|
|
118
|
+
{
|
|
119
|
+
kind: "motion",
|
|
120
|
+
viewport: logicalSize(deck.deck.ratio),
|
|
121
|
+
html,
|
|
122
|
+
motion: {
|
|
123
|
+
...(plan.from ? { from: plan.from } : {}),
|
|
124
|
+
beats,
|
|
125
|
+
fractions: MOTION_FRACTIONS,
|
|
126
|
+
dir: bundle.dir,
|
|
127
|
+
title,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
options.runner,
|
|
131
|
+
);
|
|
132
|
+
if (response.motion.length !== beats.length) {
|
|
133
|
+
throw new DekError("the Playwright worker returned the wrong number of beats", {
|
|
134
|
+
hint: `asked for ${beats.length}, got ${response.motion.length}; check DEK_PLAYWRIGHT`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const result: ShotMotion = {
|
|
138
|
+
slug: section.slug,
|
|
139
|
+
sheets: response.sheets,
|
|
140
|
+
beats: response.motion.map((beat, i) => ({
|
|
141
|
+
step: plan.beats[i]?.step ?? "0",
|
|
142
|
+
frames: beat.frames,
|
|
143
|
+
})),
|
|
144
|
+
};
|
|
145
|
+
replaceFile(manifest, `${JSON.stringify(result)}\n`);
|
|
146
|
+
bundle.commit();
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A motion answered before, while every file it names is still there. The cache is dek's own
|
|
152
|
+
* output, but a manifest that does not read back whole, or names a file outside its own folder,
|
|
153
|
+
* is taken again rather than trusted.
|
|
154
|
+
*/
|
|
155
|
+
function readMotionManifest(path: string, dir: string): ShotMotion | undefined {
|
|
156
|
+
if (!isCachedFile(path)) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const result = JSON.parse(readFileSync(path, "utf8")) as ShotMotion;
|
|
161
|
+
const files = [
|
|
162
|
+
...result.sheets,
|
|
163
|
+
...result.beats.flatMap((beat) => beat.frames.map((frame) => frame.path)),
|
|
164
|
+
];
|
|
165
|
+
const inDir = (file: unknown): file is string => {
|
|
166
|
+
if (typeof file !== "string") {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const rel = relative(dir, file);
|
|
170
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
171
|
+
};
|
|
172
|
+
// Every path is checked to be in the folder before any is looked at, since a look removes a link.
|
|
173
|
+
return files.length > 0 && files.every(inDir) && files.every((file) => isCachedFile(file))
|
|
174
|
+
? result
|
|
175
|
+
: undefined;
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** "arrival" for a slide as it arrives; "beat 2 · hook" at a beat. */
|
|
182
|
+
function stopLabel(section: Section, beatIndex: number): string {
|
|
183
|
+
if (beatIndex === 0) {
|
|
184
|
+
return "arrival";
|
|
185
|
+
}
|
|
186
|
+
return [`beat ${beatIndex}`, beatAt(section.beats, beatIndex)?.id].filter(Boolean).join(" · ");
|
|
187
|
+
}
|