feat: init

This commit is contained in:
Your Name 2026-06-27 11:11:20 -04:00
commit bdc9928db8
20 changed files with 93277 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Build output
/target/
/checkouts/
/out/
/src/gen/
public/js
resources/public/js/compiled/
# Clojure / Leiningen / shadow-cljs
pom.xml
pom.xml.asc
*.jar
.lein-*
.nrepl-port
.cpcache/
.shadow-cljs/
*-init.clj
# Tooling caches (keep configs, drop caches)
.clj-kondo/.cache/
.lsp/.cache/
# Node
node_modules/
# Logs
*.log
# Editors / OS
*.iml
.idea/
.vscode/
.DS_Store

15
tl/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
/out/
/resources/public/js/compiled/
/target/
/*-init.clj
/*.log
# Leiningen
/.lein-*
/.nrepl-port
# Node.js dependencies
/node_modules/
# shadow-cljs cache, port files
/.shadow-cljs/

194
tl/README.md Normal file
View file

@ -0,0 +1,194 @@
# tl
A [re-frame](https://github.com/day8/re-frame) application designed to ... well, that part is up to
you.
## Getting Started
### Project Overview
* Architecture:
[Single Page Application (SPA)](https://en.wikipedia.org/wiki/Single-page_application)
* Languages
- Front end is [ClojureScript](https://clojurescript.org/) with ([re-frame](https://github.com/day8/re-frame))
* Dependencies
- UI framework: [re-frame](https://github.com/day8/re-frame)
([docs](https://github.com/day8/re-frame/blob/master/docs/README.md),
[FAQs](https://github.com/day8/re-frame/blob/master/docs/FAQs/README.md)) ->
[Reagent](https://github.com/reagent-project/reagent) ->
[React](https://github.com/facebook/react)
* Build tools
- CLJS compilation, dependency management, REPL, & hot reload: [`shadow-cljs`](https://github.com/thheller/shadow-cljs)
* Development tools
- Debugging: [CLJS DevTools](https://github.com/binaryage/cljs-devtools)
#### Directory structure
* [`/`](/../../): project config files
* [`dev/`](dev/): source files compiled only with the [dev](#running-the-app) profile
- [`user.cljs`](dev/cljs/user.cljs): symbols for use during development in the
[ClojureScript REPL](#connecting-to-the-browser-repl-from-a-terminal)
* [`resources/public/`](resources/public/): SPA root directory;
[dev](#running-the-app) / [prod](#production) profile depends on the most recent build
- [`index.html`](resources/public/index.html): SPA home page
- Dynamic SPA content rendered in the following `div`:
```html
<div id="app"></div>
```
- Customizable; add headers, footers, links to other scripts and styles, etc.
- Generated directories and files
- Created on build with either the [dev](#running-the-app) or [prod](#production) profile
- `js/compiled/`: compiled CLJS (`shadow-cljs`)
- Not tracked in source control; see [`.gitignore`](.gitignore)
* [`src/tl/`](src/tl/): SPA source files (ClojureScript,
[re-frame](https://github.com/Day8/re-frame))
- [`core.cljs`](src/tl/core.cljs): contains the SPA entry point, `init`
* [`.github/workflows/`](.github/workflows/): contains the
[github actions](https://github.com/features/actions) pipelines.
- [`test.yaml`](.github/workflows/test.yaml): Pipeline for testing.
### Editor/IDE
Use your preferred editor or IDE that supports Clojure/ClojureScript development. See
[Clojure tools](https://clojure.org/community/resources#_clojure_tools) for some popular options.
### Environment Setup
1. Install [JDK 8 or later](https://openjdk.java.net/install/) (Java Development Kit)
2. Install [Node.js](https://nodejs.org/) (JavaScript runtime environment) which should include
[NPM](https://docs.npmjs.com/cli/npm) or if your Node.js installation does not include NPM also install it.
5. Clone this repo and open a terminal in the `tl` project root directory
### Browser Setup
Browser caching should be disabled when developer tools are open to prevent interference with
[`shadow-cljs`](https://github.com/thheller/shadow-cljs) hot reloading.
Custom formatters must be enabled in the browser before
[CLJS DevTools](https://github.com/binaryage/cljs-devtools) can display ClojureScript data in the
console in a more readable way.
#### Chrome/Chromium
1. Open [DevTools](https://developers.google.com/web/tools/chrome-devtools/) (Linux/Windows: `F12`
or `Ctrl-Shift-I`; macOS: `⌘-Option-I`)
2. Open DevTools Settings (Linux/Windows: `?` or `F1`; macOS: `?` or `Fn+F1`)
3. Select `Preferences` in the navigation menu on the left, if it is not already selected
4. Under the `Network` heading, enable the `Disable cache (while DevTools is open)` option
5. Under the `Console` heading, enable the `Enable custom formatters` option
#### Firefox
1. Open [Developer Tools](https://developer.mozilla.org/en-US/docs/Tools) (Linux/Windows: `F12` or
`Ctrl-Shift-I`; macOS: `⌘-Option-I`)
2. Open [Developer Tools Settings](https://developer.mozilla.org/en-US/docs/Tools/Settings)
(Linux/macOS/Windows: `F1`)
3. Under the `Advanced settings` heading, enable the `Disable HTTP Cache (when toolbox is open)`
option
Unfortunately, Firefox does not yet support custom formatters in their devtools. For updates, follow
the enhancement request in their bug tracker:
[1262914 - Add support for Custom Formatters in devtools](https://bugzilla.mozilla.org/show_bug.cgi?id=1262914).
## Development
### Running the App
Start a temporary local web server, build the app with the `dev` profile, and serve the app,
browser test runner and node test runner with hot reload:
```sh
npm install
npx shadow-cljs watch app
```
Please be patient; it may take over 20 seconds to see any output, and over 40 seconds to complete.
When `[:app] Build completed` appears in the output, browse to
[http://localhost:8280/](http://localhost:8280/).
[`shadow-cljs`](https://github.com/thheller/shadow-cljs) will automatically push ClojureScript code
changes to your browser on save. To prevent a few common issues, see
[Hot Reload in ClojureScript: Things to avoid](https://code.thheller.com/blog/shadow-cljs/2019/08/25/hot-reload-in-clojurescript.html#things-to-avoid).
Opening the app in your browser starts a
[ClojureScript browser REPL](https://clojurescript.org/reference/repl#using-the-browser-as-an-evaluation-environment),
to which you may now connect.
#### Connecting to the browser REPL from your editor
See
[Shadow CLJS User's Guide: Editor Integration](https://shadow-cljs.github.io/docs/UsersGuide.html#_editor_integration).
Note that `npm run watch` runs `npx shadow-cljs watch` for you, and that this project's running build ids is
`app`, `browser-test`, `node-test`, or the keywords `:app`, `:browser-test`, `:node-test` in a Clojure context.
Alternatively, search the web for info on connecting to a `shadow-cljs` ClojureScript browser REPL
from your editor and configuration.
For example, in Vim / Neovim with `fireplace.vim`
1. Open a `.cljs` file in the project to activate `fireplace.vim`
2. In normal mode, execute the `Piggieback` command with this project's running build id, `:app`:
```vim
:Piggieback :app
```
#### Connecting to the browser REPL from a terminal
1. Connect to the `shadow-cljs` nREPL:
```sh
lein repl :connect localhost:8777
```
The REPL prompt, `shadow.user=>`, indicates that is a Clojure REPL, not ClojureScript.
2. In the REPL, switch the session to this project's running build id, `:app`:
```clj
(shadow.cljs.devtools.api/nrepl-select :app)
```
The REPL prompt changes to `cljs.user=>`, indicating that this is now a ClojureScript REPL.
3. See [`user.cljs`](dev/cljs/user.cljs) for symbols that are immediately accessible in the REPL
without needing to `require`.
### Running `shadow-cljs` Actions
See a list of [`shadow-cljs CLI`](https://shadow-cljs.github.io/docs/UsersGuide.html#_command_line)
actions:
```sh
npx shadow-cljs --help
```
Please be patient; it may take over 10 seconds to see any output. Also note that some actions shown
may not actually be supported, outputting "Unknown action." when run.
Run a shadow-cljs action on this project's build id (without the colon, just `app`):
```sh
npx shadow-cljs <action> app
```
### Debug Logging
The `debug?` variable in [`config.cljs`](src/cljs/tl/config.cljs) defaults to `true` in
[`dev`](#running-the-app) builds, and `false` in [`prod`](#production) builds.
Use `debug?` for logging or other tasks that should run only on `dev` builds:
```clj
(ns tl.example
(:require [tl.config :as config])
(when config/debug?
(println "This message will appear in the browser console only on dev builds."))
```
## Production
Build the app with the `prod` profile:
```sh
npm install
npm run release
```
Please be patient; it may take over 15 seconds to see any output, and over 30 seconds to complete.
The `resources/public/js/compiled` directory is created, containing the compiled `app.js` and
`manifest.edn` files.

19
tl/dev/dev.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Dev launcher: runs the shadow-cljs watch AND the range-capable media server
# together, and tears both down on exit (Ctrl-C kills the whole group).
#
# npm run dev
#
set -euo pipefail
cd "$(dirname "$0")/.."
cleanup() { kill 0 2>/dev/null || true; }
trap cleanup EXIT INT TERM
echo "[dev] starting media server (port ${MEDIA_PORT:-8281}, range-capable, autoreload)"
python3 dev/media_server.py &
echo "[dev] starting shadow-cljs watch app (port 8280)"
npx shadow-cljs watch app &
wait

117
tl/dev/media_server.py Executable file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Range-capable static file server for dev media (video).
shadow-cljs :dev-http and Python's stdlib http.server both ignore the HTTP
Range header and answer 200 with the whole file. Safari/iOS refuse to play
an HTML5 <video> unless the server answers Range requests with 206, so the
video fails when viewed over Tailscale on a phone/tablet.
This serves resources/public on a dedicated port WITH byte-range support, so
<video> can stream/seek. Run alongside `npx shadow-cljs watch app`:
python3 dev/media_server.py
"""
import http.server
import os
import re
import sys
import threading
import time
PORT = int(os.environ.get("MEDIA_PORT", "8281"))
ROOT = os.path.join(os.path.dirname(__file__), "..", "resources", "public")
class RangeHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=os.path.abspath(ROOT), **kwargs)
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Accept-Ranges", "bytes")
super().end_headers()
def send_head(self):
rng = self.headers.get("Range")
if rng is None:
return super().send_head()
path = self.translate_path(self.path)
try:
f = open(path, "rb")
except OSError:
self.send_error(404, "File not found")
return None
size = os.fstat(f.fileno()).st_size
m = re.match(r"bytes=(\d*)-(\d*)", rng)
start_s, end_s = (m.group(1), m.group(2)) if m else ("", "")
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else size - 1
end = min(end, size - 1)
if start > end:
f.close()
self.send_error(416, "Requested Range Not Satisfiable")
self.send_header("Content-Range", f"bytes */{size}")
return None
self.send_response(206)
self.send_header("Content-Type", self.guess_type(path))
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(end - start + 1))
self.end_headers()
f.seek(start)
self._range = (f, start, end) # consumed by copyfile override
return f
def copyfile(self, source, outputfile):
rng = getattr(self, "_range", None)
try:
if rng is None:
return super().copyfile(source, outputfile)
f, start, end = rng
remaining = end - start + 1
while remaining > 0:
chunk = f.read(min(64 * 1024, remaining))
if not chunk:
break
outputfile.write(chunk)
remaining -= len(chunk)
except (BrokenPipeError, ConnectionResetError):
# Client (e.g. Safari) closed the connection mid-stream while
# seeking/pausing — normal for <video>, nothing to log.
pass
def handle_one_request(self):
try:
super().handle_one_request()
except (BrokenPipeError, ConnectionResetError):
self.close_connection = True
def _autoreload(interval=1.0):
"""Restart the process when this script changes, so edits take effect live."""
path = os.path.abspath(__file__)
try:
mtime = os.path.getmtime(path)
except OSError:
return
while True:
time.sleep(interval)
try:
m = os.path.getmtime(path)
except OSError:
continue
if m != mtime:
print("media server: change detected, reloading...", flush=True)
os.execv(sys.executable, [sys.executable] + sys.argv)
if __name__ == "__main__":
addr = ("0.0.0.0", PORT)
print(f"media server (range-capable) on http://0.0.0.0:{PORT} serving {os.path.abspath(ROOT)}")
threading.Thread(target=_autoreload, daemon=True).start()
try:
http.server.ThreadingHTTPServer(addr, RangeHandler).serve_forever()
except KeyboardInterrupt:
sys.exit(0)

8
tl/dev/user.cljs Normal file
View file

@ -0,0 +1,8 @@
(ns cljs.user
"Commonly used symbols for easy access in the ClojureScript REPL during
development."
(:require
[cljs.repl :refer (Error->map apropos dir doc error->str ex-str ex-triage
find-doc print-doc pst source)]
[clojure.pprint :refer (pprint)]
[clojure.string :as str]))

273
tl/package-lock.json generated Normal file
View file

@ -0,0 +1,273 @@
{
"name": "tl",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tl",
"dependencies": {
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"shadow-cljs": "3.4.6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
"integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
"integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"scheduler": "^0.20.2"
},
"peerDependencies": {
"react": "17.0.2"
}
},
"node_modules/readline-sync": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz",
"integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/scheduler": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
"integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"node_modules/shadow-cljs": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-3.4.6.tgz",
"integrity": "sha512-4IyEN00b38MEGBBAhSJJmilkCvSK/t/TPxsqhPBDWDLO6Xpt8zn0TiCO0eIhcR8AbmJ5V02Qzc4N3mffl61MFA==",
"dev": true,
"license": "ISC",
"dependencies": {
"buffer": "^6.0.3",
"process": "^0.11.10",
"readline-sync": "^1.4.10",
"shadow-cljs-jar": "1.3.4",
"source-map-support": "^0.5.21",
"which": "^5.0.0",
"ws": "^8.18.1"
},
"bin": {
"shadow-cljs": "cli/runner.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/shadow-cljs-jar": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz",
"integrity": "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

18
tl/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "tl",
"scripts": {
"ancient": "clojure -Sdeps '{:deps {com.github.liquidz/antq {:mvn/version \"RELEASE\"}}}' -m antq.core",
"dev": "bash dev/dev.sh",
"media": "python3 dev/media_server.py",
"watch": "npx shadow-cljs watch app",
"release": "npx shadow-cljs release app",
"build-report": "npx shadow-cljs run shadow.cljs.build-report app target/build-report.html"
},
"dependencies": {
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"shadow-cljs": "3.4.6"
}
}

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>tl</title>
</head>
<body>
<noscript>
tl is a JavaScript app. Please enable JavaScript to continue.
</noscript>
<div id="app"></div>
<script src="/js/compiled/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

29
tl/shadow-cljs.edn Normal file
View file

@ -0,0 +1,29 @@
{:nrepl {:port 8777}
:source-paths ["src" "test"]
:dependencies
[[reagent "1.2.0"]
[re-frame "1.4.7"]
[day8.re-frame/http-fx "0.2.4"]
[binaryage/devtools "1.0.7"]]
:dev-http
{8280 "resources/public"
8290 "target/browser-test"}
:builds
{:app
{:target :browser
:output-dir "resources/public/js/compiled"
:asset-path "/js/compiled"
:modules
{:app {:init-fn tl.core/init}}
:devtools
{:preloads []}
:dev
{:compiler-options
{:closure-defines
{ }}}}}}

4
tl/src/tl/config.cljs Normal file
View file

@ -0,0 +1,4 @@
(ns tl.config)
(def debug?
^boolean goog.DEBUG)

26
tl/src/tl/core.cljs Normal file
View file

@ -0,0 +1,26 @@
(ns tl.core
(:require
[reagent.dom :as rdom]
[re-frame.core :as re-frame]
[day8.re-frame.http-fx] ;; <-- add this
[tl.events :as events]
[tl.views :as views]
[tl.config :as config]
))
(defn dev-setup []
(when config/debug?
(println "dev mode")))
(defn ^:dev/after-load mount-root []
(re-frame/clear-subscription-cache!)
(let [root-el (.getElementById js/document "app")]
(rdom/unmount-component-at-node root-el)
(rdom/render [views/main-panel] root-el)))
(defn init []
(re-frame/dispatch-sync [::events/initialize-db])
(re-frame/dispatch [::events/load-otio])
(dev-setup)
(mount-root))

56
tl/src/tl/db.cljs Normal file
View file

@ -0,0 +1,56 @@
(ns tl.db)
(def default-db
{;; load lifecycle for the OTIO fetch
:load {:status :idle ; :idle | :loading | :ready | :error
:error nil}
;; the parsed, immutable-after-load document (tl.otio/parse output)
:timeline nil
;; Mark-groups: the annotation layer (see tl.marks for the grammar).
;; Clips come from :timeline; these are the user-authored groups + the
;; timeline descriptors. A timeline's :mark-groups says which annotations
;; IT surfaces ("*" | [ids] | {:except #{ids}}); the top of :timeline-stack
;; decides what's currently visible. For now everything is hardcoded.
:marks
{:timeline-stack [:root]
:groups
{:root {:type :timeline :name "Sequence" :mark-groups "*"}
:opening-wides
{:type :annotation :name "The opening wides"
:marks [:t0-c0 :t1-c0]
:content "# The opening wides\n\nWe **establish the geography** before a single face. Two wides, back to back — court, players, the *distance* between them. Everything tightens from here.\n\n- full court\n- baseline"}
:tashis-face
{:type :annotation :name "Tashi's gravity"
:marks [:t2-c0 :t2-c1 :t2-c2 :t2-c3 :t2-c4]
:content "## Tashi's gravity\n\nThe cut keeps *returning* to her. Five times in the first minute we come back to this **CU** — the edit can't leave her alone, and neither can they."}
:the-volley
{:type :annotation :name "Beat: the volley"
:marks [[1831 2660]]
:content "## Beat: the volley\n\nTashi → Art → Patrick, **MS**, accelerating. The rhythm of the cutting *is* the rally — shot lengths shrink as the point builds."}
:the-eyes
{:type :annotation :name "Eyes"
:marks [:t19-c0 :t20-c0 :t21-c0 :t19-c1 :t20-c1]
:content "## Eyes\n\nThree pairs of eyes, intercut. Hers searching, his certain, his afraid. The film **rhymes** them and lets you do the math."}
:a-spectator
{:type :annotation :name "A spectator"
:marks [[:at :t11-c0 0]]
:content "A spectator, caught for a single frame. *Remember this face.*"}
:slow-mo
{:type :annotation :name "Climax: the serve"
:marks [:t30-c0]
:content "# Climax: the serve (slow-mo)\n\n3000+ frames on one motion. Time **dilates** at the decisive moment — the cut finally stops cutting and just *watches*."}}}
;; transient view state (mutates while scrubbing/zooming)
:view {:zoom 50 ; pixels per SECOND (px/frame = zoom/fps)
:playhead 0.0 ; frames (media/display coordinate)
:playing? false
:seg 0 ; current segment index within the active program
:selected-clip nil}})

73
tl/src/tl/events.cljs Normal file
View file

@ -0,0 +1,73 @@
(ns tl.events
(:require
[re-frame.core :as rf]
[tl.db :as db]
[tl.otio :as otio]
[ajax.core :as ajax]))
(rf/reg-event-db
::initialize-db
(fn [_ _]
db/default-db))
;; --- load + parse ---------------------------------------------------------
(rf/reg-event-fx
::load-otio
(fn [{:keys [db]} _]
{:db (assoc-in db [:load :status] :loading)
:http-xhrio {:method :get
:uri "/one_two_three.otio"
:response-format (ajax/json-response-format {:keywords? true})
:on-success [::otio-loaded]
:on-failure [::otio-error]}}))
(rf/reg-event-db
::otio-loaded
(fn [db [_ raw]]
(-> db
(assoc :timeline (otio/parse raw))
(assoc-in [:load :status] :ready))))
(rf/reg-event-db
::otio-error
(fn [db [_ err]]
(-> db
(assoc-in [:load :status] :error)
(assoc-in [:load :error] err))))
;; --- playback ------------------------------------------------------------
;; The <video> element is the clock (direct 1:1): it pushes its currentTime
;; (as frames) into :playhead. See tl.views/video-monitor.
(rf/reg-event-db
::set-playhead
(fn [db [_ frames]]
(assoc-in db [:view :playhead] frames)))
(rf/reg-event-db
::set-playing
(fn [db [_ playing?]]
(assoc-in db [:view :playing?] playing?)))
;; --- timeline stack (zoom into / out of a mark-group) --------------------
(rf/reg-event-db
::push-timeline
(fn [db [_ gid]]
(-> db
(update-in [:marks :timeline-stack] conj gid)
(assoc-in [:view :seg] 0))))
(rf/reg-event-db
::pop-timeline
(fn [db _]
(-> db
(update-in [:marks :timeline-stack] #(if (> (count %) 1) (pop %) %))
(assoc-in [:view :seg] 0))))
;; current segment index within the active program (set by the player as it
;; jumps from span to span)
(rf/reg-event-db
::set-seg
(fn [db [_ i]] (assoc-in db [:view :seg] i)))

89
tl/src/tl/marks.cljs Normal file
View file

@ -0,0 +1,89 @@
(ns tl.marks
"Pure mark resolution: a *mark* resolves to one or more timeline *spans*.
No re-frame, no DOM — REPL/test friendly. Canonical unit is FRAMES (see tl.otio).
Mark grammar (data, not a textual DSL):
ref :t0-c0 ; whole clip (or another mark-group) span
instant 1234 ; absolute timeline frame
[:at ref i] ; frame i within a clip; 0-based, -1 = last
range [p1 p2] ; p = absolute frame OR [:at ref i]
marks [mark ...] ; a mark-group's ordered list (an EDL / union)
A *span* is {:start :end :track :clip :instant?} in DISPLAY frames — i.e.
media/source frames (position within the .mov), matching :media-in and the
<video> clock, which is what the timeline view and playhead use. Absolute
frames in marks are interpreted in this same coordinate.")
(defn clip-index
"Map of clip-id keyword -> clip, enriched with its track's setup :name + :kind.
(tl.otio/parse keeps the setup name on the track, not the clip.)"
[timeline]
(into {} (for [t (:tracks timeline)
c (:clips t)]
[(keyword (:id c)) (assoc c :track (:name t) :kind (:kind t))])))
(defn- ref? [m] (or (keyword? m) (string? m)))
(defn- point
"Resolve a point to {:frame :clip}. p is an absolute frame or [:at ref i]."
[idx p]
(cond
(number? p) {:frame p :clip nil}
(and (vector? p) (= :at (first p)))
(let [[_ ref i] p
c (idx (keyword ref))]
(when-not c (throw (ex-info "unknown clip ref" {:ref ref})))
(when-not (number? i) (throw (ex-info "indexed point needs a frame index" {:point p})))
(let [local (if (neg? i) (+ (:duration c) i) i)] ; -1 => last frame
{:frame (+ (:media-in c) local) :clip c}))
:else (throw (ex-info "bad point" {:point p}))))
(defn- clip-span [c]
{:start (:media-in c)
:end (+ (:media-in c) (:duration c))
:track (:track c) :clip c :instant? false})
(declare resolve-marks)
(defn- resolve-mark [idx groups seen m]
(cond
;; ref -> a clip span, or recurse into another mark-group
(ref? m)
(let [k (keyword m)]
(cond
(idx k) [(clip-span (idx k))]
(groups k) (do (when (seen k)
(throw (ex-info "cycle in mark-groups" {:at k :seen seen})))
(resolve-marks idx groups (conj seen k) (:marks (groups k))))
:else (throw (ex-info "unknown ref" {:ref m}))))
;; instant: [:at ref i]
(and (vector? m) (= :at (first m)))
(let [{:keys [frame clip]} (point idx m)]
[{:start frame :end frame :track (:track clip) :clip clip :instant? true}])
;; range: [p1 p2]
(and (vector? m) (= 2 (count m)))
(let [a (point idx (first m))
b (point idx (second m))]
[{:start (:frame a) :end (:frame b)
:track (:track (:clip a)) :clip (:clip a) :instant? false}])
:else (throw (ex-info "bad mark" {:mark m}))))
(defn resolve-marks
"Resolve a vector of marks to a flat, ordered vector of spans."
[idx groups seen marks]
(vec (mapcat #(resolve-mark idx groups seen %) marks)))
(defn resolve-group
"Resolve mark-group `gid` (key into `groups`) against clip index `idx`."
[idx groups gid]
(resolve-marks idx groups #{gid} (:marks (groups gid))))
(defn anchor
"Earliest display frame across spans — the scroll/sort anchor. nil if empty."
[spans]
(when (seq spans) (reduce min (map :start spans))))

59
tl/src/tl/md.cljs Normal file
View file

@ -0,0 +1,59 @@
(ns tl.md
"Tiny Markdown -> Hiccup. Handles the subset commentary needs: ATX headings
(#..######), unordered lists (- / *), paragraphs, and inline **bold**,
*italic*, `code`. No dependencies, no dangerouslySetInnerHTML."
(:require [clojure.string :as str]))
(def ^:private inline-re #"(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)")
(defn- inline
"Parse inline markers in a line into a seq of strings / hiccup elements."
[s]
(loop [s s, acc []]
(if-let [m (re-find inline-re s)]
(let [tok (if (vector? m) (first m) m)
i (str/index-of s tok)
before (subs s 0 i)
after (subs s (+ i (count tok)))
el (cond
(str/starts-with? tok "`") [:code (subs tok 1 (dec (count tok)))]
(str/starts-with? tok "**") [:strong (subs tok 2 (- (count tok) 2))]
:else [:em (subs tok 1 (dec (count tok)))])]
(recur after (conj acc before el)))
(conj acc s))))
(defn- flush-para [out para]
(if (seq para)
(conj out (into [:p {:style {:margin "0 0 8px"}}] (inline (str/join " " para))))
out))
(defn render
"Markdown string -> a Hiccup [:div ...] tree."
[md]
(loop [ls (str/split-lines (or md "")), out [], para []]
(if (empty? ls)
(into [:div] (flush-para out para))
(let [t (str/trim (first ls))]
(cond
(str/blank? t)
(recur (rest ls) (flush-para out para) [])
(re-find #"^#{1,6}\s" t)
(let [n (count (re-find #"^#+" t))
txt (str/replace t #"^#+\s*" "")
tag (keyword (str "h" (min n 6)))]
(recur (rest ls)
(conj (flush-para out para)
(into [tag {:style {:margin "0 0 6px" :lineHeight 1.25}}] (inline txt)))
[]))
(re-find #"^[-*]\s" t)
(let [[items more] (split-with #(re-find #"^\s*[-*]\s" %) ls)
lis (for [it items]
(into [:li] (inline (str/replace (str/trim it) #"^[-*]\s*" ""))))]
(recur more
(conj (flush-para out para) (into [:ul {:style {:margin "0 0 8px 18px" :padding 0}}] lis))
[]))
:else
(recur (rest ls) out (conj para t)))))))

66
tl/src/tl/otio.cljs Normal file
View file

@ -0,0 +1,66 @@
(ns tl.otio
"Pure OTIO -> render-ready model. No re-frame, no DOM: easy to test in the REPL.
Two independent time domains, both normalized to 0 (see CLAUDE design notes):
- timeline position : accumulated per track from 0 (global_start_time ignored)
- media/source time : every clip shifted by (- media-offset), where
media-offset = min source start across all clips, so the
first referenced frame == frame 0 of the local .mov.
Canonical unit is FRAMES at :fps. Seconds are derived only when talking to <video>."
(:require [clojure.string :as str]))
(defn- clip? [item]
(str/starts-with? (:OTIO_SCHEMA item "") "Clip"))
(defn- frames [rational-time]
(:value rational-time))
(defn- clip-starts
"Source start_time (frames) of every clip across all tracks."
[tracks]
(for [t tracks
c (:children t)
:when (clip? c)]
(frames (get-in c [:source_range :start_time]))))
(defn- parse-track [media-offset idx track]
(loop [pos 0.0
items (:children track)
ci 0
clips (transient [])]
(if-let [item (first items)]
(let [dur (frames (get-in item [:source_range :duration]))
is-clip (clip? item)]
(recur (+ pos dur)
(rest items)
(if is-clip (inc ci) ci)
(if is-clip
(conj! clips {:id (str "t" idx "-c" ci)
:name (:name item)
:start pos ; timeline frame, 0-based
:duration dur
:media-in (- (frames (get-in item [:source_range :start_time]))
media-offset)}) ; frame into local .mov
clips)))
{:id idx
:index idx
:name (:name track)
:kind (if (= "Audio" (:kind track)) :audio :video)
:duration pos ; track length, frames
:clips (persistent! clips)})))
(defn parse
"OTIO Timeline JSON (keywordized) -> {:fps :media-offset :duration :tracks}."
[otio]
(let [tracks (get-in otio [:tracks :children])
fps (get-in otio [:global_start_time :rate] 24)
starts (clip-starts tracks)
media-offset (if (seq starts) (apply min starts) 0.0)
parsed (vec (map-indexed (partial parse-track media-offset) tracks))]
{:fps fps
:media-offset media-offset
:duration (reduce max 0.0 (for [t parsed
c (:clips t)]
(+ (:media-in c) (:duration c))))
:tracks parsed}))

94
tl/src/tl/subs.cljs Normal file
View file

@ -0,0 +1,94 @@
(ns tl.subs
(:require
[re-frame.core :as rf]
[tl.marks :as marks]))
(rf/reg-sub ::load-status (fn [db] (get-in db [:load :status])))
(rf/reg-sub ::timeline (fn [db] (:timeline db)))
(rf/reg-sub ::fps :<- [::timeline] (fn [t] (:fps t)))
(rf/reg-sub ::duration :<- [::timeline] (fn [t] (:duration t)))
(rf/reg-sub ::tracks :<- [::timeline] (fn [t] (:tracks t)))
;; --- mark-groups / annotations ------------------------------------------
(rf/reg-sub ::groups (fn [db] (get-in db [:marks :groups])))
(rf/reg-sub ::timeline-stack (fn [db] (get-in db [:marks :timeline-stack])))
(rf/reg-sub ::clip-index :<- [::timeline] (fn [t] (when t (marks/clip-index t))))
(rf/reg-sub
::current-timeline
:<- [::groups] :<- [::timeline-stack]
(fn [[groups stack] _] (get groups (last stack))))
(defn- admits?
"Does a timeline's :mark-groups rule surface annotation `gid`?"
[rule gid]
(cond
(= "*" rule) true
(vector? rule) (boolean (some #{gid} rule))
(map? rule) (not (contains? (set (:except rule)) gid))
:else false))
(rf/reg-sub
::annotations
:<- [::groups] :<- [::clip-index] :<- [::current-timeline]
(fn [[groups idx cur] _]
(when idx
(let [rule (:mark-groups cur "*")]
(->> groups
(filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid))))
(map (fn [[gid g]]
(let [spans (marks/resolve-group idx groups gid)]
{:id gid :name (:name g) :content (:content g)
:spans spans :anchor (marks/anchor spans)
:instant? (every? :instant? spans)})))
(sort-by :anchor)
vec)))))
;; The annotation the playhead is currently "in" — the latest one whose anchor
;; we've reached. Drives the commentary scroll + highlight.
(rf/reg-sub
::active-annotation
:<- [::annotations] :<- [::playhead]
(fn [[anns ph] _]
(:id (or (last (filter #(<= (:anchor %) ph) anns))
(first anns)))))
;; --- playback program (the active timeline as an ordered EDL) ------------
(rf/reg-sub ::current-timeline-id :<- [::timeline-stack] (fn [st] (last st)))
(rf/reg-sub ::seg (fn [db] (get-in db [:view :seg])))
(defn- ->segments
"Lay spans end-to-end in virtual time, tagging each with its virtual offset."
[spans]
(loop [v 0, ss spans, out []]
(if-let [s (first ss)]
(let [len (max 1 (- (:end s) (:start s)))]
(recur (+ v len) (rest ss)
(conj out (assoc s :virt-start v :virt-len len :virt-end (+ v len)))))
out)))
;; A program = ordered segments {:start :end (media) :virt-start :virt-len :virt-end :track}.
;; :root (or any timeline without :marks) plays the whole media linearly as one
;; segment; a pushed mark-group plays its resolved spans, jumping between them.
(rf/reg-sub
::program
:<- [::current-timeline-id] :<- [::groups] :<- [::clip-index] :<- [::duration]
(fn [[gid groups idx dur] _]
(cond
(nil? dur)
{:root? true :id gid :segments [] :virt-len 0}
(or (= gid :root) (nil? (:marks (get groups gid))))
{:root? true :id gid :virt-len dur
:segments [{:start 0 :end dur :virt-start 0 :virt-len dur :virt-end dur :track "Sequence"}]}
:else
(let [segs (->segments (marks/resolve-group idx groups gid))]
{:root? false :id gid :segments segs
:virt-len (reduce + 0 (map :virt-len segs))}))))
;; --- view state (frames in; views multiply by zoom for pixels) ---
(rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) ; px per second
(rf/reg-sub ::playhead (fn [db] (get-in db [:view :playhead]))) ; frames
(rf/reg-sub ::playing? (fn [db] (get-in db [:view :playing?])))

335
tl/src/tl/views.cljs Normal file
View file

@ -0,0 +1,335 @@
(ns tl.views
(:require
[reagent.core :as r]
[re-frame.core :as rf]
[tl.subs :as subs]
[tl.md :as md]
[tl.events :as events]))
(def ^:const row-h 28)
;; Served by dev/media_server.py (range-capable) rather than shadow's :dev-http,
;; which doesn't do byte-range serving — Safari/iOS won't play <video> without it.
;; Host-relative so it works over localhost and Tailscale alike.
(def media-src
(str (.. js/window -location -protocol) "//"
(.. js/window -location -hostname) ":8281/challengers_480p.mp4"))
(defonce video-el (atom nil))
(defonce scroll-el (atom nil))
(defonce following? (atom true))
(defn- secs [frames fps] (/ frames fps))
(defn- center-on-playhead! [fps zoom playhead]
(when-let [el @scroll-el]
(let [px (* (secs playhead fps) zoom)
max-sl (max 0 (- (.-scrollWidth el) (.-clientWidth el)))
target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))]
(set! (.-scrollLeft el) target))))
(defn- seek-to-frame! [fps frames]
(when-let [v @video-el]
(set! (.-currentTime v) (secs frames fps))))
(defn- toggle-play! []
(when-let [v @video-el]
(if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF
(defn- on-keydown [e]
;; Space toggles play. If the <video> itself is focused, let its native
;; control handle it so we don't double-toggle.
(when (and (= "Space" (.-code e))
(not= @video-el (.-activeElement js/document)))
(.preventDefault e)
(toggle-play!)))
(defonce ^:private _kbd
(.addEventListener js/document "keydown" on-keydown))
(defn video-monitor
"Native-controls <video> that plays the active *program*. A program is an
ordered list of source spans; while playing, a rAF loop pushes currentTime
(media frames) into :playhead AND, when it reaches the end of the current
span, seeks the video to the next span's source frame — so a pushed
sub-timeline plays its clips back-to-back, jumping across the source media.
:root plays the whole media as one span (unchanged behaviour)."
[fps]
(let [raf (atom nil)
prog (atom nil) ; latest program (mirrored from the sub each render)
seg (atom 0) ; current segment index (hot path)
prev (atom :init) ; previous program id, to detect push/pop
seek! (fn [frame] (when-let [v @video-el]
(set! (.-currentTime v) (secs frame fps))))
push! (fn [] (when-let [v @video-el]
(rf/dispatch [::events/set-playhead (* (.-currentTime v) fps)])))
tick (fn tick []
(when-let [v @video-el]
(let [segs (:segments @prog)
i @seg
cur (get segs i)
mf (* (.-currentTime v) fps)]
(when cur
(when (>= mf (- (:end cur) 0.5)) ; reached end of this span
(if (< (inc i) (count segs))
(do (reset! seg (inc i))
(rf/dispatch [::events/set-seg (inc i)])
(seek! (:start (get segs (inc i)))))
(when-not (:root? @prog) (.pause v)))) ; end of program
(rf/dispatch [::events/set-playhead mf]))))
(reset! raf (js/requestAnimationFrame tick)))
stop (fn [] (when @raf (js/cancelAnimationFrame @raf) (reset! raf nil)))]
(fn [_fps]
(let [program @(rf/subscribe [::subs/program])]
(reset! prog program)
;; entered a different timeline: reset to its first span (and try to play)
(when (not= (:id program) @prev)
(reset! prev (:id program))
(reset! seg 0)
(when-not (:root? program)
(r/after-render
(fn []
(seek! (:start (first (:segments program))))
(when-let [v @video-el] (-> (.play v) (.catch (fn [_]))))))))
[:video {:src media-src
:controls true
;; Keep playback inline on iOS instead of jumping to the
;; native fullscreen player.
:plays-inline true
:webkit-playsinline "true"
:ref (fn [node] (reset! video-el node))
:on-play (fn [_] (reset! following? true)
(rf/dispatch [::events/set-playing true]) (stop) (tick))
:on-pause (fn [_] (rf/dispatch [::events/set-playing false]) (stop) (push!))
:on-seeked (fn [_] (push!))
:style {:width 480 :display :block :background "#000"}}]))))
;; --- annotation layer -----------------------------------------------------
(def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"])
(defn annotation-lane
"One labeled row per annotation, drawn above the tracks. Bars are positioned
in display frames (same coordinate as clips), so they align with the timeline."
[fps zoom annotations active]
[:div {:style {:position :relative}}
(doall
(map-indexed
(fn [i {:keys [id name spans] :as _ann}]
(let [color (nth ann-colors (mod i (count ann-colors)))
on? (= id active)]
^{:key id}
[:div {:style {:position :relative :height 18}}
(for [[j s] (map-indexed vector spans)]
(let [left (* (secs (:start s) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)]
^{:key j}
[:div {:title name
:style {:position :absolute :left left :top 2 :height 14
:width (max 3 w)
:background (if on? color (str color "80"))
:border (str "1px solid " color) :border-radius 2}}]))
(when-let [a (some-> spans first :start)]
[:div {:style {:position :absolute :left (+ 4 (* (secs a fps) zoom)) :top 1
:font-size 9 :line-height "16px" :color "#eee"
:white-space :nowrap :pointer-events :none
:text-shadow "0 1px 2px #000"}}
name])]))
annotations))])
(defn commentary
"Markdown commentary sidebar. Each annotation is a block anchored to its start
frame; the active one is highlighted and scrolled to ~1/3 from the top as the
video plays."
[]
(let [el (atom nil)]
(fn []
(let [anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])]
(r/after-render
(fn []
(when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
[:div {:ref (fn [n] (reset! el n))
:style {:position :relative :width 360 :height 270 :overflow-y :auto
:background "#111" :border "1px solid #333" :border-radius 4
:padding "8px 12px" :box-sizing :border-box}}
(if (seq anns)
(for [{:keys [id content instant?] nm :name} anns]
^{:key id}
[:div {:id (str "ann-" (name id))
:style {:padding "8px 10px" :margin-bottom 8 :border-radius 4
:border (if (= id active) "1px solid #3a6ea5" "1px solid transparent")
:background (if (= id active) "#152333" "#161616")}}
[:div {:style {:display :flex :align-items :center :justify-content :space-between
:margin-bottom 4}}
[:div {:style {:font-size 10 :text-transform :uppercase :letter-spacing 1
:color "#7aa6d6"}} nm]
(when-not instant?
[:button {:on-click #(rf/dispatch [::events/push-timeline id])
:title "Play these shots back-to-back"
:style {:background "#1c2a38" :color "#cfe3f5"
:border "1px solid #3a6ea5" :border-radius 4
:padding "1px 7px" :cursor :pointer :font-size 10}}
"▶ zoom in"])]
[md/render content]])
[:div {:style {:color "#666" :font-size 12}} "No annotations."])]))))
;; --- timeline stack: zoom into a mark-group + program strip ---------------
(defn breadcrumb []
(let [stack @(rf/subscribe [::subs/timeline-stack])
groups @(rf/subscribe [::subs/groups])]
[:div {:style {:display :flex :align-items :center :gap 8 :margin-bottom 8
:font-size 12 :min-height 24}}
(when (> (count stack) 1)
[:button {:on-click #(rf/dispatch [::events/pop-timeline])
:style {:background "#222" :color "#cfe3f5" :border "1px solid #3a6ea5"
:border-radius 4 :padding "2px 8px" :cursor :pointer :font-size 11}}
"← back"])
(into [:div {:style {:display :flex :gap 6 :align-items :center}}]
(map-indexed
(fn [i gid]
^{:key i}
[:span (when (pos? i) {})
(when (pos? i) [:span {:style {:color "#555" :margin-right 6}} "›"])
[:span {:style {:color (if (= gid (last stack)) "#cfe3f5" "#888")}}
(get-in groups [gid :name] (name gid))]])
stack))]))
(defn program-strip
"Contiguous view of the active sub-timeline: each source span laid end-to-end
in virtual time, current span highlighted, smooth virtual playhead."
[]
(let [program @(rf/subscribe [::subs/program])
seg @(rf/subscribe [::subs/seg])
playhead @(rf/subscribe [::subs/playhead])]
(when-not (:root? program)
(let [segs (:segments program)
vlen (max 1 (:virt-len program))
w 852
sc (/ w vlen)
cur (get segs seg)
vp (when cur (+ (:virt-start cur) (- playhead (:start cur))))]
[:div {:style {:position :relative :width w :height 34 :margin-bottom 12
:background "#111" :border "1px solid #333" :border-radius 4
:overflow :hidden}}
(for [[i s] (map-indexed vector segs)]
^{:key i}
[:div {:style {:position :absolute :top 0 :bottom 0
:left (* (:virt-start s) sc)
:width (max 1 (- (* (:virt-len s) sc) 1))
:background (if (= i seg) "#2a4a6a" "#1c2a38")
:box-sizing :border-box :overflow :hidden
:font-size 9 :color "#bcd" :padding "3px 4px" :white-space :nowrap}}
(:track s)])
(when vp
[:div {:style {:position :absolute :top 0 :bottom 0 :left (* vp sc)
:width 2 :background "#e33" :pointer-events :none}}])]))))
(defn clip-block [fps zoom {:keys [name media-in duration] :as _clip}]
(let [left (* (secs media-in fps) zoom)
width (max 1 (* (secs duration fps) zoom))]
[:div {:style {:position :absolute :left left :top 1
:width width :height (- row-h 2)
:box-sizing :border-box :overflow :hidden
:background "#3a6ea5" :border "1px solid #16324f"
:border-radius 2 :font-size 10 :line-height "26px"
:color "#cfe3f5" :white-space :nowrap :padding-left 3}}
name]))
(defn track-row [fps zoom track]
[:div {:style {:position :relative :height row-h
:background (if (= :audio (:kind track)) "#161616" "#1c1c1c")
:border-bottom "1px solid #2a2a2a"}}
(for [c (:clips track)]
^{:key (:id c)} [clip-block fps zoom c])])
(defn- ev->frames
"Pointer clientX -> clamped timeline frame, relative to the content div
(whose bounding rect already accounts for horizontal scroll)."
[content clientX zoom fps duration]
(let [px (- clientX (.-left (.getBoundingClientRect content)))
frames (* (/ px zoom) fps)]
(-> frames (max 0) (min duration))))
(defn- begin-scrub!
"mousedown on the timeline: seek to the click, then follow the mouse (even
outside the element) until mouseup."
[content zoom fps duration ev]
(.preventDefault ev)
(let [to (fn [clientX]
(let [f (ev->frames content clientX zoom fps duration)]
(rf/dispatch [::events/set-playhead f]) ; move the red line now
(seek-to-frame! fps f))) ; and seek the video
move (fn [e] (to (.-clientX e)))
up (fn up [_]
(.removeEventListener js/document "mousemove" move)
(.removeEventListener js/document "mouseup" up))]
(to (.-clientX ev))
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))
(def ^:private gutter-w 150)
(defn timeline []
(let [content (atom nil)]
(fn []
(let [fps @(rf/subscribe [::subs/fps])
zoom @(rf/subscribe [::subs/zoom])
duration @(rf/subscribe [::subs/duration])
tracks @(rf/subscribe [::subs/tracks])
playhead @(rf/subscribe [::subs/playhead])
playing? @(rf/subscribe [::subs/playing?])
anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])
vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden
lane-h (* 18 (count anns))
width (* (secs duration fps) zoom)]
(when (and playing? @following?)
(r/after-render #(center-on-playhead! fps zoom playhead)))
[:div {:style {:display :flex :align-items :flex-start
:border "1px solid #333" :background "#111"}}
;; fixed left gutter: setup (track) names, always visible
[:div {:style {:flex-shrink 0 :width gutter-w :user-select :none}}
[:div {:style {:height lane-h}}] ; spacer aligning labels with rows
(for [t vtracks]
^{:key (:id t)}
[:div {:style {:height row-h :box-sizing :border-box
:border-bottom "1px solid #2a2a2a" :border-right "1px solid #2a2a2a"
:padding "0 6px" :font-size 10 :line-height (str (dec row-h) "px")
:color "#9ab" :background "#141414"
:white-space :nowrap :overflow :hidden :text-overflow :ellipsis}}
(:name t)])]
;; horizontally-scrolling timeline
[:div {:ref (fn [n] (reset! scroll-el n))
:on-wheel (fn [_] (reset! following? false))
:style {:overflow-x :auto :flex 1}}
[:div {:ref (fn [n] (reset! content n))
:on-mouse-down (fn [e] (begin-scrub! @content zoom fps duration e))
:style {:position :relative :width width :cursor "ew-resize"}}
;; playhead: line spanning all rows + a grab handle at the top
(let [x (* (secs playhead fps) zoom)]
[:div {:style {:position :absolute :top 0 :bottom 0 :left x
:width 2 :background "#e33" :z-index 10
:pointer-events "none"}}
[:div {:style {:position :absolute :top 0 :left -5 :width 12 :height 10
:background "#e33" :border-radius 2}}]])
[annotation-lane fps zoom anns active]
(for [t vtracks]
^{:key (:id t)} [track-row fps zoom t])]]]))))
(defn main-panel []
(let [status @(rf/subscribe [::subs/load-status])
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))]
[:div {:style {:font-family "sans-serif" :background "#0c0c0c"
:color "#ddd" :min-height "100vh" :padding 12}}
[breadcrumb]
[program-strip]
[:div {:style {:display :flex :gap 12 :align-items :flex-start :margin-bottom 12}}
[video-monitor fps]
[commentary]]
(case status
:ready [timeline]
:error [:div {:style {:color "#e66"}} "Failed to load OTIO"]
[:div "Loading timeline…"])]))