Video support, comment section, more

This commit is contained in:
2026-10-30 00:09:58 +01:00
parent c8789214bd
commit f8dcc01a4a
21 changed files with 3154 additions and 249 deletions
+55 -20
View File
@@ -1,6 +1,6 @@
# TaskForge # TaskForge
Task management for game projects, written in Jai. The same client code runs as a native Windows app (OpenGL 3.3) and in the browser (WebGL2 via WASM). All UI and rendering is custom immediate-mode code; the only web code is a small JS shim that forwards GL calls, input and HTTP. Task management for game projects, written in Jai. The same client code runs as a native Windows app (OpenGL 3.3) and in the browser (WebGL2 via WASM). All UI and rendering is custom immediate-mode code; the only web code is a small JS shim that forwards GL calls, input, HTTP and media decoding.
It runs in two modes: It runs in two modes:
@@ -9,13 +9,24 @@ It runs in two modes:
## Features ## Features
- **Custom fields** (Fields page): Text, Number, Checkbox, Select (one option), Tags (several options) and User (assign people). Options have colours, and a field can apply to tasks, epics or both. Add, rename, reorder and delete fields as you like. - **Custom fields** (Fields page): Text, Number, Checkbox, Select (one option), Tags (several options) and User (assign people). Options have colours, and a field can apply to tasks, epics or both.
- **Views** for each context: - **Views** for each context:
- A **Board** turns any Select or Checkbox field into kanban columns, with drag & drop. - A **Board** turns any Select or Checkbox field into kanban columns, with drag & drop.
- A **Checklist** turns each task into a checkbox. "Done" means a field has a chosen value, and items can be grouped by epic. - A **Checklist** turns each task into a checkbox. "Done" means a field has a chosen value, and items can be grouped by epic.
- Views can filter by epic, by an option (e.g. `Discipline: Art`) or by a user (e.g. `Assignee: Me`), and choose which fields appear on cards. - Views can filter by epic, by an option (e.g. `Discipline: Art`) or by a user (e.g. `Assignee: Me`), and choose which fields appear on cards.
- **Epic rules** (Epic rules page): set a field on an epic from the state of its tasks, e.g. *Set **Art** to **Done** when **all** of its **Art** tasks have **Status** is **Done***. For each epic and field, the first matching rule from the top wins. If none match, the field keeps its value. - **Epic rules** (Epic rules page): set a field on an epic from the state of its tasks, e.g. *Set **Art** to **Done** when **all** of its **Art** tasks have **Status** is **Done***. For each epic and field, the first matching rule from the top wins. If none match, the field keeps its value.
- **Users** (server mode): sign-in with sessions, admins and deactivation. Assign people with User fields. Admins add users and reset passwords on the Users page; everyone can change their own password. - **Users** (server mode): sign-in with sessions, admins and deactivation. Assign people with User fields. Admins add users and reset passwords on the Users page; everyone can change their own password.
- **Attachments:** images (PNG, JPEG, GIF, BMP, WebP) and videos (MP4/MOV, WebM, AVI) on tasks and comments. You can drop files onto the window, use the **Add image or video** button, or paste with Ctrl+V. The first attachment becomes the card's cover. Click a thumbnail to open the viewer; use Left/Right to move between attachments and Esc to close.
- **Screenshots:** pasting a screenshot attaches it, e.g. after Win+Shift+S. The **Snip screen** button (native) opens the Windows snipping overlay and attaches the result automatically. **Capture screen** (web) grabs a frame of a screen, window or tab you pick.
- **Video playback** in the viewer has play/pause (click or Space), seeking, time and mute.
- Natively, Windows Media Foundation decodes video and audio inside the app: H.264/HEVC/AAC and anything else Windows has codecs for. Audio plays through waveOut and drives the clock.
- In the browser, the browser's own decoder is used.
- Attaching a video stores its size, duration and a poster frame, so thumbnails need no video decoding.
- **Comments** on every task:
- threads with replies,
- editing that keeps the full edit history ("edited (2)" shows the earlier versions),
- deletion that leaves a placeholder, so replies still make sense,
- images, videos and screenshots inside comments.
- **Text editing:** selection with the mouse (drag, double-click selects a word, triple-click selects all) and the keyboard (Shift+arrows/Home/End, Ctrl+A). Copy, cut and paste work on the selection, and typing replaces it. - **Text editing:** selection with the mouse (drag, double-click selects a word, triple-click selects all) and the keyboard (Shift+arrows/Home/End, Ctrl+A). Copy, cut and paste work on the selection, and typing replaces it.
## Building ## Building
@@ -34,20 +45,23 @@ Each client build bakes `data/Karla-Regular.ttf` (SIL Open Font License) into `f
## Running locally ## Running locally
- **Native, local file:** `bin/TaskForge.exe [path/to/project.taskforge]`. The plain-text project file can live in your game's repository and diffs cleanly. - **Native, local file:** `bin/TaskForge.exe [path/to/project.taskforge]`.
- **Web, local:** serve `web/` with any static server (`python -m http.server -d web 8000`). The project lives in the browser; Export/Import move it to or from a file. - The project is a plain-text file that can live in your game's repository and diffs cleanly.
- Attachments are stored next to it in `<project>.taskforge.files/`.
- **Web, local:** serve `web/` with any static server (`python -m http.server -d web 8000`). The project lives in the browser, with attachments in IndexedDB. Export/Import move the project (without attachments) to or from a file.
- **Server on your machine:** - **Server on your machine:**
``` ```
cd server/bin cd server/bin
taskforge-server add-user daniel "Daniel" --admin # asks for a password taskforge-server add-user daniel "Daniel" --admin # asks for a password
taskforge-server --web ../../web --port 8080 # http://localhost:8080 taskforge-server --web ../../web --port 8080 # http://localhost:8080
``` ```
`--demo` on the first start seeds the sample game project; without it you get a clean starter project (Status, Assignee, Discipline, Priority; Board and My tasks views). On Windows the server needs `sqlite3.dll` next to it (the build copies the one in `server/dev/`). - `--demo` on the first start seeds the sample game project. Without it you get a clean starter project: Status, Assignee, Discipline and Priority fields, plus Board and My tasks views.
- On Windows the server needs `sqlite3.dll` next to it; the build copies the one in `server/dev/`.
- **Native app against a server:** `bin/TaskForge.exe --server https://tasks.example.com`. The session is remembered in `%APPDATA%\TaskForge\session.txt`. - **Native app against a server:** `bin/TaskForge.exe --server https://tasks.example.com`. The session is remembered in `%APPDATA%\TaskForge\session.txt`.
## Deploying to a DigitalOcean droplet ## Deploying to a DigitalOcean droplet
1. Create an Ubuntu 24.04 droplet (the smallest size is plenty) and point a domain's A record at its IP. 1. Create an Ubuntu 24.04 droplet and point a domain's A record at its IP. The smallest size is fine for tasks; pick more disk if you'll attach a lot of video.
2. From Git Bash on your machine: 2. From Git Bash on your machine:
``` ```
deploy/deploy.sh root@<droplet-ip> tasks.example.com deploy/deploy.sh root@<droplet-ip> tasks.example.com
@@ -57,7 +71,7 @@ Each client build bakes `data/Karla-Regular.ttf` (SIL Open Font License) into `f
- creates a `taskforge` system user and a hardened systemd service, - creates a `taskforge` system user and a hardened systemd service,
- configures HTTPS for your domain through Caddy (automatic Let's Encrypt), - configures HTTPS for your domain through Caddy (automatic Let's Encrypt),
- enables the firewall, - enables the firewall,
- adds a daily SQLite backup to `/var/backups/taskforge` (kept for 14 days). - adds a daily backup of the database and attachments to `/var/backups/taskforge` (database copies kept for 14 days).
Every run builds the web client and the Linux server, uploads them, links the server on the droplet and restarts it. For later updates, run `deploy/deploy.sh root@<droplet-ip>`. Every run builds the web client and the Linux server, uploads them, links the server on the droplet and restarts it. For later updates, run `deploy/deploy.sh root@<droplet-ip>`.
3. Create the first admin on the droplet: 3. Create the first admin on the droplet:
@@ -68,12 +82,22 @@ Each client build bakes `data/Karla-Regular.ttf` (SIL Open Font License) into `f
Why link on the droplet: the server is cross-compiled to a Linux object file on Windows, but linking a Linux executable needs Linux's system libraries, which the droplet has. Alternatively, copy your Jai distribution to the droplet and run `jai-linux build.jai - server` there. Why link on the droplet: the server is cross-compiled to a Linux object file on Windows, but linking a Linux executable needs Linux's system libraries, which the droplet has. Alternatively, copy your Jai distribution to the droplet and run `jai-linux build.jai - server` there.
The backups live on the same droplet. For real safety, also enable DigitalOcean's droplet backups, or copy `/var/backups/taskforge` off the machine.
Useful on the droplet: `journalctl -u taskforge -f` (logs), `taskforge-server --db ... list-users`, `taskforge-server --db ... set-password <user>`. Useful on the droplet: `journalctl -u taskforge -f` (logs), `taskforge-server --db ... list-users`, `taskforge-server --db ... set-password <user>`.
## How the server works ## How the server works
- **Database:** SQLite in WAL mode, loaded at runtime (`libsqlite3.so.0` / `sqlite3.dll`). Tables: `records` (the project), `users`, `sessions`, `login_failures`, `meta`. Each worker thread has its own connection; writes are short `BEGIN IMMEDIATE` transactions. - **Database:** SQLite in WAL mode, loaded at runtime (`libsqlite3.so.0` / `sqlite3.dll`). Tables: `records` (the project), `users`, `sessions`, `login_failures`, `blobs`, `meta`. Each worker thread has its own connection; writes are short `BEGIN IMMEDIATE` transactions.
- **Project sync:** a project is a set of records, one text line per entity (`task 42 ...`, `val 42 7 sel 5`, ...), the same format as the local file. Every write bumps a global revision. Clients send the records they changed (found by diffing against what the server last confirmed) and receive everything changed since their last revision. The last writer wins per record, so two people editing different fields of the same task don't overwrite each other. Clients poll every 2 seconds, and immediately after local edits. New ids come in blocks of 1000 per client, so they never collide. - **Attachments** are content-addressed:
- Files are named by their SHA-256 and stored in `blobs/` next to the database (`--blobs` moves it).
- Uploads (`POST /api/blob`, up to 600 MB) stream to disk while being hashed, and only images and videos are accepted.
- Downloads (`GET /api/blob/<hash>`) need a session.
- **Project sync:**
- A project is a set of records, one text line per entity (`task 42 ...`, `val 42 7 sel 5`, `comment ...`, `attach ...`), the same format as the local file.
- Every write bumps a global revision. Clients send the records they changed (found by diffing against what the server last confirmed) and receive everything changed since their last revision.
- The last writer wins per record, so two people editing different fields of the same task don't overwrite each other.
- Clients poll every 2 seconds, and immediately after local edits. New ids come in blocks of 1000 per client, so they never collide.
- **Security:** - **Security:**
- Passwords are hashed with PBKDF2-HMAC-SHA256 (310,000 iterations, random salt). - Passwords are hashed with PBKDF2-HMAC-SHA256 (310,000 iterations, random salt).
- Session tokens are 256-bit random values, stored only as SHA-256 hashes; they expire after 30 days of inactivity. - Session tokens are 256-bit random values, stored only as SHA-256 hashes; they expire after 30 days of inactivity.
@@ -81,9 +105,13 @@ Useful on the droplet: `journalctl -u taskforge -f` (logs), `taskforge-server --
- Changing a password signs out that user's other sessions. - Changing a password signs out that user's other sessions.
- Static file serving only accepts plain file names from the web directory. - Static file serving only accepts plain file names from the web directory.
- HTTPS is handled by Caddy; the server listens on 127.0.0.1 only. - HTTPS is handled by Caddy; the server listens on 127.0.0.1 only.
- **HTTP API** (plain text bodies): `GET /api/info`, `POST /api/login`, `/api/logout`, `/api/sync`, `/api/users`. - **HTTP API:** `GET /api/info`, `POST /api/login`, `/api/logout`, `/api/sync`, `/api/users`, `POST /api/blob`, `GET /api/blob/<hash>`.
Current limits: one project per server, and changes arrive by polling (up to ~2 s delay) rather than push. Current limits:
- One project per server.
- Changes arrive by polling (up to ~2 s delay) rather than push.
- Deleting an attachment removes it from the project but leaves its file in the blob store.
- Client size limits are 50 MB for images and 500 MB for videos.
## Code layout ## Code layout
@@ -91,23 +119,30 @@ Current limits: one project per server, and changes arrive by polling (up to ~2
|---|---| |---|---|
| `build.jai` | Build metaprogram for all targets + font atlas baker | | `build.jai` | Build metaprogram for all targets + font atlas baker |
| `src/main.jai` | Client entry; picks the platform layer | | `src/main.jai` | Client entry; picks the platform layer |
| `src/platform_native.jai` | Win32 window, WGL, input, files, clipboard, libcurl HTTP, `--sim` test harness | | `src/platform_native.jai` | Win32 window, WGL, input, files, clipboard (incl. images), file picker, drag & drop, snipping, libcurl HTTP, `--sim` test harness |
| `src/platform_web.jai`, `web/taskforge.js` | WASM exports + the JS shim (WebGL2, input, fetch, localStorage) | | `src/video_native.jai` | Media Foundation + waveOut video playback and probing (Windows) |
| `src/platform_web.jai`, `web/taskforge.js` | WASM exports + the JS shim (WebGL2, input, fetch, localStorage/IndexedDB, image/video decoding) |
| `src/web_gl.jai` | GL declarations for WASM (same names as the GL module) | | `src/web_gl.jai` | GL declarations for WASM (same names as the GL module) |
| `src/render.jai`, `src/font.jai` | Batched renderer and text | | `src/render.jai`, `src/font.jai` | Batched renderer (shapes, text, images) and text |
| `src/ui.jai` | Immediate-mode widgets: buttons, text editing with selection, dropdowns, scrolling | | `src/ui.jai` | Immediate-mode widgets: buttons, text editing with selection, dropdowns, scrolling |
| `src/model.jai` | Fields, tasks, views, users, epic rule evaluation | | `src/model.jai` | Fields, tasks, views, users, attachments, comments, epic rule evaluation |
| `src/storage.jai` | Record format, local file, starter projects | | `src/storage.jai` | Record format, local file, starter projects |
| `src/sync.jai` | Sign-in and project sync with the server | | `src/sync.jai` | Sign-in and project sync with the server |
| `src/media.jai` | Blob store, image cache, attaching files, video player state |
| `src/attachments_ui.jai`, `src/comments.jai` | Thumbnails, viewer, card covers; comment threads |
| `src/app.jai`, `src/account.jai` | Screens | | `src/app.jai`, `src/account.jai` | Screens |
| `server/server.jai` | HTTP server, API, users and sessions | | `src/sha256.jai` | SHA-256 / HMAC / PBKDF2 (shared with the server) |
| `server/sqlite.jai`, `server/crypto.jai` | SQLite binding; SHA-256 / HMAC / PBKDF2 / OS randomness | | `server/server.jai` | HTTP server, API, users, sessions, blob storage |
| `server/sqlite.jai`, `server/crypto.jai` | SQLite binding; OS randomness |
| `deploy/` | Droplet setup, deploy script, systemd unit, Caddyfile | | `deploy/` | Droplet setup, deploy script, systemd unit, Caddyfile |
## Keyboard ## Keyboard
- `Esc`: close a popup or the task panel - `Esc`: close the viewer, a popup or the task panel
- `Ctrl+S`: save / sync now - `Ctrl+S`: save / sync now
- `Ctrl+V` with an image on the clipboard (task open): attach it
- In the viewer: Left/Right for previous/next, `Space` for play/pause
- In the comment box: `Ctrl+Enter` posts
- In text fields: - In text fields:
- `Shift`+arrows, `Home`, `End`: select - `Shift`+arrows, `Home`, `End`: select
- `Ctrl`+arrows: move by word - `Ctrl`+arrows: move by word
@@ -121,7 +156,7 @@ Current limits: one project per server, and changes arrive by polling (up to ~2
The native client can be scripted, for screenshots and regression checks: The native client can be scripted, for screenshots and regression checks:
``` ```
bin/TaskForge.exe test.taskforge --sim "wait 2;click 710 230;wait 2;type Hello;shot out.bmp;quit" bin/TaskForge.exe test.taskforge --sim "wait 2;click 710 230;wait 2;drop C:/art/concept.png;type Hello;shot out.bmp;quit"
``` ```
Commands: `move X Y`, `down`, `up`, `click X Y`, `type TEXT`, `key CODE [MODS]`, `wheel LINES`, `wait N`, `shot FILE.bmp`, `quit`. Commands: `move X Y`, `down`, `up`, `click X Y`, `type TEXT`, `key CODE [MODS]`, `wheel LINES`, `drop PATH`, `wait N`, `shot FILE.bmp`, `quit`.
+4 -1
View File
@@ -31,11 +31,14 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
install -m 644 "$HERE/taskforge.service" /etc/systemd/system/taskforge.service install -m 644 "$HERE/taskforge.service" /etc/systemd/system/taskforge.service
sed "s/{DOMAIN}/$DOMAIN/" "$HERE/Caddyfile" > /etc/caddy/Caddyfile sed "s/{DOMAIN}/$DOMAIN/" "$HERE/Caddyfile" > /etc/caddy/Caddyfile
# Daily consistent backup (safe while the server runs), kept for 14 days. # Daily consistent backup (safe while the server runs), kept for 14 days. Attachments are immutable files
# named by their hash, so copying the new ones is enough.
cat > /etc/cron.daily/taskforge-backup <<'EOF' cat > /etc/cron.daily/taskforge-backup <<'EOF'
#!/bin/sh #!/bin/sh
sqlite3 /var/lib/taskforge/taskforge.db ".backup '/var/backups/taskforge/taskforge-$(date +%F).db'" sqlite3 /var/lib/taskforge/taskforge.db ".backup '/var/backups/taskforge/taskforge-$(date +%F).db'"
find /var/backups/taskforge -name 'taskforge-*.db' -mtime +14 -delete find /var/backups/taskforge -name 'taskforge-*.db' -mtime +14 -delete
mkdir -p /var/backups/taskforge/blobs
cp -rn /var/lib/taskforge/blobs/. /var/backups/taskforge/blobs/ 2>/dev/null || true
EOF EOF
chmod 755 /etc/cron.daily/taskforge-backup chmod 755 /etc/cron.daily/taskforge-backup
+1 -181
View File
@@ -1,184 +1,4 @@
// SHA-256, HMAC-SHA256, PBKDF2-HMAC-SHA256 and OS randomness. Used for password hashing and session tokens. // OS randomness for salts and session tokens. (Hashing lives in src/sha256.jai.)
Sha256 :: struct {
state: [8] u32;
block: [64] u8;
used: int;
length: u64;
}
SHA256_K :: u32.[
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
sha256_init :: (s: *Sha256) {
s.state = .[0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
s.used = 0;
s.length = 0;
}
rotr :: inline (x: u32, n: u32) -> u32 {
return (x >> n) | (x << (32 - n));
}
sha256_compress :: (s: *Sha256, block: *u8) {
w: [64] u32 = ---;
for i: 0..15 {
p := block + i * 4;
w[i] = ((cast(u32) p[0]) << 24) | ((cast(u32) p[1]) << 16) | ((cast(u32) p[2]) << 8) | (cast(u32) p[3]);
}
for i: 16..63 {
s0 := rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3);
s1 := rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10);
w[i] = w[i-16] + s0 + w[i-7] + s1;
}
a, b, c, d := s.state[0], s.state[1], s.state[2], s.state[3];
e, f, g, h := s.state[4], s.state[5], s.state[6], s.state[7];
for i: 0..63 {
S1 := rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
ch := (e & f) ^ (~e & g);
t1 := h + S1 + ch + SHA256_K[i] + w[i];
S0 := rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
mj := (a & b) ^ (a & c) ^ (b & c);
t2 := S0 + mj;
h = g; g = f; f = e; e = d + t1;
d = c; c = b; b = a; a = t1 + t2;
}
s.state[0] += a; s.state[1] += b; s.state[2] += c; s.state[3] += d;
s.state[4] += e; s.state[5] += f; s.state[6] += g; s.state[7] += h;
}
sha256_update :: (s: *Sha256, data: *u8, count: int) {
s.length += cast(u64) count;
i := 0;
while i < count {
n := min(64 - s.used, count - i);
memcpy(s.block.data + s.used, data + i, n);
s.used += n;
i += n;
if s.used == 64 {
sha256_compress(s, s.block.data);
s.used = 0;
}
}
}
sha256_update :: inline (s: *Sha256, data: string) {
sha256_update(s, data.data, data.count);
}
sha256_final :: (s: *Sha256) -> [32] u8 {
bits := s.length * 8;
pad: [72] u8;
pad[0] = 0x80;
pad_len := ifx s.used < 56 then 56 - s.used else 120 - s.used;
for i: 0..7 pad[pad_len + i] = cast(u8) (bits >> cast(u64) ((7 - i) * 8));
sha256_update(s, pad.data, pad_len + 8);
result: [32] u8;
for i: 0..7 {
result[i*4 + 0] = cast(u8) (s.state[i] >> 24);
result[i*4 + 1] = cast(u8) (s.state[i] >> 16);
result[i*4 + 2] = cast(u8) (s.state[i] >> 8);
result[i*4 + 3] = cast(u8) s.state[i];
}
return result;
}
sha256 :: (data: string) -> [32] u8 {
s: Sha256;
sha256_init(*s);
sha256_update(*s, data);
return sha256_final(*s);
}
// HMAC with the inner/outer pad states precomputed, so PBKDF2 costs two compressions per iteration.
Hmac_Sha256 :: struct {
inner, outer: Sha256;
}
hmac_init :: (h: *Hmac_Sha256, key: string) {
k: [64] u8;
if key.count > 64 {
digest := sha256(key);
memcpy(k.data, digest.data, 32);
} else {
memcpy(k.data, key.data, key.count);
}
ipad, opad: [64] u8;
for i: 0..63 {
ipad[i] = k[i] ^ 0x36;
opad[i] = k[i] ^ 0x5c;
}
sha256_init(*h.inner);
sha256_update(*h.inner, ipad.data, 64);
sha256_init(*h.outer);
sha256_update(*h.outer, opad.data, 64);
}
hmac_compute :: (h: *Hmac_Sha256, message: *u8, count: int) -> [32] u8 {
inner := h.inner;
sha256_update(*inner, message, count);
digest := sha256_final(*inner);
outer := h.outer;
sha256_update(*outer, digest.data, 32);
return sha256_final(*outer);
}
hmac_sha256 :: (key: string, message: string) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, key);
return hmac_compute(*h, message.data, message.count);
}
// PBKDF2-HMAC-SHA256 with a 32-byte output (one block).
pbkdf2_sha256 :: (password: string, salt: string, iterations: int) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, password);
first := alloc_string(salt.count + 4,, temp);
memcpy(first.data, salt.data, salt.count);
first[salt.count + 0] = 0;
first[salt.count + 1] = 0;
first[salt.count + 2] = 0;
first[salt.count + 3] = 1;
u := hmac_compute(*h, first.data, first.count);
result := u;
for 2..iterations {
u = hmac_compute(*h, u.data, 32);
for i: 0..31 result[i] ^= u[i];
}
return result;
}
constant_time_equal :: (a: string, b: string) -> bool {
if a.count != b.count return false;
diff: u8;
for i: 0..a.count-1 diff |= a[i] ^ b[i];
return diff == 0;
}
to_hex :: (data: *u8, count: int) -> string {
DIGITS :: "0123456789abcdef";
result := alloc_string(count * 2);
for i: 0..count-1 {
result[i*2] = DIGITS[data[i] >> 4];
result[i*2 + 1] = DIGITS[data[i] & 0xF];
}
return result;
}
as_string :: (bytes: *[32] u8) -> string {
s: string;
s.data = bytes.data;
s.count = 32;
return s;
}
// //
// Randomness from the OS. // Randomness from the OS.
+163 -1
View File
@@ -15,6 +15,7 @@ Config :: struct {
listen_on := "127.0.0.1"; listen_on := "127.0.0.1";
db_path := "taskforge.db"; db_path := "taskforge.db";
web_dir := "web"; web_dir := "web";
blob_dir := ""; // Defaults to "blobs" next to the database.
threads: s32 = 8; threads: s32 = 8;
demo: bool; demo: bool;
} }
@@ -40,6 +41,7 @@ main :: () {
if a == "--listen" && has_value { config.listen_on = args[i + 1]; i += 2; continue; } if a == "--listen" && has_value { config.listen_on = args[i + 1]; i += 2; continue; }
if a == "--db" && has_value { config.db_path = args[i + 1]; i += 2; continue; } if a == "--db" && has_value { config.db_path = args[i + 1]; i += 2; continue; }
if a == "--web" && has_value { config.web_dir = args[i + 1]; i += 2; continue; } if a == "--web" && has_value { config.web_dir = args[i + 1]; i += 2; continue; }
if a == "--blobs" && has_value { config.blob_dir = args[i + 1]; i += 2; continue; }
if a == "--threads" && has_value { config.threads = cast(s32) string_to_int(args[i + 1]); i += 2; continue; } if a == "--threads" && has_value { config.threads = cast(s32) string_to_int(args[i + 1]); i += 2; continue; }
if a == "--demo" { config.demo = true; i += 1; continue; } if a == "--demo" { config.demo = true; i += 1; continue; }
if a == "--admin" { admin_flag = true; i += 1; continue; } if a == "--admin" { admin_flag = true; i += 1; continue; }
@@ -53,6 +55,13 @@ main :: () {
i += 1; i += 1;
} }
if !config.blob_dir.count {
dir := path_strip_filename(config.db_path);
// Heap copy: temporary storage is reset between requests, and this must live for the whole run.
config.blob_dir = ifx dir.count then sprint("%blobs", dir) else "blobs";
}
make_directory_if_it_does_not_exist(config.blob_dir);
if !load_sqlite() exit(1); if !load_sqlite() exit(1);
db, ok := db_open(config.db_path); db, ok := db_open(config.db_path);
if !ok exit(1); if !ok exit(1);
@@ -132,6 +141,12 @@ CREATE TABLE IF NOT EXISTS login_failures (
at INTEGER NOT NULL at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS login_failures_by_name ON login_failures(username, at); CREATE INDEX IF NOT EXISTS login_failures_by_name ON login_failures(username, at);
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created INTEGER NOT NULL,
user_id INTEGER
);
SQL SQL
migrate :: (db: Db) -> bool { migrate :: (db: Db) -> bool {
@@ -370,8 +385,13 @@ Request :: struct {
path: string; path: string;
authorization: string; authorization: string;
body: string; body: string;
// Streamed uploads: the body isn't read up front; `body` holds what arrived with the headers.
streamed: bool;
content_length: s64;
} }
MAX_UPLOAD :: 600 * 1024 * 1024;
handle_connection :: (db: Db, s: Socket) { handle_connection :: (db: Db, s: Socket) {
request, ok := read_request(s); request, ok := read_request(s);
if !ok { if !ok {
@@ -379,6 +399,14 @@ handle_connection :: (db: Db, s: Socket) {
return; return;
} }
if request.method == "GET" && begins_with(request.path, "/api/blob/") {
handle_blob_download(db, s, request);
return;
}
if request.method == "POST" && request.path == "/api/blob" {
handle_blob_upload(db, s, request);
return;
}
if request.method == "GET" { if request.method == "GET" {
if request.path == "/api/info" { if request.path == "/api/info" {
respond(s, 200, "text/plain", "taskforge-server 1\n"); respond(s, 200, "text/plain", "taskforge-server 1\n");
@@ -445,10 +473,17 @@ read_request :: (s: Socket) -> Request, bool {
if name == "content-length" content_length = string_to_int(value); if name == "content-length" content_length = string_to_int(value);
if name == "authorization" request.authorization = value; if name == "authorization" request.authorization = value;
} }
have := min(buffer.count - header_end, content_length);
if request.method == "POST" && request.path == "/api/blob" {
if content_length < 0 || content_length > MAX_UPLOAD return request, false;
request.streamed = true;
request.content_length = content_length;
request.body = substring(string.{buffer.count, buffer.data}, header_end, header_end + have);
return request, true;
}
if content_length < 0 || content_length > MAX_BODY return request, false; if content_length < 0 || content_length > MAX_BODY return request, false;
body := alloc_string(content_length,, temp); body := alloc_string(content_length,, temp);
have := min(buffer.count - header_end, content_length);
memcpy(body.data, buffer.data + header_end, have); memcpy(body.data, buffer.data + header_end, have);
while have < content_length { while have < content_length {
n := recv(s, body.data + have, xx min(content_length - have, 1 << 20), 0); n := recv(s, body.data + have, xx min(content_length - have, 1 << 20), 0);
@@ -467,6 +502,8 @@ respond :: (s: Socket, status: int, content_type: string, body: string, extra_he
case 403; reason = "Forbidden"; case 403; reason = "Forbidden";
case 404; reason = "Not Found"; case 404; reason = "Not Found";
case 405; reason = "Method Not Allowed"; case 405; reason = "Method Not Allowed";
case 413; reason = "Payload Too Large";
case 415; reason = "Unsupported Media Type";
case 429; reason = "Too Many Requests"; case 429; reason = "Too Many Requests";
case 500; reason = "Internal Server Error"; case 500; reason = "Internal Server Error";
} }
@@ -723,6 +760,129 @@ handle_users :: (db: Db, s: Socket, request: Request) {
else respond(s, 200, "text/plain", "ok\n"); else respond(s, 200, "text/plain", "ok\n");
} }
//
// Attachments: content-addressed blobs (images, videos) stored as files, named by their SHA-256.
//
blob_path :: (hash: string) -> string {
return tprint("%/%/%", config.blob_dir, substring(hash, 0, 2), hash);
}
valid_hash :: (hash: string) -> bool {
if hash.count != 64 return false;
for i: 0..63 {
c := hash[i];
if !((c >= #char "0" && c <= #char "9") || (c >= #char "a" && c <= #char "f")) return false;
}
return true;
}
// Only images and videos may be stored.
allowed_media :: (head: string) -> bool {
starts :: (data: string, offset: int, magic: string) -> bool {
if data.count < offset + magic.count return false;
return memcmp(data.data + offset, magic.data, magic.count) == 0;
}
return starts(head, 0, "\x89PNG") || starts(head, 0, "\xFF\xD8\xFF") || starts(head, 0, "GIF8") || starts(head, 0, "BM") ||
(starts(head, 0, "RIFF") && (starts(head, 8, "WEBP") || starts(head, 8, "AVI "))) ||
starts(head, 4, "ftyp") || starts(head, 0, "\x1A\x45\xDF\xA3");
}
handle_blob_upload :: (db: Db, s: Socket, request: Request) {
session, authed := authenticate(db, request);
if !authed {
respond(s, 401, "text/plain", "error \"Please sign in again.\"\n");
return;
}
total := request.content_length;
// Stream to a temp file while hashing, so large videos never sit in memory.
token: [8] u8;
random_bytes(token.data, 8);
temp_path := tprint("%/upload-%.tmp", config.blob_dir, to_hex(token.data, 8));
file, ok := file_open(temp_path, for_writing = true);
if !ok {
respond(s, 500, "text/plain", "error \"Could not store the file.\"\n");
return;
}
hasher: Sha256;
sha256_init(*hasher);
head: [16] u8;
head_count := 0;
received := 0;
failed := false;
consume :: (data: *u8, count: int) #expand {
sha256_update(*hasher, data, count);
take := min(count, head.count - head_count);
if take > 0 { memcpy(head.data + head_count, data, take); head_count += take; }
if !file_write(*file, data, count) failed = true;
received += count;
}
if request.body.count consume(request.body.data, request.body.count);
chunk := NewArray(1 << 20, u8,, temp);
while received < total && !failed {
n := recv(s, chunk.data, xx min(total - received, chunk.count), 0);
if n <= 0 { failed = true; break; }
consume(chunk.data, n);
}
file_close(*file);
head_string := string.{head_count, head.data};
if failed || received != total || !allowed_media(head_string) {
file_delete(temp_path);
if !failed && received == total respond(s, 415, "text/plain", "error \"Only images and videos can be attached.\"\n");
else respond(s, 400, "text/plain", "error \"Upload interrupted.\"\n");
return;
}
digest := sha256_final(*hasher);
hash := to_hex(digest.data, 32);
final_path := blob_path(hash);
make_directory_if_it_does_not_exist(path_strip_filename(final_path));
if file_exists(final_path) file_delete(temp_path);
else file_move(temp_path, final_path);
ins := prepare(db, tprint("INSERT OR IGNORE INTO blobs(hash, size, created, user_id) VALUES (?, ?, %, ?)", NOW_SQL));
bind(ins, 1, hash);
bind(ins, 2, total);
bind(ins, 3, session.user_id);
run(ins);
finalize(ins);
respond(s, 200, "text/plain", tprint("hash %\n", hash));
}
handle_blob_download :: (db: Db, s: Socket, request: Request) {
_, authed := authenticate(db, request);
if !authed {
respond(s, 401, "text/plain", "error \"Please sign in again.\"\n");
return;
}
hash := substring(request.path, "/api/blob/".count, request.path.count);
if !valid_hash(hash) {
respond(s, 404, "text/plain", "Not found\n");
return;
}
file, ok := file_open(blob_path(hash), log_errors = false);
if !ok {
respond(s, 404, "text/plain", "Not found\n");
return;
}
defer file_close(*file);
length := file_length(file);
header := tprint("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: private, max-age=31536000, immutable\r\n\r\n", length);
send_all(s, header);
chunk := NewArray(1 << 20, u8,, temp);
sent := 0;
while sent < length {
ok, n := file_read(file, chunk.data, min(length - sent, chunk.count));
if !ok || n <= 0 break;
send_all(s, string.{n, chunk.data});
sent += n;
}
}
users_command :: (db: Db, session: Session, body: string, token_hash: string) -> error: string { users_command :: (db: Db, session: Session, body: string, token_hash: string) -> error: string {
r := Line_Reader.{line = body}; r := Line_Reader.{line = body};
command := next_token(*r); command := next_token(*r);
@@ -771,6 +931,7 @@ users_command :: (db: Db, session: Session, body: string, token_hash: string) ->
#load "sqlite.jai"; #load "sqlite.jai";
#load "crypto.jai"; #load "crypto.jai";
#load "../src/sha256.jai";
#load "../src/common.jai"; #load "../src/common.jai";
#load "../src/model.jai"; #load "../src/model.jai";
#load "../src/storage.jai"; #load "../src/storage.jai";
@@ -781,6 +942,7 @@ users_command :: (db: Db, session: Session, body: string, token_hash: string) ->
#import "File"; #import "File";
#import "Socket"; #import "Socket";
#import "Thread"; #import "Thread";
#import "File_Utilities";
#if OS != .WINDOWS { #if OS != .WINDOWS {
#import "POSIX"; #import "POSIX";
} }
+73 -2
View File
@@ -42,7 +42,7 @@ App :: struct {
app: App; app: App;
SIDEBAR_W :: 244.0; SIDEBAR_W :: 244.0;
EDITOR_W :: 400.0; EDITOR_W :: 440.0;
COLUMN_W :: 288.0; COLUMN_W :: 288.0;
COLUMN_GAP :: 12.0; COLUMN_GAP :: 12.0;
@@ -108,7 +108,26 @@ save_now :: () {
app_frame :: (input: *Frame_Input) -> bool { app_frame :: (input: *Frame_Input) -> bool {
p := app.project; p := app.project;
begin_render(input.width, input.height, input.dpr); begin_render(input.width, input.height, input.dpr);
ui.modal = viewer.open;
ui_begin_frame(input); ui_begin_frame(input);
// Files dropped, pasted, picked or captured since the last frame.
for input.files app_add_file(it.name, it.data);
// Ctrl+V with an image on the clipboard (e.g. a screenshot) attaches it to the open task.
if app.selected {
for k, index: input.keys {
if k.key != #char "V" || !(k.mods & MOD_CTRL) continue;
png := platform_clipboard_image();
if png.count {
app_add_file(timestamp_file_name("Screenshot", "png"), png);
free(png);
array_ordered_remove_by_index(*input.keys, index);
}
break;
}
}
if sync.enabled blobs_frame();
video_frame();
app.drag.target_valid = false; app.drag.target_valid = false;
screen := make_rect(0, 0, input.width, input.height); screen := make_rect(0, 0, input.width, input.height);
@@ -135,6 +154,8 @@ app_frame :: (input: *Frame_Input) -> bool {
} }
if app.selected draw_task_editor(editor); if app.selected draw_task_editor(editor);
draw_viewer();
draw_toast();
update_drag(); update_drag();
handle_shortcuts(); handle_shortcuts();
ui_end_frame(); ui_end_frame();
@@ -148,7 +169,7 @@ app_frame :: (input: *Frame_Input) -> bool {
end_render(COL_BG); end_render(COL_BG);
reset_temporary_storage(); reset_temporary_storage();
return ui.wants_frame || app.drag.active || app.dirty || sync.in_flight[Http_Tag.SYNC]; return ui.wants_frame || app.drag.active || app.dirty || sync.in_flight[Http_Tag.SYNC] || blobs_busy() || pending_files.count > 0 || video.playing;
} }
handle_shortcuts :: () { handle_shortcuts :: () {
@@ -818,6 +839,13 @@ layout_card :: (t: *Task, view: *View, r: Rect, draw: bool) -> float {
w := r.w - 2 * CARD_PAD; w := r.w - 2 * CARD_PAD;
y := r.y + CARD_PAD; y := r.y + CARD_PAD;
cover := task_cover(t);
if cover {
ch := cover_height(cover, r.w - 8);
if draw draw_cover(cover, make_rect(r.x + 4, r.y + 4, r.w - 8, ch));
y = r.y + 4 + ch + 8;
}
if t.is_epic { if t.is_epic {
if draw chip(x, y, "EPIC", 0xBC8CFF); if draw chip(x, y, "EPIC", 0xBC8CFF);
y += chip_height() + 6; y += chip_height() + 6;
@@ -851,6 +879,31 @@ layout_card :: (t: *Task, view: *View, r: Rect, draw: bool) -> float {
} }
y += flow_height(*flow, y); y += flow_height(*flow, y);
} }
// Comment and attachment counts.
comments := comment_count(p, t.id);
files := 0;
for p.attachments if it.task == t.id files += 1;
if comments || files {
y += 8;
if draw {
mx := x;
if comments {
draw_rect_outline(make_rect(mx, y + 2, 13, 10), COL_TEXT_FAINT, 1.3, 3);
draw_triangle(mx + 3, y + 11, mx + 7, y + 11, mx + 3, y + 15, COL_TEXT_FAINT);
label := tprint("%", comments);
draw_text(label, mx + 17, y, .SMALL, COL_TEXT_FAINT);
mx += 17 + measure_text(label, .SMALL) + 12;
}
if files {
draw_rect_outline(make_rect(mx, y + 1, 13, 12), COL_TEXT_FAINT, 1.3, 2);
draw_triangle(mx + 2, y + 11, mx + 6, y + 6, mx + 9, y + 11, COL_TEXT_FAINT);
label := tprint("%", files);
draw_text(label, mx + 17, y, .SMALL, COL_TEXT_FAINT);
}
}
y += line_height(.SMALL);
}
return y + CARD_PAD - r.y; return y + CARD_PAD - r.y;
} }
@@ -1199,8 +1252,26 @@ draw_task_editor :: (r: Rect) {
if text_edit(make_rect(x - 4, y, w + 4, nh), *t.notes, ui_id(t, 13), "Add notes...", multiline = true) mark_dirty(); if text_edit(make_rect(x - 4, y, w + 4, nh), *t.notes, ui_id(t, 13), "Add notes...", multiline = true) mark_dirty();
y += nh + 18; y += nh + 18;
// Attachments.
task_attachments := attachments_of(p, t.id);
draw_text(tprint("Attachments (%)", task_attachments.count), x, y, .MEDIUM, COL_TEXT);
y += 30;
gh, removed := draw_attachment_grid(task_attachments, x, y, w, 122, true, 600);
if removed {
remove_attachment(p, removed.id);
mark_dirty();
}
if gh y += gh + 10;
else {
draw_text("Drop, paste or add images and videos.", x, y, .SMALL, COL_TEXT_FAINT);
y += 22;
}
y += draw_add_media_buttons(x - 4, y, ui_id(t, 14)) + 22;
if t.is_epic y = draw_epic_details(t, x, y, w); if t.is_epic y = draw_epic_details(t, x, y, w);
y = draw_comments(t, x, y, w);
scroll_end(area, sid, y + off - area.y); scroll_end(area, sid, y + off - area.y);
} }
+272
View File
@@ -0,0 +1,272 @@
// Attachment thumbnails, the full-screen viewer (images and video player), and card covers.
Viewer :: struct {
open: bool;
task: u32;
comment: u32;
index: int;
seeking: bool;
}
viewer: Viewer;
open_viewer :: (a: *Attachment) {
list := attachments_of(app.project, a.task, a.comment);
viewer.open = true;
viewer.task = a.task;
viewer.comment = a.comment;
viewer.index = 0;
for list if it == a viewer.index = it_index;
}
close_viewer :: () {
viewer.open = false;
video_close();
}
// A grid of thumbnails. Returns its height. `can_delete` shows a remove button on hover.
draw_attachment_grid :: (list: [] *Attachment, x: float, y: float, w: float, tile: float, can_delete: bool, salt: int) -> float, removed: *Attachment {
if !list.count return 0, null;
gap :: 8.0;
columns := max(1, cast(int) ((w + gap) / (tile + gap)));
removed: *Attachment;
for a, i: list {
col := i % columns;
row := i / columns;
r := make_rect(x + col * (tile + gap), y + row * (tile + gap), tile, tile);
id := ui_id(a, salt);
clicked, hovered := button_behavior(id, r);
draw_attachment_tile(a, r, hovered);
if can_delete && hovered {
del := make_rect(r.x + r.w - 26, r.y + 4, 22, 22);
draw_rect(del, hex(0x000000, 0.6), 11);
if icon_button(del, .CLOSE, id + 1, hex(0xFFFFFF)) { removed = a; clicked = false; }
}
if clicked && !removed open_viewer(a);
}
rows := (list.count + columns - 1) / columns;
return rows * (tile + gap) - gap, removed;
}
draw_attachment_tile :: (a: *Attachment, r: Rect, hovered: bool) {
image := attachment_image(a);
if image {
draw_image_cover(r, image.texture, xx image.width, xx image.height, 6);
} else {
draw_rect(r, COL_INPUT, 6);
draw_text_centered(ifx a.kind == .VIDEO then "Video" else "Loading...", r, .SMALL, COL_TEXT_FAINT);
}
if a.kind == .VIDEO {
play_badge(r.x + r.w * 0.5, r.y + r.h * 0.5, min(r.w, r.h) * 0.16);
if a.duration > 0 {
label := format_duration(a.duration);
bw := measure_text(label, .SMALL) + 10;
br := make_rect(r.x + r.w - bw - 4, r.y + r.h - 22, bw, 18);
draw_rect(br, hex(0x000000, 0.65), 4);
draw_text_centered(label, br, .SMALL, hex(0xFFFFFF));
}
}
if hovered draw_rect_outline(r, COL_ACCENT, 1.5, 6);
}
play_badge :: (cx: float, cy: float, radius: float) {
draw_rect(make_rect(cx - radius, cy - radius, radius * 2, radius * 2), hex(0x000000, 0.55), radius);
s := radius * 0.45;
draw_triangle(cx - s * 0.7, cy - s, cx - s * 0.7, cy + s, cx + s * 1.1, cy, hex(0xFFFFFF));
}
// Buttons to add files. Returns the height used.
draw_add_media_buttons :: (x: float, y: float, salt: u64) -> float {
w1 := measure_text("Add image or video", .NORMAL) + 24;
if button(make_rect(x, y, w1, 30), "Add image or video", id = salt + 1) platform_pick_files();
label := platform_capture_label();
w2 := measure_text(label, .NORMAL) + 24;
if button(make_rect(x + w1 + 8, y, w2, 30), label, id = salt + 2) platform_capture_screenshot();
return 30;
}
//
// Card cover: the first image (or video poster) of a task, across the top of its card.
//
COVER_MAX_H :: 150.0;
task_cover :: (task: *Task) -> *Attachment {
best: *Attachment;
for app.project.attachments {
if it.task != task.id || it.comment != 0 continue;
if !best || it.order < best.order best = it;
}
return best;
}
cover_height :: (a: *Attachment, w: float) -> float {
if !a || a.width <= 0 || a.height <= 0 return 0;
return min(w * a.height / a.width, COVER_MAX_H);
}
draw_cover :: (a: *Attachment, r: Rect) {
image := attachment_image(a);
if image draw_image_cover(r, image.texture, xx image.width, xx image.height, 6);
else draw_rect(r, COL_INPUT, 6);
if a.kind == .VIDEO play_badge(r.x + r.w * 0.5, r.y + r.h * 0.5, min(r.h * 0.18, 18.0));
}
//
// Full-screen viewer.
//
draw_viewer :: () {
if !viewer.open return;
p := app.project;
list := attachments_of(p, viewer.task, viewer.comment);
if !list.count || !find_task(p, viewer.task) {
close_viewer();
return;
}
viewer.index = clamp(viewer.index, 0, list.count - 1);
a := list[viewer.index];
set_layer(LAYER_POPUP);
defer set_layer(LAYER_BASE);
screen := make_rect(0, 0, renderer.width, renderer.height);
draw_rect(screen, hex(0x05060a, 0.92));
// Top bar.
bar := make_rect(0, 0, screen.w, 52);
info := tprint("% · % · %x%", a.name, format_size(a.size), a.width, a.height);
if list.count > 1 info = tprint("% (% of %)", info, viewer.index + 1, list.count);
draw_text_in_rect(info, inset(make_rect(bar.x, bar.y, bar.w - 220, bar.h), 20, 0), .NORMAL, COL_TEXT);
tools := make_rect(bar.w - 16, 11, 0, 30);
tools.x -= 30;
if icon_button(make_rect(tools.x, tools.y, 30, 30), .CLOSE, ui_id("viewer close")) { close_viewer(); return; }
tools.x -= 98;
if can_remove_attachment(a) && confirm_button(make_rect(tools.x, tools.y, 90, 30), "Delete", ui_id(a, 900), "Delete?") {
close_viewer();
remove_attachment(p, a.id);
mark_dirty();
return;
}
area := make_rect(60, 60, screen.w - 120, screen.h - 60 - (ifx a.kind == .VIDEO then 76.0 else 30.0));
if a.kind == .IMAGE {
video_close();
image := get_image(a.hash);
if image && image.state == .READY {
r := fit_rect(area, xx image.width, xx image.height);
draw_image(r, image.texture, 4);
} else {
draw_text_centered(ifx image && image.state == .FAILED then "Couldn't load this image." else "Loading...", area, .MEDIUM, COL_TEXT_DIM);
}
} else {
video_open(a);
r := fit_rect(area, xx max(a.width, 1), xx max(a.height, 1));
if video.state == .READY && video.texture {
draw_image(r, video.texture, 4);
} else {
poster := attachment_image(a);
if poster draw_image(r, poster.texture, 4, hex(0x808080));
label := "Loading video...";
if video.state == .FAILED label = "This video can't be played here.";
draw_text_centered(label, r, .MEDIUM, COL_TEXT);
}
// Clicking the picture toggles playback.
clicked := button_behavior(ui_id("viewer video"), r);
if clicked && video.state == .READY platform_video_set_playing(!video.playing);
draw_video_controls(make_rect(area.x, area.y + area.h + 18, area.w, 40));
}
// Previous / next.
if list.count > 1 {
left := make_rect(10, screen.h * 0.5 - 30, 40, 60);
right := make_rect(screen.w - 50, screen.h * 0.5 - 30, 40, 60);
if nav_arrow(left, -1, ui_id("viewer prev")) viewer.index = (viewer.index + list.count - 1) % list.count;
if nav_arrow(right, 1, ui_id("viewer next")) viewer.index = (viewer.index + 1) % list.count;
}
for ui.input.keys {
if it.key == {
case KEY_ESCAPE; close_viewer(); return;
case KEY_LEFT; if list.count > 1 viewer.index = (viewer.index + list.count - 1) % list.count;
case KEY_RIGHT; if list.count > 1 viewer.index = (viewer.index + 1) % list.count;
case #char " "; if video.state == .READY platform_video_set_playing(!video.playing);
}
}
for ui.input.chars if it == #char " " && video.state == .READY platform_video_set_playing(!video.playing);
ui.input.keys.count = 0;
ui.input.chars.count = 0;
// Clicking the dark background closes.
if ui.pressed && ui.active == 0 && ui.my > 52 && !contains(area, ui.mx, ui.my) && !(a.kind == .VIDEO && ui.my > area.y + area.h) && (list.count <= 1 || (ui.mx > 50 && ui.mx < screen.w - 50)) {
close_viewer();
}
}
nav_arrow :: (r: Rect, direction: int, id: u64) -> bool {
clicked, hovered := button_behavior(id, r);
draw_rect(r, hex(0xFFFFFF, ifx hovered then 0.15 else 0.06), 8);
cx, cy := r.x + r.w * 0.5, r.y + r.h * 0.5;
d := cast(float) direction * 5;
draw_line(cx - d, cy - 9, cx + d, cy, 2, COL_TEXT);
draw_line(cx + d, cy, cx - d, cy + 9, 2, COL_TEXT);
return clicked;
}
draw_video_controls :: (r: Rect) {
ready := video.state == .READY;
// Play / pause.
pr := make_rect(r.x, r.y, 40, 40);
clicked, hovered := button_behavior(ui_id("video play"), pr);
draw_rect(pr, hex(0xFFFFFF, ifx hovered then 0.15 else 0.08), 20);
cx, cy := pr.x + 20, pr.y + 20;
if video.playing {
draw_rect(make_rect(cx - 6, cy - 8, 4, 16), COL_TEXT, 1);
draw_rect(make_rect(cx + 2, cy - 8, 4, 16), COL_TEXT, 1);
} else {
draw_triangle(cx - 5, cy - 8, cx - 5, cy + 8, cx + 8, cy, COL_TEXT);
}
if clicked && ready platform_video_set_playing(!video.playing);
// Mute.
mr := make_rect(r.x + r.w - 40, r.y, 40, 40);
mclicked, mhovered := button_behavior(ui_id("video mute"), mr);
draw_rect(mr, hex(0xFFFFFF, ifx mhovered then 0.15 else 0.08), 20);
sx, sy := mr.x + 13, mr.y + 20;
draw_rect(make_rect(sx, sy - 4, 5, 8), COL_TEXT, 1);
draw_triangle(sx + 3, sy - 4, sx + 11, sy - 10, sx + 11, sy + 10, COL_TEXT);
draw_triangle(sx + 3, sy - 4, sx + 11, sy + 10, sx + 3, sy + 4, COL_TEXT);
if video.muted draw_line(sx - 2, sy + 10, sx + 16, sy - 10, 2, COL_DANGER);
else draw_line(sx + 14, sy - 5, sx + 14, sy + 5, 1.5, COL_TEXT);
if mclicked {
video.muted = !video.muted;
platform_video_set_muted(video.muted);
}
// Time and seek bar.
duration := max(video.duration, 0.001);
time_label := tprint("% / %", format_duration(video.time), format_duration(video.duration));
tw := measure_text("00:00:00 / 00:00:00", .NORMAL);
draw_text_in_rect(time_label, make_rect(mr.x - tw - 12, r.y, tw, 40), .NORMAL, COL_TEXT_DIM);
bar := make_rect(pr.x + 56, r.y + 14, mr.x - tw - 24 - (pr.x + 56), 12);
id := ui_id("video seek");
_, bar_hovered, held := button_behavior(id, bar);
draw_rect(make_rect(bar.x, bar.y + 4, bar.w, 4), hex(0xFFFFFF, 0.2), 2);
t := cast(float) (video.time / duration);
if held && ready {
t = clamp((ui.mx - bar.x) / bar.w, 0, 1);
if !viewer.seeking || ui.pressed || abs(cast(float) (t * duration - video.time)) > 0.25 platform_video_seek(t * duration);
viewer.seeking = true;
video.time = t * duration;
} else {
viewer.seeking = false;
}
draw_rect(make_rect(bar.x, bar.y + 4, bar.w * t, 4), COL_ACCENT, 2);
draw_rect(make_rect(bar.x + bar.w * t - 6, bar.y, 12, 12), ifx bar_hovered || held then hex(0xFFFFFF) else hex(0xE6EDF3), 6);
}
can_remove_attachment :: (a: *Attachment) -> bool {
if !a.comment return true;
c := find_comment(app.project, a.comment);
return !c || c.author == app.project.me || app.project.me_admin;
}
+293
View File
@@ -0,0 +1,293 @@
// Comment threads on tasks: write, reply, edit (keeping the previous versions), delete; with images, videos and
// screenshots attached to comments.
Comments_State :: struct {
task: u32; // Task the composer belongs to; switching tasks resets it.
text: string;
replying_to: u32;
editing: u32;
edit_text: string;
history_of: u32; // Comment whose previous versions are shown.
}
discussion: Comments_State;
composer_focus_id :: () -> u64 {
return ui_id("comment composer", cast(int) discussion.task);
}
comment_author_name :: (author: u32) -> string {
p := app.project;
if !sync.enabled return "You";
u := find_user(p, author);
return ifx u then u.name else "Former user";
}
// Returns the y after the comments section.
draw_comments :: (task: *Task, x: float, y: float, w: float) -> float {
p := app.project;
if discussion.task != task.id {
discussion.task = task.id;
set_string(*discussion.text, "");
discussion.replying_to = 0;
discussion.editing = 0;
discussion.history_of = 0;
for composer_attachments { free(it.hash); free(it.poster); free(it.name); free(it); }
composer_attachments.count = 0;
}
draw_text(tprint("Comments (%)", comment_count(p, task.id)), x, y, .MEDIUM, COL_TEXT);
y += 30;
y = draw_composer(task, x, y, w);
y += 14;
top := comments_of(p, task.id, 0);
for < c: top y = draw_comment_thread(c, x, y, w, 0); // Newest conversations first.
if !top.count {
draw_text("No comments yet.", x, y, .SMALL, COL_TEXT_FAINT);
y += 22;
}
return y + 10;
}
draw_composer :: (task: *Task, x: float, y: float, w: float) -> float {
p := app.project;
if discussion.replying_to {
parent := find_comment(p, discussion.replying_to);
if !parent || parent.task != task.id {
discussion.replying_to = 0;
} else {
label := tprint("Replying to %", comment_author_name(parent.author));
draw_text(label, x, y + 4, .SMALL, COL_ACCENT);
if icon_button(make_rect(x + measure_text(label, .SMALL) + 6, y, 22, 22), .CLOSE, ui_id("cancel reply")) discussion.replying_to = 0;
y += 24;
}
}
id := composer_focus_id();
h := max(text_edit_height(discussion.text, w + 8, .NORMAL), 62.0);
text_edit(make_rect(x - 4, y, w + 4, h), *discussion.text, id, "Write a comment...", multiline = true);
y += h + 8;
if composer_attachments.count {
gh, removed := draw_attachment_grid(composer_attachments, x, y, w, 64, true, 700);
if removed {
for composer_attachments if it == removed { remove it; break; }
free(removed.hash); free(removed.poster); free(removed.name); free(removed);
}
y += gh + 8;
}
draw_add_media_buttons(x - 4, y, ui_id("composer media", cast(int) task.id));
can_post := trim(discussion.text).count > 0 || composer_attachments.count > 0;
post_label := ifx discussion.replying_to then "Reply" else "Comment";
pr := make_rect(x + w - 100, y, 100, 30);
clicked := button(pr, post_label, ifx can_post then Button_Style.PRIMARY else .NORMAL, id = ui_id("post comment"));
// Ctrl+Enter in the composer posts.
if ui.focus == id for ui.input.keys if it.key == KEY_ENTER && (it.mods & MOD_CTRL) clicked = true;
if clicked && can_post post_comment(task);
return y + 30;
}
post_comment :: (task: *Task) {
p := app.project;
c := New(Comment);
c.id = new_id(p);
c.task = task.id;
c.parent = discussion.replying_to;
c.author = p.me;
c.created = ui.input.unix_time;
c.text = copy_string(trim(discussion.text));
array_add(*p.comments, c);
for composer_attachments {
it.id = new_id(p);
it.comment = c.id;
array_add(*p.attachments, it);
}
composer_attachments.count = 0;
set_string(*discussion.text, "");
discussion.replying_to = 0;
ui.focus = 0;
mark_dirty();
}
draw_comment_thread :: (c: *Comment, x: float, y: float, w: float, depth: int) -> float {
p := app.project;
indent := ifx depth > 0 then 26.0 else 0.0;
y = draw_comment(c, x + indent, y, w - indent);
replies := comments_of(p, c.task, c.id);
if replies.count {
line_top := y - 4;
for replies y = draw_comment_thread(it, x + indent, y, w - indent, min(depth + 1, 3));
draw_rect(make_rect(x + indent + 11, line_top, 2, y - line_top - 10), hex(0xFFFFFF, 0.06), 1);
}
return y;
}
draw_comment :: (c: *Comment, x: float, y: float, w: float) -> float {
p := app.project;
mine := c.author == p.me;
name := comment_author_name(c.author);
avatar(x + 12, y + 12, 12, c.author, name);
tx := x + 32;
tw := w - 32;
draw_text(name, tx, y + 2, .NORMAL, COL_TEXT);
meta_x := tx + measure_text(name, .NORMAL) + 8;
when := format_time_ago(c.created);
draw_text(when, meta_x, y + 4, .SMALL, COL_TEXT_FAINT);
meta_x += measure_text(when, .SMALL) + 8;
if c.history.count && !c.deleted {
label := ifx discussion.history_of == c.id then "hide edits" else tprint("edited (%)", c.history.count);
lr := make_rect(meta_x - 4, y + 2, measure_text(label, .SMALL) + 8, 18);
clicked, hovered := button_behavior(ui_id(c, 2), lr);
draw_text(label, meta_x, y + 4, .SMALL, ifx hovered then COL_ACCENT else COL_TEXT_FAINT);
if clicked discussion.history_of = ifx discussion.history_of == c.id then 0 else c.id;
}
y += 24;
if c.deleted {
draw_text("This comment was deleted.", tx, y, .NORMAL, COL_TEXT_FAINT);
y += line_height(.NORMAL) + 10;
return y;
}
if discussion.editing == c.id {
h := max(text_edit_height(discussion.edit_text, tw + 4, .NORMAL), 50.0);
text_edit(make_rect(tx - 4, y, tw + 4, h), *discussion.edit_text, ui_id(c, 3), "", multiline = true);
y += h + 6;
if button(make_rect(tx, y, 70, 28), "Save", .PRIMARY, id = ui_id(c, 4)) {
edit_comment(c, trim(discussion.edit_text), ui.input.unix_time);
discussion.editing = 0;
mark_dirty();
}
if button(make_rect(tx + 78, y, 76, 28), "Cancel", id = ui_id(c, 5)) discussion.editing = 0;
y += 36;
} else if c.text.count {
y += draw_wrapped_text(c.text, tx, y, tw, .NORMAL, hex(0xC9D1D9));
y += 6;
}
attachments := attachments_of(p, c.task, c.id);
if attachments.count {
gh := draw_attachment_grid(attachments, tx, y, tw, 96, false, 800);
y += gh + 8;
}
if discussion.history_of == c.id {
for < v: c.history {
label := tprint("Before %", format_time_ago(v.time));
text := ifx v.text.count then v.text else "(empty)";
th := measure_wrapped_height(text, tw - 20, .SMALL);
box := make_rect(tx, y, tw, th + 30);
draw_rect(box, hex(0xFFFFFF, 0.03), 6);
draw_rect(make_rect(box.x, box.y, 2, box.h), hex(0xFFFFFF, 0.12), 1);
draw_text(label, box.x + 10, box.y + 6, .SMALL, COL_TEXT_FAINT);
draw_wrapped_text(text, box.x + 10, box.y + 22, tw - 20, .SMALL, COL_TEXT_DIM);
y += box.h + 6;
}
}
// Actions.
ax := tx - 6;
if comment_action(*ax, y, "Reply", ui_id(c, 6)) {
discussion.replying_to = c.id;
focus_text(composer_focus_id(), discussion.text);
}
if mine && comment_action(*ax, y, "Edit", ui_id(c, 7)) {
discussion.editing = c.id;
set_string(*discussion.edit_text, c.text);
focus_text(ui_id(c, 3), c.text);
}
if mine || p.me_admin {
r := make_rect(ax, y, measure_text("Delete", .SMALL) + 14, 22);
armed := ui.confirm_id == ui_id(c, 8);
if armed && ui.pressed && !ui_hover(r) ui.confirm_id = 0;
label := ifx armed then "Click again to delete" else "Delete";
r.w = measure_text(label, .SMALL) + 14;
clicked, hovered := button_behavior(ui_id(c, 8), r);
if hovered draw_rect(r, COL_HOVER, 4);
draw_text(label, r.x + 7, r.y + 4, .SMALL, ifx armed then COL_DANGER else COL_TEXT_FAINT);
if clicked {
if armed {
ui.confirm_id = 0;
delete_comment(c);
} else ui.confirm_id = ui_id(c, 8);
}
}
return y + 30;
}
comment_action :: (x: *float, y: float, label: string, id: u64) -> bool {
r := make_rect(x.*, y, measure_text(label, .SMALL) + 14, 22);
clicked, hovered := button_behavior(id, r);
if hovered draw_rect(r, COL_HOVER, 4);
draw_text(label, r.x + 7, r.y + 4, .SMALL, ifx hovered then COL_TEXT else COL_TEXT_FAINT);
x.* += r.w + 2;
return clicked;
}
// Deleted comments stay as a placeholder so the replies under them still make sense.
delete_comment :: (c: *Comment) {
p := app.project;
c.deleted = true;
set_string(*c.text, "");
for c.history free(it.text);
c.history.count = 0;
i := 0;
while i < p.attachments.count {
if p.attachments[i].comment == c.id remove_attachment(p, p.attachments[i].id);
else i += 1;
}
if discussion.editing == c.id discussion.editing = 0;
mark_dirty();
}
//
// Time formatting.
//
MONTHS :: string.["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// Days since 1970-01-01 -> civil date (proleptic Gregorian).
civil_from_days :: (days: s64) -> year: s64, month: s64, day: s64 {
z := days + 719468;
era := ifx z >= 0 then z / 146097 else (z - 146096) / 146097;
doe := z - era * 146097;
yoe := (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
y := yoe + era * 400;
doy := doe - (365 * yoe + yoe / 4 - yoe / 100);
mp := (5 * doy + 2) / 153;
d := doy - (153 * mp + 2) / 5 + 1;
m := ifx mp < 10 then mp + 3 else mp - 9;
if m <= 2 y += 1;
return y, m, d;
}
format_local_time :: (unix: float64) -> string {
t := cast(s64) (unix + ui.input.utc_offset);
days := t / 86400;
if t < 0 && t % 86400 days -= 1;
seconds := t - days * 86400;
y, m, d := civil_from_days(days);
return tprint("% % %, %:%", d, MONTHS[m - 1], y, formatInt(seconds / 3600, minimum_digits = 2), formatInt((seconds / 60) % 60, minimum_digits = 2));
}
format_time_ago :: (unix: float64) -> string {
delta := ui.input.unix_time - unix;
if delta < 45 return "just now";
if delta < 3600 return tprint("% min ago", max(1, cast(int) (delta / 60)));
if delta < 86400 return tprint("% h ago", cast(int) (delta / 3600));
if delta < 7 * 86400 return tprint("% d ago", cast(int) (delta / 86400));
return format_local_time(unix);
}
timestamp_file_name :: (prefix: string, extension: string) -> string {
t := cast(s64) (ui.input.unix_time + ui.input.utc_offset);
days := t / 86400;
seconds := t - days * 86400;
y, m, d := civil_from_days(days);
return tprint("% %-%-% %.%.%.%", prefix, y, formatInt(m, minimum_digits = 2), formatInt(d, minimum_digits = 2),
formatInt(seconds / 3600, minimum_digits = 2), formatInt((seconds / 60) % 60, minimum_digits = 2), formatInt(seconds % 60, minimum_digits = 2), extension);
}
+11
View File
@@ -36,6 +36,8 @@ Frame_Input :: struct {
width, height: float; // Logical (DPI independent) size of the drawable area. width, height: float; // Logical (DPI independent) size of the drawable area.
dpr: float = 1; // Physical pixels per logical pixel. dpr: float = 1; // Physical pixels per logical pixel.
time: float64; // Seconds. time: float64; // Seconds.
unix_time: float64; // Wall clock, seconds since 1970 (for timestamps).
utc_offset: float64; // Local time zone offset from UTC, in seconds.
mouse_x, mouse_y: float; mouse_x, mouse_y: float;
mouse_down: [3] bool; mouse_down: [3] bool;
@@ -46,6 +48,13 @@ Frame_Input :: struct {
keys: [..] Key_Press; keys: [..] Key_Press;
chars: [..] u32; // Text input, as UTF-32 code points. chars: [..] u32; // Text input, as UTF-32 code points.
mods: u32; mods: u32;
files: [..] Input_File; // Dropped, pasted, picked or captured files, owned by the input until the next frame.
}
Input_File :: struct {
name: string;
data: string;
} }
reset_per_frame_input :: (input: *Frame_Input) { reset_per_frame_input :: (input: *Frame_Input) {
@@ -57,6 +66,8 @@ reset_per_frame_input :: (input: *Frame_Input) {
input.wheel_y = 0; input.wheel_y = 0;
input.keys.count = 0; input.keys.count = 0;
input.chars.count = 0; input.chars.count = 0;
for input.files { free(it.name); free(it.data); }
input.files.count = 0;
} }
Cursor_Kind :: enum s32 { Cursor_Kind :: enum s32 {
+5
View File
@@ -7,6 +7,7 @@
} else { } else {
PLATFORM_WEB :: false; PLATFORM_WEB :: false;
#load "platform_native.jai"; #load "platform_native.jai";
#load "video_native.jai";
} }
#load "common.jai"; #load "common.jai";
@@ -17,6 +18,10 @@
#load "storage.jai"; #load "storage.jai";
#load "app.jai"; #load "app.jai";
#load "sync.jai"; #load "sync.jai";
#load "sha256.jai";
#load "media.jai";
#load "attachments_ui.jai";
#load "comments.jai";
#load "account.jai"; #load "account.jai";
#import "Basic"; #import "Basic";
+465
View File
@@ -0,0 +1,465 @@
// Images and videos: a content-addressed blob store, an image texture cache, attaching files, and video playback.
//
// Blobs are identified by the hex SHA-256 of their bytes. Where they live depends on the mode:
// server mode -> uploaded to / downloaded from the server (POST /api/blob, GET /api/blob/<hash>)
// native file -> <project file>.files/<hash>
// web local -> IndexedDB
// Decoding is done by the platform (stb_image natively, the browser's decoders on the web), always
// downscaled to at most MAX_IMAGE_SIDE, and reported back through image_decoded().
MAX_IMAGE_BYTES :: 50 * 1024 * 1024;
MAX_VIDEO_BYTES :: 500 * 1024 * 1024;
MAX_IMAGE_SIDE :: 2048;
MAX_IMAGE_TEXTURES :: 200; // Least recently used textures beyond this are freed.
MAX_BLOB_DOWNLOADS :: 4;
File_Kind :: enum {
UNKNOWN;
IMAGE;
VIDEO;
}
detect_file_kind :: (data: string) -> File_Kind {
starts :: (data: string, offset: int, magic: string) -> bool {
if data.count < offset + magic.count return false;
return memcmp(data.data + offset, magic.data, magic.count) == 0;
}
if starts(data, 0, "\x89PNG") return .IMAGE;
if starts(data, 0, "\xFF\xD8\xFF") return .IMAGE;
if starts(data, 0, "GIF8") return .IMAGE;
if starts(data, 0, "BM") return .IMAGE;
if starts(data, 0, "RIFF") && starts(data, 8, "WEBP") return .IMAGE;
if starts(data, 4, "ftyp") return .VIDEO; // MP4 / MOV / M4V
if starts(data, 0, "\x1A\x45\xDF\xA3") return .VIDEO; // WebM / MKV
if starts(data, 0, "RIFF") && starts(data, 8, "AVI ") return .VIDEO;
return .UNKNOWN;
}
hash_hex :: (data: string) -> string {
digest := sha256(data);
return to_hex(digest.data, 32);
}
//
// Blob store
//
Upload :: struct {
hash: string;
data: string;
retry_at: float64;
in_flight: bool;
}
Blob_Store :: struct {
uploads: [..] *Upload;
downloads: [..] string; // Hashes waiting to be fetched.
fetching: int;
}
blobs: Blob_Store;
blob_save :: (hash: string, data: string) {
if !sync.enabled {
platform_local_blob_save(hash, data);
return;
}
u := New(Upload);
u.hash = copy_string(hash);
u.data = copy_string(data);
array_add(*blobs.uploads, u);
}
blob_load :: (hash: string) {
if !sync.enabled {
platform_local_blob_load(hash);
return;
}
for blobs.downloads if it == hash return;
array_add(*blobs.downloads, copy_string(hash));
}
// Starts queued uploads and downloads. Called every frame.
blobs_frame :: () {
if !sync.enabled || !sync.logged_in return;
// One upload at a time keeps bandwidth for everything else.
uploading := false;
for blobs.uploads if it.in_flight uploading = true;
if !uploading {
for blobs.uploads {
if it.retry_at > ui.time continue;
it.in_flight = true;
platform_http_request(.BLOB_PUT, "POST", "/api/blob", it.data, sync.token, it.hash);
break;
}
}
while blobs.fetching < MAX_BLOB_DOWNLOADS && blobs.downloads.count {
hash := blobs.downloads[0];
array_ordered_remove_by_index(*blobs.downloads, 0);
blobs.fetching += 1;
platform_http_request(.BLOB_GET, "GET", tprint("/api/blob/%", hash), "", sync.token, hash);
free(hash);
}
}
blobs_busy :: () -> bool {
return blobs.uploads.count > 0 || blobs.downloads.count > 0 || blobs.fetching > 0;
}
on_blob_put_response :: (status: int, hash: string) {
for blobs.uploads {
if it.hash != hash continue;
it.in_flight = false;
if status == 200 {
free(it.hash); free(it.data); free(it);
array_ordered_remove_by_index(*blobs.uploads, it_index);
} else {
it.retry_at = ui.time + 5;
if status == 413 || status == 415 {
show_toast("The server refused this file.");
free(it.hash); free(it.data); free(it);
array_ordered_remove_by_index(*blobs.uploads, it_index);
}
}
return;
}
}
on_blob_get_response :: (status: int, body: string, hash: string) {
blobs.fetching -= 1;
blob_loaded(hash, status == 200, body);
}
// A blob finished loading (or failed). The data is only valid during this call.
blob_loaded :: (hash: string, ok: bool, data: string) {
entry := find_image(hash);
if entry && entry.state == .LOADING {
if ok {
entry.state = .DECODING;
platform_decode_image(hash, data);
} else {
entry.state = .FAILED;
entry.retry_at = ui.time + 4; // It may simply not be uploaded yet.
}
}
if video.state == .LOADING && video.hash == hash {
if ok && platform_video_open(data) {
if video.state == .LOADING video.state = .OPENING; // The platform may already be READY.
} else {
video.state = .FAILED;
}
}
ui.wants_frame = true;
}
//
// Image cache
//
Image_State :: enum {
LOADING;
DECODING;
READY;
FAILED;
}
Image_Entry :: struct {
hash: string;
state: Image_State;
texture: GLuint;
width, height: s32;
last_used: float64;
retry_at: float64;
}
images: [..] *Image_Entry;
find_image :: (hash: string) -> *Image_Entry {
for images if it.hash == hash return it;
return null;
}
// The texture for a blob, loading and decoding it on first use. Check state == .READY before drawing.
get_image :: (hash: string) -> *Image_Entry {
if !hash.count return null;
entry := find_image(hash);
if !entry {
entry = New(Image_Entry);
entry.hash = copy_string(hash);
entry.state = .LOADING;
array_add(*images, entry);
blob_load(hash);
} else if entry.state == .FAILED && ui.time > entry.retry_at {
entry.state = .LOADING;
blob_load(hash);
}
entry.last_used = ui.time;
if entry.state != .READY ui.wants_frame = true;
return entry;
}
// Decoded RGBA pixels for the given key (a blob hash). pixels is only valid during this call.
image_decoded :: (key: string, ok: bool, w: s32, h: s32, pixels: *u8) {
entry := find_image(key);
if !entry {
entry = New(Image_Entry);
entry.hash = copy_string(key);
array_add(*images, entry);
}
entry.last_used = ui.time;
if ok {
delete_texture(entry.texture);
entry.texture = create_texture_rgba(pixels, w, h);
entry.width, entry.height = w, h;
entry.state = .READY;
} else {
entry.state = .FAILED;
entry.retry_at = ui.time + 1000000000.0; // Undecodable; don't retry.
}
attach_image_decoded(key, ok, w, h);
evict_images();
ui.wants_frame = true;
}
evict_images :: () {
ready := 0;
for images if it.state == .READY ready += 1;
while ready > MAX_IMAGE_TEXTURES {
oldest: *Image_Entry;
for images if it.state == .READY && (!oldest || it.last_used < oldest.last_used) oldest = it;
if !oldest || ui.time - oldest.last_used < 2 return;
delete_texture(oldest.texture);
oldest.texture = 0;
for images if it == oldest { remove it; break; }
free(oldest.hash);
free(oldest);
ready -= 1;
}
}
//
// Attaching files. Images are decoded first (to learn their size); videos are probed for size, duration
// and a poster frame. Then the attachment is created and the blob stored/uploaded.
//
Pending_File :: struct {
hash: string;
name: string;
data: string;
kind: File_Kind;
task: u32;
to_composer: bool; // Goes into the comment being written instead of onto the task.
}
pending_files: [..] *Pending_File;
// Composer attachments are created when the comment is posted.
composer_attachments: [..] *Attachment;
// Entry point for files from the file picker, drag & drop, paste and screenshots.
app_add_file :: (name: string, data: string) {
p := app.project;
task := find_task(p, app.selected);
if !task {
show_toast("Open a task first, then add the file.");
return;
}
kind := detect_file_kind(data);
if kind == .UNKNOWN {
show_toast(tprint("% isn't an image or video TaskForge can show.", name));
return;
}
if (kind == .IMAGE && data.count > MAX_IMAGE_BYTES) || (kind == .VIDEO && data.count > MAX_VIDEO_BYTES) {
show_toast(tprint("% is too large (max % MB).", name, ifx kind == .IMAGE then MAX_IMAGE_BYTES >> 20 else MAX_VIDEO_BYTES >> 20));
return;
}
if sync.enabled && p.id_limit && p.next_id + 2 >= p.id_limit {
show_toast("Still connecting to the server, try again in a moment.");
return;
}
f := New(Pending_File);
f.hash = hash_hex(data);
f.name = copy_string(name);
f.data = copy_string(data);
f.kind = kind;
f.task = task.id;
f.to_composer = ui.focus == composer_focus_id();
array_add(*pending_files, f);
show_toast(tprint("Adding %...", name));
if kind == .IMAGE {
// Reuse an already decoded copy if we have one.
entry := find_image(f.hash);
if entry && entry.state == .READY attach_image_decoded(f.hash, true, entry.width, entry.height);
else platform_decode_image(f.hash, f.data);
} else {
platform_probe_video(f.hash, f.data);
}
}
attach_image_decoded :: (hash: string, ok: bool, w: s32, h: s32) {
for f: pending_files {
if f.hash != hash || f.kind != .IMAGE continue;
if ok {
a := make_attachment(f, .IMAGE, w, h, 0, "");
blob_save(f.hash, f.data);
show_toast(tprint("Added %.", a.name));
} else {
show_toast(tprint("Couldn't read %.", f.name));
}
free_pending(f);
remove f;
return;
}
}
// Video size, duration and a PNG of the first frame (poster). poster_png is only valid during this call.
video_probed :: (key: string, ok: bool, w: s32, h: s32, duration: float64, poster_png: string) {
for f: pending_files {
if f.hash != key || f.kind != .VIDEO continue;
if ok {
poster := "";
if poster_png.count {
poster = hash_hex(poster_png);
blob_save(poster, poster_png);
platform_decode_image(poster, poster_png);
}
a := make_attachment(f, .VIDEO, w, h, duration, poster);
blob_save(f.hash, f.data);
show_toast(tprint("Added %.", a.name));
} else {
show_toast(tprint("Couldn't play % (unsupported codec?).", f.name));
}
free_pending(f);
remove f;
return;
}
ui.wants_frame = true;
}
make_attachment :: (f: *Pending_File, kind: Attachment_Kind, w: s32, h: s32, duration: float64, poster: string) -> *Attachment {
p := app.project;
a := New(Attachment);
a.task = f.task;
a.kind = kind;
a.hash = copy_string(f.hash);
a.poster = copy_string(poster);
a.name = copy_string(f.name);
a.width, a.height = w, h;
a.size = f.data.count;
a.duration = duration;
if f.to_composer {
a.order = cast(float64) composer_attachments.count + 1;
array_add(*composer_attachments, a);
} else {
a.id = new_id(p);
a.order = next_order(attachments_of(p, f.task));
array_add(*p.attachments, a);
mark_dirty();
}
return a;
}
free_pending :: (f: *Pending_File) {
free(f.hash); free(f.name); free(f.data); free(f);
}
// The texture to show for an attachment (videos show their poster), or null if not ready.
attachment_image :: (a: *Attachment) -> *Image_Entry {
hash := ifx a.kind == .VIDEO then a.poster else a.hash;
entry := get_image(hash);
if entry && entry.state == .READY return entry;
return null;
}
//
// Video playback (one video at a time, in the viewer). The platform decodes and keeps `texture` updated.
//
Video_State :: enum {
CLOSED;
LOADING; // Fetching the blob.
OPENING; // Platform is preparing playback.
READY;
FAILED;
}
Video_Player :: struct {
state: Video_State;
hash: string;
texture: GLuint;
width, height: s32;
duration: float64;
time: float64;
playing: bool;
muted: bool;
flip_y: bool; // Native frames can arrive bottom-up.
}
video: Video_Player;
video_open :: (a: *Attachment) {
if video.state != .CLOSED && video.hash == a.hash return;
video_close();
video.hash = copy_string(a.hash);
video.state = .LOADING;
video.width, video.height = a.width, a.height;
video.duration = a.duration;
blob_load(a.hash);
}
video_close :: () {
if video.state == .CLOSED return;
if video.state == .OPENING || video.state == .READY platform_video_close();
free(video.hash);
muted := video.muted;
video = .{};
video.muted = muted;
}
video_frame :: () {
if video.state == .OPENING || video.state == .READY {
platform_video_update(*video);
if video.playing ui.wants_frame = true;
if video.state == .OPENING ui.wants_frame = true;
}
}
format_duration :: (seconds: float64) -> string {
s := cast(int) max(seconds, 0);
if s >= 3600 return tprint("%:%:%", s / 3600, formatInt((s / 60) % 60, minimum_digits = 2), formatInt(s % 60, minimum_digits = 2));
return tprint("%:%", s / 60, formatInt(s % 60, minimum_digits = 2));
}
format_size :: (bytes: s64) -> string {
if bytes >= 1024 * 1024 return tprint("% MB", formatFloat(cast(float64) bytes / (1024 * 1024), trailing_width = 1));
if bytes >= 1024 return tprint("% KB", bytes / 1024);
return tprint("% B", bytes);
}
//
// Toasts: short status messages at the bottom of the window.
//
toast_text: string;
toast_time: float64;
show_toast :: (text: string) {
set_string(*toast_text, text);
toast_time = ui.time;
ui.wants_frame = true;
}
draw_toast :: () {
if !toast_text.count || ui.time - toast_time > 3.5 return;
set_layer(LAYER_DRAG);
defer set_layer(LAYER_BASE);
w := measure_text(toast_text, .NORMAL) + 32;
r := make_rect(round_f((renderer.width - w) * 0.5), renderer.height - 64, w, 36);
alpha := clamp(cast(float) (3.5 - (ui.time - toast_time)) * 3, 0, 1);
draw_rect(r, hex(0x2a303a, alpha), 8);
draw_rect_outline(r, hex(0x3a414d, alpha), 1, 8);
draw_text_centered(toast_text, r, .NORMAL, hex(0xE6EDF3, alpha));
ui.wants_frame = true;
}
+130
View File
@@ -124,6 +124,9 @@ Project :: struct {
views: [..] *View; views: [..] *View;
rules: [..] *Epic_Rule; rules: [..] *Epic_Rule;
attachments: [..] *Attachment;
comments: [..] *Comment;
// Server mode only. // Server mode only.
users: [..] User_Info; users: [..] User_Info;
me: u32; me: u32;
@@ -372,6 +375,16 @@ delete_task :: (p: *Project, id: u32) {
} }
remove_rules(p, (r: *Epic_Rule, id: u32) -> bool { return r.epic == id; }, id); remove_rules(p, (r: *Epic_Rule, id: u32) -> bool { return r.epic == id; }, id);
for p.views if it.filter_epic == id it.filter_epic = 0; for p.views if it.filter_epic == id it.filter_epic = 0;
i := 0;
while i < p.attachments.count {
if p.attachments[i].task == id remove_attachment(p, p.attachments[i].id);
else i += 1;
}
i = 0;
while i < p.comments.count {
if p.comments[i].task == id remove_comment(p, p.comments[i].id);
else i += 1;
}
for p.tasks { for p.tasks {
if it.id == id { if it.id == id {
for * v: it.values free_value(v); for * v: it.values free_value(v);
@@ -616,3 +629,120 @@ field_is_rule_driven :: (p: *Project, epic: *Task, field: u32) -> bool {
for p.rules if it.target_field == field && rule_applies_to(it, epic) return true; for p.rules if it.target_field == field && rule_applies_to(it, epic) return true;
return false; return false;
} }
//
// Attachments: images and videos, on a task or on one of its comments. The bytes live in a content-addressed
// blob store (see media.jai); records only reference them by SHA-256 hash.
//
Attachment_Kind :: enum u8 {
IMAGE :: 0;
VIDEO :: 1;
}
Attachment :: struct {
id: u32;
task: u32;
comment: u32; // 0 = attached to the task itself.
kind: Attachment_Kind;
order: float64;
hash: string; // Blob with the file.
poster: string; // Videos: blob with a PNG of the first frame.
name: string;
width, height: s32;
size: s64;
duration: float64; // Videos, seconds.
}
find_attachment :: (p: *Project, id: u32) -> *Attachment {
for p.attachments if it.id == id return it;
return null;
}
// Attachments of a task (comment = 0) or of a comment, in order. Temp allocated.
attachments_of :: (p: *Project, task: u32, comment: u32 = 0) -> [..] *Attachment {
result: [..] *Attachment;
result.allocator = temp;
for p.attachments if it.task == task && it.comment == comment array_add(*result, it);
sort_by_order_field(result);
return result;
}
remove_attachment :: (p: *Project, id: u32) {
for p.attachments {
if it.id == id {
free(it.hash); free(it.poster); free(it.name);
free(it);
array_ordered_remove_by_index(*p.attachments, it_index);
return;
}
}
}
//
// Comments: threads per task, with replies and edit history.
//
Comment_Version :: struct {
text: string;
time: float64; // When this version was replaced.
}
Comment :: struct {
id: u32;
task: u32;
parent: u32; // 0 = top level, otherwise the comment this replies to.
author: u32; // User id; 0 in a local project.
created: float64; // Unix seconds.
edited: float64; // 0 if never edited.
deleted: bool; // Deleted comments keep their place so replies still make sense.
text: string;
history: [..] Comment_Version; // Previous texts, oldest first.
}
find_comment :: (p: *Project, id: u32) -> *Comment {
for p.comments if it.id == id return it;
return null;
}
comments_of :: (p: *Project, task: u32, parent: u32) -> [..] *Comment {
result: [..] *Comment;
result.allocator = temp;
for p.comments if it.task == task && it.parent == parent array_add(*result, it);
for i: 1..result.count-1 {
c := result[i];
j := i - 1;
while j >= 0 && result[j].created > c.created { result[j + 1] = result[j]; j -= 1; }
result[j + 1] = c;
}
return result;
}
comment_count :: (p: *Project, task: u32) -> int {
n := 0;
for p.comments if it.task == task && !it.deleted n += 1;
return n;
}
edit_comment :: (c: *Comment, text: string, now: float64) {
if c.text == text return;
v: Comment_Version;
v.text = c.text;
v.time = now;
array_add(*c.history, v);
c.text = copy_string(text);
c.edited = now;
}
remove_comment :: (p: *Project, id: u32) {
for p.comments {
if it.id == id {
free(it.text);
for v: it.history free(v.text);
array_free(it.history);
free(it);
array_ordered_remove_by_index(*p.comments, it_index);
return;
}
}
}
+269 -9
View File
@@ -12,6 +12,10 @@
#import "Windows"; #import "Windows";
#import "Thread"; #import "Thread";
#import "Curl"; #import "Curl";
#import "Windows_Utf8";
#import "stb_image";
#import "stb_image_resize";
#import "stb_image_write";
tf_user32 :: #system_library "user32"; tf_user32 :: #system_library "user32";
SetProcessDpiAwarenessContext_ :: (value: *void) -> s32 #foreign tf_user32 "SetProcessDpiAwarenessContext"; SetProcessDpiAwarenessContext_ :: (value: *void) -> s32 #foreign tf_user32 "SetProcessDpiAwarenessContext";
@@ -47,6 +51,7 @@ main :: () {
native_window = create_window(1440, 900, "TaskForge", background_color_rgb = .[0.067, 0.075, 0.094]); native_window = create_window(1440, 900, "TaskForge", background_color_rgb = .[0.067, 0.075, 0.094]);
_, hdc := wgl_create_context(native_window, null, 3, 3); _, hdc := wgl_create_context(native_window, null, 3, 3);
gl_load(*gl); gl_load(*gl);
DragAcceptFiles_(native_window, 1);
#if OS == .WINDOWS { #if OS == .WINDOWS {
if wglSwapIntervalEXT wglSwapIntervalEXT(1); if wglSwapIntervalEXT wglSwapIntervalEXT(1);
} }
@@ -58,6 +63,7 @@ main :: () {
return; return;
} }
if server_url.count curl_global_init(CURL_GLOBAL_DEFAULT); if server_url.count curl_global_init(CURL_GLOBAL_DEFAULT);
video_native_init();
if !app_init(font_data, server_url.count > 0) { if !app_init(font_data, server_url.count > 0) {
log_error("Failed to initialize."); log_error("Failed to initialize.");
return; return;
@@ -100,6 +106,8 @@ main :: () {
} }
case .TEXT_INPUT; case .TEXT_INPUT;
if event.utf32 >= 32 && event.utf32 != 127 array_add(*input.chars, event.utf32); if event.utf32 >= 32 && event.utf32 != 127 array_add(*input.chars, event.utf32);
case .DRAG_AND_DROP_FILES;
for event.files queue_file_from_disk(it);
case .MOUSE_WHEEL; case .MOUSE_WHEEL;
if event.typical_wheel_delta { if event.typical_wheel_delta {
lines := -cast(float) event.wheel_delta / cast(float) event.typical_wheel_delta; lines := -cast(float) event.wheel_delta / cast(float) event.typical_wheel_delta;
@@ -128,6 +136,11 @@ main :: () {
input.width = pw / input.dpr; input.width = pw / input.dpr;
input.height = ph / input.dpr; input.height = ph / input.dpr;
input.time = seconds_since_init(); input.time = seconds_since_init();
input.unix_time = unix_now();
input.utc_offset = utc_offset_seconds();
poll_snip();
for platform_pending_files array_add(*input.files, it);
platform_pending_files.count = 0;
if sim_script.count { if sim_script.count {
if !simulate_frame(*input, pw, ph) running = false; if !simulate_frame(*input, pw, ph) running = false;
@@ -163,7 +176,7 @@ main :: () {
else idle_frames += 1; else idle_frames += 1;
// A task app should not burn a CPU core: sleep until input arrives (or the caret needs to blink). // A task app should not burn a CPU core: sleep until input arrives (or the caret needs to blink).
if idle_frames > 2 MsgWaitForMultipleObjects_(0, null, 0, xx ifx http_jobs.count then 15 else 500, QS_ALLINPUT_); if idle_frames > 2 MsgWaitForMultipleObjects_(0, null, 0, xx ifx http_jobs.count || snip_waiting then 15 else 500, QS_ALLINPUT_);
} }
app_shutdown(); app_shutdown();
@@ -179,7 +192,10 @@ platform_save_project :: (data: string) -> bool {
// Write to a temp file first so a crash mid-write can't destroy the project. // Write to a temp file first so a crash mid-write can't destroy the project.
temp_path := tprint("%.tmp", project_path); temp_path := tprint("%.tmp", project_path);
if !write_entire_file(temp_path, data) return false; if !write_entire_file(temp_path, data) return false;
return file_move(temp_path, project_path); // MoveFileW refuses to overwrite; replace the old file atomically instead.
MOVEFILE_REPLACE_EXISTING :: 0x1;
MOVEFILE_WRITE_THROUGH :: 0x8;
return MoveFileExW_(utf8_to_wide(temp_path,, temp), utf8_to_wide(project_path,, temp), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
} }
platform_storage_description :: () -> string { platform_storage_description :: () -> string {
@@ -196,6 +212,8 @@ platform_server_label :: () -> string {
Http_Job :: struct { Http_Job :: struct {
tag: Http_Tag; tag: Http_Tag;
method: string;
key: string;
url: string; url: string;
body: string; body: string;
token: string; token: string;
@@ -206,9 +224,11 @@ Http_Job :: struct {
http_jobs: [..] *Http_Job; http_jobs: [..] *Http_Job;
platform_http_post :: (tag: Http_Tag, path: string, body: string, token: string) { platform_http_request :: (tag: Http_Tag, method: string, path: string, body: string, token: string, key := "") {
job := New(Http_Job); job := New(Http_Job);
job.tag = tag; job.tag = tag;
job.method = copy_string(method);
job.key = copy_string(key);
job.url = sprint("%1%2", server_url, path); job.url = sprint("%1%2", server_url, path);
job.body = copy_string(body); job.body = copy_string(body);
job.token = copy_string(token); job.token = copy_string(token);
@@ -236,13 +256,17 @@ http_thread_proc :: (thread: *Thread) -> s64 {
defer curl_slist_free_all(headers); defer curl_slist_free_all(headers);
curl_easy_setopt(curl, .URL, temp_c_string(job.url)); curl_easy_setopt(curl, .URL, temp_c_string(job.url));
curl_easy_setopt(curl, .POST, cast(s64) 1); if job.method == "GET" {
curl_easy_setopt(curl, .POSTFIELDS, job.body.data); curl_easy_setopt(curl, .HTTPGET, cast(s64) 1);
curl_easy_setopt(curl, .POSTFIELDSIZE, cast(s64) job.body.count); } else {
curl_easy_setopt(curl, .POST, cast(s64) 1);
curl_easy_setopt(curl, .POSTFIELDS, job.body.data);
curl_easy_setopt(curl, .POSTFIELDSIZE_LARGE, cast(s64) job.body.count);
}
curl_easy_setopt(curl, .HTTPHEADER, headers); curl_easy_setopt(curl, .HTTPHEADER, headers);
curl_easy_setopt(curl, .WRITEFUNCTION, write); curl_easy_setopt(curl, .WRITEFUNCTION, write);
curl_easy_setopt(curl, .WRITEDATA, *job.response); curl_easy_setopt(curl, .WRITEDATA, *job.response);
curl_easy_setopt(curl, .TIMEOUT, cast(s64) 20); curl_easy_setopt(curl, .TIMEOUT, cast(s64) ifx job.tag == .BLOB_PUT || job.tag == .BLOB_GET then 600 else 20);
curl_easy_setopt(curl, .CONNECTTIMEOUT, cast(s64) 10); curl_easy_setopt(curl, .CONNECTTIMEOUT, cast(s64) 10);
if curl_easy_perform(curl) == .OK { if curl_easy_perform(curl) == .OK {
@@ -264,8 +288,10 @@ poll_http_jobs :: () {
thread_deinit(*job.thread); thread_deinit(*job.thread);
array_ordered_remove_by_index(*http_jobs, i); array_ordered_remove_by_index(*http_jobs, i);
response := builder_to_string(*job.response); response := builder_to_string(*job.response);
http_on_response(job.tag, job.status, response); http_on_response(job.tag, job.status, response, job.key);
free(response); free(response);
free(job.method);
free(job.key);
free(job.url); free(job.url);
free(job.body); free(job.body);
free(job.token); free(job.token);
@@ -374,6 +400,8 @@ simulate_frame :: (input: *Frame_Input, pw: s32, ph: s32) -> bool {
k.key = cast(u32) string_to_int(parts[1]); k.key = cast(u32) string_to_int(parts[1]);
if parts.count > 2 k.mods = cast(u32) string_to_int(parts[2]); if parts.count > 2 k.mods = cast(u32) string_to_int(parts[2]);
array_add(*input.keys, k); array_add(*input.keys, k);
case "drop";
queue_file_from_disk(substring(command, 5, command.count));
case "wheel"; case "wheel";
input.wheel_y += string_to_float(parts[1]); input.wheel_y += string_to_float(parts[1]);
case "wait"; case "wait";
@@ -415,3 +443,235 @@ save_screenshot :: (path: string, w: s32, h: s32) {
append(*b, pixels.data, pixels.count); append(*b, pixels.data, pixels.count);
write_entire_file(path, *b); write_entire_file(path, *b);
} }
//
// Media: local blob files, image decoding, clipboard screenshots, file picker, drag & drop, snipping tool.
//
tf_shell32 :: #system_library "shell32";
tf_comdlg :: #system_library "comdlg32";
tf_kernel :: #system_library "kernel32";
DragAcceptFiles_ :: (hwnd: HWND, accept: s32) #foreign tf_shell32 "DragAcceptFiles";
ShellExecuteW_ :: (hwnd: HWND, op: *u16, file: *u16, params: *u16, dir: *u16, show: s32) -> *void #foreign tf_shell32 "ShellExecuteW";
IsClipboardFormatAvailable_ :: (format: u32) -> s32 #foreign tf_user32 "IsClipboardFormatAvailable";
GetClipboardSequenceNumber_ :: () -> u32 #foreign tf_user32 "GetClipboardSequenceNumber";
OpenClipboard_ :: (hwnd: HWND) -> s32 #foreign tf_user32 "OpenClipboard";
CloseClipboard_ :: () -> s32 #foreign tf_user32 "CloseClipboard";
GetClipboardData_ :: (format: u32) -> *void #foreign tf_user32 "GetClipboardData";
GlobalLock_ :: (mem: *void) -> *u8 #foreign tf_kernel "GlobalLock";
GlobalUnlock_ :: (mem: *void) -> s32 #foreign tf_kernel "GlobalUnlock";
GlobalSize_ :: (mem: *void) -> u64 #foreign tf_kernel "GlobalSize";
GetSystemTimeAsFileTime_ :: (time: *u64) #foreign tf_kernel "GetSystemTimeAsFileTime";
GetOpenFileNameW_ :: (ofn: *Open_File_Name) -> s32 #foreign tf_comdlg "GetOpenFileNameW";
MoveFileExW_ :: (from: *u16, to: *u16, flags: u32) -> s32 #foreign tf_kernel "MoveFileExW";
CF_DIB_ :: 8;
Open_File_Name :: struct {
lStructSize: u32;
hwndOwner: HWND;
hInstance: *void;
lpstrFilter: *u16;
lpstrCustomFilter: *u16;
nMaxCustFilter: u32;
nFilterIndex: u32;
lpstrFile: *u16;
nMaxFile: u32;
lpstrFileTitle: *u16;
nMaxFileTitle: u32;
lpstrInitialDir: *u16;
lpstrTitle: *u16;
Flags: u32;
nFileOffset: u16;
nFileExtension: u16;
lpstrDefExt: *u16;
lCustData: *void;
lpfnHook: *void;
lpTemplateName: *u16;
pvReserved: *void;
dwReserved: u32;
FlagsEx: u32;
}
// Files from the picker or the snipping tool; handed to the app with the next frame's input.
platform_pending_files: [..] Input_File;
unix_now :: () -> float64 {
ft: u64;
GetSystemTimeAsFileTime_(*ft);
return cast(float64) (ft - 116444736000000000) / 10000000.0;
}
utc_offset_seconds :: () -> float64 {
now := current_time_consensus();
local := to_calendar(now, .LOCAL);
utc := to_calendar(now, .UTC);
minutes: s64 = (cast(s64) local.hour * 60 + local.minute) - (cast(s64) utc.hour * 60 + utc.minute);
if local.day_of_month_starting_at_0 != utc.day_of_month_starting_at_0 {
// Crossed midnight: the side that is "ahead" is one day later.
if local.year > utc.year || (local.year == utc.year && (local.month_starting_at_0 > utc.month_starting_at_0 ||
(local.month_starting_at_0 == utc.month_starting_at_0 && local.day_of_month_starting_at_0 > utc.day_of_month_starting_at_0)))
minutes += 24 * 60;
else
minutes -= 24 * 60;
}
return cast(float64) minutes * 60;
}
// Blobs of a local project live next to the project file.
blob_dir :: () -> string {
return tprint("%.files", project_path);
}
platform_local_blob_save :: (hash: string, data: string) {
dir := blob_dir();
make_directory_if_it_does_not_exist(dir);
path := tprint("%/%", dir, hash);
if !file_exists(path) write_entire_file(path, data);
}
platform_local_blob_load :: (hash: string) {
data, ok := read_entire_file(tprint("%/%", blob_dir(), hash), log_errors = false);
blob_loaded(hash, ok, data);
if ok free(data);
}
platform_decode_image :: (key: string, data: string) {
w, h, n: s32;
pixels := stbi_load_from_memory(data.data, cast(s32) data.count, *w, *h, *n, 4);
if !pixels {
image_decoded(key, false, 0, 0, null);
return;
}
defer stbi_image_free(pixels);
if max(w, h) > MAX_IMAGE_SIDE {
scale := cast(float) MAX_IMAGE_SIDE / max(w, h);
sw := max(cast(s32) 1, cast(s32) (w * scale));
sh := max(cast(s32) 1, cast(s32) (h * scale));
small := alloc(sw * sh * 4);
defer free(small);
stbir_resize_uint8(pixels, w, h, 0, small, sw, sh, 0, 4);
image_decoded(key, true, sw, sh, small);
} else {
image_decoded(key, true, w, h, pixels);
}
}
encode_png :: (rgba: *u8, w: s32, h: s32) -> string {
b: String_Builder;
write :: (ctx: *void, data: *void, size: s32) #c_call {
push_context { append(cast(*String_Builder) ctx, data, size); }
}
stbi_write_png_to_func(write, *b, w, h, 4, rgba, w * 4);
return builder_to_string(*b);
}
// The clipboard's bitmap as PNG bytes, or "" if it holds no image.
platform_clipboard_image :: () -> string {
if !IsClipboardFormatAvailable_(CF_DIB_) return "";
if !OpenClipboard_(native_window) return "";
defer CloseClipboard_();
handle := GetClipboardData_(CF_DIB_);
if !handle return "";
dib := GlobalLock_(handle);
if !dib return "";
defer GlobalUnlock_(handle);
total := cast(s64) GlobalSize_(handle);
header_size := (cast(*u32) dib).*;
w := (cast(*s32) (dib + 4)).*;
h := (cast(*s32) (dib + 8)).*;
bits := (cast(*u16) (dib + 14)).*;
compression := (cast(*u32) (dib + 16)).*;
if (bits != 24 && bits != 32) || w <= 0 || h == 0 return "";
offset := cast(s64) header_size;
if compression == 3 && header_size == 40 offset += 12; // BI_BITFIELDS masks follow a plain header.
bottom_up := h > 0;
height := abs(h);
stride := ((w * bits + 31) / 32) * 4;
if offset + cast(s64) stride * height > total return "";
rgba := cast(*u8) alloc(w * height * 4);
defer free(rgba);
for y: 0..height-1 {
src := dib + offset + (ifx bottom_up then height - 1 - y else y) * stride;
dst := rgba + y * w * 4;
step := bits / 8;
for x: 0..w-1 {
dst[x*4 + 0] = src[x*step + 2];
dst[x*4 + 1] = src[x*step + 1];
dst[x*4 + 2] = src[x*step + 0];
dst[x*4 + 3] = 255; // Screenshot DIBs usually carry no meaningful alpha.
}
}
return encode_png(rgba, w, xx height);
}
screenshot_name :: () -> string {
c := to_calendar(current_time_consensus(), .LOCAL);
return tprint("Screenshot %-%-% %.%.%.png", c.year, formatInt(c.month_starting_at_0 + 1, minimum_digits = 2), formatInt(c.day_of_month_starting_at_0 + 1, minimum_digits = 2),
formatInt(c.hour, minimum_digits = 2), formatInt(c.minute, minimum_digits = 2), formatInt(c.second, minimum_digits = 2));
}
platform_pick_files :: () {
buffer := NewArray(65536, u16,, temp);
filter := utf8_to_wide("Images and videos\0*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.webp;*.mp4;*.mov;*.m4v;*.webm;*.mkv;*.avi\0All files\0*.*\0\0",, temp);
ofn: Open_File_Name;
ofn.lStructSize = size_of(Open_File_Name);
ofn.hwndOwner = native_window;
ofn.lpstrFilter = filter;
ofn.lpstrFile = buffer.data;
ofn.nMaxFile = xx buffer.count;
OFN_FILEMUSTEXIST :: 0x00001000;
OFN_ALLOWMULTISELECT :: 0x00000200;
OFN_EXPLORER :: 0x00080000;
OFN_NOCHANGEDIR :: 0x00000008;
ofn.Flags = OFN_FILEMUSTEXIST | OFN_ALLOWMULTISELECT | OFN_EXPLORER | OFN_NOCHANGEDIR;
if !GetOpenFileNameW_(*ofn) return;
// Either one full path, or a directory followed by file names, each zero terminated.
parts: [..] string;
parts.allocator = temp;
start := 0;
for i: 0..buffer.count-1 {
if buffer[i] != 0 continue;
if i == start break;
array_add(*parts, wide_to_utf8(buffer.data + start, cast(s32) (i - start),, temp));
start = i + 1;
}
paths: [..] string;
paths.allocator = temp;
if parts.count == 1 array_add(*paths, parts[0]);
else for 1..parts.count-1 array_add(*paths, tprint("%\\%", parts[0], parts[it]));
for paths queue_file_from_disk(it);
}
queue_file_from_disk :: (path: string) {
data, ok := read_entire_file(path, log_errors = false);
if !ok return;
array_add(*platform_pending_files, .{copy_string(path_filename(path)), data});
}
// Opens the Windows snipping overlay; the snip is picked up from the clipboard when it arrives.
snip_waiting: bool;
snip_sequence: u32;
snip_deadline: float64;
platform_capture_screenshot :: () {
snip_sequence = GetClipboardSequenceNumber_();
snip_waiting = true;
snip_deadline = seconds_since_init() + 120;
ShellExecuteW_(null, utf8_to_wide("open",, temp), utf8_to_wide("ms-screenclip:",, temp), null, null, 1);
}
poll_snip :: () {
if !snip_waiting return;
if seconds_since_init() > snip_deadline { snip_waiting = false; return; }
if GetClipboardSequenceNumber_() == snip_sequence return;
png := platform_clipboard_image();
if !png.count return; // Clipboard changed but no image yet (the snip may still be processing).
snip_waiting = false;
array_add(*platform_pending_files, .{copy_string(screenshot_name()), png});
}
platform_capture_label :: () -> string { return "Snip screen"; }
+109 -9
View File
@@ -5,7 +5,29 @@
js_storage_save :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign; js_storage_save :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign;
js_storage_load_size :: (key: *u8, key_count: s64) -> s64 #foreign; js_storage_load_size :: (key: *u8, key_count: s64) -> s64 #foreign;
js_storage_load :: (dest: *u8) #foreign; js_storage_load :: (dest: *u8) #foreign;
js_http_post :: (tag: s32, path: *u8, path_count: s64, body: *u8, body_count: s64, token: *u8, token_count: s64) #foreign; js_http :: (tag: s32, method: *u8, method_count: s64, path: *u8, path_count: s64, body: *u8, body_count: s64, token: *u8, token_count: s64, key: *u8, key_count: s64) #foreign;
js_decode_image :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign;
js_probe_video :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign;
js_blob_save :: (key: *u8, key_count: s64, data: *u8, count: s64) #foreign;
js_blob_load :: (key: *u8, key_count: s64) #foreign;
js_pick_files :: () #foreign;
js_capture_screenshot :: () #foreign;
js_video_open :: (data: *u8, count: s64) -> s32 #foreign;
js_video_update :: (texture: u32, state: *Web_Video_State) #foreign;
js_video_set_playing :: (playing: s32) #foreign;
js_video_seek :: (seconds: float64) #foreign;
js_video_set_muted :: (muted: s32) #foreign;
js_video_close :: () #foreign;
Web_Video_State :: struct {
time: float64;
duration: float64;
playing: s32;
ready: s32;
width: s32;
height: s32;
failed: s32;
}
js_clipboard_write :: (data: *u8, count: s64) #foreign; js_clipboard_write :: (data: *u8, count: s64) #foreign;
js_set_cursor :: (kind: s32) #foreign; js_set_cursor :: (kind: s32) #foreign;
js_download :: (data: *u8, count: s64) #foreign; js_download :: (data: *u8, count: s64) #foreign;
@@ -40,13 +62,15 @@ web_init :: (font_data: *u8, font_size: s64, server_mode: s32) -> s32 #c_call {
} }
#program_export #program_export
web_frame :: (width: float, height: float, dpr: float, time: float64) -> s32 #c_call { web_frame :: (width: float, height: float, dpr: float, time: float64, unix_time: float64, utc_offset: float64) -> s32 #c_call {
wants_more: bool; wants_more: bool;
push_context saved_context { push_context saved_context {
web_input.width = width; web_input.width = width;
web_input.height = height; web_input.height = height;
web_input.dpr = dpr; web_input.dpr = dpr;
web_input.time = time; web_input.time = time;
web_input.unix_time = unix_time;
web_input.utc_offset = utc_offset;
wants_more = app_frame(*web_input); wants_more = app_frame(*web_input);
reset_per_frame_input(*web_input); reset_per_frame_input(*web_input);
} }
@@ -101,12 +125,50 @@ web_char :: (codepoint: u32) #c_call {
} }
#program_export #program_export
web_http_response :: (tag: s32, status: s32, data: *u8, size: s64) #c_call { web_http_response :: (tag: s32, status: s32, data: *u8, size: s64, key: *u8, key_count: s64) #c_call {
push_context saved_context { push_context saved_context {
body: string; http_on_response(xx tag, status, make_string(data, size), make_string(key, key_count));
body.data = data; if data free(data);
body.count = size; if key free(key);
http_on_response(xx tag, status, body); }
}
make_string :: (data: *u8, count: s64) -> string {
s: string;
s.data = data;
s.count = count;
return s;
}
// A file from the picker, drag & drop, paste or screen capture. Takes ownership of both buffers.
#program_export
web_add_file :: (name: *u8, name_count: s64, data: *u8, count: s64) #c_call {
push_context saved_context { array_add(*web_input.files, .{make_string(name, name_count), make_string(data, count)}); }
}
#program_export
web_image_decoded :: (key: *u8, key_count: s64, ok: s32, w: s32, h: s32, pixels: *u8) #c_call {
push_context saved_context {
image_decoded(make_string(key, key_count), ok != 0, w, h, pixels);
free(key);
if pixels free(pixels);
}
}
#program_export
web_video_probed :: (key: *u8, key_count: s64, ok: s32, w: s32, h: s32, duration: float64, png: *u8, png_count: s64) #c_call {
push_context saved_context {
video_probed(make_string(key, key_count), ok != 0, w, h, duration, make_string(png, png_count));
free(key);
if png free(png);
}
}
#program_export
web_blob_loaded :: (key: *u8, key_count: s64, ok: s32, data: *u8, count: s64) #c_call {
push_context saved_context {
blob_loaded(make_string(key, key_count), ok != 0, make_string(data, count));
free(key);
if data free(data); if data free(data);
} }
} }
@@ -152,8 +214,46 @@ platform_save_session :: (token: string) {
js_storage_save(SESSION_KEY.data, SESSION_KEY.count, token.data, token.count); js_storage_save(SESSION_KEY.data, SESSION_KEY.count, token.data, token.count);
} }
platform_http_post :: (tag: Http_Tag, path: string, body: string, token: string) { platform_http_request :: (tag: Http_Tag, method: string, path: string, body: string, token: string, key := "") {
js_http_post(xx tag, path.data, path.count, body.data, body.count, token.data, token.count); js_http(xx tag, method.data, method.count, path.data, path.count, body.data, body.count, token.data, token.count, key.data, key.count);
}
platform_decode_image :: (key: string, data: string) { js_decode_image(key.data, key.count, data.data, data.count); }
platform_probe_video :: (key: string, data: string) { js_probe_video(key.data, key.count, data.data, data.count); }
platform_local_blob_save :: (hash: string, data: string) { js_blob_save(hash.data, hash.count, data.data, data.count); }
platform_local_blob_load :: (hash: string) { js_blob_load(hash.data, hash.count); }
platform_pick_files :: () { js_pick_files(); }
platform_capture_screenshot :: () { js_capture_screenshot(); }
platform_capture_label :: () -> string { return "Capture screen"; }
platform_clipboard_image :: () -> string { return ""; } // Pasted images arrive as files via the paste event.
platform_video_open :: (data: string) -> bool {
if !js_video_open(data.data, data.count) return false;
delete_texture(video.texture);
video.texture = create_texture_rgba(null, 2, 2, mipmaps = false);
return true;
}
platform_video_update :: (v: *Video_Player) {
state: Web_Video_State;
js_video_update(v.texture, *state);
if state.failed { v.state = .FAILED; return; }
if state.ready {
v.state = .READY;
v.width, v.height = state.width, state.height;
}
if state.duration > 0 v.duration = state.duration;
v.time = state.time;
v.playing = state.playing != 0;
}
platform_video_set_playing :: (playing: bool) { js_video_set_playing(xx playing); }
platform_video_seek :: (seconds: float64) { js_video_seek(seconds); }
platform_video_set_muted :: (muted: bool) { js_video_set_muted(xx muted); }
platform_video_close :: () {
js_video_close();
delete_texture(video.texture);
video.texture = 0;
} }
platform_server_label :: () -> string { platform_server_label :: () -> string {
+101 -6
View File
@@ -5,6 +5,7 @@
// mode 1: rounded rect, filled (analytic SDF, anti-aliased) // mode 1: rounded rect, filled (analytic SDF, anti-aliased)
// mode 2: rounded rect, outline (thickness in u) // mode 2: rounded rect, outline (thickness in u)
// mode 3: solid triangle // mode 3: solid triangle
// mode 4: image (rgba texture, per draw command) clipped to a rounded rect, multiplied by color
// All coordinates are logical pixels; the shader maps them to the framebuffer using the DPI scale. // All coordinates are logical pixels; the shader maps them to the framebuffer using the DPI scale.
Vertex :: struct { Vertex :: struct {
@@ -21,6 +22,7 @@ Draw_Cmd :: struct {
first: s32; first: s32;
count: s32; count: s32;
clip: Rect; clip: Rect;
texture: GLuint; // Image texture for mode 4 vertices; 0 if the command has none.
} }
LAYER_BASE :: 0; LAYER_BASE :: 0;
@@ -41,6 +43,7 @@ Renderer :: struct {
u_screen: GLint; u_screen: GLint;
u_scale: GLint; u_scale: GLint;
u_tex: GLint; u_tex: GLint;
u_image: GLint;
atlas: GLuint; atlas: GLuint;
atlas_w, atlas_h: float; atlas_w, atlas_h: float;
@@ -86,6 +89,7 @@ in vec2 v_half;
in vec2 v_rm; in vec2 v_rm;
in vec4 v_color; in vec4 v_color;
uniform sampler2D u_tex; uniform sampler2D u_tex;
uniform sampler2D u_image;
uniform float u_scale; uniform float u_scale;
out vec4 o_color; out vec4 o_color;
float sd_round_box(vec2 p, vec2 b, float r) { float sd_round_box(vec2 p, vec2 b, float r) {
@@ -102,8 +106,13 @@ void main() {
float a = clamp(0.5 - d * u_scale, 0.0, 1.0); float a = clamp(0.5 - d * u_scale, 0.0, 1.0);
if (mode > 1.5) a *= clamp(0.5 + (d + v_uv.x) * u_scale, 0.0, 1.0); if (mode > 1.5) a *= clamp(0.5 + (d + v_uv.x) * u_scale, 0.0, 1.0);
o_color = vec4(v_color.rgb, v_color.a * a); o_color = vec4(v_color.rgb, v_color.a * a);
} else { } else if (mode < 3.5) {
o_color = v_color; o_color = v_color;
} else {
vec4 c = texture(u_image, v_uv);
float d = sd_round_box(v_local, v_half, v_rm.x);
float a = clamp(0.5 - d * u_scale, 0.0, 1.0);
o_color = vec4(c.rgb * v_color.rgb, c.a * v_color.a * a);
} }
} }
GLSL GLSL
@@ -136,6 +145,7 @@ init_renderer :: () -> bool {
u_screen = glGetUniformLocation(program, "u_screen".data); u_screen = glGetUniformLocation(program, "u_screen".data);
u_scale = glGetUniformLocation(program, "u_scale".data); u_scale = glGetUniformLocation(program, "u_scale".data);
u_tex = glGetUniformLocation(program, "u_tex".data); u_tex = glGetUniformLocation(program, "u_tex".data);
u_image = glGetUniformLocation(program, "u_image".data);
glGenVertexArrays(1, *vao); glGenVertexArrays(1, *vao);
glBindVertexArray(vao); glBindVertexArray(vao);
@@ -222,18 +232,24 @@ pop_clip :: () {
} }
// Returns a pointer to n new vertices in the current layer, starting a new draw command if the clip changed. // Returns a pointer to n new vertices in the current layer, starting a new draw command if the clip changed.
reserve_vertices :: (n: int) -> *Vertex { reserve_vertices :: (n: int, texture: GLuint = 0) -> *Vertex {
layer := *renderer.layers[renderer.layer]; layer := *renderer.layers[renderer.layer];
clip := layer.clip_stack[layer.clip_stack.count - 1]; clip := layer.clip_stack[layer.clip_stack.count - 1];
need_new := layer.cmds.count == 0; need_new := layer.cmds.count == 0;
if !need_new { if !need_new {
last := layer.cmds[layer.cmds.count - 1].clip; last := *layer.cmds[layer.cmds.count - 1];
need_new = last.x != clip.x || last.y != clip.y || last.w != clip.w || last.h != clip.h; need_new = last.clip.x != clip.x || last.clip.y != clip.y || last.clip.w != clip.w || last.clip.h != clip.h;
// Only image vertices care about the texture, so commands merge unless two different images meet.
if !need_new && texture {
if !last.texture last.texture = texture;
else if last.texture != texture need_new = true;
}
} }
if need_new { if need_new {
cmd: Draw_Cmd; cmd: Draw_Cmd;
cmd.first = xx layer.verts.count; cmd.first = xx layer.verts.count;
cmd.clip = clip; cmd.clip = clip;
cmd.texture = texture;
array_add(*layer.cmds, cmd); array_add(*layer.cmds, cmd);
} }
layer.cmds[layer.cmds.count - 1].count += xx n; layer.cmds[layer.cmds.count - 1].count += xx n;
@@ -291,6 +307,78 @@ draw_glyph_quad :: (x0: float, y0: float, x1: float, y1: float, u0: float, v0: f
} }
} }
// Draws a texture into r with rounded corners. uv0/uv1 select the part of the image (for cropping).
draw_image :: (r: Rect, texture: GLuint, radius: float = 0, tint: u32 = 0xFFFFFFFF, u0: float = 0, v0: float = 0, u1: float = 1, v1: float = 1) {
if !texture || r.w <= 0 || r.h <= 0 return;
hx, hy := r.w * 0.5, r.h * 0.5;
cx, cy := r.x + hx, r.y + hy;
radius = min(radius, min(hx, hy));
xs := float.[r.x, r.x + r.w, r.x + r.w, r.x, r.x + r.w, r.x];
ys := float.[r.y, r.y, r.y + r.h, r.y, r.y + r.h, r.y + r.h];
us := float.[u0, u1, u1, u0, u1, u0];
vs := float.[v0, v0, v1, v0, v1, v1];
v := reserve_vertices(6, texture);
for i: 0..5 {
p := v + i;
p.x, p.y = xs[i], ys[i];
p.u, p.v = us[i], vs[i];
p.lx, p.ly = xs[i] - cx, ys[i] - cy;
p.hx, p.hy = hx, hy;
p.radius = radius;
p.mode = 4;
p.color = tint;
}
}
// Draws an image of size (iw, ih) to cover r, cropping the overflowing side (like CSS object-fit: cover).
draw_image_cover :: (r: Rect, texture: GLuint, iw: float, ih: float, radius: float = 0) {
if iw <= 0 || ih <= 0 return;
image_aspect := iw / ih;
rect_aspect := r.w / r.h;
u0, v0, u1, v1 := 0.0, 0.0, 1.0, 1.0;
if image_aspect > rect_aspect {
visible := rect_aspect / image_aspect;
u0 = (1 - visible) * 0.5;
u1 = u0 + visible;
} else {
visible := image_aspect / rect_aspect;
v0 = (1 - visible) * 0.5;
v1 = v0 + visible;
}
draw_image(r, texture, radius, 0xFFFFFFFF, u0, v0, u1, v1);
}
// The largest rect with the image's aspect ratio that fits inside r, centered (object-fit: contain).
fit_rect :: (r: Rect, iw: float, ih: float) -> Rect {
if iw <= 0 || ih <= 0 return r;
scale := min(r.w / iw, r.h / ih);
w, h := iw * scale, ih * scale;
return make_rect(round_f(r.x + (r.w - w) * 0.5), round_f(r.y + (r.h - h) * 0.5), round_f(w), round_f(h));
}
//
// Image textures.
//
create_texture_rgba :: (pixels: *u8, w: s32, h: s32, mipmaps := true) -> GLuint {
tex: GLuint;
glGenTextures(1, *tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, xx w, xx h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx ifx mipmaps then GL_LINEAR_MIPMAP_LINEAR else GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
if mipmaps glGenerateMipmap(GL_TEXTURE_2D);
return tex;
}
delete_texture :: (tex: GLuint) {
t := tex;
if t glDeleteTextures(1, *t);
}
draw_triangle :: (ax: float, ay: float, bx: float, by: float, cx: float, cy: float, color: u32) { draw_triangle :: (ax: float, ay: float, bx: float, by: float, cx: float, cy: float, color: u32) {
v := reserve_vertices(3); v := reserve_vertices(3);
for 0..2 { for 0..2 {
@@ -362,8 +450,11 @@ end_render :: (clear_color: u32) {
glUniform2f(u_screen, width, height); glUniform2f(u_screen, width, height);
glUniform1f(u_scale, dpr); glUniform1f(u_scale, dpr);
glUniform1i(u_tex, 0); glUniform1i(u_tex, 0);
glUniform1i(u_image, 1);
glActiveTexture(GL_TEXTURE0); glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, atlas); glBindTexture(GL_TEXTURE_2D, atlas);
glActiveTexture(GL_TEXTURE1);
bound_image: GLuint;
glBindVertexArray(vao); glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, upload.count * size_of(Vertex), upload.data, GL_STREAM_DRAW); glBufferData(GL_ARRAY_BUFFER, upload.count * size_of(Vertex), upload.data, GL_STREAM_DRAW);
@@ -377,6 +468,10 @@ end_render :: (clear_color: u32) {
sw := cast(s32) round_f((it.clip.x + it.clip.w) * dpr) - sx; sw := cast(s32) round_f((it.clip.x + it.clip.w) * dpr) - sx;
sh := cast(s32) round_f((it.clip.y + it.clip.h) * dpr) - sy; sh := cast(s32) round_f((it.clip.y + it.clip.h) * dpr) - sy;
glScissor(sx, fb_h - sy - sh, xx sw, xx sh); glScissor(sx, fb_h - sy - sh, xx sw, xx sh);
if it.texture && it.texture != bound_image {
glBindTexture(GL_TEXTURE_2D, it.texture);
bound_image = it.texture;
}
glDrawArrays(GL_TRIANGLES, base + it.first, xx it.count); glDrawArrays(GL_TRIANGLES, base + it.first, xx it.count);
} }
base += xx layer.verts.count; base += xx layer.verts.count;
+182
View File
@@ -0,0 +1,182 @@
// SHA-256, HMAC-SHA256 and PBKDF2-HMAC-SHA256. Shared by the server (passwords, sessions) and the client (content-addressed attachments).
Sha256 :: struct {
state: [8] u32;
block: [64] u8;
used: int;
length: u64;
}
SHA256_K :: u32.[
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
sha256_init :: (s: *Sha256) {
s.state = .[0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
s.used = 0;
s.length = 0;
}
rotr :: inline (x: u32, n: u32) -> u32 {
return (x >> n) | (x << (32 - n));
}
sha256_compress :: (s: *Sha256, block: *u8) {
w: [64] u32 = ---;
for i: 0..15 {
p := block + i * 4;
w[i] = ((cast(u32) p[0]) << 24) | ((cast(u32) p[1]) << 16) | ((cast(u32) p[2]) << 8) | (cast(u32) p[3]);
}
for i: 16..63 {
s0 := rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3);
s1 := rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10);
w[i] = w[i-16] + s0 + w[i-7] + s1;
}
a, b, c, d := s.state[0], s.state[1], s.state[2], s.state[3];
e, f, g, h := s.state[4], s.state[5], s.state[6], s.state[7];
for i: 0..63 {
S1 := rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
ch := (e & f) ^ (~e & g);
t1 := h + S1 + ch + SHA256_K[i] + w[i];
S0 := rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
mj := (a & b) ^ (a & c) ^ (b & c);
t2 := S0 + mj;
h = g; g = f; f = e; e = d + t1;
d = c; c = b; b = a; a = t1 + t2;
}
s.state[0] += a; s.state[1] += b; s.state[2] += c; s.state[3] += d;
s.state[4] += e; s.state[5] += f; s.state[6] += g; s.state[7] += h;
}
sha256_update :: (s: *Sha256, data: *u8, count: int) {
s.length += cast(u64) count;
i := 0;
while i < count {
n := min(64 - s.used, count - i);
memcpy(s.block.data + s.used, data + i, n);
s.used += n;
i += n;
if s.used == 64 {
sha256_compress(s, s.block.data);
s.used = 0;
}
}
}
sha256_update :: inline (s: *Sha256, data: string) {
sha256_update(s, data.data, data.count);
}
sha256_final :: (s: *Sha256) -> [32] u8 {
bits := s.length * 8;
pad: [72] u8;
pad[0] = 0x80;
pad_len := ifx s.used < 56 then 56 - s.used else 120 - s.used;
for i: 0..7 pad[pad_len + i] = cast,trunc(u8) (bits >> cast(u64) ((7 - i) * 8));
sha256_update(s, pad.data, pad_len + 8);
result: [32] u8;
for i: 0..7 {
result[i*4 + 0] = cast,trunc(u8) (s.state[i] >> 24);
result[i*4 + 1] = cast,trunc(u8) (s.state[i] >> 16);
result[i*4 + 2] = cast,trunc(u8) (s.state[i] >> 8);
result[i*4 + 3] = cast,trunc(u8) s.state[i];
}
return result;
}
sha256 :: (data: string) -> [32] u8 {
s: Sha256;
sha256_init(*s);
sha256_update(*s, data);
return sha256_final(*s);
}
// HMAC with the inner/outer pad states precomputed, so PBKDF2 costs two compressions per iteration.
Hmac_Sha256 :: struct {
inner, outer: Sha256;
}
hmac_init :: (h: *Hmac_Sha256, key: string) {
k: [64] u8;
if key.count > 64 {
digest := sha256(key);
memcpy(k.data, digest.data, 32);
} else {
memcpy(k.data, key.data, key.count);
}
ipad, opad: [64] u8;
for i: 0..63 {
ipad[i] = k[i] ^ 0x36;
opad[i] = k[i] ^ 0x5c;
}
sha256_init(*h.inner);
sha256_update(*h.inner, ipad.data, 64);
sha256_init(*h.outer);
sha256_update(*h.outer, opad.data, 64);
}
hmac_compute :: (h: *Hmac_Sha256, message: *u8, count: int) -> [32] u8 {
inner := h.inner;
sha256_update(*inner, message, count);
digest := sha256_final(*inner);
outer := h.outer;
sha256_update(*outer, digest.data, 32);
return sha256_final(*outer);
}
hmac_sha256 :: (key: string, message: string) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, key);
return hmac_compute(*h, message.data, message.count);
}
// PBKDF2-HMAC-SHA256 with a 32-byte output (one block).
pbkdf2_sha256 :: (password: string, salt: string, iterations: int) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, password);
first := alloc_string(salt.count + 4,, temp);
memcpy(first.data, salt.data, salt.count);
first[salt.count + 0] = 0;
first[salt.count + 1] = 0;
first[salt.count + 2] = 0;
first[salt.count + 3] = 1;
u := hmac_compute(*h, first.data, first.count);
result := u;
for 2..iterations {
u = hmac_compute(*h, u.data, 32);
for i: 0..31 result[i] ^= u[i];
}
return result;
}
constant_time_equal :: (a: string, b: string) -> bool {
if a.count != b.count return false;
diff: u8;
for i: 0..a.count-1 diff |= a[i] ^ b[i];
return diff == 0;
}
to_hex :: (data: *u8, count: int) -> string {
DIGITS :: "0123456789abcdef";
result := alloc_string(count * 2);
for i: 0..count-1 {
result[i*2] = DIGITS[data[i] >> 4];
result[i*2 + 1] = DIGITS[data[i] & 0xF];
}
return result;
}
as_string :: (bytes: *[32] u8) -> string {
s: string;
s.data = bytes.data;
s.count = 32;
return s;
}
+73 -1
View File
@@ -7,6 +7,8 @@
// val <task> <field> text "..." | num "3.5" | sel <id> | tags <id>... // val <task> <field> text "..." | num "3.5" | sel <id> | tags <id>...
// view <id> <order> <kind> <items> <group_field> <check_field> <check_value> <group_by_epic> <filter_epic> <filter_field> <filter_option> "name" {<card field>}* // view <id> <order> <kind> <items> <group_field> <check_field> <check_value> <group_by_epic> <filter_epic> <filter_field> <filter_option> "name" {<card field>}*
// rule <id> <order> <epic> <target_field> <target_value> <quant> <filter_field> <filter_option> <cond_field> <cond_op> <cond_value> // rule <id> <order> <epic> <target_field> <target_value> <quant> <filter_field> <filter_option> <cond_field> <cond_op> <cond_value>
// attach <id> <task> <comment> <kind> <order> "hash" "poster hash" "name" <width> <height> <size> <duration>
// comment <id> <task> <parent> <author> <created> <edited> <deleted> "text" {"previous text" <replaced at>}*
// //
// A local project file starts with "taskforge 2" and "next_id N". Version 1 files (the first format) still load. // A local project file starts with "taskforge 2" and "next_id N". Version 1 files (the first format) still load.
@@ -42,6 +44,22 @@ project_records :: (p: *Project, lines: *[..] string) {
array_add(lines, tprint("rule % % % % % % % % % % %", r.id, r.order, r.epic, r.target_field, r.target_value, cast(u8) r.quant, array_add(lines, tprint("rule % % % % % % % % % % %", r.id, r.order, r.epic, r.target_field, r.target_value, cast(u8) r.quant,
r.filter_field, r.filter_option, r.cond_field, cast(u8) r.cond_op, r.cond_value)); r.filter_field, r.filter_option, r.cond_field, cast(u8) r.cond_op, r.cond_value));
} }
for a: p.attachments {
array_add(lines, tprint("attach % % % % % % % % % % % %", a.id, a.task, a.comment, cast(u8) a.kind, a.order,
quoted(a.hash), quoted(a.poster), quoted(a.name), a.width, a.height, a.size, a.duration));
}
for c: p.comments {
b: String_Builder;
print_to_builder(*b, "comment % % % % % % % %", c.id, c.task, c.parent, c.author, time_text(c.created),
time_text(c.edited), cast(u8) c.deleted, quoted(c.text));
for c.history print_to_builder(*b, " % %", quoted(it.text), time_text(it.time));
array_add(lines, builder_to_string(*b));
}
}
time_text :: (t: float64) -> string {
if t == 0 return "0";
return tprint("%", formatFloat(t, trailing_width = 3, zero_removal = .NO));
} }
value_record :: (t: *Task, f: *Field, v: *Field_Value) -> string { value_record :: (t: *Task, f: *Field, v: *Field_Value) -> string {
@@ -76,7 +94,9 @@ record_key :: (line: string) -> string {
case "field"; #through; case "field"; #through;
case "task"; #through; case "task"; #through;
case "view"; #through; case "view"; #through;
case "rule"; ids = 1; case "rule"; #through;
case "attach"; #through;
case "comment"; ids = 1;
case "val"; ids = 2; case "val"; ids = 2;
case; return ""; case; return "";
} }
@@ -230,6 +250,8 @@ max_entity_id :: (p: *Project) -> u32 {
for p.tasks max_id = max(max_id, it.id); for p.tasks max_id = max(max_id, it.id);
for p.views max_id = max(max_id, it.id); for p.views max_id = max(max_id, it.id);
for p.rules max_id = max(max_id, it.id); for p.rules max_id = max(max_id, it.id);
for p.attachments max_id = max(max_id, it.id);
for p.comments max_id = max(max_id, it.id);
return max_id; return max_id;
} }
@@ -326,6 +348,52 @@ apply_record :: (p: *Project, line: string) {
array_add(*v.card_fields, cast(u32) string_to_int(s)); array_add(*v.card_fields, cast(u32) string_to_int(s));
} }
case "attach";
id := next_u32(*r);
a := find_attachment(p, id);
if !a {
a = New(Attachment);
a.id = id;
array_add(*p.attachments, a);
}
a.task = next_u32(*r);
a.comment = next_u32(*r);
a.kind = xx next_u32(*r);
a.order = next_f64(*r);
set_string(*a.hash, next_token(*r));
set_string(*a.poster, next_token(*r));
set_string(*a.name, next_token(*r));
a.width = xx next_u32(*r);
a.height = xx next_u32(*r);
a.size = cast(s64) next_f64(*r);
a.duration = next_f64(*r);
case "comment";
id := next_u32(*r);
c := find_comment(p, id);
if !c {
c = New(Comment);
c.id = id;
array_add(*p.comments, c);
}
c.task = next_u32(*r);
c.parent = next_u32(*r);
c.author = next_u32(*r);
c.created = next_f64(*r);
c.edited = next_f64(*r);
c.deleted = next_u32(*r) != 0;
set_string(*c.text, next_token(*r));
for c.history free(it.text);
c.history.count = 0;
while true {
text, ok := next_token(*r);
if !ok break;
v: Comment_Version;
v.text = copy_string(text);
v.time = next_f64(*r);
array_add(*c.history, v);
}
case "rule"; case "rule";
id := next_u32(*r); id := next_u32(*r);
rule: *Epic_Rule; rule: *Epic_Rule;
@@ -362,6 +430,10 @@ delete_record :: (p: *Project, key: string) {
for p.views if it.id == id { array_ordered_remove_by_index(*p.views, it_index); break; } for p.views if it.id == id { array_ordered_remove_by_index(*p.views, it_index); break; }
case "rule"; case "rule";
for p.rules if it.id == id { array_ordered_remove_by_index(*p.rules, it_index); break; } for p.rules if it.id == id { array_ordered_remove_by_index(*p.rules, it_index); break; }
case "attach";
remove_attachment(p, id);
case "comment";
remove_comment(p, id);
case "val"; case "val";
t := find_task(p, id); t := find_task(p, id);
if t clear_value(t, next_u32(*r)); if t clear_value(t, next_u32(*r));
+10 -6
View File
@@ -10,6 +10,8 @@ Http_Tag :: enum s32 {
LOGIN :: 1; LOGIN :: 1;
USERS :: 2; USERS :: 2;
LOGOUT :: 3; LOGOUT :: 3;
BLOB_PUT :: 4;
BLOB_GET :: 5;
} }
SYNC_INTERVAL :: 2.0; // Seconds between polls when nothing changed locally. SYNC_INTERVAL :: 2.0; // Seconds between polls when nothing changed locally.
@@ -25,7 +27,7 @@ Sync_State :: struct {
users_rev: s64; users_rev: s64;
base: Table(string, Base_Record); base: Table(string, Base_Record);
in_flight: [4] bool; // Per Http_Tag. in_flight: [6] bool; // Per Http_Tag (blob requests are tracked in media.jai instead).
sent: [..] Sent_Record; sent: [..] Sent_Record;
last_sync: float64; last_sync: float64;
offline: bool; offline: bool;
@@ -84,11 +86,11 @@ sign_in :: () {
set_string(*sync.login_error, ""); set_string(*sync.login_error, "");
body := tprint("% %", quoted(sync.login_name), quoted(sync.login_password)); body := tprint("% %", quoted(sync.login_name), quoted(sync.login_password));
sync.in_flight[Http_Tag.LOGIN] = true; sync.in_flight[Http_Tag.LOGIN] = true;
platform_http_post(.LOGIN, "/api/login", body, ""); platform_http_request(.LOGIN, "POST", "/api/login", body, "");
} }
sign_out :: () { sign_out :: () {
if sync.token.count platform_http_post(.LOGOUT, "/api/logout", "", sync.token); if sync.token.count platform_http_request(.LOGOUT, "POST", "/api/logout", "", sync.token);
sync.logged_in = false; sync.logged_in = false;
set_string(*sync.token, ""); set_string(*sync.token, "");
platform_save_session(""); platform_save_session("");
@@ -98,7 +100,7 @@ sign_out :: () {
users_request :: (body: string) { users_request :: (body: string) {
if sync.in_flight[Http_Tag.USERS] return; if sync.in_flight[Http_Tag.USERS] return;
sync.in_flight[Http_Tag.USERS] = true; sync.in_flight[Http_Tag.USERS] = true;
platform_http_post(.USERS, "/api/users", body, sync.token); platform_http_request(.USERS, "POST", "/api/users", body, sync.token);
} }
// Called once per frame by the app. // Called once per frame by the app.
@@ -142,14 +144,16 @@ sync_frame :: () {
app.dirty = false; app.dirty = false;
sync.last_sync = now; sync.last_sync = now;
sync.in_flight[Http_Tag.SYNC] = true; sync.in_flight[Http_Tag.SYNC] = true;
platform_http_post(.SYNC, "/api/sync", builder_to_string(*b,, allocator = temp), sync.token); platform_http_request(.SYNC, "POST", "/api/sync", builder_to_string(*b,, allocator = temp), sync.token);
} }
// Called by the platform layer when a request finishes. status 0 means the network request failed. // Called by the platform layer when a request finishes. status 0 means the network request failed.
http_on_response :: (tag: Http_Tag, status: int, body: string) { http_on_response :: (tag: Http_Tag, status: int, body: string, key: string) {
sync.in_flight[tag] = false; sync.in_flight[tag] = false;
ui.wants_frame = true; ui.wants_frame = true;
if tag == { if tag == {
case .BLOB_PUT; on_blob_put_response(status, key);
case .BLOB_GET; on_blob_get_response(status, body, key);
case .SYNC; on_sync_response(status, body); case .SYNC; on_sync_response(status, body);
case .LOGIN; on_login_response(status, body); case .LOGIN; on_login_response(status, body);
case .USERS; on_users_response(status, body); case .USERS; on_users_response(status, body);
+2 -1
View File
@@ -68,6 +68,7 @@ UI_State :: struct {
cursor_kind: Cursor_Kind; cursor_kind: Cursor_Kind;
wants_frame: bool; wants_frame: bool;
modal: bool; // A full-screen overlay (on LAYER_POPUP) has the input.
} }
ui: UI_State; ui: UI_State;
@@ -111,7 +112,7 @@ ui_begin_frame :: (input: *Frame_Input) {
ui.cursor_kind = .ARROW; ui.cursor_kind = .ARROW;
ui.wants_frame = input.keys.count > 0 || input.chars.count > 0 || ui.pressed || ui.released; ui.wants_frame = input.keys.count > 0 || input.chars.count > 0 || ui.pressed || ui.released;
ui.input_layer = LAYER_BASE; ui.input_layer = xx ifx ui.modal then LAYER_POPUP else LAYER_BASE;
if ui.popup.id { if ui.popup.id {
ui.input_layer = LAYER_POPUP; ui.input_layer = LAYER_POPUP;
if ui.pressed && !contains(ui.popup.rect, ui.mx, ui.my) { if ui.pressed && !contains(ui.popup.rect, ui.mx, ui.my) {
+666
View File
@@ -0,0 +1,666 @@
// Native video playback on Windows: Media Foundation decodes (H.264/HEVC/VP9/AAC/... whatever Windows has codecs
// for) into RGB32 frames and 16-bit PCM on a background thread; audio plays through waveOut and drives the clock;
// frames are uploaded to an OpenGL texture as their time comes.
Guid :: struct {
a: u32;
b, c: u16;
d: [8] u8;
}
make_guid :: (a: u32, b: u16, c: u16, d0: u8, d1: u8, d2: u8, d3: u8, d4: u8, d5: u8, d6: u8, d7: u8) -> Guid {
g: Guid;
g.a, g.b, g.c = a, b, c;
g.d = .[d0, d1, d2, d3, d4, d5, d6, d7];
return g;
}
MFMediaType_Video :: #run make_guid(0x73646976, 0x0000, 0x0010, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71);
MFMediaType_Audio :: #run make_guid(0x73647561, 0x0000, 0x0010, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71);
MFVideoFormat_RGB32 :: #run make_guid(0x00000016, 0x0000, 0x0010, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71);
MFAudioFormat_PCM :: #run make_guid(0x00000001, 0x0000, 0x0010, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71);
MF_MT_MAJOR_TYPE :: #run make_guid(0x48eba18e, 0xf8c9, 0x4687, 0xbf, 0x11, 0x0a, 0x74, 0xc9, 0xf9, 0x6a, 0x8f);
MF_MT_SUBTYPE :: #run make_guid(0xf7e34c9a, 0x42e8, 0x4714, 0xb7, 0x4b, 0xcb, 0x29, 0xd7, 0x2c, 0x35, 0xe5);
MF_MT_FRAME_SIZE :: #run make_guid(0x1652c33d, 0xd6b2, 0x4012, 0xb8, 0x34, 0x72, 0x03, 0x08, 0x49, 0xa3, 0x7d);
MF_MT_DEFAULT_STRIDE :: #run make_guid(0x644b4e48, 0x1e02, 0x4516, 0xb0, 0xeb, 0xc0, 0x1c, 0xa9, 0xd4, 0x9a, 0xc6);
MF_MT_AUDIO_NUM_CHANNELS :: #run make_guid(0x37e48bf5, 0x645e, 0x4c5b, 0x89, 0xde, 0xad, 0xa9, 0xe2, 0x9b, 0x69, 0x6a);
MF_MT_AUDIO_SAMPLES_PER_SECOND :: #run make_guid(0x5faeeae7, 0x0290, 0x4c31, 0x9e, 0x8a, 0xc5, 0x34, 0xf6, 0x8d, 0x9d, 0xba);
MF_MT_AUDIO_BITS_PER_SAMPLE :: #run make_guid(0xf2deb57f, 0x40fa, 0x4764, 0xaa, 0x33, 0xed, 0x4f, 0x2d, 0x1f, 0xf6, 0x69);
MF_PD_DURATION :: #run make_guid(0x6c990d33, 0xbb8e, 0x477a, 0x85, 0x98, 0x0d, 0x5d, 0x96, 0xfc, 0xd8, 0x8a);
MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING :: #run make_guid(0xfb394f3d, 0xccf1, 0x42ee, 0xbb, 0xb3, 0xf9, 0xb8, 0x45, 0xd5, 0x68, 0x1d);
GUID_NULL_ :: Guid.{};
MF_SOURCE_READER_FIRST_VIDEO_STREAM :: 0xFFFFFFFC;
MF_SOURCE_READER_FIRST_AUDIO_STREAM :: 0xFFFFFFFD;
MF_SOURCE_READER_ALL_STREAMS :: 0xFFFFFFFE;
MF_SOURCE_READER_MEDIASOURCE :: 0xFFFFFFFF;
MF_SOURCE_READERF_ENDOFSTREAM :: 0x2;
MF_VERSION :: 0x00020070;
Prop_Variant :: struct {
vt: u16;
r1, r2, r3: u16;
value: s64;
extra: s64;
}
VT_I8 :: 20;
// COM interfaces, with every vtable slot listed so the offsets are right. Unused methods are *void.
IUnknown_Slots :: struct {
QueryInterface: *void;
AddRef: *void;
Release: (this: *void) -> u32 #c_call;
}
Attribute_Slots :: struct {
using unknown: IUnknown_Slots;
GetItem, GetItemType, CompareItem, Compare: *void;
GetUINT32: (this: *void, key: *Guid, value: *u32) -> s32 #c_call;
GetUINT64: (this: *void, key: *Guid, value: *u64) -> s32 #c_call;
GetDouble, GetGUID, GetStringLength, GetString, GetAllocatedString, GetBlobSize, GetBlob, GetAllocatedBlob, GetUnknown, SetItem, DeleteItem, DeleteAllItems: *void;
SetUINT32: (this: *void, key: *Guid, value: u32) -> s32 #c_call;
SetUINT64: (this: *void, key: *Guid, value: u64) -> s32 #c_call;
SetDouble: *void;
SetGUID: (this: *void, key: *Guid, value: *Guid) -> s32 #c_call;
SetString, SetBlob, SetUnknown, LockStore, UnlockStore, GetCount, GetItemByIndex, CopyAllItems: *void;
}
Com_Attributes :: struct { vtable: *Attribute_Slots; }
Sample_Slots :: struct {
using attributes: Attribute_Slots;
GetSampleFlags, SetSampleFlags, GetSampleTime, SetSampleTime, GetSampleDuration, SetSampleDuration, GetBufferCount, GetBufferByIndex: *void;
ConvertToContiguousBuffer: (this: *void, buffer: **Com_Media_Buffer) -> s32 #c_call;
AddBuffer, RemoveBufferByIndex, RemoveAllBuffers, GetTotalLength, CopyToBuffer: *void;
}
Com_Sample :: struct { vtable: *Sample_Slots; }
Media_Buffer_Slots :: struct {
using unknown: IUnknown_Slots;
Lock: (this: *void, data: **u8, max_length: *u32, current_length: *u32) -> s32 #c_call;
Unlock: (this: *void) -> s32 #c_call;
GetCurrentLength, SetCurrentLength, GetMaxLength: *void;
}
Com_Media_Buffer :: struct { vtable: *Media_Buffer_Slots; }
Source_Reader_Slots :: struct {
using unknown: IUnknown_Slots;
GetStreamSelection: *void;
SetStreamSelection: (this: *void, stream: u32, selected: s32) -> s32 #c_call;
GetNativeMediaType: *void;
GetCurrentMediaType: (this: *void, stream: u32, type: **Com_Attributes) -> s32 #c_call;
SetCurrentMediaType: (this: *void, stream: u32, reserved: *u32, type: *Com_Attributes) -> s32 #c_call;
SetCurrentPosition: (this: *void, format: *Guid, position: *Prop_Variant) -> s32 #c_call;
ReadSample: (this: *void, stream: u32, control: u32, actual_stream: *u32, flags: *u32, timestamp: *s64, sample: **Com_Sample) -> s32 #c_call;
Flush: *void;
GetServiceForStream: *void;
GetPresentationAttribute: (this: *void, stream: u32, key: *Guid, value: *Prop_Variant) -> s32 #c_call;
}
Com_Source_Reader :: struct { vtable: *Source_Reader_Slots; }
tf_mfplat :: #system_library "mfplat";
tf_mfreadwrite :: #system_library "mfreadwrite";
tf_ole32 :: #system_library "ole32";
tf_winmm :: #system_library "winmm";
MFStartup_ :: (version: u32, flags: u32) -> s32 #foreign tf_mfplat "MFStartup";
MFCreateAttributes_ :: (attributes: **Com_Attributes, initial_size: u32) -> s32 #foreign tf_mfplat "MFCreateAttributes";
MFCreateMediaType_ :: (type: **Com_Attributes) -> s32 #foreign tf_mfplat "MFCreateMediaType";
MFCreateSourceReaderFromURL_ :: (url: *u16, attributes: *Com_Attributes, reader: **Com_Source_Reader) -> s32 #foreign tf_mfreadwrite "MFCreateSourceReaderFromURL";
CoInitializeEx_ :: (reserved: *void, flags: u32) -> s32 #foreign tf_ole32 "CoInitializeEx";
PropVariantClear_ :: (value: *Prop_Variant) -> s32 #foreign tf_ole32 "PropVariantClear";
GetTempPathW_ :: (length: u32, buffer: *u16) -> u32 #foreign tf_kernel "GetTempPathW";
Wave_Format :: struct {
format_tag: u16;
channels: u16;
sample_rate: u32;
bytes_per_second: u32;
block_align: u16;
bits_per_sample: u16;
extra_size: u16;
}
Wave_Header :: struct {
data: *u8;
length: u32;
recorded: u32;
user: u64;
flags: u32;
loops: u32;
next: *void;
reserved: u64;
}
Mm_Time :: struct {
type: u32;
value: u32;
pad: u32;
}
waveOutOpen_ :: (handle: **void, device: u32, format: *Wave_Format, callback: u64, instance: u64, flags: u32) -> u32 #foreign tf_winmm "waveOutOpen";
waveOutPrepareHeader_ :: (handle: *void, header: *Wave_Header, size: u32) -> u32 #foreign tf_winmm "waveOutPrepareHeader";
waveOutUnprepareHeader_:: (handle: *void, header: *Wave_Header, size: u32) -> u32 #foreign tf_winmm "waveOutUnprepareHeader";
waveOutWrite_ :: (handle: *void, header: *Wave_Header, size: u32) -> u32 #foreign tf_winmm "waveOutWrite";
waveOutReset_ :: (handle: *void) -> u32 #foreign tf_winmm "waveOutReset";
waveOutPause_ :: (handle: *void) -> u32 #foreign tf_winmm "waveOutPause";
waveOutRestart_ :: (handle: *void) -> u32 #foreign tf_winmm "waveOutRestart";
waveOutClose_ :: (handle: *void) -> u32 #foreign tf_winmm "waveOutClose";
waveOutGetPosition_ :: (handle: *void, time: *Mm_Time, size: u32) -> u32 #foreign tf_winmm "waveOutGetPosition";
waveOutSetVolume_ :: (handle: *void, volume: u32) -> u32 #foreign tf_winmm "waveOutSetVolume";
WHDR_DONE :: 0x1;
TIME_SAMPLES :: 2;
com_release :: (p: *void) {
if !p return;
unknown := cast(*Com_Attributes) p;
unknown.vtable.Release(p);
}
video_native_init :: () {
COINIT_APARTMENTTHREADED :: 0x2;
CoInitializeEx_(null, COINIT_APARTMENTTHREADED);
MFStartup_(MF_VERSION, 0);
}
//
// Opening a file.
//
Opened_Reader :: struct {
reader: *Com_Source_Reader;
width, height, stride: s32;
duration: float64;
has_audio: bool;
sample_rate, channels: s32;
}
// Media Foundation reads from files, so the blob goes into a temp file first (named by hash, reused).
video_temp_file :: (data: string) -> string {
buffer: [512] u16;
n := GetTempPathW_(buffer.count, buffer.data);
dir := wide_to_utf8(buffer.data, cast(s32) n,, temp);
ext := "mp4";
if data.count > 4 && memcmp(data.data, "\x1A\x45\xDF\xA3".data, 4) == 0 ext = "webm";
if data.count > 12 && memcmp(data.data + 8, "AVI ".data, 4) == 0 ext = "avi";
path := tprint("%taskforge-%.%", dir, hash_hex(data), ext);
if !file_exists(path) write_entire_file(path, data);
return path;
}
open_reader :: (path: string, want_audio: bool) -> Opened_Reader, bool {
result: Opened_Reader;
attributes: *Com_Attributes;
MFCreateAttributes_(*attributes, 1);
defer com_release(attributes);
attributes.vtable.SetUINT32(attributes, *MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, 1);
hr := MFCreateSourceReaderFromURL_(utf8_to_wide(path,, temp), attributes, *result.reader);
if hr < 0 || !result.reader return result, false;
reader := result.reader;
reader.vtable.SetStreamSelection(reader, MF_SOURCE_READER_ALL_STREAMS, 0);
reader.vtable.SetStreamSelection(reader, MF_SOURCE_READER_FIRST_VIDEO_STREAM, 1);
// Video as RGB32.
type: *Com_Attributes;
MFCreateMediaType_(*type);
type.vtable.SetGUID(type, *MF_MT_MAJOR_TYPE, *MFMediaType_Video);
type.vtable.SetGUID(type, *MF_MT_SUBTYPE, *MFVideoFormat_RGB32);
hr = reader.vtable.SetCurrentMediaType(reader, MF_SOURCE_READER_FIRST_VIDEO_STREAM, null, type);
com_release(type);
if hr < 0 {
com_release(reader);
return result, false;
}
current: *Com_Attributes;
reader.vtable.GetCurrentMediaType(reader, MF_SOURCE_READER_FIRST_VIDEO_STREAM, *current);
size: u64;
current.vtable.GetUINT64(current, *MF_MT_FRAME_SIZE, *size);
result.width = cast(s32) (size >> 32);
result.height = cast(s32) (size & 0xFFFFFFFF);
stride: u32;
if current.vtable.GetUINT32(current, *MF_MT_DEFAULT_STRIDE, *stride) >= 0 result.stride = cast,no_check(s32) stride;
else result.stride = result.width * 4;
com_release(current);
// Audio as 16-bit stereo PCM, if there is an audio stream we can decode.
if want_audio && reader.vtable.SetStreamSelection(reader, MF_SOURCE_READER_FIRST_AUDIO_STREAM, 1) >= 0 {
MFCreateMediaType_(*type);
type.vtable.SetGUID(type, *MF_MT_MAJOR_TYPE, *MFMediaType_Audio);
type.vtable.SetGUID(type, *MF_MT_SUBTYPE, *MFAudioFormat_PCM);
type.vtable.SetUINT32(type, *MF_MT_AUDIO_BITS_PER_SAMPLE, 16);
type.vtable.SetUINT32(type, *MF_MT_AUDIO_NUM_CHANNELS, 2);
hr = reader.vtable.SetCurrentMediaType(reader, MF_SOURCE_READER_FIRST_AUDIO_STREAM, null, type);
com_release(type);
if hr >= 0 {
reader.vtable.GetCurrentMediaType(reader, MF_SOURCE_READER_FIRST_AUDIO_STREAM, *current);
rate, channels: u32;
current.vtable.GetUINT32(current, *MF_MT_AUDIO_SAMPLES_PER_SECOND, *rate);
current.vtable.GetUINT32(current, *MF_MT_AUDIO_NUM_CHANNELS, *channels);
com_release(current);
if rate && channels >= 1 && channels <= 2 {
result.has_audio = true;
result.sample_rate = xx rate;
result.channels = xx channels;
}
}
if !result.has_audio reader.vtable.SetStreamSelection(reader, MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0);
}
value: Prop_Variant;
if reader.vtable.GetPresentationAttribute(reader, MF_SOURCE_READER_MEDIASOURCE, *MF_PD_DURATION, *value) >= 0 {
result.duration = cast(float64) value.value / 10000000.0;
PropVariantClear_(*value);
}
return result, true;
}
// Copies an RGB32 sample into tightly packed top-down BGRA.
copy_frame :: (sample: *Com_Sample, r: *Opened_Reader, dest: *u8) -> bool {
buffer: *Com_Media_Buffer;
if sample.vtable.ConvertToContiguousBuffer(sample, *buffer) < 0 return false;
defer com_release(buffer);
data: *u8;
max_length, length: u32;
if buffer.vtable.Lock(buffer, *data, *max_length, *length) < 0 return false;
defer buffer.vtable.Unlock(buffer);
row := r.width * 4;
stride := abs(r.stride);
if stride < row stride = row;
if cast(s64) stride * r.height > length return false;
for y: 0..r.height-1 {
src_row := ifx r.stride < 0 then r.height - 1 - y else y;
memcpy(dest + y * row, data + src_row * stride, row);
}
return true;
}
// Size, duration and a PNG poster of the first frame, for a newly attached video.
platform_probe_video :: (key: string, data: string) {
path := video_temp_file(data);
r, ok := open_reader(path, false);
if !ok {
video_probed(key, false, 0, 0, 0, "");
return;
}
defer com_release(r.reader);
pixels := cast(*u8) alloc(r.width * r.height * 4);
defer free(pixels);
got_frame := false;
for 1..30 {
stream, flags: u32;
timestamp: s64;
sample: *Com_Sample;
if r.reader.vtable.ReadSample(r.reader, MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, *stream, *flags, *timestamp, *sample) < 0 break;
if sample {
got_frame = copy_frame(sample, *r, pixels);
com_release(sample);
if got_frame break;
}
if flags & MF_SOURCE_READERF_ENDOFSTREAM break;
}
if !got_frame {
video_probed(key, false, 0, 0, 0, "");
return;
}
// BGRA -> RGBA, opaque, scaled down for the poster.
for i: 0..r.width * r.height - 1 {
p := pixels + i * 4;
b := p[0];
p[0] = p[2];
p[2] = b;
p[3] = 255;
}
pw, ph := r.width, r.height;
poster := pixels;
scaled: *u8;
if max(pw, ph) > 1280 {
scale := 1280.0 / max(pw, ph);
pw, ph = max(cast(s32) 1, cast(s32) (r.width * scale)), max(cast(s32) 1, cast(s32) (r.height * scale));
scaled = alloc(pw * ph * 4);
stbir_resize_uint8(pixels, r.width, r.height, 0, scaled, pw, ph, 0, 4);
poster = scaled;
}
png := encode_png(poster, pw, ph);
if scaled free(scaled);
video_probed(key, true, r.width, r.height, r.duration, png);
free(png);
}
//
// Playback.
//
Decoded_Frame :: struct {
time: float64;
pixels: *u8;
}
Audio_Chunk :: struct {
time: float64;
data: string;
}
Native_Player :: struct {
using r: Opened_Reader;
thread: Thread;
mutex: Mutex;
running: bool;
// Shared with the decode thread (under mutex).
frames: [..] Decoded_Frame;
audio: [..] Audio_Chunk;
audio_seconds: float64;
video_done, audio_done: bool;
seek_to: float64 = -1;
generation: int; // Bumped on every seek, so stale samples are dropped.
// Main thread.
wave: *void;
headers: [..] *Wave_Header;
playing: bool;
clock_base: float64; // Media time when the clock was last (re)started.
wall_start: float64;
audio_base: float64; // Media time of the first audio written since the last reset.
audio_started: bool;
last_frame_time: float64 = -1;
ended: bool;
}
player: *Native_Player;
decode_thread_proc :: (thread: *Thread) -> s64 {
COINIT_MULTITHREADED :: 0x0;
CoInitializeEx_(null, COINIT_MULTITHREADED);
pl := cast(*Native_Player) thread.data;
reader := pl.reader;
frame_bytes := pl.width * pl.height * 4;
while true {
lock(*pl.mutex);
if !pl.running { unlock(*pl.mutex); break; }
if pl.seek_to >= 0 {
target := pl.seek_to;
pl.seek_to = -1;
for pl.frames free(it.pixels);
for pl.audio free(it.data);
pl.frames.count = 0;
pl.audio.count = 0;
pl.audio_seconds = 0;
pl.video_done = false;
pl.audio_done = !pl.has_audio;
unlock(*pl.mutex);
position: Prop_Variant;
position.vt = VT_I8;
position.value = cast(s64) (target * 10000000.0);
reader.vtable.SetCurrentPosition(reader, *GUID_NULL_, *position);
continue;
}
need_video := !pl.video_done && pl.frames.count < 8;
need_audio := !pl.audio_done && pl.audio_seconds < 1.0;
generation := pl.generation;
unlock(*pl.mutex);
if !need_video && !need_audio {
sleep_milliseconds(4);
continue;
}
// Read whichever stream is further behind.
stream_id := ifx need_audio && (!need_video || pl.audio_seconds < 0.3) then MF_SOURCE_READER_FIRST_AUDIO_STREAM else MF_SOURCE_READER_FIRST_VIDEO_STREAM;
actual, flags: u32;
timestamp: s64;
sample: *Com_Sample;
hr := reader.vtable.ReadSample(reader, xx stream_id, 0, *actual, *flags, *timestamp, *sample);
is_audio := stream_id == MF_SOURCE_READER_FIRST_AUDIO_STREAM;
if hr < 0 || (flags & MF_SOURCE_READERF_ENDOFSTREAM) {
lock(*pl.mutex);
if is_audio pl.audio_done = true;
else pl.video_done = true;
unlock(*pl.mutex);
}
if !sample continue;
time := cast(float64) timestamp / 10000000.0;
if is_audio {
buffer: *Com_Media_Buffer;
if sample.vtable.ConvertToContiguousBuffer(sample, *buffer) >= 0 {
data: *u8;
max_length, length: u32;
if buffer.vtable.Lock(buffer, *data, *max_length, *length) >= 0 {
chunk: Audio_Chunk;
chunk.time = time;
chunk.data = alloc_string(length);
memcpy(chunk.data.data, data, length);
buffer.vtable.Unlock(buffer);
lock(*pl.mutex);
if generation == pl.generation {
array_add(*pl.audio, chunk);
pl.audio_seconds += cast(float64) length / (pl.sample_rate * pl.channels * 2);
} else free(chunk.data);
unlock(*pl.mutex);
}
com_release(buffer);
}
} else {
pixels := cast(*u8) alloc(frame_bytes);
if copy_frame(sample, *pl.r, pixels) {
lock(*pl.mutex);
if generation == pl.generation array_add(*pl.frames, .{time, pixels});
else free(pixels);
unlock(*pl.mutex);
} else {
free(pixels);
}
}
com_release(sample);
}
return 0;
}
platform_video_open :: (data: string) -> bool {
path := video_temp_file(data);
r, ok := open_reader(path, true);
if !ok return false;
player = New(Native_Player);
player.r = r;
player.audio_done = !r.has_audio;
init(*player.mutex);
player.running = true;
if r.has_audio {
format: Wave_Format;
format.format_tag = 1; // PCM
format.channels = xx r.channels;
format.sample_rate = xx r.sample_rate;
format.bits_per_sample = 16;
format.block_align = xx (r.channels * 2);
format.bytes_per_second = xx (r.sample_rate * r.channels * 2);
if waveOutOpen_(*player.wave, 0xFFFFFFFF, *format, 0, 0, 0) != 0 player.wave = null;
if player.wave waveOutPause_(player.wave);
}
thread_init(*player.thread, decode_thread_proc);
player.thread.data = player;
thread_start(*player.thread);
delete_texture(video.texture);
video.texture = create_video_texture(r.width, r.height);
video.width, video.height = r.width, r.height;
if r.duration > 0 video.duration = r.duration;
video.time = 0;
video.state = .READY;
platform_video_set_playing(true);
return true;
}
create_video_texture :: (w: s32, h: s32) -> GLuint {
tex: GLuint;
glGenTextures(1, *tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, xx w, xx h, 0, GL_BGRA, GL_UNSIGNED_BYTE, null);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
return tex;
}
media_clock :: () -> float64 {
pl := player;
if !pl.playing return pl.clock_base;
if pl.wave && pl.audio_started {
t: Mm_Time;
t.type = TIME_SAMPLES;
waveOutGetPosition_(pl.wave, *t, size_of(Mm_Time));
return pl.audio_base + cast(float64) t.value / pl.sample_rate;
}
return pl.clock_base + (seconds_since_init() - pl.wall_start);
}
platform_video_update :: (v: *Video_Player) {
pl := player;
if !pl return;
// Recycle finished audio buffers.
i := 0;
while i < pl.headers.count {
h := pl.headers[i];
if h.flags & WHDR_DONE {
waveOutUnprepareHeader_(pl.wave, h, size_of(Wave_Header));
free(h.data);
free(h);
array_unordered_remove_by_index(*pl.headers, i);
} else i += 1;
}
lock(*pl.mutex);
// Keep about half a second of audio queued in waveOut.
if pl.wave {
while pl.audio.count && pl.headers.count < 16 {
chunk := pl.audio[0];
array_ordered_remove_by_index(*pl.audio, 0);
pl.audio_seconds -= cast(float64) chunk.data.count / (pl.sample_rate * pl.channels * 2);
if chunk.time + cast(float64) chunk.data.count / (pl.sample_rate * pl.channels * 2) < pl.clock_base - 0.05 && !pl.audio_started {
free(chunk.data); // Before the seek target.
continue;
}
if !pl.audio_started {
pl.audio_started = true;
pl.audio_base = chunk.time;
}
h := New(Wave_Header);
h.data = chunk.data.data;
h.length = xx chunk.data.count;
waveOutPrepareHeader_(pl.wave, h, size_of(Wave_Header));
waveOutWrite_(pl.wave, h, size_of(Wave_Header));
array_add(*pl.headers, h);
}
}
now := media_clock();
// Show the latest frame that is due; drop older ones.
show: *u8;
show_time: float64;
while pl.frames.count && (pl.frames[0].time <= now + 0.005 || pl.last_frame_time < 0) {
if show free(show);
show = pl.frames[0].pixels;
show_time = pl.frames[0].time;
array_ordered_remove_by_index(*pl.frames, 0);
if pl.last_frame_time < 0 { pl.last_frame_time = show_time; break; }
}
finished := pl.video_done && !pl.frames.count && (!pl.wave || (pl.audio_done && !pl.audio.count && !pl.headers.count));
unlock(*pl.mutex);
if show {
glBindTexture(GL_TEXTURE_2D, v.texture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, xx pl.width, xx pl.height, GL_BGRA, GL_UNSIGNED_BYTE, show);
free(show);
pl.last_frame_time = show_time;
}
if pl.playing && finished {
pl.playing = false;
pl.clock_base = v.duration;
pl.ended = true;
if pl.wave waveOutPause_(pl.wave);
}
v.time = clamp(ifx pl.playing then now else pl.clock_base, 0, max(v.duration, 0.001));
v.playing = pl.playing;
if pl.wave waveOutSetVolume_(pl.wave, xx ifx v.muted then 0 else 0xFFFFFFFF);
}
platform_video_set_playing :: (playing: bool) {
pl := player;
if !pl || pl.playing == playing return;
if playing && pl.ended {
pl.ended = false;
platform_video_seek(0);
}
if playing {
pl.wall_start = seconds_since_init();
pl.playing = true;
if pl.wave waveOutRestart_(pl.wave);
} else {
pl.clock_base = media_clock();
pl.playing = false;
if pl.wave waveOutPause_(pl.wave);
}
}
platform_video_seek :: (seconds: float64) {
pl := player;
if !pl return;
was_playing := pl.playing;
if pl.wave {
waveOutReset_(pl.wave); // Marks all queued buffers done.
if !was_playing waveOutPause_(pl.wave);
}
lock(*pl.mutex);
pl.seek_to = max(seconds, 0);
pl.generation += 1;
for pl.frames free(it.pixels);
for pl.audio free(it.data);
pl.frames.count = 0;
pl.audio.count = 0;
pl.audio_seconds = 0;
unlock(*pl.mutex);
pl.clock_base = max(seconds, 0);
pl.wall_start = seconds_since_init();
pl.audio_started = false;
pl.last_frame_time = -1;
pl.ended = false;
if was_playing && pl.wave waveOutRestart_(pl.wave);
}
platform_video_close :: () {
pl := player;
if !pl return;
lock(*pl.mutex);
pl.running = false;
unlock(*pl.mutex);
while !thread_is_done(*pl.thread, 50) {}
thread_deinit(*pl.thread);
if pl.wave {
waveOutReset_(pl.wave);
for pl.headers {
waveOutUnprepareHeader_(pl.wave, it, size_of(Wave_Header));
free(it.data);
free(it);
}
waveOutClose_(pl.wave);
}
for pl.frames free(it.pixels);
for pl.audio free(it.data);
array_free(pl.frames);
array_free(pl.audio);
array_free(pl.headers);
com_release(pl.reader);
destroy(*pl.mutex);
free(pl);
player = null;
delete_texture(video.texture);
video.texture = 0;
}
platform_video_set_muted :: (muted: bool) {} // Volume is applied in platform_video_update from video.muted.
+7
View File
@@ -34,6 +34,10 @@ GL_TEXTURE_WRAP_T :: 0x2803;
GL_COLOR_BUFFER_BIT :: 0x4000; GL_COLOR_BUFFER_BIT :: 0x4000;
GL_CLAMP_TO_EDGE :: 0x812F; GL_CLAMP_TO_EDGE :: 0x812F;
GL_R8 :: 0x8229; GL_R8 :: 0x8229;
GL_RGBA :: 0x1908;
GL_RGBA8 :: 0x8058;
GL_LINEAR_MIPMAP_LINEAR :: 0x2703;
GL_TEXTURE1 :: 0x84C1;
GL_TEXTURE0 :: 0x84C0; GL_TEXTURE0 :: 0x84C0;
GL_ARRAY_BUFFER :: 0x8892; GL_ARRAY_BUFFER :: 0x8892;
GL_STREAM_DRAW :: 0x88E0; GL_STREAM_DRAW :: 0x88E0;
@@ -70,6 +74,9 @@ glGenTextures :: (n: GLsizei, textures: *GLuint) #foreign;
glBindTexture :: (target: GLenum, texture: GLuint) #foreign; glBindTexture :: (target: GLenum, texture: GLuint) #foreign;
glActiveTexture :: (texture: GLenum) #foreign; glActiveTexture :: (texture: GLenum) #foreign;
glTexImage2D :: (target: GLenum, level: GLint, internal_format: GLint, width: GLsizei, height: GLsizei, border: GLint, format: GLenum, type: GLenum, pixels: *void) #foreign; glTexImage2D :: (target: GLenum, level: GLint, internal_format: GLint, width: GLsizei, height: GLsizei, border: GLint, format: GLenum, type: GLenum, pixels: *void) #foreign;
glTexSubImage2D :: (target: GLenum, level: GLint, x: GLint, y: GLint, width: GLsizei, height: GLsizei, format: GLenum, type: GLenum, pixels: *void) #foreign;
glDeleteTextures :: (n: GLsizei, textures: *GLuint) #foreign;
glGenerateMipmap :: (target: GLenum) #foreign;
glTexParameteri :: (target: GLenum, pname: GLenum, param: GLint) #foreign; glTexParameteri :: (target: GLenum, pname: GLenum, param: GLint) #foreign;
glPixelStorei :: (pname: GLenum, param: GLint) #foreign; glPixelStorei :: (pname: GLenum, param: GLint) #foreign;
glEnable :: (cap: GLenum) #foreign; glEnable :: (cap: GLenum) #foreign;
+263 -12
View File
@@ -119,6 +119,17 @@ const env = {
gl.texImage2D(target, level, internalFormat, w, h, border, format, type, data); gl.texImage2D(target, level, internalFormat, w, h, border, format, type, data);
}, },
glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param), glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param),
glTexSubImage2D(target, level, x, y, w, h, format, type, pixels) {
gl.texSubImage2D(target, level, x, y, w, h, format, type, u8().subarray(Number(pixels), Number(pixels) + w * h * 4));
},
glDeleteTextures(n, ptr) {
for (let i = 0; i < n; i++) {
const h = dv().getUint32(Number(ptr) + i * 4, true);
if (objects[h]) gl.deleteTexture(objects[h]);
objects[h] = null;
}
},
glGenerateMipmap: (target) => gl.generateMipmap(target),
glPixelStorei: (pname, param) => gl.pixelStorei(pname, param), glPixelStorei: (pname, param) => gl.pixelStorei(pname, param),
glEnable: (cap) => gl.enable(cap), glEnable: (cap) => gl.enable(cap),
glDisable: (cap) => gl.disable(cap), glDisable: (cap) => gl.disable(cap),
@@ -158,21 +169,245 @@ const env = {
setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}, },
js_request_import() { importInput.click(); }, js_request_import() { importInput.click(); },
js_http_post(tag, path, pathCount, body, bodyCount, token, tokenCount) { js_http(tag, method, methodCount, path, pathCount, body, bodyCount, token, tokenCount, key, keyCount) {
const headers = { 'Content-Type': 'text/plain; charset=utf-8' }; const verb = str(method, methodCount);
const headers = {};
if (verb !== 'GET') headers['Content-Type'] = 'text/plain; charset=utf-8';
if (tokenCount) headers['Authorization'] = 'Bearer ' + str(token, tokenCount); if (tokenCount) headers['Authorization'] = 'Bearer ' + str(token, tokenCount);
const request = fetch(str(path, pathCount), { method: 'POST', headers, body: u8().slice(Number(body), Number(body) + Number(bodyCount)) }); const keyText = str(key, keyCount);
request.then(async (response) => [response.status, new Uint8Array(await response.arrayBuffer())]) const options = { method: verb, headers };
.catch(() => [0, new Uint8Array(0)]) if (verb !== 'GET') options.body = u8().slice(Number(body), Number(body) + Number(bodyCount));
.then(([status, bytes]) => { fetch(str(path, pathCount), options)
const ptr = bytes.length ? X.web_alloc(BigInt(bytes.length)) : 0n; .then(async (response) => [response.status, new Uint8Array(await response.arrayBuffer())])
if (bytes.length) u8().set(bytes, Number(ptr)); .catch(() => [0, new Uint8Array(0)])
X.web_http_response(tag, status, ptr, BigInt(bytes.length)); .then(([status, bytes]) => {
requestFrame(); const [kp, kn] = toWasm(encoder.encode(keyText));
}); const [bp, bn] = toWasm(bytes);
X.web_http_response(tag, status, bp, bn, kp, kn);
requestFrame();
});
}, },
// Images are decoded by the browser, scaled down to at most 2048 px, and handed back as RGBA.
js_decode_image(key, keyCount, data, count) {
const keyText = str(key, keyCount);
const blob = new Blob([u8().slice(Number(data), Number(data) + Number(count))]);
decodeImage(blob).then((image) => {
const [kp, kn] = toWasm(encoder.encode(keyText));
if (!image) { X.web_image_decoded(kp, kn, 0, 0, 0, 0n); requestFrame(); return; }
const [pp] = toWasm(image.pixels);
X.web_image_decoded(kp, kn, 1, image.width, image.height, pp);
requestFrame();
});
},
// Size, duration and a PNG poster (first frame) of a video.
js_probe_video(key, keyCount, data, count) {
const keyText = str(key, keyCount);
const url = URL.createObjectURL(new Blob([u8().slice(Number(data), Number(data) + Number(count))]));
probeVideo(url).then((info) => {
URL.revokeObjectURL(url);
const [kp, kn] = toWasm(encoder.encode(keyText));
if (!info) { X.web_video_probed(kp, kn, 0, 0, 0, 0, 0n, 0n); requestFrame(); return; }
const [pp, pn] = toWasm(info.png);
X.web_video_probed(kp, kn, 1, info.width, info.height, info.duration, pp, pn);
requestFrame();
});
},
// Local projects keep attachments in IndexedDB (localStorage is far too small).
js_blob_save(key, keyCount, data, count) {
const keyText = str(key, keyCount);
const bytes = u8().slice(Number(data), Number(data) + Number(count));
idb('readwrite', (store) => store.put(bytes, keyText));
},
js_blob_load(key, keyCount) {
const keyText = str(key, keyCount);
idb('readonly', (store) => store.get(keyText)).then((bytes) => {
const [kp, kn] = toWasm(encoder.encode(keyText));
if (!bytes) { X.web_blob_loaded(kp, kn, 0, 0n, 0n); requestFrame(); return; }
const [dp, dn] = toWasm(bytes);
X.web_blob_loaded(kp, kn, 1, dp, dn);
requestFrame();
}).catch(() => {
const [kp, kn] = toWasm(encoder.encode(keyText));
X.web_blob_loaded(kp, kn, 0, 0n, 0n);
requestFrame();
});
},
js_pick_files() { mediaInput.click(); },
// Grabs one frame of a screen, window or tab the user picks.
js_capture_screenshot() {
captureScreen().then((blob) => { if (blob) addFile(screenshotName(), blob); })
.catch((e) => console.warn('Screen capture cancelled:', e));
},
// One video at a time plays in an off-screen <video>; its frames are uploaded into the given texture.
js_video_open(data, count) {
closeVideo();
const url = URL.createObjectURL(new Blob([u8().slice(Number(data), Number(data) + Number(count))]));
const v = document.createElement('video');
v.src = url;
v.playsInline = true;
v.muted = videoMuted;
v.preload = 'auto';
player = { video: v, url, failed: false };
v.addEventListener('error', () => { if (player && player.video === v) player.failed = true; requestFrame(); });
for (const name of ['loadeddata', 'play', 'pause', 'seeked', 'ended']) v.addEventListener(name, requestFrame);
v.play().catch(() => {});
return 1;
},
js_video_update(texture, state) {
const d = dv();
const base = Number(state);
if (!player) return;
const v = player.video;
const ready = v.readyState >= 2 && v.videoWidth > 0;
if (ready) {
gl.bindTexture(gl.TEXTURE_2D, objects[texture]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, v);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
d.setFloat64(base + 0, v.currentTime || 0, true);
d.setFloat64(base + 8, isFinite(v.duration) ? v.duration : 0, true);
d.setInt32(base + 16, (!v.paused && !v.ended) ? 1 : 0, true);
d.setInt32(base + 20, ready ? 1 : 0, true);
d.setInt32(base + 24, v.videoWidth, true);
d.setInt32(base + 28, v.videoHeight, true);
d.setInt32(base + 32, player.failed ? 1 : 0, true);
},
js_video_set_playing(playing) {
if (!player) return;
if (playing) player.video.play().catch(() => {});
else player.video.pause();
},
js_video_seek(seconds) { if (player) player.video.currentTime = seconds; },
js_video_set_muted(muted) { videoMuted = !!muted; if (player) player.video.muted = videoMuted; },
js_video_close() { closeVideo(); },
}; };
//
// Media helpers.
//
let player = null;
let videoMuted = false;
function closeVideo() {
if (!player) return;
player.video.pause();
player.video.removeAttribute('src');
player.video.load();
URL.revokeObjectURL(player.url);
player = null;
}
// Copies bytes into WASM memory (allocated with web_alloc; Jai frees them). Returns [pointer, length] as BigInts.
function toWasm(bytes) {
if (!bytes || !bytes.length) return [0n, 0n];
const ptr = X.web_alloc(BigInt(bytes.length));
u8().set(bytes, Number(ptr));
return [ptr, BigInt(bytes.length)];
}
const MAX_IMAGE_SIDE = 2048;
async function decodeImage(blob) {
try {
let bitmap = await createImageBitmap(blob);
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
const w = Math.max(1, Math.round(bitmap.width * scale)), h = Math.max(1, Math.round(bitmap.height * scale));
const canvas2 = new OffscreenCanvas(w, h);
const ctx = canvas2.getContext('2d');
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const data = ctx.getImageData(0, 0, w, h).data;
return { width: w, height: h, pixels: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) };
} catch (e) {
console.warn('Could not decode image:', e);
return null;
}
}
function probeVideo(url) {
return new Promise((resolve) => {
const v = document.createElement('video');
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
const timer = setTimeout(() => resolve(null), 20000);
v.addEventListener('error', () => { clearTimeout(timer); resolve(null); });
v.addEventListener('loadeddata', () => { v.currentTime = Math.min(0.1, (v.duration || 1) / 2); });
v.addEventListener('seeked', async () => {
clearTimeout(timer);
const scale = Math.min(1, 1280 / Math.max(v.videoWidth, v.videoHeight));
const c = new OffscreenCanvas(Math.max(1, Math.round(v.videoWidth * scale)), Math.max(1, Math.round(v.videoHeight * scale)));
c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
const png = new Uint8Array(await (await c.convertToBlob({ type: 'image/png' })).arrayBuffer());
resolve({ width: v.videoWidth, height: v.videoHeight, duration: isFinite(v.duration) ? v.duration : 0, png });
}, { once: true });
v.src = url;
});
}
async function captureScreen() {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
try {
const v = document.createElement('video');
v.srcObject = stream;
v.muted = true;
await v.play();
await new Promise((r) => setTimeout(r, 300)); // Let the first real frame arrive.
const c = new OffscreenCanvas(v.videoWidth, v.videoHeight);
c.getContext('2d').drawImage(v, 0, 0);
return await c.convertToBlob({ type: 'image/png' });
} finally {
for (const track of stream.getTracks()) track.stop();
}
}
function screenshotName() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `Screenshot ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}.png`;
}
async function addFile(name, blob) {
const bytes = new Uint8Array(await blob.arrayBuffer());
const [np, nn] = toWasm(encoder.encode(name));
const [dp, dn] = toWasm(bytes);
X.web_add_file(np, nn, dp, dn);
requestFrame();
}
let dbPromise = null;
function idb(mode, action) {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open('taskforge', 1);
request.onupgradeneeded = () => request.result.createObjectStore('blobs');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return dbPromise.then((db) => new Promise((resolve, reject) => {
const request = action(db.transaction('blobs', mode).objectStore('blobs'));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}));
}
const mediaInput = document.createElement('input');
mediaInput.type = 'file';
mediaInput.multiple = true;
mediaInput.accept = 'image/*,video/*';
mediaInput.addEventListener('change', () => {
for (const file of mediaInput.files) addFile(file.name, file);
mediaInput.value = '';
});
// Report any import we forgot instead of failing to instantiate. // Report any import we forgot instead of failing to instantiate.
const imports = { const imports = {
env: new Proxy(env, { env: new Proxy(env, {
@@ -203,7 +438,9 @@ function frame(timeMs) {
canvas.width = pw; canvas.width = pw;
canvas.height = ph; canvas.height = ph;
} }
if (X.web_frame(w, h, dpr, timeMs / 1000)) requestFrame(); const now = Date.now() / 1000;
const utcOffset = -new Date().getTimezoneOffset() * 60;
if (X.web_frame(w, h, dpr, timeMs / 1000, now, utcOffset)) requestFrame();
} }
function sendString(text, exportName) { function sendString(text, exportName) {
@@ -248,6 +485,13 @@ function installInput() {
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 0); X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 0);
}); });
canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Drag & drop images and videos onto the window.
window.addEventListener('dragover', (e) => { e.preventDefault(); });
window.addEventListener('drop', (e) => {
e.preventDefault();
mouseMove(e);
for (const f of e.dataTransfer?.files || []) addFile(f.name, f);
});
canvas.addEventListener('wheel', (e) => { canvas.addEventListener('wheel', (e) => {
const scale = e.deltaMode === 1 ? 1 : e.deltaMode === 2 ? 10 : 1 / 40; const scale = e.deltaMode === 1 ? 1 : e.deltaMode === 2 ? 10 : 1 / 40;
X.web_modifiers(modifiers(e)); X.web_modifiers(modifiers(e));
@@ -278,6 +522,13 @@ function installInput() {
}); });
window.addEventListener('keyup', (e) => { X.web_modifiers(modifiers(e)); }); window.addEventListener('keyup', (e) => { X.web_modifiers(modifiers(e)); });
window.addEventListener('paste', (e) => { window.addEventListener('paste', (e) => {
// Pasted images (e.g. a screenshot) become attachments; otherwise paste the text.
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/') || f.type.startsWith('video/'));
if (files.length) {
for (const f of files) addFile(f.name && f.name !== 'image.png' ? f.name : screenshotName(), f);
e.preventDefault();
return;
}
const text = e.clipboardData?.getData('text') || ''; const text = e.clipboardData?.getData('text') || '';
for (const ch of text.replace(/\r\n/g, '\n')) X.web_char(ch.codePointAt(0)); for (const ch of text.replace(/\r\n/g, '\n')) X.web_char(ch.codePointAt(0));
e.preventDefault(); e.preventDefault();