feat: init
This commit is contained in:
commit
bdc9928db8
20 changed files with 93277 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
15
tl/.gitignore
vendored
Normal 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
194
tl/README.md
Normal 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
19
tl/dev/dev.sh
Executable 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
117
tl/dev/media_server.py
Executable 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
8
tl/dev/user.cljs
Normal 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
273
tl/package-lock.json
generated
Normal 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
18
tl/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
tl/resources/public/index.html
Normal file
15
tl/resources/public/index.html
Normal 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>
|
||||
91754
tl/resources/public/one_two_three.otio
Normal file
91754
tl/resources/public/one_two_three.otio
Normal file
File diff suppressed because it is too large
Load diff
29
tl/shadow-cljs.edn
Normal file
29
tl/shadow-cljs.edn
Normal 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
4
tl/src/tl/config.cljs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
(ns tl.config)
|
||||
|
||||
(def debug?
|
||||
^boolean goog.DEBUG)
|
||||
26
tl/src/tl/core.cljs
Normal file
26
tl/src/tl/core.cljs
Normal 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
56
tl/src/tl/db.cljs
Normal 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
73
tl/src/tl/events.cljs
Normal 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
89
tl/src/tl/marks.cljs
Normal 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
59
tl/src/tl/md.cljs
Normal 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
66
tl/src/tl/otio.cljs
Normal 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
94
tl/src/tl/subs.cljs
Normal 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
335
tl/src/tl/views.cljs
Normal 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…"])]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue