Compare commits

..
Author SHA1 Message Date
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
Brandon Thomas eadfb4f821 Merge origin/main into bt-media-previews
Combine the media share implementations, preserving interactive players, social cards, model/provider labels, reference thumbnails, and legacy redirects. Verify with 17 isolated Chrome integration tests, TypeScript checks, and the production build.
2026-09-26 09:58:16 -07:00
Brandon Thomas 0c163d6c19 routes 2026-09-26 09:33:04 -07:00
Brandon Thomas 26484685ee media previews on website 2026-09-26 09:25:40 -07:00
BFlat 5bceb33801 Website: model/provider names and icons, video reference thumbs on share page (#34)
Ports the model-list display names and brand marks, shows the Created
row as date + time like the webapp lightbox, and uses the still-frame
preview for video references.
2026-09-26 21:49:03 +07:00
BFlat c6e2c4296a Website: port media share page (/media/[token]) to artcraft-website-next (#33)
* Website: port the media share page to artcraft-website-next

Adds the /media/[token] route with server-side OG metadata, the media
viewer with prompt/reference/info details, and legacy ?media= redirects.

* no ruler on media share page
2026-09-26 21:36:04 +07:00
Brandon Thomas 8551d58def fix download version again 2026-09-26 04:32:59 -07:00
Brandon Thomas 807180bf22 Merge pull request #32 from storytold/bt-new-website-downloads
new website downloads
2026-09-26 04:24:45 -07:00
Brandon Thomas f48d2beea7 new website downloads 2026-09-26 04:24:15 -07:00
Brandon Thomas dc72bbd5e6 Merge pull request #31 from storytold/bt-sso-otp
desktop app login bridge
2026-09-26 02:43:00 -07:00
33 changed files with 2236 additions and 37 deletions

No files matched your search

+36 -3
View File
@@ -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
View File
@@ -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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
@@ -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 &amp; stars — ArtCraft</title>");
expect(head).toContain('property="og:title" content="Night sky &amp; 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 &amp; stars"');
expect(head).toContain('name="twitter:card" content="summary_large_image"');
expect(head).toContain('name="twitter:title" content="Night sky &amp; 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;