diff --git a/frontend/apps/artcraft-website/_redirects b/frontend/apps/artcraft-website/_redirects
index ecd015321d..9f225ec521 100644
--- a/frontend/apps/artcraft-website/_redirects
+++ b/frontend/apps/artcraft-website/_redirects
@@ -51,8 +51,11 @@
/media/ /index.html 200
/media/* /index.html 200
-/pricing /index.html 200
-/pricing/ /index.html 200
+# Prerendered: serve the per-route index.html so social crawlers get the
+# correct OG/title tags (see scripts/prerender-meta.mjs). The SPA still boots
+# from this file and React Router takes over for users.
+/pricing /pricing/index.html 200
+/pricing/ /pricing/index.html 200
/pricing/* /index.html 200
/faq /index.html 200
@@ -92,8 +95,17 @@
/password-reset/ /index.html 200
/password-reset/* /index.html 200
-/seedance-2 /index.html 200
-/seedance-2/ /index.html 200
+# Prerendered: exact paths serve their per-route index.html (correct OG tags
+# for social crawlers); deep sub-paths fall back to the SPA shell. See
+# scripts/prerender-meta.mjs. NOTE: these exact-match serving rules MUST come
+# before the /seedance2* alias below, otherwise that wildcard would 301
+# /seedance2-5 away to /seedance-2 (first match wins in _redirects).
+/seedance2-5 /seedance2-5/index.html 200
+/seedance2-5/ /seedance2-5/index.html 200
+/seedance2-5/* /index.html 200
+
+/seedance-2 /seedance-2/index.html 200
+/seedance-2/ /seedance-2/index.html 200
/seedance-2/* /index.html 200
# Google ads breakage
@@ -102,7 +114,15 @@
/seedance-2%7Bignore%7D /seedance-2 301
/seedance-2%7Bignore%7D* /seedance-2 301
-# Similar URLs
+# Similar URLs — Seedance 2.5 aliases (kept above the broad /seedance2* rule)
+/seedance-2-5 /seedance2-5 301
+/seedance-2-5* /seedance2-5 301
+/seedance-2.5 /seedance2-5 301
+/seedance-2.5* /seedance2-5 301
+/seedance25 /seedance2-5 301
+/seedance25* /seedance2-5 301
+
+# Similar URLs — Seedance 2.0 aliases
/seedance2 /seedance-2 301
/seedance2* /seedance-2 301
/seedance-2.0 /seedance-2 301
diff --git a/frontend/apps/artcraft-website/script/netlify_build.sh b/frontend/apps/artcraft-website/script/netlify_build.sh
index 7dcb758d40..1389c9da12 100755
--- a/frontend/apps/artcraft-website/script/netlify_build.sh
+++ b/frontend/apps/artcraft-website/script/netlify_build.sh
@@ -5,6 +5,9 @@ set -euxo pipefail
echo "Run build script (TODO: Make strict)"
nx build artcraft-website
+echo "Prerender per-route social-share (OG/Twitter) meta tags into dist"
+node scripts/prerender-meta.mjs
+
echo "Change to project dir"
pushd apps/artcraft-website/
diff --git a/frontend/apps/artcraft-website/src/app/app.tsx b/frontend/apps/artcraft-website/src/app/app.tsx
index add62f5a0c..7a301b7e5e 100644
--- a/frontend/apps/artcraft-website/src/app/app.tsx
+++ b/frontend/apps/artcraft-website/src/app/app.tsx
@@ -18,6 +18,7 @@ import CreateVFX from "../pages/create-vfx";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
+import LandingSD25 from "../pages/landing-sd25";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
@@ -67,6 +68,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/apps/artcraft-website/src/components/seo.tsx b/frontend/apps/artcraft-website/src/components/seo.tsx
index 52ee52dd35..b0300464cd 100644
--- a/frontend/apps/artcraft-website/src/components/seo.tsx
+++ b/frontend/apps/artcraft-website/src/components/seo.tsx
@@ -1,26 +1,38 @@
import { useEffect } from "react";
+import { DEFAULT_OG_IMAGE, SITE_ORIGIN, toAbsoluteUrl } from "../config/page-meta";
type SeoProps = {
title: string;
description?: string;
+ /** Share-card title (og:/twitter:). Defaults to `title` when omitted. */
+ ogTitle?: string;
+ /** Site-root-relative or absolute share image. Defaults to DEFAULT_OG_IMAGE. */
+ ogImage?: string;
jsonLd?: object | object[];
};
-const Seo = ({ title, description, jsonLd }: SeoProps) => {
+// Keeps the document's title + social-share meta tags in sync as the user
+// navigates the SPA client-side. NOTE: crawlers don't run this — they read the
+// prerendered static HTML (scripts/prerender-meta.mjs). Both pull from the same
+// config/page-meta source so the user-visible and crawler-visible tags agree.
+const Seo = ({ title, description, ogTitle, ogImage, jsonLd }: SeoProps) => {
useEffect(() => {
document.title = title;
- if (description) {
- let meta = document.querySelector(
- 'meta[name="description"]'
- ) as HTMLMetaElement | null;
- if (!meta) {
- meta = document.createElement("meta");
- meta.setAttribute("name", "description");
- document.head.appendChild(meta);
- }
- meta.setAttribute("content", description);
- }
+ const shareTitle = ogTitle ?? title;
+ const image = toAbsoluteUrl(ogImage ?? DEFAULT_OG_IMAGE);
+ const url = SITE_ORIGIN + window.location.pathname;
+
+ setMetaByName("description", description);
+ setMetaByProperty("og:title", shareTitle);
+ setMetaByProperty("og:description", description);
+ setMetaByProperty("og:type", "website");
+ setMetaByProperty("og:url", url);
+ setMetaByProperty("og:image", image);
+ setMetaByName("twitter:card", "summary_large_image");
+ setMetaByName("twitter:title", shareTitle);
+ setMetaByName("twitter:description", description);
+ setMetaByName("twitter:image", image);
let script: HTMLScriptElement | null = null;
if (jsonLd) {
@@ -33,9 +45,34 @@ const Seo = ({ title, description, jsonLd }: SeoProps) => {
return () => {
if (script && script.parentNode) script.parentNode.removeChild(script);
};
- }, [title, description, jsonLd]);
+ }, [title, description, ogTitle, ogImage, jsonLd]);
return null;
};
export default Seo;
+
+// Upsert a tag. Skips no-op when content is undefined so we
+// don't clobber an existing tag with an empty value.
+function setMetaByName(name: string, content: string | undefined) {
+ if (content === undefined) return;
+ upsertMeta("name", name, content);
+}
+
+// Upsert a tag (Open Graph uses `property`, not `name`).
+function setMetaByProperty(property: string, content: string | undefined) {
+ if (content === undefined) return;
+ upsertMeta("property", property, content);
+}
+
+function upsertMeta(attr: "name" | "property", key: string, content: string) {
+ let meta = document.head.querySelector(
+ `meta[${attr}="${key}"]`,
+ );
+ if (!meta) {
+ meta = document.createElement("meta");
+ meta.setAttribute(attr, key);
+ document.head.appendChild(meta);
+ }
+ meta.setAttribute("content", content);
+}
diff --git a/frontend/apps/artcraft-website/src/config/page-meta.ts b/frontend/apps/artcraft-website/src/config/page-meta.ts
new file mode 100644
index 0000000000..87a295cd5d
--- /dev/null
+++ b/frontend/apps/artcraft-website/src/config/page-meta.ts
@@ -0,0 +1,91 @@
+// Single source of truth for per-page SEO + social-share metadata.
+//
+// This is consumed in TWO places, so it must stay framework-agnostic (no
+// React, no Vite `import.meta`, no DOM access):
+// 1. Runtime — the component reads it to set document.title and meta
+// tags after the SPA hydrates (good for users navigating client-side).
+// 2. Build time — scripts/prerender-meta.mjs reads it to bake a static
+// index.html per route with real //og:/twitter: tags, so
+// social crawlers (Discord, Slack, X, iMessage…) that DON'T run JS still
+// get the correct preview for each page.
+//
+// To give a new marketing route its own share preview: add an entry to
+// PAGE_META keyed by its pathname, then make sure scripts/prerender-meta.mjs
+// emits it (it iterates PAGE_META) and _redirects points the route at the
+// prerendered file.
+
+// Canonical production origin (no trailing slash). Used to build absolute
+// og:url / og:image URLs, which crawlers require — relative paths are ignored.
+export const SITE_ORIGIN = "https://getartcraft.com";
+
+// Default share image, served from /public. Pages may override per-route.
+export const DEFAULT_OG_IMAGE = "/images/3d-interface-preview.jpg";
+
+export interface PageMeta {
+ title: string;
+ description: string;
+ /**
+ * Short title used for the social share card (og:title / twitter:title) when
+ * it should differ from the full, SEO-oriented . Defaults to `title`.
+ */
+ ogTitle?: string;
+ /** Absolute or site-root-relative image path. Defaults to DEFAULT_OG_IMAGE. */
+ ogImage?: string;
+}
+
+// Homepage / fallback metadata. Used for "/" and as the base any page meta is
+// merged onto, so partial overrides still produce a complete tag set.
+export const DEFAULT_META: PageMeta = {
+ title: "ArtCraft. AI Video and Images. Fast and Open Desktop App.",
+ description:
+ "ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!",
+ ogImage: DEFAULT_OG_IMAGE,
+};
+
+// Keyed by pathname (leading slash, no trailing slash). Only marketing pages
+// that benefit from a tailored social preview need an entry — everything else
+// falls back to DEFAULT_META.
+export const PAGE_META: Record = {
+ "/": DEFAULT_META,
+ "/seedance-2": {
+ title:
+ "Seedance 2.0 in ArtCraft. AI Video Generation. Fast and Open Desktop App.",
+ description:
+ "Seedance 2.0 is available now in ArtCraft. Generate jaw-dropping AI videos with the fastest open desktop app for artists.",
+ },
+ "/seedance2-5": {
+ title:
+ "Seedance 2.5 in ArtCraft. AI Video Generation. Fast and Open Desktop App.",
+ ogTitle: "Seedance 2.5",
+ description:
+ "Seedance 2.5 is ByteDance's anticipated next-generation AI video model — reports point to 4K, real-time generation, longer clips, and persistent characters. See what's expected, and create with Seedance in ArtCraft today.",
+ },
+ "/pricing": {
+ title: "Pricing - ArtCraft. AI Video and Images.",
+ description:
+ "Simple pricing for ArtCraft. Generate AI video and images with every model in one open desktop app. No subscriptions needed — you own ArtCraft.",
+ },
+};
+
+// Resolve full metadata for a pathname, filling any missing fields from the
+// defaults. Trailing slashes are normalized so "/pricing" and "/pricing/" match.
+export function getPageMeta(pathname: string): PageMeta {
+ const normalized =
+ pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+ const entry = PAGE_META[normalized];
+ const title = entry?.title ?? DEFAULT_META.title;
+ return {
+ title,
+ description: entry?.description ?? DEFAULT_META.description,
+ ogTitle: entry?.ogTitle ?? title,
+ ogImage: entry?.ogImage ?? DEFAULT_META.ogImage,
+ };
+}
+
+// Build an absolute URL from a site-root-relative path (or pass through an
+// already-absolute URL). Crawlers need absolute og:image / og:url values.
+export function toAbsoluteUrl(pathOrUrl: string): string {
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
+ const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
+ return `${SITE_ORIGIN}${path}`;
+}
diff --git a/frontend/apps/artcraft-website/src/pages/landing-sd25/index.ts b/frontend/apps/artcraft-website/src/pages/landing-sd25/index.ts
new file mode 100644
index 0000000000..7b2c7a8868
--- /dev/null
+++ b/frontend/apps/artcraft-website/src/pages/landing-sd25/index.ts
@@ -0,0 +1 @@
+export { default } from "./landing-sd25";
diff --git a/frontend/apps/artcraft-website/src/pages/landing-sd25/landing-sd25.tsx b/frontend/apps/artcraft-website/src/pages/landing-sd25/landing-sd25.tsx
new file mode 100644
index 0000000000..6bedaf2a4b
--- /dev/null
+++ b/frontend/apps/artcraft-website/src/pages/landing-sd25/landing-sd25.tsx
@@ -0,0 +1,1577 @@
+import { useEffect, useRef, useState, useLayoutEffect } from "react";
+import type { ReactNode } from "react";
+import { isMobile, isMacOs } from "react-device-detect";
+import Lenis from "lenis";
+import gsap from "gsap";
+import { ScrollTrigger } from "gsap/ScrollTrigger";
+import { ManifestoThreeBackground } from "../../components/manifesto-three-background";
+import {
+ KnightCinema,
+ setupKnightCinemaTimeline,
+ type KnightCinemaHandle,
+} from "../../components/knight-cinema";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons";
+import { faWindows, faApple } from "@fortawesome/free-brands-svg-icons";
+import {
+ faPlay,
+ faMapMarkerAlt,
+ faCube,
+ faLayerGroup,
+ faUser,
+ faTools,
+ faShapes,
+ faEraser,
+ faCheck,
+ faXmark,
+ faFilm,
+ faPaintBrush,
+ faCamera,
+ faGlobe,
+} from "@fortawesome/pro-solid-svg-icons";
+import Seo from "../../components/seo";
+import Footer from "../../components/footer";
+import { DownloadModal } from "../../components/download-modal";
+import ModelBadgeGrid from "../../components/model-badge-grid";
+import { getSession } from "../../lib/session";
+import { BillingApi } from "@storyteller/api";
+import { DOWNLOAD_LINKS } from "../../config/github_download_links";
+import { webappUrl, SOCIAL_LINKS } from "../../config/links";
+import { Button } from "@storyteller/ui-button";
+import { Tooltip } from "@storyteller/ui-tooltip";
+import { Link } from "react-router-dom";
+import {
+ TruchetPattern,
+ type TruchetVariant,
+} from "../../components/truchet-pattern";
+
+gsap.registerPlugin(ScrollTrigger);
+
+// Hero embed — matches the Seedance 2 page (/seedance-2): an autoplaying,
+// looping, muted Vimeo background embed rather than a self-hosted