Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9169e4d662 | ||
|
|
4c7af51851 | ||
|
|
eadfb4f821 | ||
|
|
0c163d6c19 | ||
|
|
26484685ee | ||
|
|
5bceb33801 | ||
|
|
c6e2c4296a | ||
|
|
8551d58def | ||
|
|
807180bf22 | ||
|
|
f48d2beea7 | ||
|
|
dc72bbd5e6 |
No files matched your search
@@ -32,12 +32,45 @@ artcraft-services/
|
||||
│ ├── plugins/ # Shared billing and service components
|
||||
│ └── web/storyteller_web/ # Main Actix Web HTTP API
|
||||
├── frontend/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
```
|
||||
|
||||
## Updating Desktop Download Links
|
||||
|
||||
Desktop release links are configured independently in **three places**. When
|
||||
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
|
||||
`MAC_VERSION` in all three unless the request explicitly targets one platform or
|
||||
app:
|
||||
|
||||
1. **Current public website (Next.js):**
|
||||
[frontend/apps/artcraft-website-next/src/lib/download-links.ts](frontend/apps/artcraft-website-next/src/lib/download-links.ts).
|
||||
This controls the installer links and visible version at
|
||||
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
|
||||
(newest first), then update both version selectors.
|
||||
2. **Legacy website (Vite):**
|
||||
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
|
||||
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
|
||||
Updating only this file does **not** update the current public website.
|
||||
3. **Browser webapp:**
|
||||
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
|
||||
Update both version constants; these links are used by the welcome and
|
||||
checkout-success pages at `app.getartcraft.com`.
|
||||
|
||||
Keep previous releases for rollback. The Next.js download page reads its visible
|
||||
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
|
||||
not hardcode a separate version in the page component.
|
||||
|
||||
Check that all three configs produce the requested release tag and installer
|
||||
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
|
||||
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
|
||||
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
|
||||
require deployment of the affected apps before the public pages change; do not
|
||||
report a local update as a deployed update.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Rust with no minimum supported version
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
//const WINDOWS_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const WINDOWS_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const WINDOWS_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const WINDOWS_VERSION = "0.41.0";
|
||||
|
||||
//const MAC_VERSION = "0.7.0"; // 2026-02-23 Seedance
|
||||
//const MAC_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const MAC_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const MAC_VERSION = "0.41.0";
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
|
||||
|
||||
@@ -2,3 +2,5 @@ node_modules/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -11,3 +11,13 @@ publish = ".next"
|
||||
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
|
||||
# Share links. The Next runtime serves /media/:token itself (src/app/media);
|
||||
# this only maps the legacy query form the Vite site accepted onto the
|
||||
# canonical route at the edge. Also declared in next.config.ts for local dev.
|
||||
[[redirects]]
|
||||
from = "/media"
|
||||
query = { media = ":token" }
|
||||
to = "/media/:token"
|
||||
status = 301
|
||||
force = true
|
||||
@@ -2,23 +2,39 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Share previews must work from the raw <head>, even for an unknown crawler
|
||||
// or curl. Wait for generateMetadata instead of streaming it into the body.
|
||||
// Media lookups have a five-second timeout; static marketing pages do not
|
||||
// perform that lookup. https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
|
||||
htmlLimitedBots: /.*/,
|
||||
// Pin the project root: the repo has lockfiles both here and in the Nx
|
||||
// workspace above, and letting Next guess picks the wrong one (breaks
|
||||
// output file tracing on serverless deploys).
|
||||
turbopack: { root: __dirname },
|
||||
outputFileTracingRoot: __dirname,
|
||||
async redirects() {
|
||||
// Legacy Stripe return paths from the Vite site; checkout now lives in
|
||||
// the webapp, which serves the canonical slash forms.
|
||||
return ["checkout_success", "checkout_cancel", "portal_closed"].map(
|
||||
(route) => ({
|
||||
source: `/${route}`,
|
||||
destination: `https://app.getartcraft.com/checkout/${
|
||||
route === "checkout_success" ? "success" : "cancel"
|
||||
}`,
|
||||
permanent: false,
|
||||
}),
|
||||
);
|
||||
return [
|
||||
// Legacy Stripe return paths from the Vite site; checkout now lives in
|
||||
// the webapp, which serves the canonical slash forms.
|
||||
...["checkout_success", "checkout_cancel", "portal_closed"].map(
|
||||
(route) => ({
|
||||
source: `/${route}`,
|
||||
destination: `https://app.getartcraft.com/checkout/${
|
||||
route === "checkout_success" ? "success" : "cancel"
|
||||
}`,
|
||||
permanent: false,
|
||||
}),
|
||||
),
|
||||
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
|
||||
// the canonical route is /media/<token>. Mirrored in netlify.toml so
|
||||
// the edge answers it before the Next runtime.
|
||||
{
|
||||
source: "/media",
|
||||
has: [{ type: "query", key: "media", value: "(?<token>.+)" }],
|
||||
destination: "/media/:token",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
// The hero wall uses WebGL video textures, which need CORS-clean sources.
|
||||
// The showcase CDN sends no Access-Control-Allow-Origin header, so these
|
||||
|
||||
+84
-13
@@ -12,6 +12,7 @@
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.6.1",
|
||||
"@react-three/postprocessing": "3.0.4",
|
||||
"@sparkjsdev/spark": "0.1.10",
|
||||
"gsap": "3.15.0",
|
||||
"lenis": "1.3.23",
|
||||
"lucide-react": "^1.31.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@@ -652,9 +654,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,9 +670,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -690,9 +686,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -709,9 +702,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,6 +743,23 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
@@ -1126,6 +1133,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/webxr": "*",
|
||||
@@ -1195,6 +1203,15 @@
|
||||
"three": ">=0.144.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sparkjsdev/spark": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@sparkjsdev/spark/-/spark-0.1.10.tgz",
|
||||
"integrity": "sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1508,6 +1525,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1518,6 +1536,7 @@
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1542,6 +1561,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
@@ -1762,6 +1782,21 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
@@ -2316,6 +2351,38 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -2350,6 +2417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
|
||||
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
|
||||
"license": "Zlib",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.168.0 < 0.186.0"
|
||||
}
|
||||
@@ -2375,6 +2443,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2384,6 +2453,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -2602,7 +2672,8 @@
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:media": "playwright test tests/media.spec.ts tests/media-metadata.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-navigation-menu": "^1.2.22",
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.6.1",
|
||||
"@react-three/postprocessing": "3.0.4",
|
||||
"@sparkjsdev/spark": "0.1.10",
|
||||
"gsap": "3.15.0",
|
||||
"lenis": "1.3.23",
|
||||
"lucide-react": "^1.31.0",
|
||||
@@ -27,6 +29,7 @@
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 15_000 },
|
||||
workers: 1,
|
||||
use: {
|
||||
// Use installed Google Chrome; no Playwright browser download is needed.
|
||||
channel: "chrome",
|
||||
baseURL: "http://localhost:4202",
|
||||
viewport: { width: 1440, height: 960 },
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
trace: "retain-on-failure",
|
||||
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: "node tests/fixtures/api-server.mjs",
|
||||
url: "http://127.0.0.1:4203/health",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
{
|
||||
command: "npm run dev -- --hostname localhost --port 4202",
|
||||
url: "http://localhost:4202/media/m_fixture_image",
|
||||
// Server-rendered social metadata also needs fixtures. Never reuse a
|
||||
// developer's server, which could still be configured for production.
|
||||
env: { NEXT_PUBLIC_API_HOST: "http://127.0.0.1:4203" },
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
|
||||
import { mediaTitle } from "@/lib/media";
|
||||
import { mediaEmbed, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
|
||||
|
||||
const HEADERS = { "Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow" };
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const query = new URL(request.url).searchParams;
|
||||
if (query.has("format") && query.get("format") !== "json") {
|
||||
return Response.json({ error: "Only JSON is supported" }, { status: 501, headers: HEADERS });
|
||||
}
|
||||
if (query.get("url") !== `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}`) {
|
||||
return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
|
||||
}
|
||||
const media = await fetchPublicMediaFile(token);
|
||||
const embed = media && mediaEmbed(media);
|
||||
if (!media || !embed) return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
|
||||
const playerUrl = `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}/player`;
|
||||
const scale = Math.min(1, maximum(query.get("maxwidth")) / embed.width, maximum(query.get("maxheight")) / embed.height);
|
||||
const width = Math.max(1, Math.floor(embed.width * scale));
|
||||
const height = Math.max(1, Math.floor(embed.height * scale));
|
||||
return Response.json({
|
||||
version: "1.0",
|
||||
type: embed.kind === "video" ? "video" : "rich",
|
||||
provider_name: "ArtCraft",
|
||||
provider_url: MEDIA_SHARE_ORIGIN,
|
||||
title: mediaTitle(media),
|
||||
author_name: media.maybe_creator_user?.display_name || media.maybe_creator_user?.username,
|
||||
width,
|
||||
height,
|
||||
html: `<iframe src="${playerUrl}" width="${width}" height="${height}" title="ArtCraft media player" frameborder="0" allow="fullscreen" allowfullscreen></iframe>`,
|
||||
}, { headers: HEADERS });
|
||||
}
|
||||
|
||||
function maximum(value: string | null): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Infinity;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from "next";
|
||||
import MediaPage from "@/components/media/media-page";
|
||||
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
|
||||
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
|
||||
import { mediaTitle } from "@/lib/media";
|
||||
|
||||
const FALLBACK_IMAGE = { url: "/images/og-image.png", width: 1200, height: 630, alt: "ArtCraft" };
|
||||
|
||||
type Props = { params: Promise<{ token: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { token } = await params;
|
||||
const url = `/media/${encodeURIComponent(token)}`;
|
||||
const fallbackTitle = "Shared media";
|
||||
const fallbackDescription = "View images, video, music, and interactive 3D creations shared with ArtCraft.";
|
||||
const metadata: Metadata = {
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
alternates: { canonical: url },
|
||||
// A shared link is not a request to index a user's creation.
|
||||
robots: { index: false, follow: true },
|
||||
openGraph: {
|
||||
type: "website", siteName: "ArtCraft", url,
|
||||
title: `${fallbackTitle} — ArtCraft`, description: fallbackDescription,
|
||||
images: [FALLBACK_IMAGE],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image", title: `${fallbackTitle} — ArtCraft`,
|
||||
description: fallbackDescription, images: [FALLBACK_IMAGE],
|
||||
},
|
||||
};
|
||||
const media = await fetchPublicMediaFile(token);
|
||||
if (!media) return metadata;
|
||||
const title = mediaTitle(media);
|
||||
const image = mediaShareImage(media);
|
||||
const embed = mediaEmbed(media);
|
||||
const images = image ? [{ url: image, alt: title }] : [FALLBACK_IMAGE];
|
||||
const creator = media.maybe_creator_user;
|
||||
const description = creator
|
||||
? `Made with ArtCraft by ${creator.display_name || creator.username}.`
|
||||
: "Made with ArtCraft.";
|
||||
return {
|
||||
...metadata,
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
type: embed?.kind === "video" ? "video.other" : "website",
|
||||
...(embed?.kind === "video" && { videos: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
|
||||
...(embed?.kind === "audio" && { audio: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
|
||||
siteName: "ArtCraft",
|
||||
title: `${title} — ArtCraft`,
|
||||
description,
|
||||
url,
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
...(embed ? { card: "player" as const, players: [] } : { card: "summary_large_image" as const }),
|
||||
title: `${title} — ArtCraft`,
|
||||
description,
|
||||
images,
|
||||
},
|
||||
...(embed && {
|
||||
alternates: { canonical: url, types: { "application/json+oembed": `${MEDIA_SHARE_ORIGIN}${url}/oembed?url=${encodeURIComponent(`${MEDIA_SHARE_ORIGIN}${url}`)}&format=json` } },
|
||||
// Next's player descriptor requires a stream URL, while X's optional
|
||||
// native stream must be MP4. Audio/WebM use the HTML player instead.
|
||||
other: {
|
||||
"twitter:player": `${MEDIA_SHARE_ORIGIN}${url}/player`,
|
||||
"twitter:player:width": embed.width,
|
||||
"twitter:player:height": embed.height,
|
||||
...(embed.contentType === "video/mp4" && {
|
||||
"twitter:player:stream": embed.url,
|
||||
"twitter:player:stream:content_type": embed.contentType,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Media({ params }: Props) {
|
||||
const { token } = await params;
|
||||
// Resolve in the browser, using the same session credentials as the Vite
|
||||
// viewer. Never cache one visitor's private media response in server HTML.
|
||||
return <MediaPage key={token} token={token} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
|
||||
import { mediaTitle } from "@/lib/media";
|
||||
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
|
||||
|
||||
const HEADERS = {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src https:; media-src https:; base-uri 'none'; form-action 'none'",
|
||||
};
|
||||
|
||||
// A standalone HTML document bypasses the site layout and needs no scripts.
|
||||
// Platforms that support iframe Player Cards can use native media controls.
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const media = await fetchPublicMediaFile(token);
|
||||
const embed = media && mediaEmbed(media);
|
||||
if (!media || !embed) return new Response("Media unavailable", { status: 404, headers: HEADERS });
|
||||
const title = escapeHtml(mediaTitle(media));
|
||||
const poster = escapeHtml(mediaShareImage(media) ?? `${MEDIA_SHARE_ORIGIN}/images/og-image.png`);
|
||||
const source = `<source src="${escapeHtml(embed.url)}" type="${embed.contentType}">`;
|
||||
const player = embed.kind === "video"
|
||||
? `<video controls playsinline preload="metadata" poster="${poster}" aria-label="${title}">${source}</video>`
|
||||
: `<div class="audio"><img src="${poster}" alt=""><div><p>${title}</p><audio controls preload="metadata" aria-label="${title}">${source}</audio></div></div>`;
|
||||
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} — ArtCraft</title><style>
|
||||
:root{color-scheme:light dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:14px system-ui;background:light-dark(#f2f1ee,#121316);color:light-dark(#222,#eee)}
|
||||
video{display:block;width:100%;height:100%;object-fit:contain;background:#000}.audio{display:flex;align-items:center;gap:16px;padding:16px;height:100%}.audio img{width:80px;height:80px;object-fit:cover}.audio div{min-width:0;flex:1}p{margin:0 0 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}audio{width:100%}@media(max-width:360px){.audio img{display:none}}
|
||||
</style></head><body>${player}</body></html>`, { headers: HEADERS });
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.range {
|
||||
appearance: none;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, var(--accent-ink) var(--progress), var(--line-strong) var(--progress));
|
||||
}
|
||||
|
||||
.range::-moz-range-track {
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, var(--accent-ink) var(--progress), var(--line-strong) var(--progress));
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4.5px;
|
||||
border: 2px solid var(--bg-raised);
|
||||
border-radius: 0;
|
||||
background: var(--accent-ink);
|
||||
box-shadow: 0 0 0 1px var(--accent-ink);
|
||||
}
|
||||
|
||||
.range::-moz-range-thumb {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 2px solid var(--bg-raised);
|
||||
border-radius: 0;
|
||||
background: var(--accent-ink);
|
||||
box-shadow: 0 0 0 1px var(--accent-ink);
|
||||
}
|
||||
|
||||
.range:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.range:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Music2Icon, PauseIcon, PlayIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import styles from "./audio-player.module.css";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
|
||||
export default function AudioPlayer({ src, title, onError }: {
|
||||
src: string; title: string; onError: () => void;
|
||||
}) {
|
||||
const audio = useRef<HTMLAudioElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const audioGraph = useRef<{ context: AudioContext; source: MediaElementAudioSourceNode; analyser: AnalyserNode } | null>(null);
|
||||
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
|
||||
useEffect(() => () => {
|
||||
const graph = audioGraph.current;
|
||||
graph?.source.disconnect();
|
||||
graph?.analyser.disconnect();
|
||||
if (graph) void graph.context.close();
|
||||
}, []);
|
||||
|
||||
async function togglePlayback() {
|
||||
const element = audio.current;
|
||||
if (!element) return;
|
||||
setMessage("");
|
||||
if (!element.paused) { element.pause(); return; }
|
||||
// Initialize only on the user's play gesture so browser audio policies are
|
||||
// honored. Native playback still works when Web Audio isn't available.
|
||||
if (!audioGraph.current && typeof AudioContext !== "undefined") {
|
||||
try {
|
||||
const context = new AudioContext();
|
||||
const source = context.createMediaElementSource(element);
|
||||
const node = context.createAnalyser();
|
||||
node.fftSize = 2048;
|
||||
source.connect(node);
|
||||
node.connect(context.destination);
|
||||
audioGraph.current = { context, source, analyser: node };
|
||||
setAnalyser(node);
|
||||
} catch { /* The native player can continue without a waveform. */ }
|
||||
}
|
||||
if (audioGraph.current) await audioGraph.current.context.resume().catch(() => {});
|
||||
try { await element.play(); }
|
||||
catch { setMessage("Playback could not start. Press play to try again."); }
|
||||
}
|
||||
|
||||
function seek(value: number) {
|
||||
if (!audio.current || !duration) return;
|
||||
audio.current.currentTime = value;
|
||||
setPosition(value);
|
||||
}
|
||||
|
||||
function changeVolume(value: number) {
|
||||
if (!audio.current) return;
|
||||
audio.current.volume = value;
|
||||
audio.current.muted = false;
|
||||
setVolume(value);
|
||||
setMuted(false);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!audio.current) return;
|
||||
const next = !(muted || volume === 0);
|
||||
audio.current.muted = next;
|
||||
if (!next && volume === 0) { audio.current.volume = 1; setVolume(1); }
|
||||
setMuted(next);
|
||||
}
|
||||
|
||||
const silent = muted || volume === 0;
|
||||
return (
|
||||
<div role="group" aria-label="Audio player" className="w-full max-w-xl px-6 py-12 sm:px-10">
|
||||
<audio ref={audio} src={src} crossOrigin="anonymous" preload="metadata" className="hidden" onError={onError}
|
||||
onLoadedMetadata={() => setDuration(Number.isFinite(audio.current?.duration) ? audio.current!.duration : 0)}
|
||||
onDurationChange={() => setDuration(Number.isFinite(audio.current?.duration) ? audio.current!.duration : 0)}
|
||||
onTimeUpdate={() => setPosition(audio.current?.currentTime ?? 0)}
|
||||
onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
||||
|
||||
<div className="border border-line-strong bg-bg-raised">
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<span className="hud-label text-muted">Audio / Original</span>
|
||||
<span className="hud-label flex items-center gap-2 text-accent-ink">
|
||||
<span aria-hidden className={`h-1.5 w-1.5 ${playing ? "bg-accent" : "border border-line-strong"}`} />
|
||||
{playing ? "Playing" : "Ready to play"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center px-6 py-10 sm:py-14">
|
||||
<div className="relative mb-8 flex h-32 w-32 items-center justify-center border border-line">
|
||||
<span aria-hidden className="tick -left-[5px] -top-[5px]" />
|
||||
<span aria-hidden className="tick -bottom-[5px] -right-[5px]" />
|
||||
<div className="flex h-24 w-24 items-center justify-center border border-line bg-bg-sunken">
|
||||
<Music2Icon aria-hidden className="h-10 w-10 text-accent-ink" strokeWidth={1} />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="w-full break-words text-center font-display text-xl leading-snug text-ink-strong">{title}</h2>
|
||||
<p className="hud-label mt-3 text-faint">Shared with ArtCraft</p>
|
||||
</div>
|
||||
|
||||
<AudioWaveform analyser={analyser} playing={playing} />
|
||||
<div className="p-5 sm:p-6">
|
||||
<input type="range" aria-label="Seek audio" aria-valuetext={`${timeLabel(position)} of ${timeLabel(duration)}`}
|
||||
min={0} max={duration || 1} step={0.01} value={position} disabled={!duration}
|
||||
onChange={(event) => seek(Number(event.target.value))}
|
||||
style={{ "--progress": `${duration ? position / duration * 100 : 0}%` } as CSSProperties}
|
||||
className={`${styles.range} w-full`} />
|
||||
<div className="hud-label mt-2 flex justify-between text-muted">
|
||||
<output aria-label="Elapsed time">{timeLabel(position)}</output>
|
||||
<span aria-label="Track duration">{timeLabel(duration)}</span>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<button type="button" aria-label={playing ? "Pause audio" : "Play audio"} onClick={togglePlayback}
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center bg-invert-bg text-invert-fg transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-ink">
|
||||
{playing ? <PauseIcon aria-hidden className="h-5 w-5" fill="currentColor" /> : <PlayIcon aria-hidden className="h-5 w-5" fill="currentColor" />}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" aria-label={silent ? "Unmute audio" : "Mute audio"} onClick={toggleMute}
|
||||
className="flex h-10 w-10 items-center justify-center text-muted hover:text-ink focus-visible:outline-2 focus-visible:outline-accent-ink">
|
||||
{silent ? <VolumeXIcon aria-hidden className="h-5 w-5" /> : <Volume2Icon aria-hidden className="h-5 w-5" />}
|
||||
</button>
|
||||
<input type="range" aria-label="Audio volume" aria-valuetext={`${Math.round((silent ? 0 : volume) * 100)}%`}
|
||||
min={0} max={1} step={0.01} value={silent ? 0 : volume} onChange={(event) => changeVolume(Number(event.target.value))}
|
||||
style={{ "--progress": `${(silent ? 0 : volume) * 100}%` } as CSSProperties} className={`${styles.range} w-20 sm:w-24`} />
|
||||
</div>
|
||||
</div>
|
||||
{message && <p role="status" className="mt-4 text-sm text-muted">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeLabel(seconds: number): string {
|
||||
const value = Math.max(0, Math.floor(seconds));
|
||||
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function AudioWaveform({ analyser, playing }: { analyser: AnalyserNode | null; playing: boolean }) {
|
||||
const line = useRef<SVGPathElement>(null);
|
||||
useEffect(() => {
|
||||
if (!analyser || !playing) return;
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
let frame = 0;
|
||||
// A live oscilloscope: actual audio samples, never decorative random bars.
|
||||
// Motion-sensitive visitors retain the static baseline and all controls.
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
function draw() {
|
||||
analyser!.getFloatTimeDomainData(samples);
|
||||
const points = Array.from({ length: 160 }, (_, index) => {
|
||||
const amplitude = samples[Math.floor(index / 160 * samples.length)];
|
||||
return `${index ? "L" : "M"}${index / 159 * 640},${36 - Math.max(-1, Math.min(1, amplitude)) * 30}`;
|
||||
});
|
||||
line.current?.setAttribute("d", points.join(" "));
|
||||
frame = requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [analyser, playing]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full border-y border-line bg-bg-sunken px-5 py-3" aria-label="Live audio waveform">
|
||||
<svg viewBox="0 0 640 72" className="h-16 w-full text-accent-ink" preserveAspectRatio="none" aria-hidden>
|
||||
<path d="M0,36 L640,36" stroke="var(--line)" strokeWidth="1" />
|
||||
<path ref={line} d="M0,36 L640,36" fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
|
||||
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
|
||||
|
||||
const COPY_FEEDBACK_MS = 1500;
|
||||
const SHARE_URL_BASE = "https://getartcraft.com/media/";
|
||||
|
||||
export default function MediaDetails({ media, prompt, promptLoading, dimensions }: {
|
||||
media: SharedMedia; prompt: MediaPrompt | null; promptLoading: boolean; dimensions: MediaDimensions | null;
|
||||
}) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const downloadController = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => downloadController.current?.abort(), []);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timeout = setTimeout(() => setCopied(null), COPY_FEEDBACK_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(label);
|
||||
setMessage("");
|
||||
} catch {
|
||||
setMessage("Copy is unavailable in this browser.");
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
setDownloading(true);
|
||||
setMessage("");
|
||||
const controller = new AbortController();
|
||||
downloadController.current = controller;
|
||||
try {
|
||||
const url = new URL(corsMediaUrl(media.media_links.cdn_url));
|
||||
url.searchParams.set("dl", "1");
|
||||
const response = await fetch(url, { credentials: "omit", signal: controller.signal });
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) return;
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `${media.token}.${mediaFormat(media) || "bin"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
// Give the browser time to start consuming the download before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setMessage("Download failed. Use Open original to save the file directly.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const kind = mediaKind(media);
|
||||
const created = media.created_at ? new Date(media.created_at) : null;
|
||||
const details: [string, ReactNode][] = [
|
||||
["Type", MEDIA_LABELS[kind]],
|
||||
["Format", mediaFormat(media).toUpperCase()],
|
||||
["Model", media.maybe_model_weight_info?.title || (prompt?.maybe_model_type && <BrandValue
|
||||
icon={getModelIcon(prompt.maybe_model_type)} label={getModelDisplayName(prompt.maybe_model_type)} />)],
|
||||
["Provider", prompt?.maybe_generation_provider && <BrandValue
|
||||
icon={getProviderIcon(prompt.maybe_generation_provider)} label={getProviderDisplayName(prompt.maybe_generation_provider)} />],
|
||||
["Aspect ratio", mediaDetailLabel(prompt?.maybe_aspect_ratio)],
|
||||
["Resolution", mediaDetailLabel(prompt?.maybe_resolution)],
|
||||
["Duration", media.maybe_duration_millis != null ? `${(media.maybe_duration_millis / 1000).toFixed(1)} sec` : prompt?.maybe_duration_seconds != null ? `${prompt.maybe_duration_seconds} sec` : null],
|
||||
["Audio", prompt?.maybe_generate_audio == null ? null : prompt.maybe_generate_audio ? "On" : "Off"],
|
||||
["Size", dimensions ? `${dimensions.width} × ${dimensions.height}` : null],
|
||||
["Created", created && !Number.isNaN(created.valueOf()) ? <time dateTime={media.created_at} className="flex flex-col items-end gap-0.5 tabular-nums">
|
||||
<span>{created.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}</span>
|
||||
<span className="text-xs text-muted">{created.toLocaleTimeString()}</span>
|
||||
</time> : null],
|
||||
];
|
||||
|
||||
return (
|
||||
<aside aria-label="Media details" className="min-w-0 border-t border-line bg-bg lg:border-l lg:border-t-0">
|
||||
<div className="border-b border-line p-6">
|
||||
<p className="hud-label mb-5 text-faint">Creation details</p>
|
||||
<dl className="space-y-3 text-sm">
|
||||
{details.filter((entry) => entry[1]).map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between gap-4">
|
||||
<dt className="shrink-0 text-muted">{label}</dt>
|
||||
<dd className="break-words text-right text-ink">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{media.maybe_prompt_token && (
|
||||
<div className="border-b border-line p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<h2 className="hud-label text-faint">Prompt</h2>
|
||||
{prompt?.maybe_positive_prompt && (
|
||||
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
|
||||
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
|
||||
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">
|
||||
{promptLoading ? "Loading prompt…" : prompt?.maybe_positive_prompt || "Prompt details aren’t available for this creation."}
|
||||
</p>
|
||||
{prompt?.maybe_negative_prompt && (
|
||||
<details className="mt-4 text-sm text-muted">
|
||||
<summary className="cursor-pointer">Negative prompt</summary>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words">{prompt.maybe_negative_prompt}</p>
|
||||
</details>
|
||||
)}
|
||||
{!!prompt?.maybe_context_images?.length && (
|
||||
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="Reference images">
|
||||
{prompt.maybe_context_images.map((image, index) => {
|
||||
const src = mediaThumbnail(image.media_links, 256) ?? safeMediaUrl(image.media_links.cdn_url);
|
||||
return src ? (
|
||||
<Link key={`${image.media_token}-${index}`} href={`/media/${encodeURIComponent(image.media_token)}`} className="border border-line">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={corsMediaUrl(src)} alt={image.semantic || `Reference ${index + 1}`} loading="lazy" className="aspect-square w-full object-cover" />
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.maybe_text_transcript && (
|
||||
<div className="border-b border-line p-6">
|
||||
<h2 className="hud-label mb-4 text-faint">Transcript / lyrics</h2>
|
||||
<p className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">{media.maybe_text_transcript}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
<Button onClick={download} loading={downloading} className="w-full">
|
||||
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
|
||||
</Button>
|
||||
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
|
||||
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
|
||||
{copied === "link" ? "Link copied" : "Copy share link"}
|
||||
</Button>
|
||||
<a href={media.media_links.cdn_url} target="_blank" rel="noopener noreferrer" className="hud-label flex items-center justify-center gap-2 py-2 text-muted hover:text-ink">
|
||||
Open original <ExternalLinkIcon aria-hidden className="h-3 w-3" />
|
||||
</a>
|
||||
<p role="status" className="text-sm text-muted">{message}</p>
|
||||
<div className="mt-3 border-t border-line pt-5">
|
||||
<p className="mb-4 text-sm leading-relaxed text-muted">Make something of your own. Images, films, and worlds — all in ArtCraft.</p>
|
||||
<Button href="/download" variant="secondary" className="w-full">Get ArtCraft</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandValue({ icon, label }: { icon: string | null; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{icon && <img src={icon} alt="" className="themed-logo h-4 w-4" />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUpRightIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent } from "@/components/page/page-header";
|
||||
import { Button } from "@/components/ui";
|
||||
import { getMediaPrompt, getSharedMedia } from "@/lib/media-api";
|
||||
import { MEDIA_LABELS, mediaKind, mediaTitle, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
|
||||
import { webappUrl } from "@/lib/links";
|
||||
import MediaPreview from "./media-preview";
|
||||
import MediaDetails from "./media-details";
|
||||
|
||||
type MediaState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; media: SharedMedia }
|
||||
| { status: "error"; code?: number };
|
||||
|
||||
export default function MediaPage({ token }: { token: string }) {
|
||||
const [state, setState] = useState<MediaState>({ status: "loading" });
|
||||
const [prompt, setPrompt] = useState<MediaPrompt | null>(null);
|
||||
const [promptLoading, setPromptLoading] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [dimensions, setDimensions] = useState<MediaDimensions | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState({ status: "loading" });
|
||||
setPrompt(null);
|
||||
setPromptLoading(false);
|
||||
setDimensions(null);
|
||||
async function load() {
|
||||
const result = await getSharedMedia(token, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
if (!result.success) {
|
||||
setState({ status: "error", code: result.status });
|
||||
return;
|
||||
}
|
||||
setState({ status: "ready", media: result.data });
|
||||
if (result.data.maybe_prompt_token) {
|
||||
setPromptLoading(true);
|
||||
const resultPrompt = await getMediaPrompt(result.data.maybe_prompt_token, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setPrompt(resultPrompt.success ? resultPrompt.data : null);
|
||||
setPromptLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => controller.abort();
|
||||
}, [token, attempt]);
|
||||
|
||||
const media = state.status === "ready" ? state.media : null;
|
||||
const kind = media ? mediaKind(media) : null;
|
||||
const title = media ? mediaTitle(media) : "Shared creation";
|
||||
|
||||
return (
|
||||
<SectionShell className="min-h-[70vh] overflow-x-clip md:overflow-x-visible">
|
||||
<SectionEyebrow index="01" label="Shared creation" annotation={kind ? MEDIA_LABELS[kind] : "Made with ArtCraft"} />
|
||||
<header className="flex flex-col items-start gap-6 px-6 py-8 sm:flex-row sm:items-end sm:justify-between md:px-10 md:py-10">
|
||||
<div className="w-full min-w-0 flex-1 sm:w-auto">
|
||||
<p className="hud-label mb-3 text-faint">The artist’s viewport</p>
|
||||
<h1 className="break-words font-display text-3xl font-medium leading-tight tracking-tight text-ink-strong sm:text-4xl">
|
||||
{media ? title : <>Shared <Accent>creation</Accent>.</>}
|
||||
</h1>
|
||||
{media?.maybe_creator_user && (
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
By <span className="text-ink">{media.maybe_creator_user.display_name || media.maybe_creator_user.username}</span>
|
||||
<span className="ml-2 text-faint">@{media.maybe_creator_user.username}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button href={webappUrl(`/media/${encodeURIComponent(token)}`)} variant="secondary">
|
||||
Open in ArtCraft <ArrowUpRightIcon aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<div role="status" className="flex min-h-[50vh] flex-col items-center justify-center gap-4 border-t border-line bg-bg-sunken text-muted">
|
||||
<LoaderCircleIcon aria-hidden className="h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<span className="hud-label">Loading media</span>
|
||||
</div>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<div role="alert" className="flex min-h-[45vh] flex-col items-center justify-center gap-5 border-t border-line px-6 py-16 text-center">
|
||||
<h2 className="font-display text-2xl text-ink-strong">
|
||||
{state.code === 404 ? "Media not found" : state.code === 401 || state.code === 403 ? "This media is private" : "Unable to load this media"}
|
||||
</h2>
|
||||
<p className="max-w-md text-muted">
|
||||
{state.code === 404 ? "This link may have been removed or is no longer available."
|
||||
: state.code === 401 || state.code === 403 ? "Open ArtCraft and sign in to an account with access."
|
||||
: "The media could not be reached. Please try again."}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={() => setAttempt((value) => value + 1)}>Try again</Button>
|
||||
</div>
|
||||
)}
|
||||
{media && (
|
||||
<div className="grid border-t border-line lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<MediaPreview key={media.token} media={media} onDimensions={setDimensions} />
|
||||
<MediaDetails media={media} prompt={prompt} promptLoading={promptLoading} dimensions={dimensions} />
|
||||
</div>
|
||||
)}
|
||||
<noscript>
|
||||
<p className="px-6 py-12 text-muted">Enable JavaScript to load this shared media, or open it in ArtCraft using the link above.</p>
|
||||
</noscript>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { FileIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { corsMediaUrl, mediaFormat, mediaKind, mediaPoster, mediaTitle, type MediaDimensions, type SharedMedia } from "@/lib/media";
|
||||
import AudioPlayer from "./audio-player";
|
||||
|
||||
// WebGL and Spark are only loaded when someone opens a 3D creation.
|
||||
const MediaViewer3D = dynamic(() => import("./media-viewer-3d"), {
|
||||
ssr: false,
|
||||
loading: () => <PreviewLoading label="Loading 3D viewer" />,
|
||||
});
|
||||
|
||||
export default function MediaPreview({ media, onDimensions }: {
|
||||
media: SharedMedia; onDimensions: (dimensions: MediaDimensions) => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const kind = mediaKind(media);
|
||||
const url = corsMediaUrl(media.media_links.cdn_url);
|
||||
const title = mediaTitle(media);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[360px] min-w-0 items-center justify-center bg-bg-sunken lg:min-h-[620px]" data-media-kind={kind}>
|
||||
{failed ? (
|
||||
<div role="alert" className="flex flex-col items-center gap-4 p-10 text-center">
|
||||
<p className="text-muted">This browser could not display the file.</p>
|
||||
<Button href={media.media_links.cdn_url} target="_blank" rel="noopener noreferrer" variant="secondary">Open original</Button>
|
||||
</div>
|
||||
) : kind === "mesh" || kind === "splat" ? (
|
||||
<MediaViewer3D url={url} kind={kind} format={mediaFormat(media)} />
|
||||
) : kind === "image" ? (
|
||||
<>
|
||||
{/* User media comes from the API; preserve the original resolution. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt={title} onLoad={(event) => {
|
||||
setLoaded(true);
|
||||
onDimensions({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
|
||||
}} onError={() => setFailed(true)}
|
||||
className="max-h-[80svh] w-full object-contain" />
|
||||
{!loaded && <PreviewLoading label="Loading image" />}
|
||||
</>
|
||||
) : kind === "video" ? (
|
||||
<video src={url} aria-label={title} controls playsInline preload="metadata" poster={mediaPoster(media)}
|
||||
onLoadedMetadata={(event) => onDimensions({ width: event.currentTarget.videoWidth, height: event.currentTarget.videoHeight })}
|
||||
onError={() => setFailed(true)} className="max-h-[80svh] w-full" />
|
||||
) : kind === "audio" ? (
|
||||
<AudioPlayer src={url} title={title} onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 p-10 text-center text-muted">
|
||||
<FileIcon aria-hidden className="h-10 w-10" strokeWidth={1} />
|
||||
<p>A browser preview isn’t available for this file. You can download the original or open it in ArtCraft.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewLoading({ label }: { label: string }) {
|
||||
return (
|
||||
<div role="status" className="absolute inset-0 flex items-center justify-center gap-3 bg-bg-sunken/90 text-muted">
|
||||
<LoaderCircleIcon aria-hidden className="h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
<span className="hud-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, LoaderCircleIcon, MinusIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { SplatMesh } from "@sparkjsdev/spark";
|
||||
|
||||
export default function MediaViewer3D({ url, kind, format }: {
|
||||
url: string; kind: "mesh" | "splat"; format: string;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const resetView = useRef(() => {});
|
||||
const frameView = useRef(() => {});
|
||||
const zoomView = useRef((_factor: number) => {});
|
||||
const rotateView = useRef((_angle: number) => {});
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const element = container.current;
|
||||
if (!element) return;
|
||||
let cancelled = false;
|
||||
let model: THREE.Object3D | null = null;
|
||||
let splat: SplatMesh | null = null;
|
||||
let splatDisposed = false;
|
||||
let mixer: THREE.AnimationMixer | null = null;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: kind === "mesh", alpha: true });
|
||||
} catch {
|
||||
setError("Interactive 3D needs WebGL. You can still download the file or open it in ArtCraft.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.domElement.setAttribute("aria-label", kind === "splat" ? "Interactive 3D splat" : "Interactive 3D mesh");
|
||||
renderer.domElement.tabIndex = 0;
|
||||
element.appendChild(renderer.domElement);
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 10000);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.listenToKeyEvents(renderer.domElement);
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b7280, 2));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 3);
|
||||
key.position.set(4, 8, 6);
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xd6e7ff, 1.5);
|
||||
fill.position.set(-4, 2, -3);
|
||||
scene.add(fill);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
|
||||
|
||||
function disposeSplat() {
|
||||
if (!splat || splatDisposed) return;
|
||||
splatDisposed = true;
|
||||
splat.dispose();
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const width = Math.max(element!.clientWidth, 1);
|
||||
const height = Math.max(element!.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
}
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
resize();
|
||||
|
||||
function failed(message: string) {
|
||||
if (cancelled) return;
|
||||
renderer.setAnimationLoop(null);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
}
|
||||
function lost(event: Event) {
|
||||
event.preventDefault();
|
||||
failed("The 3D viewer lost its graphics connection. Reload this page to try again.");
|
||||
}
|
||||
renderer.domElement.addEventListener("webglcontextlost", lost);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
let bounds: THREE.Box3;
|
||||
if (kind === "splat") {
|
||||
const { SplatMesh, SplatFileType } = await import("@sparkjsdev/spark");
|
||||
if (cancelled) return;
|
||||
const fileType = Object.values(SplatFileType).find((value) => value === format);
|
||||
const loaded = new SplatMesh({ url, fileType });
|
||||
// Keep ownership across await so failed decodes also release resources.
|
||||
splat = loaded;
|
||||
await loaded.initialized;
|
||||
if (cancelled) { disposeSplat(); return; }
|
||||
// Match the coordinate convention used by the Vite viewer.
|
||||
loaded.rotation.z = Math.PI;
|
||||
loaded.updateMatrixWorld(true);
|
||||
bounds = loaded.getBoundingBox().applyMatrix4(loaded.matrixWorld);
|
||||
model = loaded;
|
||||
} else {
|
||||
const loaded = await loadMesh(url, format);
|
||||
if (cancelled) { disposeModel(loaded.model); return; }
|
||||
model = loaded.model;
|
||||
bounds = new THREE.Box3().setFromObject(model);
|
||||
if (bounds.isEmpty()) {
|
||||
// Skeleton-only GLBs still need a visible rig and finite framing.
|
||||
model.updateMatrixWorld(true);
|
||||
model.traverse((node) => bounds.expandByPoint(node.getWorldPosition(new THREE.Vector3())));
|
||||
scene.add(new THREE.SkeletonHelper(model));
|
||||
}
|
||||
if (loaded.animations.length) {
|
||||
mixer = new THREE.AnimationMixer(model);
|
||||
mixer.clipAction(loaded.animations[0]).play();
|
||||
}
|
||||
}
|
||||
scene.add(model);
|
||||
const center = bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
const radius = Math.max(size.length() / 2, 0.5);
|
||||
frameView.current = () => {
|
||||
const halfFov = THREE.MathUtils.degToRad(camera.fov / 2);
|
||||
const distance = radius / Math.sin(Math.atan(Math.tan(halfFov) * Math.min(camera.aspect, 1))) * 1.15;
|
||||
camera.near = Math.max(radius / 1000, 0.001);
|
||||
camera.far = Math.max(radius * 100, 1000);
|
||||
camera.position.copy(center).add(new THREE.Vector3(1, 0.5, 1).normalize().multiplyScalar(distance));
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.copy(center);
|
||||
controls.maxDistance = radius * 50;
|
||||
controls.update();
|
||||
};
|
||||
resetView.current = () => {
|
||||
frameView.current();
|
||||
if (kind === "splat") {
|
||||
// World captures are meant to be viewed from their capture origin.
|
||||
// Framing their outer bounds shows the back of the environment.
|
||||
// Preserve the Vite viewer's initial orbit near that origin; the
|
||||
// separate Frame all action still works for object-sized splats.
|
||||
camera.near = 0.01;
|
||||
camera.position.set(0.85, 0, 0.85);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
};
|
||||
resetView.current();
|
||||
zoomView.current = (factor) => {
|
||||
const offset = camera.position.clone().sub(controls.target);
|
||||
offset.multiplyScalar(factor).clampLength(camera.near * 2, controls.maxDistance);
|
||||
camera.position.copy(controls.target).add(offset);
|
||||
controls.update();
|
||||
};
|
||||
rotateView.current = (angle) => {
|
||||
const offset = camera.position.clone().sub(controls.target).applyAxisAngle(camera.up, angle);
|
||||
camera.position.copy(controls.target).add(offset);
|
||||
controls.update();
|
||||
};
|
||||
let previous = performance.now();
|
||||
renderer.setAnimationLoop(() => {
|
||||
try {
|
||||
const now = performance.now();
|
||||
mixer?.update(Math.min((now - previous) / 1000, 0.1));
|
||||
previous = now;
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
} catch {
|
||||
failed("This file could not be rendered by your browser. The original is available to download.");
|
||||
}
|
||||
});
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
disposeSplat();
|
||||
failed("This 3D file could not be loaded. Download the original or open it in ArtCraft.");
|
||||
}
|
||||
}
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
renderer.setAnimationLoop(null);
|
||||
renderer.domElement.removeEventListener("webglcontextlost", lost);
|
||||
controls.dispose();
|
||||
mixer?.stopAllAction();
|
||||
if (model && mixer) mixer.uncacheRoot(model);
|
||||
// An in-flight splat is disposed after its decode finishes above.
|
||||
if (splat?.isInitialized) disposeSplat();
|
||||
disposeModel(scene);
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, [url, kind, format]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[60svh] min-h-[360px] w-full lg:h-[700px]" data-lenis-prevent>
|
||||
<div ref={container} className="absolute inset-0 touch-none" />
|
||||
{status === "loading" && (
|
||||
<div role="status" className="absolute inset-0 flex items-center justify-center gap-3 bg-bg-sunken/90 text-muted">
|
||||
<LoaderCircleIcon aria-hidden className="h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
<span className="hud-label">Loading 3D {kind === "splat" ? "splat" : "mesh"}</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <p role="alert" className="absolute inset-0 flex items-center justify-center bg-bg-sunken p-10 text-center text-muted">{error}</p>}
|
||||
{status === "ready" && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-center justify-between gap-3 border-t border-line bg-bg/90 p-4 backdrop-blur-md">
|
||||
<p className="hud-label text-muted">Drag to orbit · Scroll / pinch to zoom · Right-drag to pan</p>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="3D view controls">
|
||||
<Button variant="secondary" size="sm" aria-label="Rotate left" onClick={() => rotateView.current(-Math.PI / 8)}><ArrowLeftIcon aria-hidden className="h-3 w-3" /></Button>
|
||||
<Button variant="secondary" size="sm" aria-label="Rotate right" onClick={() => rotateView.current(Math.PI / 8)}><ArrowRightIcon aria-hidden className="h-3 w-3" /></Button>
|
||||
<Button variant="secondary" size="sm" aria-label="Zoom in" onClick={() => zoomView.current(0.8)}><PlusIcon aria-hidden className="h-3 w-3" /></Button>
|
||||
<Button variant="secondary" size="sm" aria-label="Zoom out" onClick={() => zoomView.current(1.25)}><MinusIcon aria-hidden className="h-3 w-3" /></Button>
|
||||
{kind === "splat" && <Button variant="secondary" size="sm" onClick={() => frameView.current()}>Frame all</Button>}
|
||||
<Button variant="secondary" size="sm" onClick={() => resetView.current()}><RotateCcwIcon aria-hidden className="h-3 w-3" />Reset view</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMesh(url: string, format: string): Promise<{ model: THREE.Object3D; animations: THREE.AnimationClip[] }> {
|
||||
if (format === "fbx") {
|
||||
const { FBXLoader } = await import("three/examples/jsm/loaders/FBXLoader.js");
|
||||
const model = await new FBXLoader().loadAsync(url);
|
||||
return { model, animations: model.animations };
|
||||
}
|
||||
if (format === "obj") {
|
||||
const { OBJLoader } = await import("three/examples/jsm/loaders/OBJLoader.js");
|
||||
return { model: await new OBJLoader().loadAsync(url), animations: [] };
|
||||
}
|
||||
if (format === "ply" || format === "stl") {
|
||||
const geometry = format === "ply"
|
||||
? await new (await import("three/examples/jsm/loaders/PLYLoader.js")).PLYLoader().loadAsync(url)
|
||||
: await new (await import("three/examples/jsm/loaders/STLLoader.js")).STLLoader().loadAsync(url);
|
||||
geometry.computeVertexNormals();
|
||||
return { model: new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0xb9c5d4, vertexColors: !!geometry.getAttribute("color"), side: THREE.DoubleSide })), animations: [] };
|
||||
}
|
||||
if (!["glb", "gltf", ""].includes(format)) throw new Error("Unsupported mesh format");
|
||||
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
||||
const gltf = await loader.loadAsync(url);
|
||||
return { model: gltf.scene, animations: gltf.animations };
|
||||
}
|
||||
|
||||
function disposeModel(model: THREE.Object3D) {
|
||||
const textures = new Set<THREE.Texture>();
|
||||
model.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
|
||||
for (const material of materials) {
|
||||
for (const value of Object.values(material)) if (value instanceof THREE.Texture) textures.add(value);
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
textures.forEach((texture) => texture.dispose());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import gsap from "gsap";
|
||||
import { HERO_SECTION_ID, RULER_SECTIONS } from "@/lib/landing-data";
|
||||
import {
|
||||
@@ -47,7 +48,21 @@ import {
|
||||
// Progressive enhancement: fine pointers on md+ get the full instrument
|
||||
// (native scrollbar hidden); reduced-motion visitors get a static rail and
|
||||
// keep their scrollbar; coarse pointers and small screens get nothing.
|
||||
|
||||
// Routes that render without the instrument: utility pages that are a
|
||||
// single viewport-tall surface rather than a scrolling story.
|
||||
const RULERLESS_ROUTES = ["/media"];
|
||||
|
||||
export default function ScrollRuler() {
|
||||
const pathname = usePathname();
|
||||
const rulerless = RULERLESS_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(`${route}/`),
|
||||
);
|
||||
// Unmounting (not hiding) so the effects release the native scrollbar.
|
||||
return rulerless ? null : <ScrollRulerInstrument />;
|
||||
}
|
||||
|
||||
function ScrollRulerInstrument() {
|
||||
const [mode, setMode] = useState<RulerMode | null>(null);
|
||||
const [geom, setGeom] = useState({ docH: 0, vh: 0, vw: 0 });
|
||||
const [layoutVersion, setLayoutVersion] = useState(0);
|
||||
|
||||
@@ -16,7 +16,7 @@ const SESSION_STORAGE_KEY = "artcraft_signed_session";
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errorMessage: string };
|
||||
| { success: false; errorMessage: string; status?: number };
|
||||
|
||||
export type SessionUser = {
|
||||
user_token: string;
|
||||
@@ -270,9 +270,9 @@ function portalRequest(
|
||||
|
||||
type Envelope = { success: boolean; error_message?: string; message?: string };
|
||||
|
||||
async function request<T extends object>(
|
||||
export async function request<T extends object>(
|
||||
path: string,
|
||||
init: { method?: "GET" | "POST"; body?: unknown } = {},
|
||||
init: { method?: "GET" | "POST"; body?: unknown; signal?: AbortSignal } = {},
|
||||
): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -286,6 +286,7 @@ async function request<T extends object>(
|
||||
method: init.method ?? "GET",
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: init.signal,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
|
||||
@@ -295,6 +296,7 @@ async function request<T extends object>(
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
errorMessage:
|
||||
payload?.message ?? payload?.error_message ?? `Request failed (${response.status})`,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// config/github_download_links.ts. Keep previous releases in the history
|
||||
// when adding new downloads, newest first.
|
||||
const DOWNLOAD_HISTORY = {
|
||||
"0.41.0": githubDownloadLinks("0.41.0"),
|
||||
"0.37.0": githubDownloadLinks("0.37.0"),
|
||||
"0.32.0": githubDownloadLinks("0.32.0"),
|
||||
"0.28.0": githubDownloadLinks("0.28.0"),
|
||||
@@ -13,8 +14,8 @@ const DOWNLOAD_HISTORY = {
|
||||
} as const;
|
||||
|
||||
// To roll back either platform, select a version from DOWNLOAD_HISTORY.
|
||||
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
|
||||
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
|
||||
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
|
||||
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
|
||||
|
||||
// Set false to temporarily hide desktop downloads throughout the website.
|
||||
export const DOWNLOADS_ENABLED = true;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { API_HOST } from "./api";
|
||||
import { mediaFilePath, safeMediaUrl, type SharedMedia } from "./media";
|
||||
|
||||
// Social cards use only the public API response. Never forward a visitor's
|
||||
// cookies or signed session into server metadata; private media stays client-side.
|
||||
export async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
|
||||
const media = payload.media_file;
|
||||
if (!payload.success || media?.token !== token || !safeMediaUrl(media.media_links?.cdn_url)) return null;
|
||||
// Hidden means unlisted but shareable by URL. Private files must never
|
||||
// appear in social cards, even if an API response includes their fields.
|
||||
return media.creator_set_visibility === "public" || media.creator_set_visibility === "hidden" ? media : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { request, type ApiResult } from "./api";
|
||||
import { mediaFilePath, safeMediaUrl, type MediaPrompt, type SharedMedia } from "./media";
|
||||
|
||||
export async function getSharedMedia(token: string, signal?: AbortSignal): Promise<ApiResult<SharedMedia>> {
|
||||
const result = await request<{ media_file?: SharedMedia }>(
|
||||
mediaFilePath(token), { signal },
|
||||
);
|
||||
if (!result.success) return result;
|
||||
const media = result.data.media_file;
|
||||
if (!media?.token || !safeMediaUrl(media.media_links?.cdn_url)) {
|
||||
return { success: false, errorMessage: "This media file has no available preview." };
|
||||
}
|
||||
return { success: true, data: media };
|
||||
}
|
||||
|
||||
export async function getMediaPrompt(token: string, signal?: AbortSignal): Promise<ApiResult<MediaPrompt>> {
|
||||
const result = await request<{ prompt?: MediaPrompt }>(
|
||||
`/v1/prompts/${encodeURIComponent(token)}`, { signal },
|
||||
);
|
||||
if (!result.success) return result;
|
||||
return result.data.prompt
|
||||
? { success: true, data: result.data.prompt }
|
||||
: { success: false, errorMessage: "Prompt unavailable" };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "./media";
|
||||
|
||||
export const MEDIA_SHARE_ORIGIN = "https://getartcraft.com";
|
||||
const PREVIEW_WIDTH = 1200;
|
||||
const VIDEO_TYPES: Record<string, string> = { mp4: "video/mp4", webm: "video/webm" };
|
||||
const AUDIO_TYPES: Record<string, string> = {
|
||||
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
|
||||
ogg: "audio/ogg", opus: "audio/ogg", flac: "audio/flac",
|
||||
};
|
||||
|
||||
type MediaEmbed = {
|
||||
kind: "video" | "audio";
|
||||
url: string;
|
||||
contentType: string;
|
||||
// These are iframe viewport dimensions, not invented source dimensions.
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function mediaEmbed(media: SharedMedia): MediaEmbed | null {
|
||||
const kind = mediaKind(media);
|
||||
if (kind !== "video" && kind !== "audio") return null;
|
||||
const contentType = (kind === "video" ? VIDEO_TYPES : AUDIO_TYPES)[mediaFormat(media)];
|
||||
const url = safeMediaUrl(media.media_links.cdn_url);
|
||||
if (!contentType || !url?.startsWith("https://")) return null;
|
||||
return { kind, contentType, url, width: 640, height: kind === "video" ? 360 : 180 };
|
||||
}
|
||||
|
||||
export function mediaShareImage(media: SharedMedia): string | undefined {
|
||||
const cover = media.cover_image?.maybe_links;
|
||||
return mediaThumbnail(media.media_links, PREVIEW_WIDTH)
|
||||
?? (mediaKind(media) === "image" ? safeMediaUrl(media.media_links.cdn_url) : undefined)
|
||||
?? safeMediaUrl(cover?.thumbnail_template?.replace("{WIDTH}", String(PREVIEW_WIDTH)))
|
||||
?? safeMediaUrl(cover?.cdn_url);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
export type MediaKind = "image" | "video" | "audio" | "mesh" | "splat" | "unsupported";
|
||||
export type MediaDimensions = { width: number; height: number };
|
||||
|
||||
// Fields from storyteller-web's GET /v1/media_files/file/{token} response.
|
||||
export type SharedMedia = {
|
||||
token: string;
|
||||
media_class?: string | null;
|
||||
media_type?: string | null;
|
||||
maybe_engine_category?: string | null;
|
||||
maybe_title?: string | null;
|
||||
creator_set_visibility?: string | null;
|
||||
maybe_original_filename?: string | null;
|
||||
maybe_prompt_token?: string | null;
|
||||
maybe_text_transcript?: string | null;
|
||||
maybe_duration_millis?: number | null;
|
||||
maybe_creator_user?: {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
} | null;
|
||||
maybe_model_weight_info?: { title: string } | null;
|
||||
created_at?: string;
|
||||
cover_image?: {
|
||||
maybe_links?: { cdn_url: string; thumbnail_template?: string | null } | null;
|
||||
} | null;
|
||||
media_links: {
|
||||
cdn_url: string;
|
||||
maybe_thumbnail_template?: string | null;
|
||||
maybe_video_previews?: { still: string; still_thumbnail_template?: string | null } | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type MediaPrompt = {
|
||||
maybe_positive_prompt?: string | null;
|
||||
maybe_negative_prompt?: string | null;
|
||||
maybe_model_type?: string | null;
|
||||
maybe_generation_provider?: string | null;
|
||||
maybe_aspect_ratio?: string | null;
|
||||
maybe_resolution?: string | null;
|
||||
maybe_duration_seconds?: number | null;
|
||||
maybe_generate_audio?: boolean | null;
|
||||
maybe_context_images?: {
|
||||
media_token: string;
|
||||
semantic?: string;
|
||||
media_links: SharedMedia["media_links"];
|
||||
}[] | null;
|
||||
};
|
||||
|
||||
export const MEDIA_LABELS: Record<MediaKind, string> = {
|
||||
image: "Image", video: "Video", audio: "Audio", mesh: "3D mesh",
|
||||
splat: "3D splat", unsupported: "Media file",
|
||||
};
|
||||
|
||||
export function mediaKind(media: SharedMedia): MediaKind {
|
||||
// Class is authoritative: PLY can be either a mesh or a Gaussian splat.
|
||||
switch (media.media_class) {
|
||||
case "image": case "video": case "audio": case "mesh": case "splat":
|
||||
return media.media_class;
|
||||
case "project": return "unsupported";
|
||||
}
|
||||
if (media.maybe_engine_category === "splat") return "splat";
|
||||
const format = mediaFormat(media);
|
||||
if (["spz", "splat", "ksplat", "ply"].includes(format)) return "splat";
|
||||
if (["glb", "gltf", "fbx", "obj", "stl", "pmx", "pmd"].includes(format)) return "mesh";
|
||||
if (["image", "png", "jpg", "jpeg", "webp", "gif", "avif"].includes(format)) return "image";
|
||||
if (["video", "mp4", "webm", "mov", "m4v"].includes(format)) return "video";
|
||||
if (["audio", "wav", "mp3", "m4a", "aac", "ogg", "opus", "flac"].includes(format)) return "audio";
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
export function mediaFormat(media: SharedMedia): string {
|
||||
const type = media.media_type?.toLowerCase();
|
||||
if (type && !["unknown", "image", "video", "audio", "dimensional"].includes(type)) return type;
|
||||
try {
|
||||
return new URL(media.media_links.cdn_url).pathname.match(/\.([a-z0-9]+)$/i)?.[1].toLowerCase() ?? type ?? "";
|
||||
} catch {
|
||||
return type ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export function safeMediaUrl(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function corsMediaUrl(value: string): string {
|
||||
const url = new URL(value);
|
||||
// Match the old CDN CORS opt-in without corrupting existing query parameters.
|
||||
url.searchParams.set("cors", "1");
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function mediaPoster(media: SharedMedia): string | undefined {
|
||||
return mediaThumbnail(media.media_links, 1280);
|
||||
}
|
||||
|
||||
// Video references need a still frame, not the raw video URL in an <img>.
|
||||
// The same thumbnail selection is used for social previews and the player.
|
||||
export function mediaThumbnail(links: SharedMedia["media_links"], width: number): string | undefined {
|
||||
const previews = links.maybe_video_previews;
|
||||
const template = links.maybe_thumbnail_template ?? previews?.still_thumbnail_template;
|
||||
return safeMediaUrl(template?.replace("{WIDTH}", String(width))) ?? safeMediaUrl(previews?.still);
|
||||
}
|
||||
|
||||
export function mediaFilePath(token: string): string {
|
||||
return `/v1/media_files/file/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function mediaTitle(media: SharedMedia): string {
|
||||
const kind = mediaKind(media);
|
||||
const label = kind === "mesh" || kind === "splat" ? MEDIA_LABELS[kind] : MEDIA_LABELS[kind].toLowerCase();
|
||||
return media.maybe_title?.trim() || media.maybe_original_filename?.trim() || `Untitled ${label}`;
|
||||
}
|
||||
|
||||
export function mediaDetailLabel(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const labels: Record<string, string> = {
|
||||
artcraft: "ArtCraft", square: "1:1", square_hd: "1:1 (HD)",
|
||||
wide_three_by_two: "3:2", wide_four_by_three: "4:3", wide_five_by_four: "5:4",
|
||||
wide_sixteen_by_nine: "16:9", wide_twenty_one_by_nine: "21:9",
|
||||
tall_two_by_three: "2:3", tall_three_by_four: "3:4", tall_four_by_five: "4:5",
|
||||
tall_nine_by_sixteen: "9:16", tall_nine_by_twenty_one: "9:21",
|
||||
half_k: "0.5K", one_k: "1K", two_k: "2K", three_k: "3K", four_k: "4K",
|
||||
four_eighty_p: "480p", seven_twenty_p: "720p", ten_eighty_p: "1080p",
|
||||
};
|
||||
return labels[value] ?? value.replace(/(\d)p(\d)/g, "$1.$2").replaceAll("_", " ").replace(/\b[a-z]/g, (letter) => letter.toUpperCase()).replace(/\b3d\b/gi, "3D");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Model / provider display names and brand marks, ported from the shared
|
||||
// @storyteller/model-list metadata (ModelMapping, ModelCreatorIconForId).
|
||||
// Marks are the services SVGs in public/images/services/.
|
||||
|
||||
import { mediaUrl } from "./links";
|
||||
|
||||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
// Grok
|
||||
grok_image: "Grok Image",
|
||||
grok_imagine_image: "Grok Imagine",
|
||||
grok_imagine_image_q: "Grok Imagine Quality",
|
||||
grok_video: "Grok Video",
|
||||
grok_imagine_video: "Grok Imagine",
|
||||
grok_imagine_video_1p5: "Grok Imagine 1.5",
|
||||
|
||||
// Flux (Black Forest Labs)
|
||||
flux_1_dev: "Flux 1 Dev",
|
||||
flux_1_schnell: "Flux 1 Schnell",
|
||||
flux_pro_1p1: "Flux Pro 1.1",
|
||||
flux_pro_1p1_ultra: "Flux Pro 1.1 Ultra",
|
||||
flux_pro_kontext_max: "Flux Pro Kontext Max",
|
||||
flux_dev_juggernaut: "Flux Dev Juggernaut",
|
||||
flux_pro_1: "Flux Pro (Inpainting)",
|
||||
flux_3: "Flux 3",
|
||||
flux_3_draft: "Flux 3 Draft",
|
||||
flux_pro_1_1: "Flux Pro 1.1",
|
||||
flux_pro_1_1_ultra: "Flux Pro 1.1 Ultra",
|
||||
flux_2_lora_angles: "Flux 2 LoRA Angles",
|
||||
|
||||
// OpenAI
|
||||
gpt_image_1: "GPT Image 1",
|
||||
gpt_image_1p5: "GPT Image 1.5",
|
||||
gpt_image_2: "GPT Image 2",
|
||||
gpt_image_2p5_flare: "GPT Image 2.5 Flare",
|
||||
gpt_image_2p5_sunburst: "GPT Image 2.5 Sunburst",
|
||||
sora_2: "Sora 2",
|
||||
sora_2_pro: "Sora 2 Pro",
|
||||
|
||||
// Kling
|
||||
kling_1p6_pro: "Kling 1.6 Pro",
|
||||
kling_2p1_pro: "Kling 2.1 Pro",
|
||||
kling_2p1_master: "Kling 2.1 Master",
|
||||
kling_2p5_turbo_pro: "Kling 2.5 Turbo Pro",
|
||||
kling_2p6_pro: "Kling 2.6 Pro",
|
||||
kling_3p0_standard: "Kling 3.0 Standard",
|
||||
kling_3p0_pro: "Kling 3.0 Pro",
|
||||
kling_1_6_pro: "Kling 1.6 Pro",
|
||||
kling_2_1_pro: "Kling 2.1 Pro",
|
||||
kling_2_1_master: "Kling 2.1 Master",
|
||||
|
||||
// Seedance (ByteDance)
|
||||
seedance_1p0_lite: "Seedance 1.0 Lite",
|
||||
seedance_1p0_pro: "Seedance 1.0 Pro",
|
||||
seedance_1p5_pro: "Seedance 1.5 Pro",
|
||||
seedance_2p0: "Seedance 2.0",
|
||||
seedance_2p0_fast: "Seedance 2.0 Fast",
|
||||
seedance_2p0_mini: "Seedance 2.0 Mini",
|
||||
seedance_2p0_bp: "Seedance 2.0 Plus",
|
||||
seedance_2p0_bp_fast: "Seedance 2.0 Plus Fast",
|
||||
seedance_2p0_bp_mini: "Seedance 2.0 Plus Mini",
|
||||
seedance_2p0_bpu: "Seedance 2.0 Plus Ultra",
|
||||
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
|
||||
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
|
||||
seedance_2p5: "Seedance 2.5",
|
||||
seedance_2p5_preview: "Seedance 2.5 Preview",
|
||||
seedance_1_0_lite: "Seedance 1.0 Lite",
|
||||
preview_model: "Preview Model",
|
||||
preview_model_fast: "Preview Model Fast",
|
||||
|
||||
// Seedream / SeedEdit (ByteDance)
|
||||
seedream_4: "Seedream 4",
|
||||
seedream_4p5: "Seedream 4.5",
|
||||
seedream_5_lite: "Seedream 5 Lite",
|
||||
seedream_5p0_pro: "Seedream 5.0 Pro",
|
||||
seedream_5p0_pro_u: "Seedream 5.0 Pro Ultra",
|
||||
seededit_3: "SeedEdit 3",
|
||||
seed_audio_1p0: "Seed Audio 1.0",
|
||||
|
||||
// Alibaba
|
||||
happy_horse_1p0: "Happy Horse 1.0",
|
||||
qwen_edit_2511_angles: "Qwen Edit 2511 Angles",
|
||||
|
||||
// MiniMax (Hailuo)
|
||||
minimax_h3: "MiniMax H3",
|
||||
|
||||
// Suno
|
||||
suno_music: "Suno Music",
|
||||
suno_remix: "Suno Remix",
|
||||
suno_sounds: "Suno Sounds",
|
||||
suno_sample: "Suno Sample",
|
||||
|
||||
// Google
|
||||
veo_2: "Google Veo 2",
|
||||
veo_3: "Google Veo 3",
|
||||
veo_3_fast: "Google Veo 3 Fast",
|
||||
veo_3p1: "Google Veo 3.1",
|
||||
veo_3p1_fast: "Google Veo 3.1 Fast",
|
||||
veo_3p1_lite: "Google Veo 3.1 Lite",
|
||||
gemini_25_flash: "Nano Banana",
|
||||
nano_banana: "Nano Banana",
|
||||
nano_banana_2: "Nano Banana 2",
|
||||
nano_banana_pro: "Nano Banana Pro",
|
||||
|
||||
// Recraft
|
||||
recraft_3: "Recraft 3",
|
||||
|
||||
// Vidu
|
||||
vidu_q3: "Vidu Q3",
|
||||
vidu_q3_turbo: "Vidu Q3 Turbo",
|
||||
|
||||
// Hunyuan (Tencent)
|
||||
hunyuan_3d: "Hunyuan 3D",
|
||||
hunyuan_3d_2: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2p0: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2p1: "Hunyuan 3D 2.1",
|
||||
hunyuan_3d_3: "Hunyuan 3D 3.0",
|
||||
hunyuan_3d_3_sketch: "Hunyuan 3D 3 Sketch",
|
||||
hunyuan_3d_3p1_pro: "Hunyuan 3D 3.1 Pro",
|
||||
hunyuan_3d_3p1_rapid: "Hunyuan 3D 3.1 Rapid",
|
||||
hunyuan_3d_3p1_part: "Hunyuan 3D 3.1 Part",
|
||||
hunyuan_3d_3p1_topology: "Hunyuan 3D 3.1 Smart Topology",
|
||||
hunyuan_3d_2_0: "Hunyuan 3D 2.0",
|
||||
hunyuan_3d_2_1: "Hunyuan 3D 2.1",
|
||||
|
||||
// Other 3D
|
||||
tripo3d_h3p1: "Tripo3D H3.1",
|
||||
triposplat: "TripoSplat",
|
||||
meshy_v6: "Meshy 6",
|
||||
rodin_2p5_fast: "Rodin 2.5 Fast",
|
||||
|
||||
// World Labs
|
||||
worldlabs_gaussian: "World Labs Marble",
|
||||
marble_0p1_mini: "Marble Mini",
|
||||
marble_0p1_plus: "Marble Plus",
|
||||
marble_1p0: "Marble 1.0",
|
||||
marble_1p0_draft: "Marble 1.0 Draft",
|
||||
marble_1p1: "Marble 1.1",
|
||||
marble_1p1_plus: "Marble 1.1 Plus",
|
||||
|
||||
// Midjourney
|
||||
midjourney: "Midjourney",
|
||||
midjourney_v6: "Midjourney V6",
|
||||
midjourney_v6p1: "Midjourney V6.1",
|
||||
midjourney_v6p1_raw: "Midjourney V6.1 (Raw)",
|
||||
midjourney_v7: "Midjourney V7",
|
||||
midjourney_v7_raw: "Midjourney V7 (Raw)",
|
||||
midjourney_v7_draft: "Midjourney V7 (Draft)",
|
||||
midjourney_v7_draft_raw: "Midjourney V7 (Draft Raw)",
|
||||
midjourney_7: "Midjourney v7",
|
||||
midjourney_7_niji: "Midjourney v7 Niji (Anime)",
|
||||
midjourney_8: "Midjourney v8",
|
||||
|
||||
// Beeble
|
||||
switch_x: "Beeble SwitchX",
|
||||
};
|
||||
|
||||
// Model-id prefix → services icon file. Order matters: first match wins.
|
||||
const MODEL_ID_PREFIX_ICONS: [string, string][] = [
|
||||
["flux", "blackforestlabs.svg"],
|
||||
["nano_banana", "google.svg"],
|
||||
["gemini", "google.svg"],
|
||||
["veo", "google.svg"],
|
||||
["gpt_image", "openai.svg"],
|
||||
["sora", "openai.svg"],
|
||||
["midjourney", "midjourney.svg"],
|
||||
["seedream", "bytedance.svg"],
|
||||
["seedance", "bytedance.svg"],
|
||||
["seededit", "bytedance.svg"],
|
||||
["seed_audio", "bytedance.svg"],
|
||||
["kling", "kling.svg"],
|
||||
["grok", "grok.svg"],
|
||||
["vidu", "vidu.svg"],
|
||||
["recraft", "recraft.svg"],
|
||||
["happy_horse", "alibaba.svg"],
|
||||
["wan", "alibaba.svg"],
|
||||
["qwen", "alibaba.svg"],
|
||||
["minimax", "minimax.svg"],
|
||||
["suno", "suno.svg"],
|
||||
["hunyuan", "tencent.svg"],
|
||||
["marble", "worldlabs.svg"],
|
||||
["worldlabs", "worldlabs.svg"],
|
||||
["switch_x", "artcraft.svg"],
|
||||
];
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
artcraft: "ArtCraft",
|
||||
fal: "FAL",
|
||||
grok: "Grok",
|
||||
midjourney: "Midjourney",
|
||||
sora: "Sora",
|
||||
worldlabs: "World Labs",
|
||||
};
|
||||
|
||||
const PROVIDER_ICONS: Record<string, string> = {
|
||||
artcraft: "artcraft.svg",
|
||||
fal: "fal.svg",
|
||||
grok: "grok.svg",
|
||||
midjourney: "midjourney.svg",
|
||||
sora: "openai.svg",
|
||||
worldlabs: "worldlabs.svg",
|
||||
};
|
||||
|
||||
const serviceIcon = (file: string) => mediaUrl(`/images/services/${file}`);
|
||||
|
||||
// Lowercase, dots → underscores, matching the shared lib's normalizer.
|
||||
const normalizeModelKey = (modelType: string): string =>
|
||||
modelType.toLowerCase().replace(/\./g, "_").trim();
|
||||
|
||||
export function getModelDisplayName(modelType: string): string {
|
||||
return MODEL_DISPLAY_NAMES[normalizeModelKey(modelType)] ?? modelType;
|
||||
}
|
||||
|
||||
export function getModelIcon(modelType: string): string {
|
||||
const key = normalizeModelKey(modelType);
|
||||
const match = MODEL_ID_PREFIX_ICONS.find(([prefix]) => key.startsWith(prefix));
|
||||
return serviceIcon(match?.[1] ?? "generic.svg");
|
||||
}
|
||||
|
||||
export function getProviderDisplayName(provider: string): string {
|
||||
return PROVIDER_DISPLAY_NAMES[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): string | null {
|
||||
const file = PROVIDER_ICONS[provider.toLowerCase()];
|
||||
return file ? serviceIcon(file) : null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Media fixtures
|
||||
|
||||
`clip.mp4` is a generated one-second solid-blue H.264 video with no audio:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i color=c=0x3366cc:s=64x64:d=1 -c:v libx264 -pix_fmt yuv420p -movflags +faststart clip.mp4
|
||||
```
|
||||
|
||||
The tests construct their tiny PNG, sine-wave WAV, triangle GLB, and 27-point
|
||||
Gaussian splat fixtures locally. All Storyteller API and media CDN requests are
|
||||
intercepted in the browser; server-side social-card lookups use `api-server.mjs`
|
||||
on `127.0.0.1:4203`, which has no upstream. No production API or database is used.
|
||||
Run `npm run test:media` with Google Chrome installed and ports 4202 and 4203 free.
|
||||
The suite starts its own Next server with the fixture API host, so it refuses to
|
||||
reuse a running development server. Playwright uses installed Chrome.
|
||||
|
||||
`media-metadata.spec.ts` inspects raw HTTP response heads for Discord, Twitter,
|
||||
Meta, Slack, an unknown crawler, curl, and a browser user agent. It executes no
|
||||
page JavaScript. The fixtures cover image titles, video stills, 3D cover images,
|
||||
unlisted links, audio without artwork, private media, API errors/timeouts, and
|
||||
updates to an existing creation. The fixture API rejects forwarded session
|
||||
credentials. Keep these tests independent of the browser's intercepted requests.
|
||||
|
||||
The route's `generateMetadata` performs the anonymous server-side lookup.
|
||||
`htmlLimitedBots: /.*/` keeps metadata in the initial `<head>` for every user
|
||||
agent. Keep the five-second API timeout and generic fallback so an API outage
|
||||
does not leave crawlers waiting indefinitely. `cache: "no-store"` avoids stale
|
||||
server metadata; social platforms can still cache cards independently.
|
||||
|
||||
Public audio/video links also advertise a standalone `/media/<token>/player`
|
||||
document and `/media/<token>/oembed` discovery endpoint. The player uses native
|
||||
HTML controls without scripts or the marketing layout. Tests play its fixture
|
||||
video and audio in Chrome with page JavaScript disabled, check oEmbed sizing,
|
||||
and verify that private media cannot be embedded. Twitter's optional native
|
||||
stream URL is emitted only for MP4; audio and WebM use the iframe player.
|
||||
|
||||
These tests verify our responses and native player, not another platform's
|
||||
acceptance of an embed. Validate a deployed URL in each platform. Discord's
|
||||
audio-tag support is still an open request:
|
||||
https://github.com/discord/discord-api-docs/discussions/3262
|
||||
Discord-playable audio needs a generated video derivative (for example, H.264
|
||||
cover art plus AAC audio in an MP4). Merely declaring an MP3 to be a video does
|
||||
not create that derivative. Instagram feed/Reel publishing is a separate media
|
||||
upload workflow; website metadata does not publish an Instagram post.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// Next's server-side metadata requests cannot be intercepted by page.route.
|
||||
// This local-only fixture has no upstream and never connects to a database.
|
||||
const CDN = "https://media-fixture.invalid";
|
||||
const publicMedia = {
|
||||
m_fixture_image: {
|
||||
token: "m_fixture_image", media_class: "image",
|
||||
creator_set_visibility: "public", maybe_title: "Night sky & stars",
|
||||
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
|
||||
media_links: { cdn_url: `${CDN}/asset.png?fixture=1` },
|
||||
},
|
||||
m_fixture_video: {
|
||||
token: "m_fixture_video", media_class: "video",
|
||||
creator_set_visibility: "public", maybe_title: "A short film",
|
||||
media_links: { cdn_url: `${CDN}/asset.mp4`, maybe_video_previews: {
|
||||
still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg`,
|
||||
} },
|
||||
},
|
||||
m_fixture_audio: {
|
||||
token: "m_fixture_audio", media_class: "audio",
|
||||
creator_set_visibility: "public", maybe_title: "A song",
|
||||
media_links: { cdn_url: `${CDN}/asset.wav` },
|
||||
},
|
||||
m_fixture_mesh: {
|
||||
token: "m_fixture_mesh", media_class: "mesh", creator_set_visibility: "public",
|
||||
media_links: { cdn_url: `${CDN}/asset.glb` },
|
||||
cover_image: { maybe_links: { cdn_url: `${CDN}/cover.jpg`, thumbnail_template: `${CDN}/cover-{WIDTH}.jpg` } },
|
||||
},
|
||||
m_fixture_splat: {
|
||||
token: "m_fixture_splat", media_class: "splat", creator_set_visibility: "public",
|
||||
media_links: { cdn_url: `${CDN}/asset.spz` },
|
||||
cover_image: { maybe_links: { cdn_url: `${CDN}/splat-cover.jpg` } },
|
||||
},
|
||||
m_fixture_private: {
|
||||
token: "m_fixture_private", media_class: "image", creator_set_visibility: "private",
|
||||
maybe_title: "Secret creation", maybe_creator_user: { username: "secret_artist" },
|
||||
media_links: { cdn_url: `${CDN}/secret-image.png` },
|
||||
},
|
||||
m_fixture_hidden: {
|
||||
token: "m_fixture_hidden", media_class: "image", creator_set_visibility: "hidden",
|
||||
media_links: { cdn_url: `${CDN}/unlisted.png`, maybe_thumbnail_template: `${CDN}/unlisted-{WIDTH}.png` },
|
||||
},
|
||||
};
|
||||
let revision = 0;
|
||||
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
if (request.url === "/health") {
|
||||
response.end(JSON.stringify({ success: true }));
|
||||
return;
|
||||
}
|
||||
// A regression forwarding visitor credentials must not produce public cards.
|
||||
if (request.headers.cookie || request.headers.authorization || request.headers.session) {
|
||||
response.writeHead(403).end(JSON.stringify({ success: false }));
|
||||
return;
|
||||
}
|
||||
const token = request.url?.match(/^\/v1\/media_files\/file\/([^/?]+)$/)?.[1];
|
||||
if (token === "m_fixture_changing") {
|
||||
response.end(JSON.stringify({ success: true, media_file: {
|
||||
...publicMedia.m_fixture_image, token, maybe_title: `Revision ${++revision}`,
|
||||
} }));
|
||||
return;
|
||||
}
|
||||
if (token === "m_fixture_malformed") {
|
||||
response.end("not json");
|
||||
return;
|
||||
}
|
||||
if (token === "m_fixture_timeout") {
|
||||
setTimeout(() => response.end(JSON.stringify({ success: false })), 6000);
|
||||
return;
|
||||
}
|
||||
const errorStatus = { m_fixture_unauthorized: 401, m_fixture_forbidden: 403, m_fixture_failure: 500 }[token];
|
||||
if (errorStatus) {
|
||||
response.writeHead(errorStatus).end(JSON.stringify({ success: false }));
|
||||
return;
|
||||
}
|
||||
const media = publicMedia[token];
|
||||
response.writeHead(media ? 200 : 404).end(JSON.stringify(media
|
||||
? { success: true, media_file: media }
|
||||
: { success: false }));
|
||||
}).listen(4203, "127.0.0.1");
|
||||
@@ -0,0 +1,10 @@
|
||||
export function wav() {
|
||||
const buffer = Buffer.alloc(44 + 80000);
|
||||
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
|
||||
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
|
||||
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
|
||||
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
|
||||
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,171 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { wav } from "./fixtures/audio";
|
||||
|
||||
const CDN = "https://media-fixture.invalid";
|
||||
const SITE = "https://getartcraft.com";
|
||||
const CRAWLERS = [
|
||||
["Discord", "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"],
|
||||
["Twitter", "Twitterbot/1.0"],
|
||||
["Meta", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"],
|
||||
["Slack", "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"],
|
||||
["unknown crawler", "ArtCraftPreviewTest/1.0"],
|
||||
["curl", "curl/8.7.1"],
|
||||
["browser", "Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36"],
|
||||
] as const;
|
||||
|
||||
// Raw HTTP only: no browser, hydration, JavaScript, or DOM repair can make
|
||||
// these assertions pass. Tags must be inside the server response's <head>.
|
||||
for (const [name, userAgent] of CRAWLERS) {
|
||||
test(`${name} gets complete media social tags in the raw HTML head`, async ({ request }) => {
|
||||
const head = await requestHead(request, "m_fixture_image", userAgent);
|
||||
expect(head).toContain("<title>Night sky & stars — ArtCraft</title>");
|
||||
expect(head).toContain('property="og:title" content="Night sky & stars — ArtCraft"');
|
||||
expect(head).toContain('property="og:description" content="Made with ArtCraft by Fixture Artist."');
|
||||
expect(head).toContain('property="og:type" content="website"');
|
||||
expect(head).toContain('property="og:site_name" content="ArtCraft"');
|
||||
expect(head).toContain(`property="og:url" content="${SITE}/media/m_fixture_image"`);
|
||||
expect(head).toContain(`property="og:image" content="${CDN}/asset.png?fixture=1"`);
|
||||
expect(head).toContain('property="og:image:alt" content="Night sky & stars"');
|
||||
expect(head).toContain('name="twitter:card" content="summary_large_image"');
|
||||
expect(head).toContain('name="twitter:title" content="Night sky & stars — ArtCraft"');
|
||||
expect(head).toContain('name="twitter:description" content="Made with ArtCraft by Fixture Artist."');
|
||||
expect(head).toContain(`name="twitter:image" content="${CDN}/asset.png?fixture=1"`);
|
||||
expect(head).toContain(`rel="canonical" href="${SITE}/media/m_fixture_image"`);
|
||||
expect(head).toContain('name="robots" content="noindex, follow"');
|
||||
});
|
||||
}
|
||||
|
||||
test("video, mesh, splat, and unlisted cards use image previews", async ({ request }) => {
|
||||
for (const [token, preview] of [
|
||||
["m_fixture_video", "still-1200.jpg"],
|
||||
["m_fixture_mesh", "cover-1200.jpg"],
|
||||
["m_fixture_splat", "splat-cover.jpg"],
|
||||
["m_fixture_hidden", "unlisted-1200.png"],
|
||||
]) {
|
||||
const head = await requestHead(request, token);
|
||||
expect(head).toContain(`property="og:image" content="${CDN}/${preview}"`);
|
||||
expect(head).toContain(`name="twitter:image" content="${CDN}/${preview}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("audio without cover art uses the site image and its own title", async ({ request }) => {
|
||||
const head = await requestHead(request, "m_fixture_audio");
|
||||
expect(head).toContain('property="og:title" content="A song — ArtCraft"');
|
||||
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
|
||||
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
|
||||
expect(head).not.toContain(`property="og:image" content="${CDN}/asset.wav"`);
|
||||
});
|
||||
|
||||
test("video and audio advertise direct media and iframe players", async ({ request }) => {
|
||||
for (const [, userAgent] of CRAWLERS) {
|
||||
const video = await requestHead(request, "m_fixture_video", userAgent);
|
||||
expect(video).toContain('property="og:type" content="video.other"');
|
||||
expect(video).toContain(`property="og:video" content="${CDN}/asset.mp4"`);
|
||||
expect(video).toContain(`property="og:video:secure_url" content="${CDN}/asset.mp4"`);
|
||||
expect(video).toContain('property="og:video:type" content="video/mp4"');
|
||||
expect(video).toContain('name="twitter:card" content="player"');
|
||||
expect(video).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_video/player"`);
|
||||
expect(video).toContain(`name="twitter:player:stream" content="${CDN}/asset.mp4"`);
|
||||
expect(video).toContain('name="twitter:player:stream:content_type" content="video/mp4"');
|
||||
expect(video).toContain('name="twitter:player:width" content="640"');
|
||||
expect(video).toContain('name="twitter:player:height" content="360"');
|
||||
expect(video).toContain('type="application/json+oembed"');
|
||||
const audio = await requestHead(request, "m_fixture_audio", userAgent);
|
||||
expect(audio).toContain(`property="og:audio" content="${CDN}/asset.wav"`);
|
||||
expect(audio).toContain('property="og:audio:type" content="audio/wav"');
|
||||
expect(audio).toContain('name="twitter:card" content="player"');
|
||||
expect(audio).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_audio/player"`);
|
||||
expect(audio).not.toContain('name="twitter:player:stream"');
|
||||
expect(audio).not.toContain('property="og:video"');
|
||||
}
|
||||
});
|
||||
|
||||
test("oEmbed returns a sized iframe and rejects unrelated URLs and unsupported formats", async ({ request }) => {
|
||||
for (const [token, type] of [["m_fixture_video", "video"], ["m_fixture_audio", "rich"]]) {
|
||||
const response = await request.get(`/media/${token}/oembed`, { params: { url: `${SITE}/media/${token}`, format: "json", maxwidth: 320, maxheight: 90 } });
|
||||
expect(response.status()).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data).toMatchObject({ version: "1.0", type, provider_name: "ArtCraft", provider_url: SITE });
|
||||
expect(data.width).toBeLessThanOrEqual(320);
|
||||
expect(data.height).toBeLessThanOrEqual(90);
|
||||
expect(data.html).toContain(`src="${SITE}/media/${token}/player"`);
|
||||
expect(data.html).toContain(`width="${data.width}" height="${data.height}"`);
|
||||
}
|
||||
const unrelated = await request.get("/media/m_fixture_video/oembed", { params: { url: "https://unrelated.invalid/" } });
|
||||
expect(unrelated.status()).toBe(404);
|
||||
const xml = await request.get("/media/m_fixture_video/oembed?format=xml");
|
||||
expect(xml.status()).toBe(501);
|
||||
});
|
||||
|
||||
test("private and non-playable media cannot be embedded", async ({ request }) => {
|
||||
for (const token of ["m_fixture_private", "m_missing", "m_fixture_image", "m_fixture_mesh"]) {
|
||||
for (const route of ["player", "oembed"]) {
|
||||
const response = await request.get(`/media/${token}/${route}`, { params: { url: `${SITE}/media/${token}` } });
|
||||
expect(response.status()).toBe(404);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain(CDN);
|
||||
expect(body).not.toContain("Secret creation");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("standalone video and audio players load and play with JavaScript disabled", async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const png = readFileSync(join(__dirname, "../public/images/og-image.png"));
|
||||
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
||||
await page.route(`${SITE}/images/og-image.png`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
||||
await page.route(`${CDN}/asset.mp4`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
|
||||
await page.route(`${CDN}/asset.wav`, (route) => route.fulfill({ body: wav(), contentType: "audio/wav" }));
|
||||
for (const kind of ["video", "audio"]) {
|
||||
const response = await page.goto(`http://localhost:4202/media/m_fixture_${kind}/player`);
|
||||
expect(response?.headers()["content-security-policy"]).toContain("default-src 'none'");
|
||||
expect(await response!.text()).not.toContain("<script");
|
||||
const player = page.locator(kind);
|
||||
await expect(player).toHaveAttribute("controls", "");
|
||||
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.readyState)).toBeGreaterThanOrEqual(1);
|
||||
// Invoke the native media API; the document itself contains no JavaScript.
|
||||
await player.evaluate((element: HTMLMediaElement) => { element.muted = true; return element.play(); });
|
||||
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.currentTime)).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("private files, missing files, and API failures get a generic card", async ({ request }) => {
|
||||
for (const token of ["m_fixture_private", "m_missing", "m_fixture_unauthorized", "m_fixture_forbidden", "m_fixture_failure", "m_fixture_malformed", "m_fixture_timeout"]) {
|
||||
const head = await requestHead(request, token);
|
||||
expect(head).toContain('property="og:title" content="Shared media — ArtCraft"');
|
||||
expect(head).toContain(`property="og:url" content="${SITE}/media/${token}"`);
|
||||
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
|
||||
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
|
||||
expect(head).not.toContain(CDN);
|
||||
expect(head).not.toContain("Secret creation");
|
||||
expect(head).not.toContain("secret_artist");
|
||||
}
|
||||
});
|
||||
|
||||
test("metadata is looked up again when a public creation changes", async ({ request }) => {
|
||||
const first = await requestHead(request, "m_fixture_changing");
|
||||
const second = await requestHead(request, "m_fixture_changing");
|
||||
const revision = (head: string) => Number(head.match(/property="og:title" content="Revision (\d+)/)?.[1]);
|
||||
expect(revision(first)).toBeGreaterThan(0);
|
||||
expect(revision(second)).toBeGreaterThan(revision(first));
|
||||
});
|
||||
|
||||
async function requestHead(request: APIRequestContext, token: string, userAgent = "Discordbot/2.0") {
|
||||
const response = await request.get(`/media/${token}`, {
|
||||
// The fixture API rejects these headers if the website forwards them.
|
||||
headers: { "User-Agent": userAgent, Cookie: "session=private-fixture", session: "private-fixture" },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("text/html");
|
||||
const html = await response.text();
|
||||
const head = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1];
|
||||
expect(head).toBeDefined();
|
||||
return head!;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { wav } from "./fixtures/audio";
|
||||
import { corsMediaUrl, mediaKind, safeMediaUrl, type SharedMedia } from "../src/lib/media";
|
||||
|
||||
const API = "http://127.0.0.1:4203";
|
||||
const CDN = "https://media-fixture.invalid";
|
||||
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1kAAAAASUVORK5CYII=", "base64");
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// All API calls are fulfilled locally, including navbar/session/referral
|
||||
// requests. Next's server-side metadata lookup uses the local fixture API.
|
||||
// These tests never reach production or a database.
|
||||
await page.route("**/v1/**", (route) => route.fulfill({ json: { success: true, logged_in: false } }));
|
||||
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: PNG, contentType: "image/png" }));
|
||||
});
|
||||
|
||||
test("direct shared image URL loads without login and survives refresh", async ({ page }) => {
|
||||
await mockMedia(page, fixture("image", "png"));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByRole("heading", { name: "Fixture image", exact: true })).toBeVisible();
|
||||
await expect.poll(() => page.getByAltText("Fixture image").evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
||||
await expect(page.getByText("@fixture_artist")).toBeVisible();
|
||||
await expect(page.getByText("1 × 1", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Open in ArtCraft" })).toHaveAttribute("href", "https://app.getartcraft.com/media/m_fixture_image");
|
||||
await page.reload();
|
||||
await expect(page.getByAltText("Fixture image")).toBeVisible();
|
||||
});
|
||||
|
||||
test("legacy query share links redirect to the canonical media page", async ({ page }) => {
|
||||
await mockMedia(page, fixture("image", "png"));
|
||||
await page.goto("/media?media=m_fixture_image");
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/media/m_fixture_image");
|
||||
await expect(page.getByAltText("Fixture image")).toBeVisible();
|
||||
});
|
||||
|
||||
test("prompt and reference media are restored without blocking the preview", async ({ page }) => {
|
||||
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
|
||||
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
|
||||
success: true, prompt: { maybe_positive_prompt: "An ocean beneath the stars", maybe_model_type: "flux_pro",
|
||||
maybe_context_images: [{ media_token: "m_reference", semantic: "Reference image", media_links: { cdn_url: `${CDN}/reference.png` } }] },
|
||||
} }));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByText("An ocean beneath the stars")).toBeVisible();
|
||||
await expect(page.getByRole("link").filter({ has: page.getByAltText("Reference image") })).toHaveAttribute("href", "/media/m_reference");
|
||||
});
|
||||
|
||||
test("prompt failure leaves the image usable", async ({ page }) => {
|
||||
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_private" });
|
||||
await page.route(`${API}/v1/prompts/**`, (route) => route.fulfill({ status: 403, json: { success: false } }));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByText("Prompt details aren’t available for this creation.")).toBeVisible();
|
||||
await expect(page.getByAltText("Fixture image")).toBeVisible();
|
||||
});
|
||||
|
||||
test("model/provider brands and video reference stills appear in creation details", async ({ page }) => {
|
||||
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
|
||||
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
|
||||
success: true, prompt: { maybe_positive_prompt: "A forest", maybe_model_type: "worldlabs_gaussian", maybe_generation_provider: "worldlabs",
|
||||
maybe_context_images: [{ media_token: "m_reference_video", semantic: "Reference video", media_links: {
|
||||
cdn_url: `${CDN}/reference.mp4`, maybe_video_previews: { still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg` },
|
||||
} }] },
|
||||
} }));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByText("World Labs Marble", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("World Labs", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Media details").locator("img.themed-logo")).toHaveCount(2);
|
||||
await expect(page.getByAltText("Reference video")).toHaveAttribute("src", `${CDN}/still-256.jpg?cors=1`);
|
||||
await expect.poll(() => page.getByAltText("Reference video").evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
||||
await expect(page.getByRole("link").filter({ has: page.getByAltText("Reference video") })).toHaveAttribute("href", "/media/m_reference_video");
|
||||
});
|
||||
|
||||
test("video loads playable metadata and exposes native controls", async ({ page }) => {
|
||||
await mockMedia(page, fixture("video", "mp4"));
|
||||
await page.route(`${CDN}/asset.mp4*`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
|
||||
await page.goto("/media/m_fixture_video");
|
||||
const video = page.locator("video[aria-label='Fixture video']");
|
||||
await expect(video).toHaveAttribute("controls", "");
|
||||
await expect.poll(() => video.evaluate((el: HTMLVideoElement) => el.readyState)).toBeGreaterThanOrEqual(1);
|
||||
await video.evaluate((el: HTMLVideoElement) => { el.muted = true; return el.play(); });
|
||||
await expect.poll(() => video.evaluate((el: HTMLVideoElement) => el.currentTime)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("music loads in an audio player and shows its transcript", async ({ page }) => {
|
||||
await mockMedia(page, { ...fixture("audio", "wav"), maybe_text_transcript: "Fixture song lyrics" });
|
||||
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
|
||||
await page.goto("/media/m_fixture_audio");
|
||||
const audio = page.locator("audio");
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(5);
|
||||
await page.getByRole("button", { name: "Play audio" }).click();
|
||||
await expect(page.getByRole("button", { name: "Pause audio" })).toBeVisible();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||||
await page.getByRole("button", { name: "Pause audio" }).click();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
|
||||
await page.getByRole("slider", { name: "Seek audio" }).click();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeCloseTo(2.5, 1);
|
||||
await expect(page.getByLabel("Elapsed time")).toHaveText("0:02");
|
||||
await page.getByRole("slider", { name: "Audio volume" }).click();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.volume)).toBe(0.5);
|
||||
await page.getByRole("button", { name: "Mute audio", exact: true }).click();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.muted)).toBe(true);
|
||||
await page.getByRole("button", { name: "Unmute audio", exact: true }).click();
|
||||
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.muted)).toBe(false);
|
||||
await expect(page.getByText("Fixture song lyrics")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live waveform reflects the playing audio samples", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await mockMedia(page, fixture("audio", "wav"));
|
||||
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
|
||||
await page.goto("/media/m_fixture_audio");
|
||||
await page.getByRole("button", { name: "Play audio" }).click();
|
||||
await expect.poll(async () => {
|
||||
const path = await page.getByLabel("Live audio waveform").locator("path").last().getAttribute("d");
|
||||
return [...(path ?? "").matchAll(/,([\d.-]+)/g)].some((match) => Math.abs(Number(match[1]) - 36) > 1);
|
||||
}).toBe(true);
|
||||
await page.getByRole("button", { name: "Pause audio" }).click();
|
||||
await expect(page.getByRole("button", { name: "Play audio" })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const kind of ["mesh", "splat"] as const) {
|
||||
test(`interactive ${kind} supports orbit, zoom, pan, and reset with no WebGL errors`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => { if (message.type() === "error" && /THREE|WebGL|shader/i.test(message.text())) errors.push(message.text()); });
|
||||
const format = kind === "mesh" ? "glb" : "splat";
|
||||
await mockMedia(page, fixture(kind, format));
|
||||
await page.route(`${CDN}/asset.${format}*`, (route) => route.fulfill({ body: kind === "mesh" ? glb() : splats(), contentType: "application/octet-stream" }));
|
||||
await page.goto(`/media/m_fixture_${kind}`);
|
||||
await expect(page.getByRole("button", { name: "Reset view" })).toBeVisible();
|
||||
const canvas = page.getByLabel(`Interactive 3D ${kind}`);
|
||||
await expect(canvas).toBeVisible();
|
||||
const box = (await canvas.boundingBox())!;
|
||||
const hash = async () => createHash("sha256").update(await canvas.screenshot()).digest("hex");
|
||||
let before = await hash();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 130, box.y + box.height / 2 + 40, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(hash).not.toBe(before);
|
||||
before = await hash();
|
||||
await page.mouse.wheel(0, -280);
|
||||
await expect.poll(hash).not.toBe(before);
|
||||
before = await hash();
|
||||
await page.mouse.down({ button: "right" });
|
||||
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2 - 40, { steps: 10 });
|
||||
await page.mouse.up({ button: "right" });
|
||||
await expect.poll(hash).not.toBe(before);
|
||||
before = await hash();
|
||||
await page.getByRole("button", { name: "Reset view" }).click();
|
||||
await expect.poll(hash).not.toBe(before);
|
||||
before = await hash();
|
||||
await page.getByRole("button", { name: "Rotate left", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Zoom out", exact: true }).click();
|
||||
await expect.poll(hash).not.toBe(before);
|
||||
await page.screenshot({ path: test.info().outputPath(`${kind}.png`) });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("missing and private files get useful errors, and retries recover", async ({ page }) => {
|
||||
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ status: 404, json: { success: false } }));
|
||||
await page.goto("/media/m_missing");
|
||||
await expect(page.getByRole("heading", { name: "Media not found" })).toBeVisible();
|
||||
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ status: 403, json: { success: false } }));
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(page.getByRole("heading", { name: "This media is private" })).toBeVisible();
|
||||
await mockMedia(page, fixture("image", "png"));
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(page.getByAltText("Fixture image")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a network failure and malformed response do not strand the loading screen", async ({ page }) => {
|
||||
await page.route(`${API}/v1/media_files/file/**`, (route) => route.abort());
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByRole("heading", { name: "Unable to load this media" })).toBeVisible();
|
||||
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true } }));
|
||||
await page.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Unable to load this media" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("broken image offers the original, and unknown formats remain downloadable", async ({ page }) => {
|
||||
await mockMedia(page, fixture("image", "png"));
|
||||
await page.route(`${CDN}/**`, (route) => route.fulfill({ status: 404 }));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
await expect(page.getByText("This browser could not display the file.")).toBeVisible();
|
||||
await mockMedia(page, fixture("project", "scene_json"));
|
||||
await page.goto("/media/m_fixture_project");
|
||||
await expect(page.getByText(/A browser preview isn’t available/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download file" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("download saves the original file, keeping existing CDN query parameters", async ({ page }) => {
|
||||
await mockMedia(page, fixture("image", "png"));
|
||||
await page.goto("/media/m_fixture_image");
|
||||
const request = page.waitForRequest((r) => r.url().startsWith(CDN) && r.url().includes("dl=1"));
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download file" }).click();
|
||||
expect((await downloaded).suggestedFilename()).toBe("m_fixture_image.png");
|
||||
expect(new URL((await request).url()).searchParams.get("fixture")).toBe("1");
|
||||
});
|
||||
|
||||
test("mobile layouts and both themes preserve the preview and actions", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockMedia(page, fixture("audio", "wav"));
|
||||
await page.route(`${CDN}/asset.wav*`, (route) => serveBytes(route, wav(), "audio/wav"));
|
||||
await page.goto("/media/m_fixture_audio");
|
||||
await expect(page.getByRole("group", { name: "Audio player" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download file" })).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("mobile-light.png"), fullPage: true });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.getByRole("button", { name: "Switch to dark theme" }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await expect(page.locator("body")).toHaveCSS("background-color", "rgb(18, 19, 22)");
|
||||
await page.screenshot({ path: test.info().outputPath("mobile-dark.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("legacy media types, PLY ambiguity, and safe CDN URLs", () => {
|
||||
expect(mediaKind({ ...fixture("dimensional", "spz") })).toBe("splat");
|
||||
expect(mediaKind({ ...fixture("dimensional", "glb") })).toBe("mesh");
|
||||
expect(mediaKind(fixture("mesh", "ply"))).toBe("mesh");
|
||||
expect(mediaKind(fixture("splat", "ply"))).toBe("splat");
|
||||
expect(mediaKind(fixture("unknown", "mp3"))).toBe("audio");
|
||||
expect(mediaKind(fixture("unknown", "webm"))).toBe("video");
|
||||
expect(mediaKind({ ...fixture("unknown", "unknown"), media_links: { cdn_url: `${CDN}/IMAGE.PNG?x=.mp4` } })).toBe("image");
|
||||
expect(corsMediaUrl(`${CDN}/scene.spz?token=test#preview`)).toBe(`${CDN}/scene.spz?token=test&cors=1#preview`);
|
||||
expect(safeMediaUrl("javascript:alert(1)")).toBeUndefined();
|
||||
});
|
||||
|
||||
function fixture(kind: string, format: string): SharedMedia {
|
||||
return { token: `m_fixture_${kind}`, media_class: kind, media_type: format,
|
||||
maybe_title: `Fixture ${kind}`, created_at: "2026-09-26T00:00:00Z",
|
||||
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
|
||||
media_links: { cdn_url: `${CDN}/asset.${format}?fixture=1` } };
|
||||
}
|
||||
|
||||
async function mockMedia(page: Page, media: SharedMedia) {
|
||||
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true, media_file: media } }));
|
||||
}
|
||||
|
||||
async function serveBytes(route: Route, bytes: Buffer, contentType: string) {
|
||||
const range = route.request().headers().range?.match(/bytes=(\d+)-(\d*)/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const end = range?.[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1;
|
||||
await route.fulfill({ status: range ? 206 : 200, contentType,
|
||||
headers: { "accept-ranges": "bytes", "access-control-allow-origin": "*",
|
||||
...(range ? { "content-range": `bytes ${start}-${end}/${bytes.length}` } : {}) },
|
||||
body: bytes.subarray(start, end + 1) });
|
||||
}
|
||||
|
||||
function glb() {
|
||||
const positions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]);
|
||||
let json = JSON.stringify({ asset: { version: "2.0" }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
|
||||
materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [0.2, 0.5, 1, 1], metallicFactor: 0 } }],
|
||||
buffers: [{ byteLength: positions.byteLength }], bufferViews: [{ buffer: 0, byteLength: positions.byteLength }],
|
||||
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3", min: [-1, -1, 0], max: [1, 1, 0] }] });
|
||||
while (json.length % 4) json += " ";
|
||||
const buffer = Buffer.alloc(28 + json.length + positions.byteLength);
|
||||
buffer.writeUInt32LE(0x46546c67, 0); buffer.writeUInt32LE(2, 4); buffer.writeUInt32LE(buffer.length, 8);
|
||||
buffer.writeUInt32LE(json.length, 12); buffer.writeUInt32LE(0x4e4f534a, 16); buffer.write(json, 20);
|
||||
buffer.writeUInt32LE(positions.byteLength, 20 + json.length); buffer.writeUInt32LE(0x004e4942, 24 + json.length);
|
||||
Buffer.from(positions.buffer).copy(buffer, 28 + json.length);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function splats() {
|
||||
const buffer = Buffer.alloc(32 * 27);
|
||||
for (let i = 0; i < 27; i++) {
|
||||
const offset = i * 32;
|
||||
[i % 3 - 1, Math.floor(i / 3) % 3 - 1, Math.floor(i / 9) - 1, 0.2, 0.2, 0.2].forEach((value, j) => buffer.writeFloatLE(value, offset + j * 4));
|
||||
buffer.set([70, 160, 255, 255, 255, 128, 128, 128], offset + 24);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Keep previous releases here when adding new downloads, newest first.
|
||||
const DOWNLOAD_HISTORY = {
|
||||
"0.41.0": githubDownloadLinks("0.41.0"),
|
||||
"0.37.0": githubDownloadLinks("0.37.0"),
|
||||
"0.32.0": githubDownloadLinks("0.32.0"),
|
||||
"0.28.0": githubDownloadLinks("0.28.0"),
|
||||
@@ -11,8 +12,8 @@ const DOWNLOAD_HISTORY = {
|
||||
} as const;
|
||||
|
||||
// To roll back either platform, select a version from DOWNLOAD_HISTORY.
|
||||
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
|
||||
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.37.0";
|
||||
const WINDOWS_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
|
||||
const MAC_VERSION: keyof typeof DOWNLOAD_HISTORY = "0.41.0";
|
||||
|
||||
// Set false to temporarily hide desktop downloads throughout the website.
|
||||
export const DOWNLOADS_ENABLED = true;
|
||||
|
||||
Reference in new issue
Block a user