Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Wilson
3e6b128643 ci update
All checks were successful
Build & Push Docker (latest) / verify (push) Successful in 9m31s
Build & Push Docker (latest) / build (push) Successful in 16s
2026-05-29 23:58:42 +10:00
Aiden Wilson
959f6590c3 conversion to type script
Some checks failed
Build & Push Docker (latest) / build (push) Has been cancelled
Build & Push Docker (latest) / verify (push) Has been cancelled
2026-05-29 23:55:31 +10:00
Aiden Wilson
30cd5c7b13 collage fixes
Some checks failed
Build & Push Docker (latest) / verify (push) Successful in 9m27s
Build & Push Docker (latest) / build (push) Has been cancelled
2026-05-29 23:45:52 +10:00
Aiden Wilson
4b488913e4 collage 2026-05-29 23:19:20 +10:00
18 changed files with 1460 additions and 61 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
dist
*.lcov *.lcov
# nyc test coverage # nyc test coverage

View File

@@ -1,13 +1,16 @@
FROM node:22-alpine FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci
COPY public ./public
COPY src ./src COPY src ./src
COPY public ./public
COPY scripts ./scripts
COPY tsconfig.json ./
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "start"] CMD ["npm", "start"]

View File

@@ -9,11 +9,14 @@ OBS Browser Source, OGraf, or any HTML-capable character generator.
```sh ```sh
npm install npm install
npm run build
npm start npm start
``` ```
Open `http://localhost:3000`. Open `http://localhost:3000`.
For development, use `npm run dev` to run the TypeScript server in watch mode.
## Run with Docker ## Run with Docker
```sh ```sh
@@ -92,10 +95,44 @@ The template also accepts a raw URL string or standard CasparCG XML
`templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`, `templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`,
`muted`, `readyDelay`, and `maxWait`. `muted`, `readyDelay`, and `maxWait`.
## Collage
Use `/collage` to render multiple social posts as independently scrolling
columns. The browser keeps only a small window of live posts per column, removes
cards after they scroll off the top, and fills new random cards at the bottom.
Repeated `url` parameters:
```txt
http://localhost:3000/collage?url=https%3A%2F%2Fbsky.app%2F...&url=https%3A%2F%2Fx.com%2F...
```
Or a JSON array:
```txt
http://localhost:3000/collage?urls=%5B%22https%3A%2F%2Fbsky.app%2F...%22%2C%22https%3A%2F%2Fx.com%2F...%22%5D
```
Useful collage parameters:
- `spacing`: pixels between a post and anything else, including screen edges and other posts, default `48`.
- `fade`: optional pixels used to fade posts in/out at the top and bottom edges, default `0`.
- `columns`: number of post columns, default `3`.
- `repeatDistance`: minimum pixel distance before the same post can be reused near another live copy, default `900`.
- `hydrateDelay`: milliseconds between hydrating newly inserted provider embeds, default `180`.
- `duration`: seconds per scroll loop, default `360`.
- `repeat`: virtual scroll buffer multiplier, default `2`.
- `shuffle`: `1` to randomize post order on each page load, default `1`.
Collage card width is calculated from `width`, `spacing`, and `columns` so the
columns fill the configured screen width with only the chosen spacing at the
edges.
## API ## API
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data. - `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data.
- `GET /caspar` returns the CasparCG update-driven HTML template. - `GET /caspar` returns the CasparCG update-driven HTML template.
- `GET /collage?url=...&url=...` returns a scrolling social post collage.
- `GET /providers` returns the loaded provider patterns. - `GET /providers` returns the loaded provider patterns.
- `GET /healthz` returns a health check response. - `GET /healthz` returns a health check response.

554
package-lock.json generated
View File

@@ -8,9 +8,563 @@
"name": "oembed-graphics", "name": "oembed-graphics",
"version": "0.1.0", "version": "0.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
},
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
} }
} }
} }

View File

@@ -4,14 +4,19 @@
"description": "Docker-hosted oEmbed graphics service for broadcast overlays.", "description": "Docker-hosted oEmbed graphics service for broadcast overlays.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --watch src/server.js", "dev": "npm run build && tsx watch src/server.ts",
"start": "node src/server.js", "start": "node dist/src/server.js",
"typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js", "typecheck": "tsc --noEmit",
"build": "npm run typecheck", "build": "tsc && node scripts/copy-assets.mjs",
"test": "node --test" "test": "npm run build && node --test"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"license": "AGPL-3.0-or-later" "license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
}
} }

View File

@@ -202,7 +202,10 @@ function applyStageOptions(options) {
document.body.classList.toggle("transparent", options.transparent); document.body.classList.toggle("transparent", options.transparent);
document.documentElement.classList.toggle("transparent", options.transparent); document.documentElement.classList.toggle("transparent", options.transparent);
const stage = document.querySelector(".stage"); const stage = document.querySelector<HTMLElement>(".stage");
if (!stage) {
return;
}
stage.classList.toggle("fit-cover", options.fit === "cover"); stage.classList.toggle("fit-cover", options.fit === "cover");
stage.classList.toggle("fit-contain", options.fit !== "cover"); stage.classList.toggle("fit-contain", options.fit !== "cover");
} }
@@ -242,7 +245,10 @@ function startAutoplayAssist(root, options) {
} }
function renderEmbed(oembed, sourceUrl, options) { function renderEmbed(oembed, sourceUrl, options) {
const embed = document.querySelector(".embed"); const embed = document.querySelector<HTMLElement>(".embed");
if (!embed) {
return;
}
const providerClass = `provider-${slugify(oembed.provider_name)}`; const providerClass = `provider-${slugify(oembed.provider_name)}`;
const embedWidth = Math.min(Number(oembed.width) || 500, 500); const embedWidth = Math.min(Number(oembed.width) || 500, 500);
const embedHeight = Number(oembed.height); const embedHeight = Number(oembed.height);

283
public/collage.ts Normal file
View File

@@ -0,0 +1,283 @@
const dataElement = document.getElementById("collage-data") as HTMLScriptElement;
const stage = document.querySelector<HTMLElement>(".collage-stage");
const track = document.querySelector<HTMLElement>(".collage-track");
const config = JSON.parse(dataElement.textContent);
if (!stage || !track) {
throw new Error("Collage stage could not be initialized.");
}
let lastFrame = performance.now();
let itemCursor = 0;
const columns = [];
const deviceScale = window.devicePixelRatio || 1;
const hydrationQueue = [];
const hydrateDelay = Math.max(Number(config.hydrateDelay) || 0, 0);
const placeholderHeight = 160;
const cardColumns = new WeakMap();
let trackTop = 0;
let hydrationTimer;
const cardObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const card = entry.target as HTMLElement;
const column = cardColumns.get(card);
const previousExtent = cardExtent(card);
card.dataset.height = String(Math.max(entry.contentRect.height, placeholderHeight));
if (column) {
column.contentHeight += cardExtent(card) - previousExtent;
}
}
});
function snapPixel(value) {
return Math.round(value * deviceScale) / deviceScale;
}
function slugify(value = "") {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "unknown";
}
function executeScripts(root) {
for (const script of root.querySelectorAll("script")) {
const replacement = document.createElement("script");
for (const attribute of script.attributes) {
replacement.setAttribute(attribute.name, attribute.value);
}
replacement.textContent = script.textContent;
script.replaceWith(replacement);
}
}
function scheduleHydration(card, item) {
if (!hydrateDelay) {
hydrateCard(card, item);
return;
}
hydrationQueue.push({ card, item });
if (!hydrationTimer) {
hydrationTimer = setInterval(hydrateNextCard, hydrateDelay);
}
}
function hydrateNextCard() {
let next = hydrationQueue.shift();
while (next && !next.card.isConnected) {
next = hydrationQueue.shift();
}
if (!next) {
clearInterval(hydrationTimer);
hydrationTimer = undefined;
return;
}
hydrateCard(next.card, next.item);
}
function nudgeProviderWidgets(root) {
window.twttr?.widgets?.load?.(root);
window.instgrm?.Embeds?.process?.();
window.bluesky?.scan?.(root);
}
function hydrateCard(card, item) {
if (item.type === "photo" && item.url) {
const image = document.createElement("img");
image.src = item.url;
image.alt = item.title || "";
card.replaceChildren(image);
} else {
card.innerHTML = item.html || "";
}
executeScripts(card);
nudgeProviderWidgets(card);
card.classList.add("is-hydrated");
}
function createCard(column, item) {
const card = document.createElement("section");
card.className = `embed collage-card provider-${slugify(item.providerName)}`;
card.dataset.source = item.targetUrl;
card.dataset.height = String(placeholderHeight);
card.style.setProperty("--embed-width", `${config.cardWidth}px`);
cardColumns.set(card, column);
column.contentHeight += cardExtent(card);
cardObserver.observe(card);
scheduleHydration(card, item);
return card;
}
function createColumn(index) {
const element = document.createElement("div");
element.className = "collage-column";
element.dataset.column = String(index);
track.append(element);
return {
element,
index,
offset: 0,
contentHeight: 0,
};
}
function cardExtent(card) {
return cardHeight(card) + config.spacing;
}
function cardHeight(card) {
return Math.max(Number(card.dataset.height) || 0, placeholderHeight);
}
function candidateY(column) {
return column.contentHeight - column.offset;
}
function isTooCloseToSamePost(item, column) {
if (!config.repeatDistance || config.items.length < 2) {
return false;
}
const minDistanceSquared = config.repeatDistance * config.repeatDistance;
const nextY = candidateY(column);
for (const otherColumn of columns) {
const columnDistance = Math.abs(column.index - otherColumn.index) * (config.cardWidth + config.spacing);
for (const card of otherColumn.element.children) {
if (card.dataset.source !== item.targetUrl) {
continue;
}
const existingY = card.offsetTop - otherColumn.offset;
const yDistance = nextY - existingY;
const distanceSquared = (columnDistance * columnDistance) + (yDistance * yDistance);
if (distanceSquared < minDistanceSquared) {
return true;
}
}
}
return false;
}
function randomCandidate() {
return config.items[Math.floor(Math.random() * config.items.length)];
}
function sequentialCandidate(attempt) {
return config.items[(itemCursor + attempt) % config.items.length];
}
function nextItem(column) {
const maxAttempts = Math.max(config.items.length * 2, 8);
let fallback = config.items[0];
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const item = config.shuffle ? randomCandidate() : sequentialCandidate(attempt);
fallback = item;
if (!isTooCloseToSamePost(item, column)) {
if (!config.shuffle) {
itemCursor += attempt + 1;
}
return item;
}
}
if (!config.shuffle) {
itemCursor += 1;
}
return fallback;
}
function fillColumn(column) {
const targetHeight = stage.clientHeight + Math.max(config.cardWidth, 720) + config.spacing;
let guard = 0;
while (column.contentHeight - column.offset < targetHeight && guard < 24) {
column.element.append(createCard(column, nextItem(column)));
guard += 1;
}
}
function fillColumns() {
for (const column of columns) {
fillColumn(column);
}
}
function recycleColumn(column) {
let firstCard = column.element.firstElementChild;
while (firstCard && isFullyAboveStage(firstCard, column)) {
column.contentHeight -= cardExtent(firstCard);
column.offset -= cardExtent(firstCard);
cardObserver.unobserve(firstCard);
cardColumns.delete(firstCard);
firstCard.remove();
column.element.append(createCard(column, nextItem(column)));
firstCard = column.element.firstElementChild;
}
}
function isFullyAboveStage(card, column) {
const cardBottom = trackTop + cardHeight(card) - column.offset;
return cardBottom <= 0;
}
function scrollSpeed() {
const baseDistance = Math.max(stage.clientHeight, config.cardWidth * config.repeat);
return baseDistance / config.duration;
}
function frame(now) {
const elapsedSeconds = Math.min((now - lastFrame) / 1000, 0.1);
lastFrame = now;
for (const column of columns) {
column.offset += scrollSpeed() * elapsedSeconds;
recycleColumn(column);
fillColumn(column);
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
}
requestAnimationFrame(frame);
}
for (let index = 0; index < config.columns; index += 1) {
columns.push(createColumn(index));
}
fillColumns();
trackTop = track.offsetTop;
for (const [index, column] of columns.entries()) {
const firstCard = column.element.firstElementChild;
const stagger = firstCard ? Math.min(index * config.spacing * 0.75, cardExtent(firstCard) * 0.4) : 0;
column.offset = stagger;
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
}
const observer = new ResizeObserver(() => {
trackTop = track.offsetTop;
fillColumns();
});
observer.observe(stage);
requestAnimationFrame(frame);

20
public/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
type ProviderWidgetRoot = Element | Document;
interface Window {
update?: (payload: unknown) => void;
play?: () => void;
stop?: () => void;
twttr?: {
widgets?: {
load?: (root?: ProviderWidgetRoot) => void;
};
};
instgrm?: {
Embeds?: {
process?: () => void;
};
};
bluesky?: {
scan?: (root?: ProviderWidgetRoot) => void;
};
}

View File

@@ -77,6 +77,7 @@ label {
input, input,
select, select,
textarea,
button { button {
min-height: 42px; min-height: 42px;
border: 1px solid #c8d2dc; border: 1px solid #c8d2dc;
@@ -85,12 +86,19 @@ button {
} }
input, input,
select { select,
textarea {
width: 100%; width: 100%;
padding: 0 12px; padding: 0 12px;
background: #fff; background: #fff;
} }
textarea {
min-height: 128px;
padding-block: 10px;
resize: vertical;
}
button { button {
padding: 0 18px; padding: 0 18px;
border-color: #153f66; border-color: #153f66;
@@ -107,6 +115,15 @@ button {
align-items: end; align-items: end;
} }
.collage-form {
padding-top: 20px;
border-top: 1px solid #d9e0e7;
}
.collage-controls {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.checkbox-label { .checkbox-label {
display: flex; display: flex;
min-height: 42px; min-height: 42px;
@@ -183,6 +200,99 @@ code {
object-fit: cover; object-fit: cover;
} }
.collage-page {
background: var(--chroma);
}
.collage-page.transparent {
background: transparent;
}
.collage-stage {
width: min(100vw, var(--stage-width));
height: min(100vh, var(--stage-height));
overflow: hidden;
display: grid;
justify-items: center;
padding: var(--collage-spacing);
contain: layout paint;
pointer-events: none;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 var(--collage-fade),
#000 calc(100% - var(--collage-fade)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 var(--collage-fade),
#000 calc(100% - var(--collage-fade)),
transparent 100%
);
}
.collage-track {
display: grid;
grid-template-columns: repeat(var(--collage-columns), minmax(0, var(--collage-card-width)));
gap: var(--collage-spacing);
width: max-content;
backface-visibility: hidden;
contain: layout style;
}
.collage-column {
display: flex;
flex-direction: column;
gap: var(--collage-spacing);
width: var(--collage-card-width);
backface-visibility: hidden;
contain: layout style;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.collage-card {
align-self: start;
width: 100%;
height: auto;
max-width: var(--collage-card-width);
max-height: none;
contain: layout style;
overflow: visible;
transform: none;
}
.collage-card:not(.is-hydrated) {
min-height: 160px;
}
.collage-card > iframe,
.collage-card > blockquote,
.collage-card > img,
.collage-card > video {
width: 100% !important;
max-width: 100% !important;
}
.collage-card iframe,
.collage-card blockquote,
.collage-card img,
.collage-card video,
.collage-card twitter-widget,
.collage-card .twitter-tweet,
.collage-card .bluesky-embed {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
margin: 0 !important;
}
.collage-card > * {
margin-block: 0 !important;
}
.error { .error {
border-color: #d77; border-color: #d77;
} }

4
scripts/copy-assets.mjs Normal file
View File

@@ -0,0 +1,4 @@
import { cp, mkdir } from "node:fs/promises";
await mkdir("dist/public", { recursive: true });
await cp("public/styles.css", "dist/public/styles.css");

View File

@@ -1,9 +1,27 @@
import { findProvider, loadProviders } from "./providers.js"; import { findProvider, loadProviders, type Provider } from "./providers.js";
const DEFAULT_TIMEOUT_MS = 8000; const DEFAULT_TIMEOUT_MS = 8000;
const DEFAULT_MAX_WIDTH = 500; const DEFAULT_MAX_WIDTH = 500;
function normalizeMaxWidth(endpoint, maxWidth) { type HttpError = Error & { status?: number };
type FetchImpl = typeof fetch;
type FetchOembedOptions = {
fetchImpl?: FetchImpl;
providers?: Provider[];
timeoutMs?: number;
maxWidth?: number;
maxHeight?: number;
};
function httpError(message: string, status: number): HttpError {
const error: HttpError = new Error(message);
error.status = status;
return error;
}
function normalizeMaxWidth(_endpoint: string, maxWidth?: number) {
if (!maxWidth) { if (!maxWidth) {
return maxWidth; return maxWidth;
} }
@@ -11,7 +29,7 @@ function normalizeMaxWidth(endpoint, maxWidth) {
return Math.min(maxWidth, DEFAULT_MAX_WIDTH); return Math.min(maxWidth, DEFAULT_MAX_WIDTH);
} }
export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) { export function buildOembedUrl(endpoint: string, targetUrl: string, maxWidth?: number, maxHeight?: number) {
const requestUrl = new URL(endpoint); const requestUrl = new URL(endpoint);
const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth); const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth);
requestUrl.searchParams.set("url", targetUrl); requestUrl.searchParams.set("url", targetUrl);
@@ -28,36 +46,30 @@ export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) {
return requestUrl; return requestUrl;
} }
export async function fetchOembed(targetUrl, { export async function fetchOembed(targetUrl: string, {
fetchImpl = fetch, fetchImpl = fetch,
providers, providers,
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS), timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
maxWidth, maxWidth,
maxHeight, maxHeight,
} = {}) { }: FetchOembedOptions = {}) {
let parsed; let parsed;
try { try {
parsed = new URL(targetUrl); parsed = new URL(targetUrl);
} catch { } catch {
const error = new Error("The url parameter must be an absolute URL."); throw httpError("The url parameter must be an absolute URL.", 400);
error.status = 400;
throw error;
} }
if (!["http:", "https:"].includes(parsed.protocol)) { if (!["http:", "https:"].includes(parsed.protocol)) {
const error = new Error("Only http and https URLs are supported."); throw httpError("Only http and https URLs are supported.", 400);
error.status = 400;
throw error;
} }
const availableProviders = providers || await loadProviders({ fetchImpl }); const availableProviders = providers || await loadProviders({ fetchImpl });
const provider = findProvider(targetUrl, availableProviders); const provider = findProvider(targetUrl, availableProviders);
if (!provider) { if (!provider) {
const error = new Error("No oEmbed provider matched this URL."); throw httpError("No oEmbed provider matched this URL.", 404);
error.status = 404;
throw error;
} }
const controller = new AbortController(); const controller = new AbortController();
@@ -73,9 +85,7 @@ export async function fetchOembed(targetUrl, {
}); });
if (!response.ok) { if (!response.ok) {
const error = new Error(`oEmbed provider returned ${response.status}.`); throw httpError(`oEmbed provider returned ${response.status}.`, response.status);
error.status = response.status;
throw error;
} }
const data = await response.json(); const data = await response.json();

View File

@@ -1,22 +1,39 @@
const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json"; const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json";
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
let providerCache = { export type RawProvider = {
expiresAt: 0, provider_name: string;
providers: [], provider_url: string;
endpoints?: Array<{
schemes?: string[];
url: string;
}>;
}; };
export function wildcardToRegExp(pattern) { export type Provider = {
providerName: string;
providerUrl: string;
endpoint: string;
scheme: string;
regex: RegExp;
};
let providerCache = {
expiresAt: 0,
providers: [] as Provider[],
};
export function wildcardToRegExp(pattern: string) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i"); return new RegExp(`^${escaped}$`, "i");
} }
export function normalizeEndpoint(endpoint) { export function normalizeEndpoint(endpoint: string) {
return endpoint.replace("{format}", "json"); return endpoint.replace("{format}", "json");
} }
export function flattenProviders(rawProviders) { export function flattenProviders(rawProviders: RawProvider[]) {
const providers = []; const providers: Provider[] = [];
for (const provider of rawProviders) { for (const provider of rawProviders) {
for (const endpoint of provider.endpoints || []) { for (const endpoint of provider.endpoints || []) {
@@ -37,7 +54,7 @@ export function flattenProviders(rawProviders) {
return providers; return providers;
} }
export function findProvider(url, providers) { export function findProvider(url: string, providers: Provider[]) {
return providers.find((provider) => provider.regex.test(url)); return providers.find((provider) => provider.regex.test(url));
} }
@@ -46,6 +63,11 @@ export async function loadProviders({
ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS), ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS),
fetchImpl = fetch, fetchImpl = fetch,
force = false, force = false,
}: {
providersUrl?: string;
ttlMs?: number;
fetchImpl?: typeof fetch;
force?: boolean;
} = {}) { } = {}) {
const now = Date.now(); const now = Date.now();
@@ -64,7 +86,7 @@ export async function loadProviders({
throw new Error(`Could not load providers: ${response.status} ${response.statusText}`); throw new Error(`Could not load providers: ${response.status} ${response.statusText}`);
} }
const rawProviders = await response.json(); const rawProviders = await response.json() as RawProvider[];
const providers = flattenProviders(rawProviders); const providers = flattenProviders(rawProviders);
providerCache = { providerCache = {

View File

@@ -1,15 +1,17 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { extname, join } from "node:path"; import { extname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { fetchOembed } from "./oembed.js"; import { fetchOembed } from "./oembed.js";
import { loadProviders } from "./providers.js"; import { loadProviders } from "./providers.js";
import { casparTemplatePage, errorPage, graphicPage, homePage } from "./templates.js"; import { casparTemplatePage, collagePage, errorPage, graphicPage, homePage } from "./templates.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url)); const __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, ".."); const rootDir = join(__dirname, "..");
const publicDir = join(rootDir, "public"); const compiledPublicDir = join(rootDir, "dist", "public");
const publicDir = existsSync(compiledPublicDir) ? compiledPublicDir : join(rootDir, "public");
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "0.0.0.0"; const host = process.env.HOST || "0.0.0.0";
@@ -51,10 +53,40 @@ function getNumber(searchParams, key, defaultValue, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
function parseUrlList(searchParams) {
const repeatedUrls = searchParams.getAll("url").filter(Boolean);
if (repeatedUrls.length) {
return repeatedUrls;
}
const urlsValue = searchParams.get("urls") || "";
if (!urlsValue) {
return [];
}
try {
const parsed = JSON.parse(urlsValue);
if (Array.isArray(parsed)) {
return parsed.filter((url) => typeof url === "string" && url.trim());
}
} catch {
// Fall through to comma/newline parsing.
}
return urlsValue
.split(/[\n,]/)
.map((url) => url.trim())
.filter(Boolean);
}
async function serveStatic(requestUrl, response) { async function serveStatic(requestUrl, response) {
const staticPaths = new Map([ const staticPaths = new Map([
["/styles.css", "styles.css"], ["/styles.css", "styles.css"],
["/caspar.js", "caspar.js"], ["/caspar.js", "caspar.js"],
["/collage.js", "collage.js"],
]); ]);
const path = staticPaths.get(requestUrl.pathname) || ""; const path = staticPaths.get(requestUrl.pathname) || "";
@@ -108,6 +140,68 @@ async function handleGraphic(requestUrl, response) {
})); }));
} }
async function handleCollage(requestUrl, response) {
const urls = parseUrlList(requestUrl.searchParams).slice(0, 24);
if (!urls.length) {
send(response, 400, errorPage("Add repeated url parameters or a urls JSON array.", 400), "text/html; charset=utf-8");
return;
}
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
const legacySpacing = requestUrl.searchParams.get("gap") || requestUrl.searchParams.get("padding");
const spacing = getNumber(requestUrl.searchParams, "spacing", Number(legacySpacing || 48), 0, 400);
const fade = getNumber(requestUrl.searchParams, "fade", 0, 0, 600);
const columns = getNumber(requestUrl.searchParams, "columns", 3, 1, 8);
const repeatDistance = getNumber(requestUrl.searchParams, "repeatDistance", 900, 0, 4000);
const hydrateDelay = getNumber(requestUrl.searchParams, "hydrateDelay", 180, 0, 2000);
const duration = getNumber(requestUrl.searchParams, "duration", 360, 10, 1200);
const repeat = getNumber(requestUrl.searchParams, "repeat", 2, 1, 8);
const shuffle = getBoolean(requestUrl.searchParams, "shuffle", true);
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
const autoplay = getBoolean(requestUrl.searchParams, "autoplay", true);
const muted = getBoolean(requestUrl.searchParams, "muted", true);
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
const results = await Promise.allSettled(
urls.map(async (url) => {
const { data } = await fetchOembed(url, { maxWidth, maxHeight });
return {
targetUrl: url,
embed: data,
};
}),
);
const items = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
if (!items.length) {
throw new Error("No collage URLs could be resolved through oEmbed.");
}
send(response, 200, collagePage({
items,
width,
height,
transparent,
chroma,
autoplay,
muted,
spacing,
fade,
columns,
repeatDistance,
hydrateDelay,
duration,
repeat,
shuffle,
}));
}
async function handleRequest(request, response) { async function handleRequest(request, response) {
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`); const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
@@ -126,6 +220,11 @@ async function handleRequest(request, response) {
return; return;
} }
if (requestUrl.pathname === "/collage") {
await handleCollage(requestUrl, response);
return;
}
if (requestUrl.pathname === "/providers") { if (requestUrl.pathname === "/providers") {
const providers = await loadProviders(); const providers = await loadProviders();
sendJson(response, 200, { sendJson(response, 200, {

View File

@@ -41,6 +41,28 @@ function cappedPixelValue(value, cap) {
return `${Math.min(number, cap)}px`; return `${Math.min(number, cap)}px`;
} }
function collageCardWidth({ width, spacing, columns }) {
const availableWidth = width - (spacing * 2) - (spacing * Math.max(columns - 1, 0));
const columnWidth = Math.floor(availableWidth / columns);
return Math.max(columnWidth, 120);
}
function shuffleItems(items) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]];
}
return shuffled;
}
function safeJson(value) {
return JSON.stringify(value).replaceAll("</", "<\\/");
}
function addIframePermissions(tag) { function addIframePermissions(tag) {
const autoplayPermission = "autoplay"; const autoplayPermission = "autoplay";
@@ -323,9 +345,50 @@ export function homePage({ providersCount = 0 } = {}) {
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label> <label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
</div> </div>
</form> </form>
<form class="collage-form" action="/collage" method="get">
<label for="urls">Collage URLs</label>
<textarea id="urls" name="urls" rows="6" placeholder="https://bsky.app/...\nhttps://x.com/...\nhttps://www.instagram.com/..."></textarea>
<div class="controls collage-controls">
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
<label>Spacing <input name="spacing" type="number" value="48" min="0" max="400"></label>
<label>Fade <input name="fade" type="number" value="0" min="0" max="600"></label>
<label>Columns <input name="columns" type="number" value="3" min="1" max="8"></label>
<label>Separation <input name="repeatDistance" type="number" value="900" min="0" max="4000"></label>
<label>Hydrate <input name="hydrateDelay" type="number" value="180" min="0" max="2000"></label>
<label>Duration <input name="duration" type="number" value="360" min="10" max="1200"></label>
<label>Repeat <input name="repeat" type="number" value="2" min="1" max="8"></label>
<label class="checkbox-label"><input name="shuffle" value="1" type="checkbox" checked> shuffle</label>
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
</div>
<button type="submit">Open collage</button>
</form>
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p> <p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
</section> </section>
</main> </main>
<script>
document.querySelector(".collage-form").addEventListener("submit", (event) => {
const form = event.currentTarget;
const textarea = form.querySelector("textarea[name='urls']");
const urls = textarea.value.split(/[\\n,]+/).map((url) => url.trim()).filter(Boolean);
if (!urls.length) {
event.preventDefault();
textarea.focus();
return;
}
const data = new FormData(form);
data.delete("urls");
for (const url of urls) {
data.append("url", url);
}
event.preventDefault();
window.location.href = form.action + "?" + new URLSearchParams(data).toString();
});
</script>
</body> </body>
</html>`; </html>`;
} }
@@ -336,7 +399,85 @@ export function casparTemplatePage() {
<main class="stage fit-contain"> <main class="stage fit-contain">
<section class="embed provider-empty" data-source="" style="--embed-width:500px"></section> <section class="embed provider-empty" data-source="" style="--embed-width:500px"></section>
</main> </main>
<script src="/caspar.js"></script> <script type="module" src="/caspar.js"></script>
</body>
</html>`;
}
function embedCardHtml({
targetUrl,
embed,
autoplay,
muted,
className = "embed",
widthCap = 500,
includeHeight = true,
}) {
const providerClass = `provider-${slugify(embed.provider_name)}`;
const embedWidth = cappedPixelValue(embed.width, widthCap);
const embedHeight = numericPixelValue(embed.height);
const embedStyle = [
embedWidth ? `--embed-width:${embedWidth}` : "",
includeHeight && embedHeight ? `--embed-height:${embedHeight}` : "",
].filter(Boolean).join(";");
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
const media = embed.type === "photo" && embed.url
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(embed.title || embed.provider_name || "")}">`
: html;
return `<section class="${escapeHtml(className)} ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
${media}
</section>`;
}
export function collagePage({
items,
width,
height,
transparent,
chroma,
autoplay = true,
muted = true,
spacing = 48,
fade = 0,
columns = 3,
repeatDistance = 900,
hydrateDelay = 180,
duration = 360,
repeat = 2,
shuffle = true,
}) {
const cardWidth = collageCardWidth({ width, spacing, columns });
const orderedItems = shuffle ? shuffleItems(items) : [...items];
const collageItems = orderedItems.map((item) => ({
targetUrl: item.targetUrl,
providerName: item.embed.provider_name || "",
type: item.embed.type || "rich",
title: item.embed.title || item.embed.provider_name || "",
url: item.embed.url || "",
html: prepareEmbedHtml(item.embed.html || "", { autoplay, muted }),
}));
const collageData = {
items: collageItems,
cardWidth,
columns,
repeatDistance,
hydrateDelay,
spacing,
duration,
repeat,
shuffle,
autoplay,
muted,
};
return `${commonHead({ title: "oEmbed Collage", htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
<body class="graphic collage-page ${transparent ? "transparent" : ""} is-ready" style="--stage-width:${width}px; --stage-height:${height}px; --chroma:${escapeHtml(chroma)}; --collage-spacing:${spacing}px; --collage-fade:${fade}px; --collage-columns:${columns}; --collage-card-width:${cardWidth}px; --collage-duration:${duration}s;">
<main class="collage-stage">
<div class="collage-track"></div>
</main>
<script id="collage-data" type="application/json">${safeJson(collageData)}</script>
<script type="module" src="/collage.js"></script>
</body> </body>
</html>`; </html>`;
} }
@@ -357,24 +498,11 @@ export function graphicPage({
maxWait = 10000, maxWait = 10000,
}) { }) {
const title = embed.title || embed.provider_name || "oEmbed Graphic"; const title = embed.title || embed.provider_name || "oEmbed Graphic";
const providerClass = `provider-${slugify(embed.provider_name)}`;
const embedWidth = cappedPixelValue(embed.width, 500);
const embedHeight = numericPixelValue(embed.height);
const embedStyle = [
embedWidth ? `--embed-width:${embedWidth}` : "",
embedHeight ? `--embed-height:${embedHeight}` : "",
].filter(Boolean).join(";");
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
const media = embed.type === "photo" && embed.url
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
: html;
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })} return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
<body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};"> <body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
<main class="stage fit-${escapeHtml(fit)}"> <main class="stage fit-${escapeHtml(fit)}">
<section class="embed ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}> ${embedCardHtml({ targetUrl, embed, autoplay, muted })}
${media}
</section>
</main> </main>
${wait ? revealScript({ readyDelay, maxWait }) : ""} ${wait ? revealScript({ readyDelay, maxWait }) : ""}
${autoplay ? autoplayAssistScript({ muted }) : ""} ${autoplay ? autoplayAssistScript({ muted }) : ""}

View File

@@ -1,8 +1,8 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { buildOembedUrl } from "../src/oembed.js"; import { buildOembedUrl } from "../dist/src/oembed.js";
import { findProvider, flattenProviders, wildcardToRegExp } from "../src/providers.js"; import { findProvider, flattenProviders, wildcardToRegExp } from "../dist/src/providers.js";
test("wildcard provider schemes match target URLs", () => { test("wildcard provider schemes match target URLs", () => {
const regex = wildcardToRegExp("https://*.example.com/watch*"); const regex = wildcardToRegExp("https://*.example.com/watch*");

View File

@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { casparTemplatePage, graphicPage, prepareEmbedHtml } from "../src/templates.js"; import { casparTemplatePage, collagePage, graphicPage, homePage, prepareEmbedHtml } from "../dist/src/templates.js";
test("adds autoplay parameters and permissions to iframe embeds", () => { test("adds autoplay parameters and permissions to iframe embeds", () => {
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>'); const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');
@@ -13,6 +13,22 @@ test("adds autoplay parameters and permissions to iframe embeds", () => {
assert.match(html, /muted=1/); assert.match(html, /muted=1/);
}); });
test("home page includes single graphic and collage forms", () => {
const page = homePage({ providersCount: 12 });
assert.match(page, /<form action="\/graphic" method="get">/);
assert.match(page, /<form class="collage-form" action="\/collage" method="get">/);
assert.match(page, /<textarea id="urls" name="urls"/);
assert.match(page, /name="spacing" type="number" value="48"/);
assert.match(page, /name="fade" type="number" value="0"/);
assert.match(page, /name="columns" type="number" value="3"/);
assert.match(page, /name="repeatDistance" type="number" value="900"/);
assert.match(page, /name="hydrateDelay" type="number" value="180"/);
assert.match(page, /name="repeat" type="number" value="2"/);
assert.match(page, /name="shuffle" value="1" type="checkbox" checked/);
assert.match(page, /Open collage/);
});
test("adds autoplay attributes to video tags", () => { test("adds autoplay attributes to video tags", () => {
const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>'); const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>');
@@ -143,7 +159,8 @@ test("adds a provider class for Bluesky embeds", () => {
}); });
assert.match(page, /<section class="embed provider-bluesky"/); assert.match(page, /<section class="embed provider-bluesky"/);
assert.match(page, /--embed-width:500px;--embed-height:480px/); assert.match(page, /--embed-width:500px/);
assert.doesNotMatch(page, /collage-card provider-example"[^>]*--embed-height/);
}); });
test("caps rendered embed width at 500px", () => { test("caps rendered embed width at 500px", () => {
@@ -190,5 +207,88 @@ test("renders a CasparCG template shell", () => {
assert.match(page, /<body class="graphic transparent is-loading"/); assert.match(page, /<body class="graphic transparent is-loading"/);
assert.match(page, /<section class="embed provider-empty"/); assert.match(page, /<section class="embed provider-empty"/);
assert.match(page, /<script src="\/caspar\.js"><\/script>/); assert.match(page, /<script type="module" src="\/caspar\.js"><\/script>/);
});
test("renders a vertical scrolling collage page with repeated embed cards", () => {
const page = collagePage({
items: [
{
targetUrl: "https://example.com/post/1",
embed: {
provider_name: "Example",
type: "rich",
width: 700,
height: 480,
html: '<iframe src="https://player.example.com/1"></iframe>',
},
},
{
targetUrl: "https://example.com/post/2",
embed: {
provider_name: "Example",
type: "rich",
width: 500,
height: 480,
html: '<iframe src="https://player.example.com/2"></iframe>',
},
},
],
width: 1920,
height: 1080,
transparent: true,
chroma: "#00ff00",
spacing: 48,
fade: 0,
columns: 3,
repeatDistance: 900,
duration: 360,
repeat: 3,
shuffle: false,
});
assert.match(page, /class="graphic collage-page transparent is-ready"/);
assert.match(page, /--collage-spacing:48px/);
assert.match(page, /--collage-fade:0px/);
assert.match(page, /--collage-columns:3/);
assert.match(page, /--collage-card-width:576px/);
assert.match(page, /--collage-duration:360s/);
assert.match(page, /<div class="collage-track"><\/div>/);
assert.match(page, /<script id="collage-data" type="application\/json">/);
assert.match(page, /"cardWidth":576/);
assert.match(page, /"columns":3/);
assert.match(page, /"repeatDistance":900/);
assert.match(page, /"hydrateDelay":180/);
assert.match(page, /"repeat":3/);
assert.match(page, /"shuffle":false/);
assert.match(page, /<script type="module" src="\/collage\.js"><\/script>/);
});
test("shrinks collage card width to fit the configured screen width", () => {
const page = collagePage({
items: [
{
targetUrl: "https://example.com/post/1",
embed: {
provider_name: "Example",
type: "rich",
width: 500,
height: 480,
html: '<iframe src="https://player.example.com/1"></iframe>',
},
},
],
width: 1280,
height: 720,
transparent: true,
chroma: "#00ff00",
spacing: 48,
columns: 4,
duration: 360,
repeat: 2,
shuffle: false,
});
assert.match(page, /--collage-card-width:260px/);
assert.match(page, /"cardWidth":260/);
}); });

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"rootDir": ".",
"outDir": "dist",
"strict": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "public/**/*.ts"],
"exclude": ["dist", "node_modules"]
}