keyframes 0.1.0__tar.gz
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.
- keyframes-0.1.0/PKG-INFO +119 -0
- keyframes-0.1.0/README.md +98 -0
- keyframes-0.1.0/pyproject.toml +62 -0
- keyframes-0.1.0/pyproject.toml.orig +47 -0
- keyframes-0.1.0/src/keyframes/__init__.py +1 -0
- keyframes-0.1.0/src/keyframes/build.py +102 -0
- keyframes-0.1.0/src/keyframes/check.py +107 -0
- keyframes-0.1.0/src/keyframes/cli.py +149 -0
- keyframes-0.1.0/src/keyframes/deck.py +67 -0
- keyframes-0.1.0/src/keyframes/export.py +106 -0
- keyframes-0.1.0/src/keyframes/guide/__init__.py +25 -0
- keyframes-0.1.0/src/keyframes/guide/components/keyframes-code.md +60 -0
- keyframes-0.1.0/src/keyframes/guide/components/keyframes-excalidraw.md +148 -0
- keyframes-0.1.0/src/keyframes/guide/components/keyframes-mujoco.md +76 -0
- keyframes-0.1.0/src/keyframes/guide/components/keyframes-slider.md +65 -0
- keyframes-0.1.0/src/keyframes/guide/components/keyframes-three.md +81 -0
- keyframes-0.1.0/src/keyframes/guide/core.md +113 -0
- keyframes-0.1.0/src/keyframes/patch.py +77 -0
- keyframes-0.1.0/src/keyframes/render.py +32 -0
- keyframes-0.1.0/src/keyframes/scaffold/.gitignore +3 -0
- keyframes-0.1.0/src/keyframes/scaffold/CLAUDE.md +1 -0
- keyframes-0.1.0/src/keyframes/scaffold/assets/deck.css +1 -0
- keyframes-0.1.0/src/keyframes/scaffold/assets/figure.svg +1 -0
- keyframes-0.1.0/src/keyframes/scaffold/deck.toml +10 -0
- keyframes-0.1.0/src/keyframes/scaffold/references/.gitkeep +0 -0
- keyframes-0.1.0/src/keyframes/scaffold/slides/01-title.html +4 -0
- keyframes-0.1.0/src/keyframes/scaffold/slides/02-intro.html +17 -0
- keyframes-0.1.0/src/keyframes/server.py +204 -0
- keyframes-0.1.0/src/keyframes/static/components/keyframes-code.js +423 -0
- keyframes-0.1.0/src/keyframes/static/components/keyframes-excalidraw.js +189 -0
- keyframes-0.1.0/src/keyframes/static/components/keyframes-mujoco.js +515 -0
- keyframes-0.1.0/src/keyframes/static/components/keyframes-slider.js +255 -0
- keyframes-0.1.0/src/keyframes/static/components/keyframes-three.js +224 -0
- keyframes-0.1.0/src/keyframes/static/editor/editor.css +228 -0
- keyframes-0.1.0/src/keyframes/static/editor/editor.js +594 -0
- keyframes-0.1.0/src/keyframes/static/editor/panel.css +127 -0
- keyframes-0.1.0/src/keyframes/static/editor/panel.js +357 -0
- keyframes-0.1.0/src/keyframes/static/index.html +23 -0
- keyframes-0.1.0/src/keyframes/static/runtime/component.js +126 -0
- keyframes-0.1.0/src/keyframes/static/runtime/live.js +31 -0
- keyframes-0.1.0/src/keyframes/static/runtime/math.js +33 -0
- keyframes-0.1.0/src/keyframes/static/runtime/presenter.js +91 -0
- keyframes-0.1.0/src/keyframes/static/runtime/runtime.css +91 -0
- keyframes-0.1.0/src/keyframes/static/runtime/runtime.js +215 -0
- keyframes-0.1.0/src/keyframes/static/runtime/steps.js +15 -0
- keyframes-0.1.0/src/keyframes/terminal.py +144 -0
- keyframes-0.1.0/src/keyframes/themes/default/STYLE_GUIDE.md +73 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/arc-top.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/orbit-ring.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/streaks-top.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/streaks-wide.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/sun-top.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/assets/title-orbit.webp +0 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/content.html +16 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/figure.html +7 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-2.html +19 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-3x2.html +28 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/grid-4.html +44 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/media-side.html +16 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/section-arc.html +4 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/section-streaks.html +4 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/section.html +4 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/split-wide.html +7 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/split.html +13 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/statement.html +4 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/title.html +5 -0
- keyframes-0.1.0/src/keyframes/themes/default/layouts/two-column.html +5 -0
- keyframes-0.1.0/src/keyframes/themes/default/theme.css +330 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/figure.html +7 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/section.html +4 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/statement.html +3 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/title-body.html +10 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/title.html +5 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/layouts/two-column.html +11 -0
- keyframes-0.1.0/src/keyframes/themes/minimal/theme.css +196 -0
- keyframes-0.1.0/src/keyframes/vendor.py +181 -0
keyframes-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: keyframes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams
|
|
5
|
+
Keywords: slides,presentation,llm,agents,three.js,mujoco,katex
|
|
6
|
+
Author: Pranay Thangeda
|
|
7
|
+
Author-email: Pranay Thangeda <contact@prny.me>
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Multimedia :: Graphics :: Presentation
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: starlette>=1.7.0
|
|
13
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
14
|
+
Requires-Dist: watchfiles>=1.3.0
|
|
15
|
+
Requires-Dist: websockets>=17.2
|
|
16
|
+
Requires-Dist: playwright>=1.63.0 ; extra == 'export'
|
|
17
|
+
Requires-Dist: pypdf>=6.20.1 ; extra == 'export'
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Provides-Extra: export
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# keyframes
|
|
23
|
+
|
|
24
|
+
HTML slide decks that LLMs write and you arrange by hand.
|
|
25
|
+
|
|
26
|
+
- **Slides are plain HTML files** on a fixed 1920×1080 canvas. Claude Code, Codex & co.
|
|
27
|
+
write them from your references; a generated `AGENTS.md` teaches them the format.
|
|
28
|
+
- **Live editor in Chrome**: press `E` to drag, resize, snap and nudge blocks. Changes are
|
|
29
|
+
written straight back into the slide source (only the touched attributes change), so
|
|
30
|
+
your layout and the LLM's edits never fight.
|
|
31
|
+
- **Rich embeds** as web components: three.js scenes, MuJoCo WASM physics, editable/runnable
|
|
32
|
+
code (JS + Python via Pyodide), Excalidraw figures, KaTeX math, video.
|
|
33
|
+
- **Present anywhere**: presenter view with notes and timer, offline-safe dependency cache,
|
|
34
|
+
PDF export, static site export.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv init my-talk && cd my-talk
|
|
40
|
+
uv add keyframes # or: uv add 'keyframes[export]' for pdf/shot/check --render
|
|
41
|
+
uv run keyframes init .
|
|
42
|
+
uv run keyframes dev # opens Chrome; edit slides/*.html or ask your LLM to
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then ask your agent something like *"Using the papers in references/, draft a 12-slide talk
|
|
46
|
+
on X. Put the MuJoCo demo from assets/ on slide 5."* and rearrange the result with `E`.
|
|
47
|
+
|
|
48
|
+
For exports install the browser once: `uv run playwright install chromium` (or have Chrome).
|
|
49
|
+
|
|
50
|
+
## In the browser
|
|
51
|
+
|
|
52
|
+
| key | action |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| → / Space, ← | next / previous (build steps included) |
|
|
55
|
+
| E | toggle the layout editor |
|
|
56
|
+
| S | presenter view (notes, next slide, timer) |
|
|
57
|
+
| F | fullscreen · B blackout |
|
|
58
|
+
|
|
59
|
+
Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
|
|
60
|
+
aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
|
|
61
|
+
embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
|
|
62
|
+
⌘Z / ⌘⇧Z undo/redo, `?` for help.
|
|
63
|
+
|
|
64
|
+
Edit mode also opens a resizable side panel (toggle with `` ` ``):
|
|
65
|
+
|
|
66
|
+
- **Source**: the current slide's HTML. Selecting a block jumps to its tag; edits save and
|
|
67
|
+
hot-reload as you type; drags and LLM edits flow back in.
|
|
68
|
+
- **Claude / Codex / Shell**: real terminals running in the deck folder (whichever CLIs are
|
|
69
|
+
installed). Sessions survive page reloads. `+ ref` (or `R`) types the selected blocks'
|
|
70
|
+
reference into the prompt, e.g. `@slides/04-problem.html (block "figure")`.
|
|
71
|
+
|
|
72
|
+
The dev server only accepts editor/terminal requests from its own page, but it does give that
|
|
73
|
+
page a shell: keep it bound to 127.0.0.1 (the default).
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
keyframes init [path] new deck from the scaffold
|
|
79
|
+
keyframes dev live server + editor
|
|
80
|
+
keyframes present serve without the editor
|
|
81
|
+
keyframes check [--render] lint; --render adds browser errors and text overflow
|
|
82
|
+
keyframes shot [N ...] PNG screenshots → .keyframes/shots/
|
|
83
|
+
keyframes pdf PDF export (one page per slide, all builds shown)
|
|
84
|
+
keyframes build static site → dist/
|
|
85
|
+
keyframes guide full authoring reference (for LLMs)
|
|
86
|
+
keyframes themes list built-in themes
|
|
87
|
+
keyframes eject-theme copy the theme into the deck to customize it
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Deck layout
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
my-talk/
|
|
94
|
+
deck.toml title, theme, canvas size, extra <head> tags, extra ES module imports
|
|
95
|
+
AGENTS.md authoring rules for LLMs (CLAUDE.md imports it)
|
|
96
|
+
slides/ 01-title.html, 02-motivation.html, ... (ordered by filename)
|
|
97
|
+
assets/ images, video, MJCF models, three.js scene modules, .excalidraw files
|
|
98
|
+
references/ papers/notes for the LLM to draw from (never exported)
|
|
99
|
+
.keyframes/ dependency cache + screenshots (gitignored)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Themes
|
|
103
|
+
|
|
104
|
+
- `default`: a clone of Google Slides' "simple-light" template (Google Sans + Roboto Light,
|
|
105
|
+
orbit artwork on title/section slides, split layouts with a grey media panel).
|
|
106
|
+
- `minimal`: plain Inter/JetBrains Mono theme.
|
|
107
|
+
|
|
108
|
+
A theme is a folder with `theme.css` (CSS variables + text/container classes),
|
|
109
|
+
`STYLE_GUIDE.md` (rules the LLM follows) and `layouts/*.html` (starting-point slides).
|
|
110
|
+
Use `theme = "./theme"` in `deck.toml` for a deck-local theme.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv sync --all-extras
|
|
116
|
+
uv run pytest
|
|
117
|
+
uv run ruff check src tests && uv run ruff format src tests
|
|
118
|
+
cd examples/showcase && uv run keyframes dev # every component in one deck
|
|
119
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# keyframes
|
|
2
|
+
|
|
3
|
+
HTML slide decks that LLMs write and you arrange by hand.
|
|
4
|
+
|
|
5
|
+
- **Slides are plain HTML files** on a fixed 1920×1080 canvas. Claude Code, Codex & co.
|
|
6
|
+
write them from your references; a generated `AGENTS.md` teaches them the format.
|
|
7
|
+
- **Live editor in Chrome**: press `E` to drag, resize, snap and nudge blocks. Changes are
|
|
8
|
+
written straight back into the slide source (only the touched attributes change), so
|
|
9
|
+
your layout and the LLM's edits never fight.
|
|
10
|
+
- **Rich embeds** as web components: three.js scenes, MuJoCo WASM physics, editable/runnable
|
|
11
|
+
code (JS + Python via Pyodide), Excalidraw figures, KaTeX math, video.
|
|
12
|
+
- **Present anywhere**: presenter view with notes and timer, offline-safe dependency cache,
|
|
13
|
+
PDF export, static site export.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv init my-talk && cd my-talk
|
|
19
|
+
uv add keyframes # or: uv add 'keyframes[export]' for pdf/shot/check --render
|
|
20
|
+
uv run keyframes init .
|
|
21
|
+
uv run keyframes dev # opens Chrome; edit slides/*.html or ask your LLM to
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then ask your agent something like *"Using the papers in references/, draft a 12-slide talk
|
|
25
|
+
on X. Put the MuJoCo demo from assets/ on slide 5."* and rearrange the result with `E`.
|
|
26
|
+
|
|
27
|
+
For exports install the browser once: `uv run playwright install chromium` (or have Chrome).
|
|
28
|
+
|
|
29
|
+
## In the browser
|
|
30
|
+
|
|
31
|
+
| key | action |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| → / Space, ← | next / previous (build steps included) |
|
|
34
|
+
| E | toggle the layout editor |
|
|
35
|
+
| S | presenter view (notes, next slide, timer) |
|
|
36
|
+
| F | fullscreen · B blackout |
|
|
37
|
+
|
|
38
|
+
Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
|
|
39
|
+
aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
|
|
40
|
+
embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
|
|
41
|
+
⌘Z / ⌘⇧Z undo/redo, `?` for help.
|
|
42
|
+
|
|
43
|
+
Edit mode also opens a resizable side panel (toggle with `` ` ``):
|
|
44
|
+
|
|
45
|
+
- **Source**: the current slide's HTML. Selecting a block jumps to its tag; edits save and
|
|
46
|
+
hot-reload as you type; drags and LLM edits flow back in.
|
|
47
|
+
- **Claude / Codex / Shell**: real terminals running in the deck folder (whichever CLIs are
|
|
48
|
+
installed). Sessions survive page reloads. `+ ref` (or `R`) types the selected blocks'
|
|
49
|
+
reference into the prompt, e.g. `@slides/04-problem.html (block "figure")`.
|
|
50
|
+
|
|
51
|
+
The dev server only accepts editor/terminal requests from its own page, but it does give that
|
|
52
|
+
page a shell: keep it bound to 127.0.0.1 (the default).
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
keyframes init [path] new deck from the scaffold
|
|
58
|
+
keyframes dev live server + editor
|
|
59
|
+
keyframes present serve without the editor
|
|
60
|
+
keyframes check [--render] lint; --render adds browser errors and text overflow
|
|
61
|
+
keyframes shot [N ...] PNG screenshots → .keyframes/shots/
|
|
62
|
+
keyframes pdf PDF export (one page per slide, all builds shown)
|
|
63
|
+
keyframes build static site → dist/
|
|
64
|
+
keyframes guide full authoring reference (for LLMs)
|
|
65
|
+
keyframes themes list built-in themes
|
|
66
|
+
keyframes eject-theme copy the theme into the deck to customize it
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Deck layout
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
my-talk/
|
|
73
|
+
deck.toml title, theme, canvas size, extra <head> tags, extra ES module imports
|
|
74
|
+
AGENTS.md authoring rules for LLMs (CLAUDE.md imports it)
|
|
75
|
+
slides/ 01-title.html, 02-motivation.html, ... (ordered by filename)
|
|
76
|
+
assets/ images, video, MJCF models, three.js scene modules, .excalidraw files
|
|
77
|
+
references/ papers/notes for the LLM to draw from (never exported)
|
|
78
|
+
.keyframes/ dependency cache + screenshots (gitignored)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Themes
|
|
82
|
+
|
|
83
|
+
- `default`: a clone of Google Slides' "simple-light" template (Google Sans + Roboto Light,
|
|
84
|
+
orbit artwork on title/section slides, split layouts with a grey media panel).
|
|
85
|
+
- `minimal`: plain Inter/JetBrains Mono theme.
|
|
86
|
+
|
|
87
|
+
A theme is a folder with `theme.css` (CSS variables + text/container classes),
|
|
88
|
+
`STYLE_GUIDE.md` (rules the LLM follows) and `layouts/*.html` (starting-point slides).
|
|
89
|
+
Use `theme = "./theme"` in `deck.toml` for a deck-local theme.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv sync --all-extras
|
|
95
|
+
uv run pytest
|
|
96
|
+
uv run ruff check src tests && uv run ruff format src tests
|
|
97
|
+
cd examples/showcase && uv run keyframes dev # every component in one deck
|
|
98
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keyframes"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
keywords = [
|
|
8
|
+
"slides",
|
|
9
|
+
"presentation",
|
|
10
|
+
"llm",
|
|
11
|
+
"agents",
|
|
12
|
+
"three.js",
|
|
13
|
+
"mujoco",
|
|
14
|
+
"katex",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"License :: Other/Proprietary License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Graphics :: Presentation",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.28.1",
|
|
23
|
+
"starlette>=1.7.0",
|
|
24
|
+
"uvicorn>=0.54.0",
|
|
25
|
+
"watchfiles>=1.3.0",
|
|
26
|
+
"websockets>=17.2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Pranay Thangeda"
|
|
31
|
+
email = "contact@prny.me"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
export = [
|
|
35
|
+
"playwright>=1.63.0",
|
|
36
|
+
"pypdf>=6.20.1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
keyframes = "keyframes.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=9.1.1",
|
|
49
|
+
"ruff>=0.17.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 110
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = [
|
|
57
|
+
"E",
|
|
58
|
+
"F",
|
|
59
|
+
"I",
|
|
60
|
+
"UP",
|
|
61
|
+
"B",
|
|
62
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keyframes"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Pranay Thangeda", email = "contact@prny.me" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["slides", "presentation", "llm", "agents", "three.js", "mujoco", "katex"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"License :: Other/Proprietary License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Multimedia :: Graphics :: Presentation",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx>=0.28.1",
|
|
18
|
+
"starlette>=1.7.0",
|
|
19
|
+
"uvicorn>=0.54.0",
|
|
20
|
+
"watchfiles>=1.3.0",
|
|
21
|
+
"websockets>=17.2",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
export = [
|
|
26
|
+
"playwright>=1.63.0",
|
|
27
|
+
"pypdf>=6.20.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
keyframes = "keyframes.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=9.1.1",
|
|
40
|
+
"ruff>=0.17.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 110
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Static export. Vendor files are renamed so they work on any static host (no query strings,
|
|
2
|
+
JS/CSS always have an extension) and every reference to a renamed file is rewritten."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from .deck import STATIC_DIR, Deck, DeckError
|
|
12
|
+
from .render import render_index
|
|
13
|
+
from .vendor import VENDOR_PREFIX, _kind, vendor_import_map
|
|
14
|
+
|
|
15
|
+
_TOKEN = re.compile(r"""(?<=["'(])[^"'()\s]+(?=["')])""")
|
|
16
|
+
TEXT_SUFFIXES = (".js", ".mjs", ".css", ".html", ".json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _vendor_entries(cache: Path):
|
|
20
|
+
for meta_path in cache.rglob("*.meta.json"):
|
|
21
|
+
meta = json.loads(meta_path.read_text())
|
|
22
|
+
file = meta_path.with_name(meta_path.name.removesuffix(".meta.json"))
|
|
23
|
+
parts = urlsplit(meta["url"])
|
|
24
|
+
key = f"{VENDOR_PREFIX}/{parts.netloc}{parts.path}" + (f"?{parts.query}" if parts.query else "")
|
|
25
|
+
name = file.relative_to(cache).as_posix()
|
|
26
|
+
if _kind(meta["type"]) == "js" and not name.endswith((".js", ".mjs")):
|
|
27
|
+
name += ".js"
|
|
28
|
+
if _kind(meta["type"]) == "css" and not name.endswith(".css"):
|
|
29
|
+
name += ".css"
|
|
30
|
+
yield key, f"{VENDOR_PREFIX}/{name}", file, meta["type"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rewrite_refs(text: str, self_path: str, renames: dict[str, str]) -> str:
|
|
34
|
+
base = posixpath.dirname(self_path)
|
|
35
|
+
|
|
36
|
+
def sub(m: re.Match) -> str:
|
|
37
|
+
token = m.group(0)
|
|
38
|
+
path, sep, query = token.partition("?")
|
|
39
|
+
if not (path.startswith((".", VENDOR_PREFIX))):
|
|
40
|
+
return token
|
|
41
|
+
resolved = posixpath.normpath(posixpath.join(base, path)) + sep + query
|
|
42
|
+
target = renames.get(resolved)
|
|
43
|
+
if target is None:
|
|
44
|
+
return token
|
|
45
|
+
rel = posixpath.relpath(target, base or ".")
|
|
46
|
+
return rel if rel.startswith(".") else f"./{rel}"
|
|
47
|
+
|
|
48
|
+
return _TOKEN.sub(sub, text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _copy_tree(src: Path, dst: Path, dst_prefix: str, renames: dict[str, str]):
|
|
52
|
+
for file in src.rglob("*"):
|
|
53
|
+
if not file.is_file() or file.name == "index.html" and src == STATIC_DIR:
|
|
54
|
+
continue
|
|
55
|
+
rel = file.relative_to(src).as_posix()
|
|
56
|
+
out = dst / rel
|
|
57
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
if file.suffix in TEXT_SUFFIXES:
|
|
59
|
+
out.write_text(rewrite_refs(file.read_text(), f"{dst_prefix}/{rel}", renames))
|
|
60
|
+
else:
|
|
61
|
+
shutil.copy2(file, out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def warm_cache(deck: Deck) -> bool:
|
|
65
|
+
"""Load every slide once in a headless browser so all vendor files get cached."""
|
|
66
|
+
from .export import render_report
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
render_report(deck)
|
|
70
|
+
except DeckError:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build(deck: Deck, out: Path | None = None) -> Path:
|
|
76
|
+
out = (out or deck.root / "dist").resolve()
|
|
77
|
+
if out.exists():
|
|
78
|
+
shutil.rmtree(out)
|
|
79
|
+
out.mkdir(parents=True)
|
|
80
|
+
|
|
81
|
+
entries = list(_vendor_entries(deck.cache_dir / "vendor"))
|
|
82
|
+
renames = {key: name for key, name, _, _ in entries if key != name}
|
|
83
|
+
|
|
84
|
+
for _, name, file, content_type in entries:
|
|
85
|
+
target = out / name
|
|
86
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
if _kind(content_type):
|
|
88
|
+
target.write_text(rewrite_refs(file.read_text(), name, renames))
|
|
89
|
+
else:
|
|
90
|
+
shutil.copy2(file, target)
|
|
91
|
+
|
|
92
|
+
_copy_tree(STATIC_DIR, out / "_keyframes", "_keyframes", renames)
|
|
93
|
+
_copy_tree(deck.theme_dir, out / "_theme", "_theme", renames)
|
|
94
|
+
if (deck.root / "assets").is_dir():
|
|
95
|
+
_copy_tree(deck.root / "assets", out / "assets", "assets", renames)
|
|
96
|
+
|
|
97
|
+
import_map = {
|
|
98
|
+
name: rewrite_refs(f'"{path}"', "index.html", renames).strip('"')
|
|
99
|
+
for name, path in vendor_import_map(deck.imports).items()
|
|
100
|
+
}
|
|
101
|
+
(out / "index.html").write_text(render_index(deck, "present", import_map))
|
|
102
|
+
return out
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .deck import STATIC_DIR, Deck
|
|
7
|
+
from .patch import find_block_tags
|
|
8
|
+
|
|
9
|
+
LOCAL_REF_ATTRS = {"src", "href", "model", "controller", "poster"}
|
|
10
|
+
COMPONENTS = {p.stem for p in (STATIC_DIR / "components").glob("keyframes-*.js")}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Collector(HTMLParser):
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.tags: list[tuple[str, dict]] = []
|
|
17
|
+
self.depth = 0
|
|
18
|
+
self.roots: list[str] = []
|
|
19
|
+
|
|
20
|
+
def handle_starttag(self, tag, attrs):
|
|
21
|
+
if self.depth == 0:
|
|
22
|
+
self.roots.append(tag)
|
|
23
|
+
self.tags.append((tag, dict(attrs)))
|
|
24
|
+
if tag not in {"img", "br", "hr", "input", "meta", "link", "source", "wbr"}:
|
|
25
|
+
self.depth += 1
|
|
26
|
+
|
|
27
|
+
def handle_startendtag(self, tag, attrs):
|
|
28
|
+
if self.depth == 0:
|
|
29
|
+
self.roots.append(tag)
|
|
30
|
+
self.tags.append((tag, dict(attrs)))
|
|
31
|
+
|
|
32
|
+
def handle_endtag(self, tag):
|
|
33
|
+
self.depth = max(0, self.depth - 1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_local(ref: str) -> bool:
|
|
37
|
+
return bool(ref) and not re.match(r"^([a-z]+:|//|#|data:|_vendor/|\$)", ref)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_slider(where: str, attrs: dict, ids: set[str]) -> list[str]:
|
|
41
|
+
target = attrs.get("for")
|
|
42
|
+
if not target:
|
|
43
|
+
return [f"{where}: <keyframes-slider> needs a for attribute"]
|
|
44
|
+
problems = []
|
|
45
|
+
if target not in ids:
|
|
46
|
+
problems.append(
|
|
47
|
+
f'{where}: <keyframes-slider for="{target}"> matches no data-block or id on this slide'
|
|
48
|
+
)
|
|
49
|
+
if "timeline" not in attrs and not attrs.get("param"):
|
|
50
|
+
problems.append(f'{where}: <keyframes-slider for="{target}"> needs param="..." or timeline')
|
|
51
|
+
return problems
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_slide(deck: Deck, path: Path) -> list[str]:
|
|
55
|
+
source = path.read_text()
|
|
56
|
+
problems = []
|
|
57
|
+
where = f"slides/{path.name}"
|
|
58
|
+
|
|
59
|
+
collector = _Collector()
|
|
60
|
+
collector.feed(source)
|
|
61
|
+
if collector.roots != ["section"]:
|
|
62
|
+
problems.append(f"{where}: must contain exactly one root <section>, found {collector.roots}")
|
|
63
|
+
|
|
64
|
+
blocks = find_block_tags(source)
|
|
65
|
+
for block_id, count in Counter(b.id for b in blocks).items():
|
|
66
|
+
if count > 1:
|
|
67
|
+
problems.append(f"{where}: duplicate data-block id {block_id!r}")
|
|
68
|
+
for block in blocks:
|
|
69
|
+
geometry = {k: block.attrs.get(f"data-{k}") for k in "xywh"}
|
|
70
|
+
missing = [k for k, v in geometry.items() if v is None]
|
|
71
|
+
if missing:
|
|
72
|
+
problems.append(f"{where}#{block.id}: missing data-{', data-'.join(missing)}")
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
x, y, w, h = (float(geometry[k]) for k in "xywh")
|
|
76
|
+
except ValueError:
|
|
77
|
+
problems.append(f"{where}#{block.id}: non-numeric geometry {geometry}")
|
|
78
|
+
continue
|
|
79
|
+
if x < 0 or y < 0 or x + w > deck.width or y + h > deck.height:
|
|
80
|
+
problems.append(f"{where}#{block.id}: extends outside the {deck.width}x{deck.height} canvas")
|
|
81
|
+
|
|
82
|
+
ids = {v for _, attrs in collector.tags for k, v in attrs.items() if k in ("id", "data-block") and v}
|
|
83
|
+
for tag, attrs in collector.tags:
|
|
84
|
+
if tag == "keyframes-slider":
|
|
85
|
+
problems += _check_slider(where, attrs, ids)
|
|
86
|
+
if tag.startswith("keyframes-") and tag not in COMPONENTS:
|
|
87
|
+
problems.append(f"{where}: unknown component <{tag}> (available: {sorted(COMPONENTS)})")
|
|
88
|
+
for attr in LOCAL_REF_ATTRS & attrs.keys():
|
|
89
|
+
ref = (attrs[attr] or "").split("?")[0].split("#")[0]
|
|
90
|
+
if _is_local(ref) and not (deck.root / ref).exists():
|
|
91
|
+
problems.append(f'{where}: <{tag} {attr}="{ref}"> file not found')
|
|
92
|
+
return problems
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_deck(deck: Deck, render: bool = False) -> list[str]:
|
|
96
|
+
paths = deck.slide_paths()
|
|
97
|
+
if not paths:
|
|
98
|
+
return [f"No slides found in {deck.slides_dir}"]
|
|
99
|
+
problems = [p for path in paths for p in check_slide(deck, path)]
|
|
100
|
+
if render:
|
|
101
|
+
from .export import render_report
|
|
102
|
+
|
|
103
|
+
for result in render_report(deck):
|
|
104
|
+
where = f"slides/{result.name}.html"
|
|
105
|
+
problems += [f"{where}: browser error: {e}" for e in result.errors]
|
|
106
|
+
problems += [f"{where}#{b}: content overflows its box" for b in result.overflowing]
|
|
107
|
+
return problems
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .deck import PACKAGE_DIR, THEMES_DIR, Deck, DeckError
|
|
9
|
+
from .guide import GUIDE_DIR
|
|
10
|
+
|
|
11
|
+
SCAFFOLD_DIR = PACKAGE_DIR / "scaffold"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_init(args):
|
|
15
|
+
target = Path(args.path).resolve()
|
|
16
|
+
if (target / "deck.toml").exists():
|
|
17
|
+
sys.exit(f"{target} already has a deck.toml")
|
|
18
|
+
shutil.copytree(SCAFFOLD_DIR, target, dirs_exist_ok=True)
|
|
19
|
+
shutil.copy(GUIDE_DIR / "core.md", target / "AGENTS.md")
|
|
20
|
+
deck_toml = target / "deck.toml"
|
|
21
|
+
deck_toml.write_text(
|
|
22
|
+
deck_toml.read_text().replace("{{title}}", args.title or target.name).replace("{{theme}}", args.theme)
|
|
23
|
+
)
|
|
24
|
+
print(f"Created deck in {target}\nNext: cd {target} && keyframes dev")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def serve(args, mode: str):
|
|
28
|
+
import uvicorn
|
|
29
|
+
|
|
30
|
+
from .server import create_app, free_port
|
|
31
|
+
|
|
32
|
+
deck = Deck.load(Path(args.path))
|
|
33
|
+
port = free_port(args.port)
|
|
34
|
+
url = f"http://127.0.0.1:{port}/"
|
|
35
|
+
print(f"keyframes {mode} → {url}")
|
|
36
|
+
if mode == "dev":
|
|
37
|
+
print("Press E in the browser to toggle the layout editor, S for presenter view.")
|
|
38
|
+
if not args.no_open:
|
|
39
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
40
|
+
uvicorn.run(create_app(deck, mode), host="127.0.0.1", port=port, log_level="warning")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_build(args):
|
|
44
|
+
from .build import build, warm_cache
|
|
45
|
+
|
|
46
|
+
deck = Deck.load(Path(args.path))
|
|
47
|
+
if not warm_cache(deck):
|
|
48
|
+
print("Warning: install keyframes[export] so build can preload every slide's dependencies.")
|
|
49
|
+
out = build(deck, Path(args.out) if args.out else None)
|
|
50
|
+
print(f"Built static deck → {out}")
|
|
51
|
+
print(f"Serve it with any static server, e.g. `python -m http.server -d {out}`")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_pdf(args):
|
|
55
|
+
from .export import export_pdf
|
|
56
|
+
|
|
57
|
+
deck = Deck.load(Path(args.path))
|
|
58
|
+
out = export_pdf(deck, Path(args.out) if args.out else deck.root / f"{deck.root.name}.pdf")
|
|
59
|
+
print(f"Wrote {out}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_shot(args):
|
|
63
|
+
from .export import screenshot_slides
|
|
64
|
+
|
|
65
|
+
deck = Deck.load(Path(args.path))
|
|
66
|
+
out_dir = Path(args.out) if args.out else deck.cache_dir / "shots"
|
|
67
|
+
for path in screenshot_slides(deck, args.slides, out_dir, scale=args.scale):
|
|
68
|
+
print(path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_check(args):
|
|
72
|
+
from .check import check_deck
|
|
73
|
+
|
|
74
|
+
problems = check_deck(Deck.load(Path(args.path)), render=args.render)
|
|
75
|
+
for problem in problems:
|
|
76
|
+
print(problem)
|
|
77
|
+
if problems:
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
print("No problems found.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_guide(args):
|
|
83
|
+
from .guide import full_guide
|
|
84
|
+
|
|
85
|
+
print(full_guide(Path(args.path)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_themes(args):
|
|
89
|
+
for theme in sorted(p.name for p in THEMES_DIR.iterdir() if p.is_dir()):
|
|
90
|
+
print(theme)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_eject_theme(args):
|
|
94
|
+
deck = Deck.load(Path(args.path))
|
|
95
|
+
target = deck.root / "theme"
|
|
96
|
+
if target.exists():
|
|
97
|
+
sys.exit(f"{target} already exists")
|
|
98
|
+
shutil.copytree(deck.theme_dir, target)
|
|
99
|
+
config = deck.root / "deck.toml"
|
|
100
|
+
config.write_text(config.read_text().replace(f'theme = "{deck.theme}"', 'theme = "./theme"'))
|
|
101
|
+
print(f"Copied theme to {target} and pointed deck.toml at it.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
105
|
+
parser = argparse.ArgumentParser(prog="keyframes", description=__doc__)
|
|
106
|
+
sub = parser.add_subparsers(required=True)
|
|
107
|
+
|
|
108
|
+
def command(name, fn, help, path=True):
|
|
109
|
+
p = sub.add_parser(name, help=help)
|
|
110
|
+
if path:
|
|
111
|
+
p.add_argument("path", nargs="?", default=".", help="deck folder (default: .)")
|
|
112
|
+
p.set_defaults(fn=fn)
|
|
113
|
+
return p
|
|
114
|
+
|
|
115
|
+
p = command("init", cmd_init, "create a new deck")
|
|
116
|
+
p.add_argument("--title")
|
|
117
|
+
p.add_argument("--theme", default="default")
|
|
118
|
+
|
|
119
|
+
for name, mode, help in [
|
|
120
|
+
("dev", "dev", "live server with layout editor"),
|
|
121
|
+
("present", "present", "serve without editor"),
|
|
122
|
+
]:
|
|
123
|
+
p = command(name, lambda a, m=mode: serve(a, m), help)
|
|
124
|
+
p.add_argument("--port", type=int, default=8765)
|
|
125
|
+
p.add_argument("--no-open", action="store_true")
|
|
126
|
+
|
|
127
|
+
p = command("build", cmd_build, "export a static site")
|
|
128
|
+
p.add_argument("-o", "--out")
|
|
129
|
+
p = command("pdf", cmd_pdf, "export a PDF (requires keyframes[export])")
|
|
130
|
+
p.add_argument("-o", "--out")
|
|
131
|
+
p = command("shot", cmd_shot, "screenshot slides to PNG (requires keyframes[export])", path=False)
|
|
132
|
+
p.add_argument("-d", "--deck", dest="path", default=".", help="deck folder (default: .)")
|
|
133
|
+
p.add_argument("slides", nargs="*", help="slide numbers or names (default: all)")
|
|
134
|
+
p.add_argument("-o", "--out")
|
|
135
|
+
p.add_argument("--scale", type=float, default=0.5)
|
|
136
|
+
p = command("check", cmd_check, "lint slides; --render also loads them in a browser")
|
|
137
|
+
p.add_argument("--render", action="store_true")
|
|
138
|
+
command("guide", cmd_guide, "print the full authoring reference for LLMs")
|
|
139
|
+
command("themes", cmd_themes, "list built-in themes", path=False)
|
|
140
|
+
command("eject-theme", cmd_eject_theme, "copy the current theme into the deck to customize")
|
|
141
|
+
return parser
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
args = build_parser().parse_args()
|
|
146
|
+
try:
|
|
147
|
+
args.fn(args)
|
|
148
|
+
except DeckError as e:
|
|
149
|
+
sys.exit(str(e))
|