Compare commits
4 Commits
bba1ab5cee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6b128643 | ||
|
|
959f6590c3 | ||
|
|
30cd5c7b13 | ||
|
|
4b488913e4 |
@@ -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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -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
554
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
283
public/collage.ts
Normal 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
20
public/globals.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
4
scripts/copy-assets.mjs
Normal 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");
|
||||||
@@ -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();
|
||||||
@@ -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 = {
|
||||||
@@ -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, {
|
||||||
@@ -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 }) : ""}
|
||||||
@@ -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*");
|
||||||
|
|||||||
@@ -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
17
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user