website-next: port marketing pages from the Vite site
Ports the interior marketing pages into artcraft-website-next, rebuilt in the landing's instrument language (SectionShell rails + ticks, mono HUD eyebrows, hairline gap-px cell grids, Archivo display headings with one serif-italic contrast word, theme tokens for light/dark) instead of the old dark-only Truchet/glow treatment. Pages - /pricing: plan grid (Basic/Pro/Max + Enterprise) with yearly/monthly TabSelector, 20% promo anchor pricing, highlighted plans invert their index row, credit-pack and footnote cells. ?ref=sd2fakeyou keeps the Seedance early-access header + reel. Checkout hands off to the webapp. - /faq and /faq/[slug]: guide grid + article, FAQPage/Article JSON-LD. - /news and /news/[slug]: post list + article, blog images resolved against the media host. - /tutorials and /tutorials/[slug]: category filter grid, click-to-load YouTube via LiteYouTube, frontmatter aliases 308 to the canonical slug. - /support, /download (client-side OS detection, requirements, quick start, post-download signup modal), /press-kit (asset cells + video modal), /beta (Google Form signup with honeypot). Platform (Vite -> Next) - Markdown content copied into src/content and read from disk in server code (lib/content.ts) with generateStaticParams, replacing import.meta.glob; markdown renderer ported to lib/markdown.ts and styled by token-based .article-content rules in globals.css. - react-router Links/useParams/useSearchParams -> next/link, awaited params/searchParams, generateMetadata; react-device-detect -> a post-hydration user-agent hook; @storyteller/* lib imports replaced by local data modules (pricing-data, download-links, press-kit-data). - Removed the legacy-route redirects to the Vite site from next.config (only the old Stripe return paths still redirect, now to the webapp); sitemap lists every page and article; ruler roster gains the interior section ids; Discord/Windows brand icons added.
This commit is contained in:
1 parent
df9654fed9
commit
c3050a33f2
40 files changed
+3476
-27
No files matched your search
@@ -1,19 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Marketing routes the nav/footer reference but this app doesn't serve yet.
|
||||
// They bounce to the currently shipping site (same dependency as MEDIA_BASE —
|
||||
// this app already assumes the Vite site stays live) so no link dead-ends.
|
||||
// Delete a route from this list when its page migrates here.
|
||||
const LEGACY_ROUTES = [
|
||||
"download",
|
||||
"pricing",
|
||||
"tutorials",
|
||||
"news",
|
||||
"faq",
|
||||
"press-kit",
|
||||
"support",
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Pin the project root: the repo has lockfiles both here and in the Nx
|
||||
@@ -22,11 +8,17 @@ const nextConfig: NextConfig = {
|
||||
turbopack: { root: __dirname },
|
||||
outputFileTracingRoot: __dirname,
|
||||
async redirects() {
|
||||
return LEGACY_ROUTES.map((route) => ({
|
||||
// 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://getartcraft.com/${route}`,
|
||||
destination: `https://app.getartcraft.com/checkout/${
|
||||
route === "checkout_success" ? "success" : "cancel"
|
||||
}`,
|
||||
permanent: false,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
},
|
||||
// Same-origin proxies for large media served from other origins. The hero
|
||||
// wall draws its clips as WebGL video textures, which need CORS-clean
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import BetaForm from "@/components/beta/beta-form";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent } from "@/components/page/page-header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Beta Signup",
|
||||
description:
|
||||
"Apply for early access to new ArtCraft features. Test unreleased models and tools before anyone else, and help shape the fastest open desktop app for AI video and images.",
|
||||
alternates: { canonical: "/beta" },
|
||||
openGraph: { title: "Join the ArtCraft Beta" },
|
||||
};
|
||||
|
||||
const PERKS = [
|
||||
{
|
||||
title: "First access to new tools",
|
||||
description: "Try unreleased models and features before they ship.",
|
||||
},
|
||||
{
|
||||
title: "A direct line to the team",
|
||||
description: "Report issues and requests straight to the people building ArtCraft.",
|
||||
},
|
||||
{
|
||||
title: "Shape the roadmap",
|
||||
description: "Your feedback decides what we build next.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BetaPage() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<SectionShell id="apply">
|
||||
<SectionEyebrow index="01" label="ArtCraft beta" annotation="Takes about thirty seconds" />
|
||||
<div className="grid gap-px bg-line lg:grid-cols-[1fr_minmax(0,480px)]">
|
||||
<div className="bg-bg px-6 py-14 md:px-10 md:py-20">
|
||||
<h1
|
||||
data-reveal
|
||||
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
|
||||
>
|
||||
Get new features <Accent>before anyone else</Accent>.
|
||||
</h1>
|
||||
<p data-reveal className="mt-5 max-w-xl text-lg leading-relaxed text-muted">
|
||||
We invite a small group of creators to test what we’re
|
||||
building next. Tell us a bit about yourself and we’ll reach
|
||||
out when a spot opens up.
|
||||
</p>
|
||||
|
||||
<ul data-reveal-group className="mt-12 flex flex-col gap-px bg-line">
|
||||
{PERKS.map((perk, i) => (
|
||||
<li key={perk.title} data-reveal className="flex gap-4 bg-bg py-5">
|
||||
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center border border-line text-accent-ink">
|
||||
<CheckIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-ink-strong">{perk.title}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted">
|
||||
{perk.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="hud-label text-faint">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-bg-raised p-6 md:p-10">
|
||||
<p className="hud-label text-faint">Apply for the beta</p>
|
||||
<p className="mt-2 mb-8 text-sm text-muted">
|
||||
No spam, ever.
|
||||
</p>
|
||||
<BetaForm />
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import DownloadPage from "@/components/download/download-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Download ArtCraft — Windows and macOS",
|
||||
description:
|
||||
"Download ArtCraft for Windows and macOS. Start creating AI artwork today.",
|
||||
alternates: { canonical: "/download" },
|
||||
};
|
||||
|
||||
export default function Download() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
<DownloadPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import {
|
||||
ArticleBody,
|
||||
BackLink,
|
||||
PageHeader,
|
||||
} from "@/components/page/page-header";
|
||||
import { getFaqItemBySlug, getFaqItems } from "@/lib/content";
|
||||
import { mediaUrl } from "@/lib/links";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getFaqItems().map((item) => ({ slug: item.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const item = getFaqItemBySlug(slug);
|
||||
if (!item) return {};
|
||||
return {
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
alternates: { canonical: `/faq/${item.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FaqArticlePage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const item = getFaqItemBySlug(slug);
|
||||
if (!item) notFound();
|
||||
|
||||
const html = markdownToHtml(item.body, "/faq/");
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: item.title,
|
||||
description: item.description,
|
||||
articleBody: item.body,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="FAQ"
|
||||
annotation="Guide"
|
||||
title={item.title}
|
||||
lede={item.description || undefined}
|
||||
>
|
||||
<div className="mt-8">
|
||||
<BackLink href="/faq">Back to FAQ</BackLink>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<SectionShell id="articles">
|
||||
{item.thumbnail && (
|
||||
<figure className="border-b border-line bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mediaUrl(item.thumbnail)}
|
||||
alt={item.title}
|
||||
className="mx-auto block h-auto w-full max-w-3xl"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<ArticleBody html={html} />
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import { getFaqItems } from "@/lib/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Frequently Asked Questions",
|
||||
description:
|
||||
"Explore ArtCraft FAQs: guides on AI image generation, editing, and workflows.",
|
||||
alternates: { canonical: "/faq" },
|
||||
};
|
||||
|
||||
export default function FaqIndexPage() {
|
||||
const items = getFaqItems();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: items.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.title,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.description },
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="FAQ"
|
||||
annotation={`${items.length} guides`}
|
||||
title={
|
||||
<>
|
||||
Frequently asked <Accent>questions</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Deep-dive guides and answers about ArtCraft."
|
||||
/>
|
||||
|
||||
<SectionShell id="articles">
|
||||
<SectionEyebrow index="02" label="Guides" annotation="Read in order or jump around" />
|
||||
<ol data-reveal-group className="grid gap-px bg-line md:grid-cols-2">
|
||||
{items.map((item, i) => (
|
||||
<li key={item.slug} data-reveal className="bg-bg">
|
||||
<Link
|
||||
href={`/faq/${item.slug}`}
|
||||
className="group flex h-full flex-col p-6 hover:bg-bg-raised md:p-8"
|
||||
>
|
||||
<p className="hud-label text-faint">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<h2 className="mt-6 flex items-start gap-2 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<ArrowRightIcon
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-faint transition-transform group-hover:translate-x-1 group-hover:text-ink"
|
||||
/>
|
||||
</h2>
|
||||
<p className="mt-2 leading-relaxed text-muted">
|
||||
{item.description}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{items.length % 2 === 1 && (
|
||||
<li aria-hidden className="hidden bg-bg md:block" />
|
||||
)}
|
||||
</ol>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -288,6 +288,133 @@ html[data-ruler="on"]::-webkit-scrollbar {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
/* Rendered markdown (FAQ, tutorials, news). Same hairline/mono vocabulary
|
||||
as the rest of the site: display headings, token colors, no radius. */
|
||||
.article-content {
|
||||
line-height: 1.75;
|
||||
color: var(--ink);
|
||||
}
|
||||
.article-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-content h1,
|
||||
.article-content h2,
|
||||
.article-content h3,
|
||||
.article-content h4 {
|
||||
font-family: var(--font-display);
|
||||
font-stretch: 118%;
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.article-content h1 {
|
||||
font-size: 2rem;
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
.article-content h2 {
|
||||
font-size: 1.625rem;
|
||||
margin: 2.5rem 0 0.875rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.article-content h3 {
|
||||
font-size: 1.25rem;
|
||||
margin: 1.75rem 0 0.625rem;
|
||||
}
|
||||
.article-content h4 {
|
||||
font-size: 1.0625rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.article-content p {
|
||||
margin: 1rem 0;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.article-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.article-content li {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.article-content li::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.article-content a {
|
||||
color: var(--accent-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
}
|
||||
.article-content a:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
.article-content strong {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.article-content blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1.25rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.article-content code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.article-content pre {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.article-content pre code {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.article-content img,
|
||||
.article-content .video-embed {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 2rem 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.article-content .youtube-embed {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.article-content .youtube-embed iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.article-content .video-embed video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
/* Mono capability marquee. Duplicated track scrolls -50%; the reduced-motion
|
||||
* blanket below freezes it in place. */
|
||||
.marquee {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import {
|
||||
ArticleBody,
|
||||
BackLink,
|
||||
PageHeader,
|
||||
} from "@/components/page/page-header";
|
||||
import { getNewsPostBySlug, getNewsPosts } from "@/lib/content";
|
||||
import { mediaUrl } from "@/lib/links";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getNewsPosts().map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getNewsPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
alternates: { canonical: `/news/${post.slug}` },
|
||||
openGraph: { type: "article", publishedTime: post.date },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function NewsPostPage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const post = getNewsPostBySlug(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
// Post bodies reference images as `./images/blog/…`, relative to the
|
||||
// site root where they are hosted.
|
||||
const html = markdownToHtml(post.body, mediaUrl("/"));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="News"
|
||||
annotation={post.date}
|
||||
title={post.title}
|
||||
lede={post.description || undefined}
|
||||
>
|
||||
<div className="mt-8">
|
||||
<BackLink href="/news">Back to News</BackLink>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<SectionShell id="articles">
|
||||
{post.thumbnail && (
|
||||
<figure className="border-b border-line bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mediaUrl(post.thumbnail)}
|
||||
alt={post.title}
|
||||
className="mx-auto block h-auto w-full max-w-3xl"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<ArticleBody html={html} />
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import { getNewsPosts } from "@/lib/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "News & Updates",
|
||||
description:
|
||||
"Latest updates, features, and announcements from the ArtCraft team.",
|
||||
alternates: { canonical: "/news" },
|
||||
};
|
||||
|
||||
export default function NewsIndexPage() {
|
||||
const posts = getNewsPosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="News"
|
||||
annotation={`${posts.length} posts`}
|
||||
title={
|
||||
<>
|
||||
News & <Accent>updates</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Latest updates, features, and announcements from the ArtCraft team."
|
||||
/>
|
||||
|
||||
<SectionShell id="articles">
|
||||
<SectionEyebrow index="02" label="Posts" annotation="Newest first" />
|
||||
<ol data-reveal-group className="flex flex-col gap-px bg-line">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug} data-reveal className="bg-bg">
|
||||
<Link
|
||||
href={`/news/${post.slug}`}
|
||||
className="group grid gap-4 p-6 hover:bg-bg-raised md:grid-cols-[10rem_1fr] md:p-8"
|
||||
>
|
||||
<p className="hud-label text-faint">{post.date}</p>
|
||||
<div>
|
||||
<h2 className="flex items-start gap-2 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
<span className="flex-1">{post.title}</span>
|
||||
<ArrowRightIcon
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-faint transition-transform group-hover:translate-x-1 group-hover:text-ink"
|
||||
/>
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl leading-relaxed text-muted">
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{posts.length === 0 && (
|
||||
<li className="bg-bg p-6 text-muted md:p-8">
|
||||
No updates yet. Check back soon.
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import PressKitGallery from "@/components/press-kit/press-kit-gallery";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SOCIAL_LINKS } from "@/lib/links";
|
||||
import { PRESS_CONTACT_PHONE } from "@/lib/press-kit-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Press Kit",
|
||||
description:
|
||||
"Download ArtCraft press assets including logos, promotional videos, screenshots, and branding materials for media coverage.",
|
||||
alternates: { canonical: "/press-kit" },
|
||||
};
|
||||
|
||||
export default function PressKitPage() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Press kit"
|
||||
annotation="Logos · Videos · Screenshots"
|
||||
title={
|
||||
<>
|
||||
Press <Accent>kit</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Everything you need for press coverage, reviews, and content creation. Download high-quality assets and promotional materials about the world's only open source precision AI tool for artists."
|
||||
/>
|
||||
|
||||
<PressKitGallery />
|
||||
|
||||
<SectionShell id="contact">
|
||||
<div className="flex flex-col items-center px-6 py-16 text-center md:py-24">
|
||||
<p className="hud-label text-faint">Need something specific?</p>
|
||||
<h2
|
||||
data-reveal
|
||||
className="mt-6 max-w-2xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl"
|
||||
>
|
||||
Talk to a <Accent>human</Accent>.
|
||||
</h2>
|
||||
<p className="mt-5 max-w-lg leading-relaxed text-muted">
|
||||
For specific press inquiries, interview requests, or custom assets,
|
||||
reach out to us on Discord. Or text Brandon at{" "}
|
||||
<a
|
||||
href={`tel:${PRESS_CONTACT_PHONE.tel}`}
|
||||
className="whitespace-nowrap text-ink underline underline-offset-4 decoration-line-strong hover:decoration-current"
|
||||
>
|
||||
{PRESS_CONTACT_PHONE.display}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
className="mt-8"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
Contact us on Discord
|
||||
</Button>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import PricingTable from "@/components/pricing/pricing-table";
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import { webappUrl } from "@/lib/links";
|
||||
import { PROMO_PCT } from "@/lib/pricing-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Simple pricing for ArtCraft. Generate AI video and images with every model in one open desktop app. No subscriptions needed — you own ArtCraft.",
|
||||
alternates: { canonical: "/pricing" },
|
||||
};
|
||||
|
||||
// Referral code that swaps the header for the Seedance early-access pitch
|
||||
// (kept from the Vite site's ?ref=sd2fakeyou campaign links).
|
||||
const SEEDANCE_REF = "sd2fakeyou";
|
||||
const SEEDANCE_VIMEO_URL =
|
||||
"https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function PricingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const ref = typeof params.ref === "string" ? params.ref : "";
|
||||
const isSeedanceRef = ref === SEEDANCE_REF;
|
||||
const checkoutQuery = ref ? `ref=${encodeURIComponent(ref)}` : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
{isSeedanceRef ? <SeedanceHeader /> : <DefaultHeader />}
|
||||
|
||||
<SectionShell id="plans">
|
||||
<SectionEyebrow
|
||||
index="02"
|
||||
label="Plans"
|
||||
annotation="Every paid plan includes video credits"
|
||||
/>
|
||||
<PricingTable showSeedanceFeatures checkoutQuery={checkoutQuery} />
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell id="credits">
|
||||
<div className="grid gap-px bg-line md:grid-cols-2">
|
||||
<div className="bg-bg p-6 md:p-10">
|
||||
<p className="hud-label text-faint">Already have an account?</p>
|
||||
<h2 className="mt-4 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
One-time credit packs
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md leading-relaxed text-muted">
|
||||
Top up without changing your plan. Credit packs are purchased
|
||||
inside the app and never expire.
|
||||
</p>
|
||||
<Button
|
||||
href={webappUrl("/pricing")}
|
||||
variant="secondary"
|
||||
className="mt-6"
|
||||
>
|
||||
Buy credits in the app
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-bg p-6 md:p-10">
|
||||
<p className="hud-label text-faint">† Footnote</p>
|
||||
<p className="mt-4 max-w-md leading-relaxed text-muted">
|
||||
ArtCraft can be used without paying for a subscription. You can
|
||||
bring your own compute and third-party subscriptions. We hope
|
||||
you’ll subscribe, though, as that helps accelerate our
|
||||
development.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultHeader() {
|
||||
return (
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Pricing"
|
||||
annotation="Free & open source · Subscriptions optional†"
|
||||
title={
|
||||
<>
|
||||
Invest in <Accent>yourself</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Get a ton of generations and invest in a tool you'll always own. Your subscription helps keep ArtCraft free and open for everyone."
|
||||
>
|
||||
<div data-reveal className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
label="Limited-time offer"
|
||||
icon={<TagIcon aria-hidden className="h-3 w-3" />}
|
||||
className="border-transparent bg-accent text-white"
|
||||
/>
|
||||
<p className="text-sm text-muted">
|
||||
Save {PROMO_PCT}% on all monthly & yearly plans — lock in the
|
||||
lowest price and start creating.
|
||||
</p>
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedanceHeader() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Early access"
|
||||
annotation="Available today in ArtCraft"
|
||||
title={
|
||||
<>
|
||||
Seedance 2.0 is <Accent>here</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Generate jaw-dropping AI videos with Seedance 2.0 before it's available anywhere else. Every paid plan includes video credits, so you can start creating right now."
|
||||
/>
|
||||
<SectionShell>
|
||||
<div className="grid gap-px bg-line lg:grid-cols-[3fr_2fr]">
|
||||
<figure className="bg-bg">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
|
||||
<figcaption className="hud-label text-muted">
|
||||
Seedance in ArtCraft
|
||||
</figcaption>
|
||||
<p className="hud-label text-faint">Reel</p>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
<iframe
|
||||
src={SEEDANCE_VIMEO_URL}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
title="Seedance in ArtCraft"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
<div className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="bg-bg p-6 md:p-8">
|
||||
<p className="hud-label text-accent-ink">Seedance video credits</p>
|
||||
<p className="mt-3 leading-relaxed text-muted">
|
||||
Included with every paid ArtCraft plan.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bg p-6 md:p-8">
|
||||
<p className="hud-label text-muted">First in the world</p>
|
||||
<p className="mt-3 leading-relaxed text-muted">
|
||||
Seedance launches in ArtCraft ahead of anywhere else.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,38 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getFaqItems, getNewsPosts, getTutorialItems } from "@/lib/content";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
|
||||
// Only routes this app actually serves. Grows as marketing pages migrate
|
||||
// over from the Vite site.
|
||||
type Entry = MetadataRoute.Sitemap[number];
|
||||
|
||||
const STATIC_ROUTES: { path: string; priority: number; changeFrequency: Entry["changeFrequency"] }[] = [
|
||||
{ path: "/", priority: 1, changeFrequency: "weekly" },
|
||||
{ path: "/pricing", priority: 0.9, changeFrequency: "weekly" },
|
||||
{ path: "/download", priority: 0.9, changeFrequency: "weekly" },
|
||||
{ path: "/tutorials", priority: 0.7, changeFrequency: "weekly" },
|
||||
{ path: "/news", priority: 0.7, changeFrequency: "weekly" },
|
||||
{ path: "/faq", priority: 0.7, changeFrequency: "monthly" },
|
||||
{ path: "/support", priority: 0.5, changeFrequency: "monthly" },
|
||||
{ path: "/press-kit", priority: 0.5, changeFrequency: "monthly" },
|
||||
{ path: "/beta", priority: 0.5, changeFrequency: "monthly" },
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/`,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
const staticEntries: Entry[] = STATIC_ROUTES.map((route) => ({
|
||||
url: `${SITE_URL}${route.path}`,
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
}));
|
||||
|
||||
const articleEntries: Entry[] = [
|
||||
...getFaqItems().map((item) => `/faq/${item.slug}`),
|
||||
...getTutorialItems().map((item) => `/tutorials/${item.slug}`),
|
||||
...getNewsPosts().map((post) => `/news/${post.slug}`),
|
||||
].map((path) => ({
|
||||
url: `${SITE_URL}${path}`,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...articleEntries];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon, CircleHelpIcon, MailIcon } from "lucide-react";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import { SOCIAL_LINKS, SUPPORT_EMAIL } from "@/lib/links";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Support",
|
||||
description:
|
||||
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.",
|
||||
alternates: { canonical: "/support" },
|
||||
};
|
||||
|
||||
type Channel = {
|
||||
index: string;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const CHANNELS: Channel[] = [
|
||||
{
|
||||
index: "A",
|
||||
icon: <DiscordIcon className="h-5 w-5" />,
|
||||
title: "Join our Discord",
|
||||
description:
|
||||
"The fastest way to reach us. Quick replies from the team and credit refunds if something goes wrong.",
|
||||
href: SOCIAL_LINKS.DISCORD,
|
||||
cta: "Open Discord",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
index: "B",
|
||||
icon: <MailIcon aria-hidden className="h-5 w-5" />,
|
||||
title: "Email us",
|
||||
description: SUPPORT_EMAIL,
|
||||
href: `mailto:${SUPPORT_EMAIL}`,
|
||||
cta: "Write to us",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
index: "C",
|
||||
icon: <CircleHelpIcon aria-hidden className="h-5 w-5" />,
|
||||
title: "Browse the FAQ",
|
||||
description: "Answers to common questions about ArtCraft.",
|
||||
href: "/faq",
|
||||
cta: "Read the FAQ",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Support"
|
||||
annotation="We're happy to help"
|
||||
title={
|
||||
<>
|
||||
Get <Accent>help</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Find answers, join the community, or reach us directly."
|
||||
/>
|
||||
|
||||
<SectionShell id="contact">
|
||||
<SectionEyebrow index="02" label="Channels" annotation="Pick one" />
|
||||
<ul data-reveal-group className="grid gap-px bg-line md:grid-cols-3">
|
||||
{CHANNELS.map((channel) => (
|
||||
<li key={channel.index} data-reveal className="bg-bg">
|
||||
<ChannelLink channel={channel}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex h-10 w-10 items-center justify-center border border-line text-ink transition-colors group-hover:bg-invert-bg group-hover:text-invert-fg">
|
||||
{channel.icon}
|
||||
</span>
|
||||
<p className="hud-label text-faint">{channel.index}</p>
|
||||
</div>
|
||||
<h2 className="mt-6 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{channel.title}
|
||||
</h2>
|
||||
<p className="mt-2 leading-relaxed text-muted">
|
||||
{channel.description}
|
||||
</p>
|
||||
<span className="hud-label mt-6 flex items-center gap-1.5 text-muted group-hover:text-ink">
|
||||
{channel.cta}
|
||||
<ArrowRightIcon
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</span>
|
||||
</ChannelLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelLink({
|
||||
channel,
|
||||
children,
|
||||
}: {
|
||||
channel: Channel;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const className = "group flex h-full flex-col p-6 hover:bg-bg-raised md:p-8";
|
||||
if (!channel.external) {
|
||||
return (
|
||||
<Link href={channel.href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const isMailto = channel.href.startsWith("mailto:");
|
||||
return (
|
||||
<a
|
||||
href={channel.href}
|
||||
target={isMailto ? undefined : "_blank"}
|
||||
rel={isMailto ? undefined : "noopener noreferrer"}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import LiteYouTube from "@/components/lite-youtube";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import {
|
||||
ArticleBody,
|
||||
BackLink,
|
||||
PageHeader,
|
||||
} from "@/components/page/page-header";
|
||||
import { getTutorialItemBySlug, getTutorialItems } from "@/lib/content";
|
||||
import { extractYouTubeId, markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
// Aliases are served too, then redirected to the canonical slug below.
|
||||
export function generateStaticParams() {
|
||||
return getTutorialItems().flatMap((item) =>
|
||||
[item.slug, ...item.aliases].map((slug) => ({ slug })),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const item = getTutorialItemBySlug(slug);
|
||||
if (!item) return {};
|
||||
return {
|
||||
title: item.title,
|
||||
description: item.abstract,
|
||||
alternates: { canonical: `/tutorials/${item.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TutorialArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const item = getTutorialItemBySlug(slug);
|
||||
if (!item) notFound();
|
||||
if (item.slug !== slug.toLowerCase()) {
|
||||
permanentRedirect(`/tutorials/${item.slug}`);
|
||||
}
|
||||
|
||||
const videoId = item.youtubeId ?? (item.videoUrl ? extractYouTubeId(item.videoUrl) : null);
|
||||
const html = markdownToHtml(item.body);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: item.title,
|
||||
description: item.abstract,
|
||||
articleBody: item.body,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Tutorials"
|
||||
annotation={item.category ? `${item.category} editor` : "Tutorial"}
|
||||
title={item.title}
|
||||
lede={item.abstract || undefined}
|
||||
>
|
||||
<div className="mt-8">
|
||||
<BackLink href="/tutorials">Back to Tutorials</BackLink>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<SectionShell id="articles">
|
||||
{videoId && (
|
||||
<figure className="border-b border-line">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-10">
|
||||
<figcaption className="hud-label text-muted">Video</figcaption>
|
||||
<p className="hud-label text-faint">Click to play</p>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
<LiteYouTube videoId={videoId} title={item.title} />
|
||||
</div>
|
||||
</figure>
|
||||
)}
|
||||
{html.trim() && <ArticleBody html={html} />}
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import TutorialGrid from "@/components/tutorials/tutorial-grid";
|
||||
import { getTutorialItems } from "@/lib/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tutorials",
|
||||
description: "Learn tips, tricks, and workflows for ArtCraft.",
|
||||
alternates: { canonical: "/tutorials" },
|
||||
};
|
||||
|
||||
export default function TutorialsPage() {
|
||||
// Only the fields the client grid renders cross the server boundary.
|
||||
const items = getTutorialItems().map(({ slug, title, category, thumbnail }) => ({
|
||||
slug,
|
||||
title,
|
||||
category,
|
||||
thumbnail,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Tutorials"
|
||||
annotation="Video walkthroughs"
|
||||
title={
|
||||
<>
|
||||
Learn the <Accent>craft</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="Learn tips, tricks, and workflows for ArtCraft."
|
||||
/>
|
||||
|
||||
<SectionShell id="articles">
|
||||
<SectionEyebrow index="02" label="Library" annotation="Filter by editor" />
|
||||
<TutorialGrid items={items} />
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, Input, Label } from "@/components/ui";
|
||||
import { webappUrl } from "@/lib/links";
|
||||
|
||||
// Submissions go to a Google Form whose responses land in a Google Sheet.
|
||||
// The form URL and field IDs are public by nature (they ship in the bundle).
|
||||
const FORM_RESPONSE_URL =
|
||||
"https://docs.google.com/forms/d/e/1FAIpQLSfjRVkOzfBlR0uefebHWSaoEm7R0DRLhUKqwHQlNvBE8bmd-w/formResponse";
|
||||
|
||||
const FORM_FIELD_IDS = {
|
||||
name: "entry.666666785",
|
||||
company: "entry.121325393",
|
||||
email: "entry.467034813",
|
||||
type: "entry.2139364913",
|
||||
typeDetail: "entry.1914395461",
|
||||
page: "entry.709868934",
|
||||
referrer: "entry.617211358",
|
||||
} as const;
|
||||
|
||||
const USER_TYPES = ["Business", "Hobbyist", "Film maker", "Agency", "Other"] as const;
|
||||
type UserType = (typeof USER_TYPES)[number];
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type FieldErrors = Partial<Record<"name" | "email" | "type" | "typeOther", string>>;
|
||||
|
||||
export default function BetaForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [userType, setUserType] = useState<UserType | null>(null);
|
||||
const [typeOther, setTypeOther] = useState("");
|
||||
// Honeypot: hidden from humans; bots that fill it are silently dropped.
|
||||
const [website, setWebsite] = useState("");
|
||||
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const validate = (): FieldErrors => {
|
||||
const next: FieldErrors = {};
|
||||
if (!name.trim()) next.name = "Please enter your name.";
|
||||
if (!email.trim()) next.email = "Please enter your email.";
|
||||
else if (!EMAIL_PATTERN.test(email.trim())) {
|
||||
next.email = "That doesn't look like a valid email address.";
|
||||
}
|
||||
if (userType === null) next.type = "Please pick the option closest to you.";
|
||||
else if (userType === "Other" && !typeOther.trim()) {
|
||||
next.typeOther = "Tell us a little about what you do.";
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
const next = validate();
|
||||
setErrors(next);
|
||||
if (Object.keys(next).length) return;
|
||||
|
||||
if (website.trim()) {
|
||||
// Bot filled the honeypot. Pretend everything worked.
|
||||
setSubmitted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
[FORM_FIELD_IDS.name]: name.trim(),
|
||||
[FORM_FIELD_IDS.company]: company.trim(),
|
||||
[FORM_FIELD_IDS.email]: email.trim(),
|
||||
[FORM_FIELD_IDS.type]: userType ?? "",
|
||||
[FORM_FIELD_IDS.typeDetail]: userType === "Other" ? typeOther.trim() : "",
|
||||
[FORM_FIELD_IDS.page]: window.location.href,
|
||||
[FORM_FIELD_IDS.referrer]: document.referrer,
|
||||
});
|
||||
// Google Forms sends no CORS headers, so this is fire-and-forget: the
|
||||
// response is opaque and "request completed" counts as success.
|
||||
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitError(
|
||||
"Something went wrong sending your application. Please try again in a moment.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectType = (type: UserType) => {
|
||||
setUserType(type);
|
||||
setErrors((prev) => ({ ...prev, type: undefined, typeOther: undefined }));
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-10 text-center" role="status" aria-live="polite">
|
||||
<span className="flex h-12 w-12 items-center justify-center bg-invert-bg text-invert-fg">
|
||||
<CheckIcon aria-hidden className="h-5 w-5" />
|
||||
</span>
|
||||
<h2 className="mt-6 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
You’re on the list
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xs leading-relaxed text-muted">
|
||||
Thanks, {name.trim().split(/\s+/)[0]}. We review applications
|
||||
regularly and will reach out at{" "}
|
||||
<span className="text-ink">{email.trim()}</span> when your spot
|
||||
opens.
|
||||
</p>
|
||||
<Button href="/" variant="secondary" className="mt-8">
|
||||
Back to the homepage
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-5" onSubmit={onSubmit} noValidate>
|
||||
{submitError && (
|
||||
<p className="border border-danger px-4 py-3 text-sm text-danger">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id="beta-name"
|
||||
label="Name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ada Lovelace"
|
||||
isError={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="beta-company"
|
||||
label="Company (optional)"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Studio or company name"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="beta-email"
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
isError={!!errors.email}
|
||||
errorMessage={errors.email}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="beta-type-Business">I am a…</Label>
|
||||
<div className="flex flex-wrap gap-px bg-line" role="radiogroup" aria-label="I am a…">
|
||||
{USER_TYPES.map((type) => {
|
||||
const selected = userType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
id={`beta-type-${type}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => selectType(type)}
|
||||
className={twMerge(
|
||||
"hud-label h-10 px-4 transition-colors",
|
||||
selected
|
||||
? "bg-invert-bg text-invert-fg"
|
||||
: "bg-bg text-muted hover:bg-bg-raised hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors.type && (
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-danger">
|
||||
{errors.type}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userType === "Other" && (
|
||||
<Input
|
||||
id="beta-type-other"
|
||||
label="What best describes you?"
|
||||
type="text"
|
||||
value={typeOther}
|
||||
onChange={(e) => setTypeOther(e.target.value)}
|
||||
placeholder="Game developer, educator, …"
|
||||
autoFocus
|
||||
isError={!!errors.typeOther}
|
||||
errorMessage={errors.typeOther}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div aria-hidden="true" className="absolute -left-[9999px] top-auto h-px w-px overflow-hidden">
|
||||
<label htmlFor="beta-website">Website</label>
|
||||
<input
|
||||
id="beta-website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" loading={loading} className="mt-1 w-full">
|
||||
Request access
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-faint">
|
||||
We only use this to contact you about the beta. Already have access?{" "}
|
||||
<a href={webappUrl("/login")} className="text-muted underline underline-offset-2 hover:text-ink">
|
||||
Log in
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowRightIcon,
|
||||
FilesIcon,
|
||||
MemoryStickIcon,
|
||||
MonitorIcon,
|
||||
AppleIcon,
|
||||
} from "lucide-react";
|
||||
import { WindowsIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import {
|
||||
DOWNLOAD_LINKS,
|
||||
DOWNLOAD_VERSIONS,
|
||||
DOWNLOADS_ENABLED,
|
||||
} from "@/lib/download-links";
|
||||
import { mediaUrl, webappUrl } from "@/lib/links";
|
||||
|
||||
type Platform = "windows" | "macos";
|
||||
|
||||
type System = {
|
||||
id: Platform;
|
||||
os: string;
|
||||
short: string;
|
||||
icon: ReactNode;
|
||||
link: string;
|
||||
version: string;
|
||||
requirements: { icon: ReactNode; text: string }[];
|
||||
};
|
||||
|
||||
const SYSTEMS: System[] = [
|
||||
{
|
||||
id: "windows",
|
||||
os: "Windows",
|
||||
short: "Windows",
|
||||
icon: <WindowsIcon className="h-5 w-5" />,
|
||||
link: DOWNLOAD_LINKS.WINDOWS,
|
||||
version: DOWNLOAD_VERSIONS.WINDOWS,
|
||||
requirements: [
|
||||
{ icon: <MonitorIcon aria-hidden className="h-4 w-4" />, text: "Windows 10 (64-bit) or newer" },
|
||||
{ icon: <MemoryStickIcon aria-hidden className="h-4 w-4" />, text: "8 GB RAM recommended" },
|
||||
{ icon: <FilesIcon aria-hidden className="h-4 w-4" />, text: "2 GB available storage" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "macos",
|
||||
os: "macOS",
|
||||
short: "Mac",
|
||||
icon: <AppleIcon aria-hidden className="h-5 w-5" />,
|
||||
link: DOWNLOAD_LINKS.MACOS,
|
||||
version: DOWNLOAD_VERSIONS.MACOS,
|
||||
requirements: [
|
||||
{ icon: <MonitorIcon aria-hidden className="h-4 w-4" />, text: "macOS 12.0 or newer" },
|
||||
{ icon: <MemoryStickIcon aria-hidden className="h-4 w-4" />, text: "8 GB RAM recommended" },
|
||||
{ icon: <FilesIcon aria-hidden className="h-4 w-4" />, text: "2 GB available storage" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ title: "Download", description: "Grab the installer for your platform." },
|
||||
{ title: "Install & sign in", description: "Create a free account or log in." },
|
||||
{ title: "Create", description: "Start generating artwork immediately." },
|
||||
];
|
||||
|
||||
const DOWNLOAD_INITIATED_KEY = "artcraft_download_initiated";
|
||||
|
||||
// Platform detection runs after mount (there is no user agent on the
|
||||
// server), so the first paint is the neutral, complete page and the
|
||||
// "your system" affordances arrive with hydration. Ported from the Vite
|
||||
// site's react-device-detect usage.
|
||||
function useDetectedPlatform() {
|
||||
const [state, setState] = useState<{ platform: Platform; mobile: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent;
|
||||
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
||||
const mac = /Macintosh|Mac OS X/i.test(ua);
|
||||
setState({ platform: mac ? "macos" : "windows", mobile });
|
||||
}, []);
|
||||
return state;
|
||||
}
|
||||
|
||||
export default function DownloadPage() {
|
||||
const detected = useDetectedPlatform();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const detectedSystem =
|
||||
SYSTEMS.find((s) => s.id === detected?.platform) ?? SYSTEMS[0];
|
||||
const isMobile = detected?.mobile ?? false;
|
||||
|
||||
// Downloads start natively via the anchor; the modal only invites the
|
||||
// visitor to create an account while the installer arrives.
|
||||
const onDownloadClick = () => {
|
||||
setModalOpen(true);
|
||||
try {
|
||||
localStorage.setItem(DOWNLOAD_INITIATED_KEY, "true");
|
||||
} catch {
|
||||
// Storage unavailable — nothing depends on the flag.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
index="01"
|
||||
label="Download"
|
||||
annotation="macOS · Windows · Web"
|
||||
title={
|
||||
<>
|
||||
Download <Accent>ArtCraft</Accent>.
|
||||
</>
|
||||
}
|
||||
lede="AI-powered artwork creation with canvas editing and 3D scene composition — right on your desktop."
|
||||
>
|
||||
<div data-reveal className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<PrimaryDownloadButton
|
||||
system={detectedSystem}
|
||||
isMobile={isMobile}
|
||||
onClick={onDownloadClick}
|
||||
/>
|
||||
<Button href={webappUrl("/")} variant="secondary" size="lg">
|
||||
Use on web
|
||||
</Button>
|
||||
</div>
|
||||
<p data-reveal className="hud-label mt-5 text-faint">
|
||||
Free · Open source · v{detectedSystem.version}
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<SectionShell>
|
||||
<figure>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2 md:px-10">
|
||||
<figcaption className="hud-label text-faint">
|
||||
Viewport — 3D scene editor
|
||||
</figcaption>
|
||||
<p className="hud-label text-faint">Desktop app</p>
|
||||
</div>
|
||||
<div className="relative w-full overflow-hidden bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mediaUrl("/images/3d-interface-preview.jpg")}
|
||||
alt="ArtCraft interface preview"
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
<span aria-hidden className="tick top-2 left-2 opacity-60" />
|
||||
<span aria-hidden className="tick top-2 right-2 opacity-60" />
|
||||
<span aria-hidden className="tick bottom-2 left-2 opacity-60" />
|
||||
<span aria-hidden className="tick bottom-2 right-2 opacity-60" />
|
||||
</div>
|
||||
</figure>
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell id="platforms">
|
||||
<SectionEyebrow index="02" label="Platforms" annotation="Universal macOS build · x64 Windows" />
|
||||
<div className="grid gap-px bg-line md:grid-cols-2">
|
||||
{SYSTEMS.map((system, i) => {
|
||||
const isDetected = detected?.platform === system.id && !isMobile;
|
||||
return (
|
||||
<article key={system.id} className="flex flex-col bg-bg">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
|
||||
<p className="hud-label text-muted">
|
||||
{isDetected ? (
|
||||
<span className="text-accent-ink">Your system</span>
|
||||
) : (
|
||||
"Platform"
|
||||
)}
|
||||
</p>
|
||||
<p className="hud-label text-faint">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-6 md:p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center border border-line text-ink">
|
||||
{system.icon}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{system.os}
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="mt-6 flex flex-1 flex-col gap-2.5">
|
||||
{system.requirements.map((req) => (
|
||||
<li key={req.text} className="flex items-center gap-2.5 text-sm text-muted">
|
||||
<span className="text-faint">{req.icon}</span>
|
||||
{req.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
{isMobile ? (
|
||||
<Button disabled className="w-full">
|
||||
Desktop only
|
||||
</Button>
|
||||
) : DOWNLOADS_ENABLED ? (
|
||||
<Button
|
||||
href={system.link}
|
||||
onClick={onDownloadClick}
|
||||
variant={isDetected ? "primary" : "secondary"}
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
|
||||
Download for {system.short}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled className="w-full">
|
||||
Temporarily unavailable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell id="quick-start">
|
||||
<SectionEyebrow index="03" label="Quick start" annotation="Minutes, not hours" />
|
||||
<ol className="grid gap-px bg-line md:grid-cols-3">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title} className="bg-bg p-6 md:p-8">
|
||||
<p className="hud-label text-faint">Step {i + 1}</p>
|
||||
<h3 className="mt-6 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="mt-2 leading-relaxed text-muted">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</SectionShell>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title="Downloading ArtCraft…"
|
||||
>
|
||||
<p className="leading-relaxed text-muted">
|
||||
Your download has started. While you wait, create an account to
|
||||
store your creations.
|
||||
</p>
|
||||
<Button href={webappUrl("/signup")} className="mt-6 w-full">
|
||||
Sign up
|
||||
<ArrowRightIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<a
|
||||
href={webappUrl("/login")}
|
||||
className="hud-label mt-4 block text-center text-faint hover:text-ink"
|
||||
>
|
||||
I already have an account
|
||||
</a>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryDownloadButton({
|
||||
system,
|
||||
isMobile,
|
||||
onClick,
|
||||
}: {
|
||||
system: System;
|
||||
isMobile: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Button size="lg" disabled>
|
||||
Download on a desktop
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (!DOWNLOADS_ENABLED) {
|
||||
return (
|
||||
<Button size="lg" disabled>
|
||||
Downloads temporarily unavailable
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button href={system.link} size="lg" onClick={onClick}>
|
||||
{system.icon}
|
||||
Download for {system.short}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Brand glyphs lucide no longer ships. Path data from Simple Icons (CC0).
|
||||
export function GitHubIcon({ className }: { className?: string }) {
|
||||
type IconProps = { className?: string };
|
||||
|
||||
export function GitHubIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -11,3 +13,29 @@ export function GitHubIcon({ className }: { className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
className={className}
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
className={className}
|
||||
>
|
||||
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
|
||||
// Header block shared by every interior marketing page: the landing's
|
||||
// section frame (rails, ticks, mono eyebrow) around a display headline with
|
||||
// one italic serif contrast word, plus an optional lede. Pages compose their
|
||||
// body sections beneath it with further SectionShells.
|
||||
export function PageHeader({
|
||||
index,
|
||||
label,
|
||||
annotation,
|
||||
title,
|
||||
lede,
|
||||
children,
|
||||
align = "left",
|
||||
className,
|
||||
}: {
|
||||
index: string;
|
||||
label: string;
|
||||
annotation?: string;
|
||||
title: ReactNode;
|
||||
lede?: ReactNode;
|
||||
/** Extra content under the lede (CTAs, back links, …). */
|
||||
children?: ReactNode;
|
||||
align?: "left" | "center";
|
||||
className?: string;
|
||||
}) {
|
||||
const centered = align === "center";
|
||||
return (
|
||||
<SectionShell className={className}>
|
||||
<SectionEyebrow index={index} label={label} annotation={annotation} />
|
||||
<div
|
||||
className={twMerge(
|
||||
"px-6 py-14 md:px-10 md:py-20",
|
||||
centered && "flex flex-col items-center text-center",
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
data-reveal
|
||||
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{lede && (
|
||||
<p
|
||||
data-reveal
|
||||
className="mt-5 max-w-xl text-lg leading-relaxed text-muted"
|
||||
>
|
||||
{lede}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// The headline's single serif-italic contrast word.
|
||||
export function Accent({ children }: { children: ReactNode }) {
|
||||
return <span className="font-serif italic font-normal">{children}</span>;
|
||||
}
|
||||
|
||||
// Mono "back to index" link used at the top of article pages.
|
||||
export function BackLink({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="hud-label inline-flex items-center gap-1.5 text-muted hover:text-ink"
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendered markdown body. Styled by `.article-content` in globals.css.
|
||||
export function ArticleBody({ html }: { html: string }) {
|
||||
return (
|
||||
<article
|
||||
className="article-content mx-auto max-w-3xl px-6 py-12 md:px-10 md:py-16"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared "not found" body for missing slugs.
|
||||
export function NotFoundBody({
|
||||
what,
|
||||
backHref,
|
||||
backLabel,
|
||||
}: {
|
||||
what: string;
|
||||
backHref: string;
|
||||
backLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<PageHeader
|
||||
index="00"
|
||||
label="Not found"
|
||||
title={
|
||||
<>
|
||||
Nothing <Accent>here</Accent>.
|
||||
</>
|
||||
}
|
||||
lede={`We couldn't find this ${what}.`}
|
||||
>
|
||||
<div className="mt-8">
|
||||
<BackLink href={backHref}>{backLabel}</BackLink>
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
FileArchiveIcon,
|
||||
ImageIcon,
|
||||
PlayIcon,
|
||||
VideoIcon,
|
||||
} from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { mediaUrl } from "@/lib/links";
|
||||
import {
|
||||
PRESS_KIT_CATEGORIES,
|
||||
type PressKitAsset,
|
||||
type PressKitAssetType,
|
||||
} from "@/lib/press-kit-data";
|
||||
|
||||
const TYPE_ICONS: Record<PressKitAssetType, typeof ImageIcon> = {
|
||||
video: VideoIcon,
|
||||
embed: VideoIcon,
|
||||
image: ImageIcon,
|
||||
link: FileArchiveIcon,
|
||||
};
|
||||
|
||||
const isPlayable = (asset: PressKitAsset) =>
|
||||
asset.type === "video" || (asset.type === "embed" && !!asset.embedUrl);
|
||||
|
||||
// Site-relative asset paths resolve against the media host; absolute URLs
|
||||
// (YouTube thumbnails, R2) pass through.
|
||||
const assetUrl = (path: string) => (/^https?:\/\//.test(path) ? path : mediaUrl(path));
|
||||
|
||||
export default function PressKitGallery() {
|
||||
const [active, setActive] = useState<PressKitAsset | null>(null);
|
||||
const categories = PRESS_KIT_CATEGORIES.filter((c) => c.assets.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{categories.map((category, ci) => (
|
||||
<SectionShell key={category.name} id={ci === 0 ? "articles" : undefined}>
|
||||
<SectionEyebrow
|
||||
index={String(ci + 2).padStart(2, "0")}
|
||||
label={category.name}
|
||||
annotation={category.description}
|
||||
/>
|
||||
<ul className="grid gap-px bg-line md:grid-cols-2 lg:grid-cols-3">
|
||||
{category.assets.map((asset, i) => (
|
||||
<li key={asset.title} className="bg-bg">
|
||||
<AssetCard
|
||||
asset={asset}
|
||||
index={String(i + 1).padStart(2, "0")}
|
||||
onPlay={() => setActive(asset)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionShell>
|
||||
))}
|
||||
|
||||
<Modal
|
||||
isOpen={active !== null}
|
||||
onClose={() => setActive(null)}
|
||||
childPadding={false}
|
||||
accessibleTitle={active?.title}
|
||||
className="max-w-5xl overflow-hidden bg-black"
|
||||
>
|
||||
{active && (
|
||||
<div className="relative aspect-video w-full">
|
||||
{active.type === "embed" && active.embedUrl ? (
|
||||
<iframe
|
||||
src={`${active.embedUrl}?autoplay=1`}
|
||||
title={active.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={active.videoUrl ?? active.downloadUrl}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
asset,
|
||||
index,
|
||||
onPlay,
|
||||
}: {
|
||||
asset: PressKitAsset;
|
||||
index: string;
|
||||
onPlay: () => void;
|
||||
}) {
|
||||
const TypeIcon = TYPE_ICONS[asset.type];
|
||||
const playable = isPlayable(asset);
|
||||
const typeLabel = asset.type === "embed" ? "Video" : asset.type;
|
||||
|
||||
const thumbnail = (
|
||||
<>
|
||||
{asset.thumbnail ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={assetUrl(asset.thumbnail)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={twMerge(
|
||||
"absolute inset-0 h-full w-full transition-transform duration-500 group-hover:scale-[1.02]",
|
||||
asset.containThumbnail ? "object-contain p-8" : "object-cover",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-faint">
|
||||
<TypeIcon aria-hidden className="h-8 w-8" />
|
||||
</span>
|
||||
)}
|
||||
{playable && (
|
||||
<span className="hud-label absolute bottom-3 left-3 flex items-center gap-1.5 bg-invert-bg px-3 py-1.5 font-bold text-invert-fg">
|
||||
<PlayIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
Play
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5">
|
||||
<p className="hud-label flex items-center gap-1.5 capitalize text-muted">
|
||||
<TypeIcon aria-hidden className="h-3 w-3" />
|
||||
{typeLabel}
|
||||
</p>
|
||||
<p className="hud-label text-faint">{index}</p>
|
||||
</div>
|
||||
|
||||
{playable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlay}
|
||||
aria-label={`Play ${asset.title}`}
|
||||
className="group relative aspect-video w-full overflow-hidden bg-bg-sunken"
|
||||
>
|
||||
{thumbnail}
|
||||
</button>
|
||||
) : (
|
||||
<div className="group relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
{thumbnail}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col px-6 py-5">
|
||||
<h3 className="font-display text-xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{asset.title}
|
||||
</h3>
|
||||
{asset.description && (
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-muted">
|
||||
{asset.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto pt-5">
|
||||
{asset.downloadUrl ? (
|
||||
<Button
|
||||
href={assetUrl(asset.downloadUrl)}
|
||||
external
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
{asset.downloadLabel ?? "Download"}
|
||||
{asset.fileSize && (
|
||||
<span className="font-normal opacity-60">{asset.fileSize}</span>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled variant="secondary" className="w-full">
|
||||
Download coming soon
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, CopyIcon, MailIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Badge, Button, TabSelector } from "@/components/ui";
|
||||
import { webappUrl } from "@/lib/links";
|
||||
import {
|
||||
CONTACT_EMAIL,
|
||||
ENTERPRISE_FEATURES,
|
||||
PROMO_PCT,
|
||||
SUBSCRIPTION_PLANS,
|
||||
planPricing,
|
||||
type BillingCadence,
|
||||
type SubscriptionPlan,
|
||||
} from "@/lib/pricing-data";
|
||||
|
||||
const BILLING_TABS: { id: BillingCadence; label: string }[] = [
|
||||
{ id: "yearly", label: "Yearly" },
|
||||
{ id: "monthly", label: "Monthly" },
|
||||
];
|
||||
|
||||
// Plan grid in the landing's hairline-cell language: every plan is a cell
|
||||
// (gap-px over the line color) with an index row, price block, CTA, and
|
||||
// feature list. Highlighted plans invert their index row instead of
|
||||
// breaking out of the grid. Checkout happens in the webapp — the CTAs hand
|
||||
// off there, carrying the referral query through.
|
||||
export default function PricingTable({
|
||||
showSeedanceFeatures = false,
|
||||
checkoutQuery = "",
|
||||
}: {
|
||||
showSeedanceFeatures?: boolean;
|
||||
/** Query string (without "?") forwarded to the webapp's pricing page. */
|
||||
checkoutQuery?: string;
|
||||
}) {
|
||||
const [cadence, setCadence] = useState<BillingCadence>("yearly");
|
||||
const checkoutHref = webappUrl(
|
||||
`/pricing${checkoutQuery ? `?${checkoutQuery}` : ""}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-line px-6 py-4 md:px-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<TabSelector
|
||||
tabs={BILLING_TABS}
|
||||
activeTab={cadence}
|
||||
onTabChange={(id) => setCadence(id as BillingCadence)}
|
||||
tabClassName="w-24"
|
||||
/>
|
||||
<Badge
|
||||
label={`${PROMO_PCT}% off`}
|
||||
className="border-transparent bg-accent text-white"
|
||||
/>
|
||||
</div>
|
||||
<p className="hud-label hidden text-faint sm:block">
|
||||
{cadence === "yearly" ? "Billed yearly · USD" : "Billed monthly · USD"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-reveal-group
|
||||
className="grid gap-px bg-line md:grid-cols-2 xl:grid-cols-4"
|
||||
>
|
||||
{SUBSCRIPTION_PLANS.map((plan, i) => (
|
||||
<PlanCell
|
||||
key={plan.slug}
|
||||
plan={plan}
|
||||
index={String(i + 1).padStart(2, "0")}
|
||||
cadence={cadence}
|
||||
showSeedanceFeatures={showSeedanceFeatures}
|
||||
href={checkoutHref}
|
||||
/>
|
||||
))}
|
||||
<EnterpriseCell index={String(SUBSCRIPTION_PLANS.length + 1).padStart(2, "0")} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCell({
|
||||
plan,
|
||||
index,
|
||||
cadence,
|
||||
showSeedanceFeatures,
|
||||
href,
|
||||
}: {
|
||||
plan: SubscriptionPlan;
|
||||
index: string;
|
||||
cadence: BillingCadence;
|
||||
showSeedanceFeatures: boolean;
|
||||
href: string;
|
||||
}) {
|
||||
const { current, basePrice } = planPricing(plan, cadence);
|
||||
const highlighted = !!plan.highlight;
|
||||
const features = plan.features.filter(
|
||||
(f) => !f.seedanceOnly || showSeedanceFeatures,
|
||||
);
|
||||
|
||||
return (
|
||||
<article data-reveal className="flex flex-col bg-bg">
|
||||
<CellIndexRow
|
||||
index={index}
|
||||
label={plan.highlight ?? "Plan"}
|
||||
inverted={highlighted}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col p-6 md:p-8">
|
||||
<h3 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{plan.name}
|
||||
</h3>
|
||||
|
||||
<div className="mt-5 flex items-baseline gap-2">
|
||||
<span className="font-mono text-base text-faint line-through decoration-danger/70">
|
||||
${basePrice}
|
||||
</span>
|
||||
<span className="font-display text-4xl font-medium tracking-[-0.03em] text-ink-strong">
|
||||
${current}
|
||||
</span>
|
||||
<span className="text-muted">/month</span>
|
||||
</div>
|
||||
<p className="hud-label mt-2 text-faint">
|
||||
{cadence === "yearly"
|
||||
? `Billed yearly · $${plan.yearlyPrice}/yr`
|
||||
: "Billed monthly"}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
href={href}
|
||||
variant={highlighted ? "primary" : "secondary"}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
Get {plan.name}
|
||||
</Button>
|
||||
|
||||
<FeatureList items={features.map((f) => f.text)} className="mt-8" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function EnterpriseCell({ index }: { index: string }) {
|
||||
return (
|
||||
<article id="enterprise" data-reveal className="flex flex-col bg-bg">
|
||||
<CellIndexRow index={index} label="Enterprise" />
|
||||
|
||||
<div className="flex flex-1 flex-col p-6 md:p-8">
|
||||
<h3 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
Enterprise
|
||||
</h3>
|
||||
<div className="mt-5 flex items-baseline gap-2">
|
||||
<span className="font-display text-4xl font-medium tracking-[-0.03em] text-ink-strong">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
<p className="hud-label mt-2 text-faint">For bespoke solutions</p>
|
||||
|
||||
<ContactButtons className="mt-6" />
|
||||
|
||||
<p className="hud-label mt-8 text-faint">Everything in Max, plus</p>
|
||||
<FeatureList items={ENTERPRISE_FEATURES} className="mt-3" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Mono index strip at the top of every cell, inverted for highlighted plans.
|
||||
function CellIndexRow({
|
||||
index,
|
||||
label,
|
||||
inverted = false,
|
||||
}: {
|
||||
index: string;
|
||||
label: string;
|
||||
inverted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8",
|
||||
inverted && "bg-invert-bg text-invert-fg",
|
||||
)}
|
||||
>
|
||||
<p className={twMerge("hud-label", inverted ? "font-bold" : "text-muted")}>
|
||||
{label}
|
||||
</p>
|
||||
<p className={twMerge("hud-label", inverted ? "opacity-70" : "text-faint")}>
|
||||
{index}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureList({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ul className={twMerge("flex flex-col gap-2.5", className)}>
|
||||
{items.map((text) => (
|
||||
<li key={text} className="flex items-start gap-2.5 text-sm text-muted">
|
||||
<CheckIcon
|
||||
aria-hidden
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-accent-ink"
|
||||
/>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Mailto plus copy-to-clipboard, so visitors without a configured mail
|
||||
// client can still grab the address.
|
||||
function ContactButtons({ className }: { className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(CONTACT_EMAIL);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard unavailable (insecure context) — the mailto still works.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex gap-px bg-line", className)}>
|
||||
<Button
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
variant="secondary"
|
||||
className="flex-1 border-0"
|
||||
>
|
||||
<MailIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
Contact us
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={copy}
|
||||
aria-label={copied ? "Email copied" : "Copy email address"}
|
||||
className="w-11 border-0 px-0"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon aria-hidden className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
import { Badge, TabSelector } from "@/components/ui";
|
||||
import { mediaUrl } from "@/lib/links";
|
||||
|
||||
// Serializable subset of TutorialItem the server page hands down.
|
||||
export type TutorialCard = {
|
||||
slug: string;
|
||||
title: string;
|
||||
category?: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
|
||||
const ALL = "All";
|
||||
const FALLBACK_THUMBNAIL = "/images/tutorial-thumbnails/2D_Editor_Basics.jpg";
|
||||
|
||||
// Category filter + card grid. Filtering is the only client state on the
|
||||
// tutorials page, so it is isolated here and the index stays a server page.
|
||||
export default function TutorialGrid({ items }: { items: TutorialCard[] }) {
|
||||
const [category, setCategory] = useState(ALL);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const categories = new Set<string>();
|
||||
for (const item of items) if (item.category) categories.add(item.category);
|
||||
return [ALL, ...categories].map((c) => ({ id: c, label: c }));
|
||||
}, [items]);
|
||||
|
||||
const visible =
|
||||
category === ALL ? items : items.filter((t) => t.category === category);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-line px-6 py-4 md:px-10">
|
||||
<TabSelector tabs={tabs} activeTab={category} onTabChange={setCategory} />
|
||||
<p className="hud-label hidden text-faint sm:block">
|
||||
{visible.length} {visible.length === 1 ? "video" : "videos"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-px bg-line md:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((item, i) => (
|
||||
<li key={item.slug} className="bg-bg">
|
||||
<Link
|
||||
href={`/tutorials/${item.slug}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5">
|
||||
<p className="hud-label text-muted">
|
||||
{item.category ?? "Tutorial"}
|
||||
</p>
|
||||
<p className="hud-label text-faint">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mediaUrl(item.thumbnail ?? FALLBACK_THUMBNAIL)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="hud-label absolute bottom-3 left-3 flex items-center gap-1.5 bg-invert-bg px-3 py-1.5 font-bold text-invert-fg">
|
||||
<PlayIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
Watch
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3 px-6 py-5">
|
||||
<h2 className="font-display text-xl font-medium tracking-[-0.02em] text-ink-strong">
|
||||
{item.title}
|
||||
</h2>
|
||||
{item.category && <Badge label={item.category} />}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: 2D Canvas — Draw and Generate
|
||||
seoTitle: 2D Canvas — Sketch, mask, and guide AI generation
|
||||
abstract: Draw, lay out references, and inpaint precisely to get the image you intended.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: 2d-canvas
|
||||
---
|
||||
|
||||
## Draw first, generate second
|
||||
|
||||
The 2D Canvas is a lightweight drawing and layout tool that the model respects. Sketch shapes, block the background, and mask areas to protect or change. When you generate, the structure you drew stays intact—only the fidelity improves.
|
||||
|
||||
## What it’s great for
|
||||
|
||||
- Storyboards, thumbnails, product mockups.
|
||||
- Replacing elements in a scene without moving the rest.
|
||||
- Fast “idea to image” iterations.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use high‑contrast sketches for clearer guidance.
|
||||
- Mask small areas for better local edits (logos, eyes, hands, reflections).
|
||||
- Keep prompts short; the canvas already supplies structure.
|
||||
|
||||
See also: [Text to Image](../text-to-image) · [Edit Image](../edit-image) · [3D Editor](../3d-editor)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: 3D Editor — Pose, Compose, and Generate
|
||||
seoTitle: 3D Editor — Block scenes, pose characters, and render with AI
|
||||
abstract: Build shots like a game, then render them consistently across angles and takes.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: 3d-editor
|
||||
---
|
||||
|
||||
## Compose scenes like a playable set
|
||||
|
||||
The 3D Editor lets you place subjects, and cameras in a real‑time scene. Add objects and characters, pose characters, and orbit the camera until the shot feels right. When you generate, the AI keeps your framing and pose intact.
|
||||
|
||||
## Why 3D matters
|
||||
|
||||
- Consistency across multiple shots and angles.
|
||||
- Natural camera motion and depth cues.
|
||||
- Character posing that matches your vision.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Choose a preset from ArtCraft presets by clicking the + button above or import images to build the scene.
|
||||
2. Place the subject, set the camera height and focal length.
|
||||
3. Add a concise prompt for style and materials.
|
||||
4. Generate, compare, and tweak until it clicks.
|
||||
|
||||
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video) · [2D Canvas](../2d-canvas)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: AI Image Generation on ArtCraft
|
||||
seoTitle: AI Image Generation on ArtCraft
|
||||
abstract: Explore ArtCraft's AI generation capabilities: features, workflows, and best practices.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: ai-image-generation-on-artcraft
|
||||
---
|
||||
|
||||
## What is AI image generation in ArtCraft?
|
||||
|
||||
ArtCraft brings modern AI image generation into a creator-first workflow that runs locally. You compose your idea on a 2D canvas or in a 3D scene, then guide the model to render the final image with consistency and control. The result is a fast loop for exploration, iteration, and polished outputs.
|
||||
|
||||
## Core capabilities
|
||||
|
||||
- 2D Canvas guidance: sketch layouts, place references, mask areas, and refine with inpainting.
|
||||
- 3D Scene composition: set camera, lighting, and subject placement for consistent multi-shot renders.
|
||||
- Structure-preserving generation: keep the arrangement you designed while improving detail and lighting.
|
||||
- Revisions that respect intent: re-run specific regions to fix details without losing the overall look.
|
||||
|
||||
## A simple workflow
|
||||
|
||||
- Plan the shot: define focal point, subject scale, angle, and background (2D) or block out a scene (3D).
|
||||
- Add guidance: references, masks, or prompts that clarify style, mood, and key details.
|
||||
- Generate and iterate: compare candidates, refine regions, and lock in the final composition.
|
||||
|
||||
## Quality and consistency tips
|
||||
|
||||
- Favor clear subjects and readable silhouettes; the model follows strong structure best.
|
||||
- Use concise prompts; describe style, lighting, and subject specifics with minimal filler.
|
||||
- For multi-image sets, keep camera, distance, and lighting consistent between shots.
|
||||
- Iterate locally on regions that need corrections (eyes, hands, logos, or textures).
|
||||
|
||||
## Responsible use
|
||||
|
||||
Only generate content you have the rights to create. Avoid infringing on other artists’ styles or producing deceptive media. When sharing, be transparent about AI involvement and follow relevant community and platform guidelines.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Edit Image — Inpaint and Replace
|
||||
seoTitle: Edit Image — Remove, replace, and restyle without Photoshop
|
||||
abstract: Paint masks, describe the change, and let AI update only what you selected.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: edit-image
|
||||
---
|
||||
|
||||
## Precise edits without leaving ArtCraft
|
||||
|
||||
Edit Image lets you remove objects, fix details, or restyle parts of an image. Paint a mask, write a brief instruction, and generate. Only the masked region changes; the rest stays locked.
|
||||
|
||||
## Common uses
|
||||
|
||||
- Clean plates: remove cables, signage, or passers‑by.
|
||||
- Product swaps: change colorways, logos, or materials.
|
||||
- Portrait touch‑ups: hairline fixes, reflections, and lighting tweaks.
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep masks tight around the area you want to change.
|
||||
- Use short, clear instructions (e.g., “replace with polished chrome”).
|
||||
- For multi‑step edits, work from big to small changes.
|
||||
|
||||
See also: [2D Canvas](../2d-canvas) · [Text to Image](../text-to-image)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Generate AI photos and videos of yourself
|
||||
seoTitle: Generate AI photos and videos of yourself in ArtCraft
|
||||
abstract: Create photorealistic images of yourself by combining reference inputs with ArtCraft workflows.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: generate-ai-photos-and-videos-of-yourself
|
||||
---
|
||||
|
||||
## Create AI portraits and short videos with strong likeness
|
||||
|
||||
With ArtCraft you can generate images and short video clips of yourself while keeping control over pose, lighting, and style. The workflow combines good references with structure-aware generation so the results look like you—and stay consistent across shots.
|
||||
|
||||
## What you need
|
||||
|
||||
- A handful of clear photos that show your face from multiple angles, plus at least one full-body reference.
|
||||
- Neutral lighting and no heavy filters. Avoid sunglasses or occlusions for the face.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
1. Prepare references: choose 4–8 images that cover front, 3/4, and profile views; include one where your outfit and background match the vibe you want.
|
||||
2. Set the composition: on the 2D canvas, sketch layout and subject scale, or use the 3D scene to place camera and lighting.
|
||||
3. Guide the model: add a concise prompt describing mood, setting, and attire; use masks to protect areas you don’t want to change.
|
||||
4. Generate and refine: compare candidates, then fix details with inpainting (eyes, hairlines, hands, accessories).
|
||||
5. Make it move: for short clips, render a small set of frames from slight camera or pose variations, then export as a looping video.
|
||||
|
||||
## Likeness and safety tips
|
||||
|
||||
- Keep hair outline and eye region sharp in references; it improves identity consistency.
|
||||
- Reuse the same camera distance and angle for a set; consistency beats variety for multi-shot galleries.
|
||||
- Be mindful of where and how you publish. When sharing AI portraits, add context so viewers understand how they were made.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Image to Video in ArtCraft
|
||||
seoTitle: Image to Video — Animate your images with AI video models
|
||||
abstract: Turn a single image into a short, cinematic clip with controllable motion and style.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: image-to-video
|
||||
---
|
||||
|
||||
## From still to motion in minutes
|
||||
|
||||
Image to Video converts any image—your Text to Image result or a photo—into a short clip. Choose a video model, set duration and motion, then add a concise prompt to guide style and mood.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Start from a clean, well‑lit image with a readable subject.
|
||||
- Keep motion subtle for realism: camera drift, gentle parallax, or small pose changes.
|
||||
- Use consistent prompts if you create a series of clips.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Pick a video model and duration.
|
||||
2. Adjust motion options (camera, subject, or both).
|
||||
3. Add a brief prompt for style and atmosphere.
|
||||
4. Render, review, and export.
|
||||
|
||||
See also: [Text to Image](../text-to-image) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: What image and video models does ArtCraft support?
|
||||
seoTitle: ArtCraft models — Image and Video generation models
|
||||
abstract: A quick overview of image and video models available in ArtCraft.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: models-supported
|
||||
---
|
||||
|
||||
## Image models
|
||||
|
||||
- Grok
|
||||
- Midjourney
|
||||
- Flux Pro 1.1 Ultra
|
||||
- Flux Pro 1.1
|
||||
- Flux 1 Dev
|
||||
- Flux 1 Schnell
|
||||
- Nano Banana
|
||||
- Nano Banana Pro
|
||||
- GPT Image 1 (GPT-4o)
|
||||
- GPT Image 1.5
|
||||
- Seedream 4
|
||||
- Seedream 4.5
|
||||
- Flux Pro Kontext Max (editing)
|
||||
- Flux Pro Inpaint (editing)
|
||||
- Flux Dev Juggernaut Inpaint (editing)
|
||||
|
||||
## Video models
|
||||
|
||||
- Grok Video
|
||||
- Kling 1.6 Pro
|
||||
- Kling 2.1 Pro
|
||||
- Kling 2.1 Master
|
||||
- Kling 2.5 Turbo Pro
|
||||
- Kling 2.6 Pro
|
||||
- Seedance 1.0 Lite
|
||||
- Seedance 2.0
|
||||
- Sora 2
|
||||
- Sora 2 Pro
|
||||
- Google Veo 2
|
||||
- Google Veo 3
|
||||
- Google Veo 3 Fast
|
||||
- Google Veo 3.1
|
||||
- Google Veo 3.1 Fast
|
||||
|
||||
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video)
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Text to Image in ArtCraft
|
||||
seoTitle: Text to Image — Prompt and generate with top AI models
|
||||
abstract: Turn ideas into images with clean prompts, model choices, and consistent structure.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
slug: text-to-image
|
||||
---
|
||||
|
||||
## Turn prompts into polished images
|
||||
|
||||
Text to Image in ArtCraft lets you move quickly from idea to result. Choose a model, write a clear prompt, and render. If you need more control, sketch a loose layout on the 2D canvas or block a scene in 3D first—generation will respect your composition.
|
||||
|
||||
## Tips for great prompts
|
||||
|
||||
- Be specific about subject, action, and style (lighting, lens, materials).
|
||||
- Prefer short, descriptive phrases over long paragraphs.
|
||||
- Use negative cues sparingly (e.g., “no text, no watermark”).
|
||||
|
||||
## Choose the right model
|
||||
|
||||
Different models excel at different looks. For photorealism, use a high‑fidelity model. For design and branding, choose a model that handles flat color and typography well. If speed matters, start with a fast model to explore options, then switch to a higher‑quality model for final renders.
|
||||
|
||||
## Iterate with structure
|
||||
|
||||
If the result is close but not perfect, keep the composition and re‑render small regions with inpainting. This preserves the framing while fixing details like hands, logos, or textures.
|
||||
|
||||
See also: [2D Canvas](../2d-canvas) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Welcome to the ArtCraft Blog
|
||||
abstract: We are excited to launch our new blog where we will share updates, tutorials, and behind-the-scenes content.
|
||||
date: 2026-01-16
|
||||
---
|
||||
|
||||
# Hello World!
|
||||
|
||||
Welcome to the official **ArtCraft Blog**. This is where we'll be posting about:
|
||||
|
||||
- **New Features**: Be the first to know about the latest tools and capabilities added to ArtCraft.
|
||||
- **Tutorials**: Learn how to get the most out of our AI generation and editing suite.
|
||||
- **Community Showcases**: Highlighting amazing work created by our users.
|
||||
- **Development Updates**: Insights into what we are building next.
|
||||
|
||||
## Stay Tuned
|
||||
|
||||
We have a lot of exciting things in the pipeline. Make sure to check back regularly!
|
||||
|
||||
Happy Crafting!
|
||||
|
||||
The ArtCraft Team
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Machinima 2.0: World Models for Consistent AI Filmmaking
|
||||
abstract: How to use World Models and 3D Rendering to Get Consistent AI Film Locations and better AI Films.
|
||||
date: 2026-01-30
|
||||
---
|
||||
|
||||
# The "AI Film Trailer" Era
|
||||
|
||||
You've seen a lot of these:
|
||||
|
||||
@youtube(pGn-1BKo3nY)
|
||||
|
||||
For the past year, it been relatively easy to make montages, "clip shows", and trailers
|
||||
like this. While they serve as fun proof-of-concepts and visual eyecandy, you'd be hard
|
||||
pressed to get a "good story" out of this video style.
|
||||
|
||||
Good stories have characters that occupy real spaces. They have room to live, breathe, and interact.
|
||||
|
||||
You don't just fast track everyone through a journey from point A to B and have that be the end of things
|
||||
(unless you're Game of Thrones). Characters need to spend time in places, to explore interpersonal relationships,
|
||||
to overcome challenges. They need places to think, talk, fight and argue, process emotions, grow, and sometimes
|
||||
do absolutely nothing at all.
|
||||
|
||||
Stories need locations. They're a first class citizen in storytelling.
|
||||
|
||||
# Location and Blocking
|
||||
|
||||
We're all filmmakers at ArtCraft. The old school, photons-on-glass kind.
|
||||
|
||||
One of the biggest problems we've had over the past few years with AI video is giving our characters room to occupy.
|
||||
Setting them up on set, precisely controlling what's on screen, and maintaining consistency with their location.
|
||||
The majority of AI video right now looks like a series of disconnected clips, which results in
|
||||
AI film having that "film trailer" montage feel to it. Lots of jumping around, very little
|
||||
continuity.
|
||||
|
||||
We've figured out how to overcome this problem. And in the spirit of "Show, Don't Tell",
|
||||
let me show you what we've discovered. You'll intuit how this technique works immediately:
|
||||
|
||||
@youtube(kzvQMdg66Go)
|
||||
|
||||
Now let me break down the rationale and the technique...
|
||||
|
||||
# Moving the Camera is Important
|
||||
|
||||
In *"film language"*, the camera's relationship to the characters conveys meaning. Sometimes we want to give the
|
||||
viewer a warm and friendly perspective, or perhaps frame the relationship between two different characters and
|
||||
imply a certain power dynamic.
|
||||
|
||||
Sometimes it's just important to show the location so the viewer can settle in. So the setting feels lived in and
|
||||
the viewer is immersed in it alongside our characters.
|
||||
|
||||
# Text is a Coarse-Grained Representation
|
||||
|
||||
Here's a simple Nano Banana Pro prompt:
|
||||
|
||||
> Sam Altman in a room. He sits at a desk, looking towards a desktop computer on the left.
|
||||
> The room is dimly lit. The glow of a computer terminal, sitting in front of him bathes the room in blue light.
|
||||
> Behind him, there is a wall with a poster. There’s an AC/DC poster on the left, and a window on the right.
|
||||
> Through the window, there’s a view of moonlit trees.
|
||||
|
||||
And the results:
|
||||
|
||||

|
||||
|
||||
What happened to the location here? Why are none of these the same? Why are the locations of the poster,
|
||||
computer, window, and so forth so imprecise?
|
||||
|
||||
Moreover, how do we adjust this? Iteratively prompting?
|
||||
|
||||
You already know the answer: text is a poor serialization of the physical world. Text-to-Image is coarse
|
||||
grained. It lacks the ability to precisely convey physical attributes and spatial relationships. You can try,
|
||||
but you'll only get so much precision, and it'll never match the vision in your head.
|
||||
|
||||
# Images are Incomplete
|
||||
|
||||
There are multiple ways to move a camera in an image-to-image workflow. You can use a ControlNet, a LoRA, or even
|
||||
ask an instructive model nicely:
|
||||
|
||||
> Rotate the camera behind Sam Altman to show him from behind. Show the desk, computer screen,
|
||||
> and wall behind the desk. Show the door to the room.
|
||||
|
||||

|
||||
|
||||
While the lighting and scene elements remain somewhat consistent, we're still ultimately left with the same
|
||||
issues we had with text-to-image. We can iterate on editing with words, but it's slow, tedious, and still
|
||||
imprecise.
|
||||
|
||||
(Why does the desk block the door? What is it even trying to do with that poster?)
|
||||
|
||||
# 3D to the Rescue!
|
||||
|
||||
One of the best tools for consistency is 3D. You can position your characters and props in a "3D set" exactly as you want them,
|
||||
and then move the "camera" to any angle, maintaining strong consistency throughout.
|
||||
|
||||

|
||||
|
||||
Pick a "smart" model like GPT Image 1.5 or Nano Banana Pro to convert this previz into a photorealistic render (or any style - anime, sci-fi, whatever):
|
||||
|
||||
> Suspense movie - live action - night time. Make a photorealistic picture of Sam Altman sitting at a fancy office desk.
|
||||
> Use the previz scene as the layout and posing of the shot. The camera and pose of Sam should match this shot.
|
||||
> Nighttime shot, moonlit glow.
|
||||
|
||||

|
||||
|
||||
If desired, you can also attach additional reference images for character designs, wardrobe choices, etc.
|
||||
|
||||
After Nano Banana Pro lighting adjustments and upscaling (and showing it the previz again to restore the missing desk pad), we get this:
|
||||
|
||||
> Make this look more photorealistic. Suspenseful night in the office
|
||||
|
||||

|
||||
|
||||
It's a great starting composition before calling "action" with a video model. (I'm no Roger Deakins. I would adjust the framing, but I wanted to show under the desk as well as bookcase features. I was also up late writing this.)
|
||||
|
||||
# 3D Kit Bashing
|
||||
|
||||
You can use 3D kits found online, many of which are free or low cost, to "kit bash" a scene. Synty, CGTrader, ... there are hundreds of sites for locations, objects, characters, and more. With these assets, you can provide exact blocking and layout you want - but more importantly, you can reuse them for additional shots and camera angles. Your characters need to be seen from multiple angles, and your props need to be consistent throughout the scene.
|
||||
|
||||
> Action movie - live action - day. Turn this previz scene into a photorealistic desert island. Keep the tree, boxes, and treasure chest in place.
|
||||
|
||||

|
||||
|
||||
And the render, with one extra NBP 4K upscale (I did ask it to change the clouds on the second pass):
|
||||
|
||||

|
||||
|
||||
Image editing models are also surprisingly robust at changing the composition controllably if you give it enough structure to start with.
|
||||
|
||||
# Greyboxing
|
||||
|
||||
But kit bashing can be slow. You have to find and curate a selection of assets.
|
||||
If you need to go faster, you can use 3D primitive shapes to "greybox" a scene:
|
||||
|
||||

|
||||
|
||||
> Historical epic - live action - sunset. Use this previz scene for the composition.
|
||||
> An ancient greek temple with ruined concrete and marble columns. The pink and orange sunset bathes the concrete and marble.
|
||||
> In the distance, there are rolling green hills and valleys. Bright pink sky.
|
||||
|
||||

|
||||
|
||||
You can drop 3D assets in alongside the greybox to position existing props or characters. You can even greybox just a single element, like a TV.
|
||||
|
||||
# Billboards and Matte Plates
|
||||
|
||||
If you want to add a dramatic backdrop behind characters and a foreground, you can use the old Hollywood technique of creating a background plate (think the "matte paintings" used in Star Wars). In video game parlance, this is called using flat or billboard textures.
|
||||
|
||||
It's quick and easy, though you do lose your ability to rotate the camera. Use it for depth and backdrops:
|
||||
|
||||

|
||||
|
||||
> Sports footage - live action - day. Woman is hiking in the mountains. She is dressed in sporty warm winter wear.
|
||||
> She’s standing at the peak, with a mountain forest behind her. Use this previz image to upscale the photo into a
|
||||
> fully lifelike and photorealistic cinematic image.
|
||||
|
||||

|
||||
|
||||
|
||||
# Object Generation for Props
|
||||
|
||||
You can turn images into 3D prop objects using models such as Hunyuan 3D. This is useful if you need angles and
|
||||
precision posing for a complicated object, or if you intend to use the asset over and over and need consistency.
|
||||
|
||||
I generated a quick photo of an FJ Cruiser SUV:
|
||||
|
||||

|
||||
|
||||
Turning this into a 3D object and instancing it around all over the place:
|
||||
|
||||

|
||||
|
||||
Then prompt, followed by a 4K upscale (with a few more fixes):
|
||||
|
||||
> An artistic collage of fj cruiser SUVs floating in a pixelated desert.
|
||||
> Match their pose and orientations exactly, including the ones that are flipped and rotated.
|
||||
> Make the low poly fj cruisers look photorealistic and high resolution.
|
||||
|
||||

|
||||
|
||||
|
||||
# World Models Make This Easy
|
||||
|
||||
The real stars of the modern 3D compositing workflow are image-to-Gaussian Splat models, such as World Labs' Marble or Apple's Sharp.
|
||||
|
||||
You can very quickly create a pleasing image of an intracate set in MidJourney, edit it in Nano Banana,
|
||||
then turn it into a fully navigable 3D scene. This gives your characters room to move, your camera places
|
||||
to go, and your location opportunities to shine.
|
||||
|
||||
@loop_autoplay(https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/Fantasy_Gif_One.mp4)
|
||||
@loop_autoplay(https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/Beach_Gif_One.mp4)
|
||||
@loop_autoplay(https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/SciFi_Gif_One.mp4)
|
||||
|
||||
Finally, we have set pieces. And they're easy to build and iterate on.
|
||||
|
||||
# Combining Techniques
|
||||
|
||||
You can use MidJourney to quickly generate brilliant scenes with high detail and magazine photoshoot quality layout, or you can draw a sketch or greybox a scene you'd like to build, iterate, then turn that into a world.
|
||||
|
||||
This is less like "prompting" and more like "crafting", with lots of different visual tools used in quick succession and coordination.
|
||||
|
||||
Need something? Generate it, turn it into 3D, edit, manipulate it. Stick it into something else. Rinse, cycle, repeat. Highly tangible,
|
||||
like an artist molding clay - except now we're moving closer to the speed of thoughts.
|
||||
|
||||
We're still being largely intentional. If there's something we want to see, we now have the tool to make it real. It's *What You See Is What You Get*.
|
||||
|
||||
We're painting with pictures. We're not prompters, but rather auteurs of crafting.
|
||||
|
||||
# Machinima 2.0
|
||||
|
||||
Do you remember "Machinima"? It's a niche, indie genre of filmmaking that was popular in the early 2000's. Creators would pilot video game characters, carefully controlling for the camera, character movements, and on-screen details in order to tell stories. They would then dub over the footage to breathe life into the narrative.
|
||||
|
||||
Popular Machinima series include "Red vs. Blue", from Rooster Teeth Productions, which was filmed using the Halo video game series. Other notable Machinima include "The Strangerhood" (which used The Sims) and "Skibidi Toilet" (which leverages Source Filmmaker).
|
||||
|
||||
I mention this obscure artform because as a former Machinima creator myself, it feels like Image-to-Video is the new Machinima. There's the character posting, keyframing, and dubbing, of course, but there's also this notion of using pixels created by someone else's engine. And it's very much in the spirit of machinima to carve new creativity out of these engines by operating at a higher level.
|
||||
|
||||
Image-to-image, 3D, and World Models make an entirely new form of Machinima possible:
|
||||
|
||||
@youtube(tAAiiKteM-U)
|
||||
|
||||
|
||||
# ArtCraft: Free and Open 3D Filmmaking
|
||||
|
||||
ArtCraft is a crafting engine and is available in its entirety on [Github (please star us!)](https://github.com/storytold/artcraft), or try it today on [Mac or Windows](/download).
|
||||
|
||||
You generate images and videos in app using a wide selection of models and providers
|
||||
such as Midjourney, Google, Sora, Grok, WorldLabs, and more - or you can bring your own keys!
|
||||
|
||||
Over the coming weeks, we'll be adding FAL, Replicate, and Google Gemini. Local GPU support and RunPod support
|
||||
is also coming soon (subscribe to stay updated, or better yet, [join our Discord](https://discord.gg/artcraft)).
|
||||
|
||||
If you're a blogger, podcaster, or creator we've got a [ton of CC-BY-SA content here](/press-kit#top) if you'd like to help us let people know about ArtCraft.
|
||||
|
||||
If you're a creative, please enjoy ArtCraft. We built it for ourselves and for you. Please join the community and let us know if there's any way we can help.
|
||||
|
||||
— Brandon Thomas
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: 2D Editor Basics
|
||||
seoTitle: 2D Editor Basics in ArtCraft
|
||||
abstract: Learn the canvas tools, arrange elements, and generate from layouts.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
thumbnail: /images/tutorial-thumbnails/2D_Editor_Basics.jpg
|
||||
slug: 2d-editor-basics
|
||||
category: 2D
|
||||
videoUrl: https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo
|
||||
---
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 3D Editor Basics
|
||||
seoTitle: 3D Editor Basics in ArtCraft
|
||||
abstract: Position camera and objects, compose shots, maintain character consistency.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
thumbnail: /images/tutorial-thumbnails/3D_Editor_Basics.jpg
|
||||
slug: 3d-editor-basics
|
||||
aliases: 3d-editor-basics
|
||||
category: 3D
|
||||
videoUrl: https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O
|
||||
---
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Turn Images into 3D Objects
|
||||
seoTitle: Turn Images into 3D Objects in ArtCraft
|
||||
abstract: Import images, convert them to scene elements, compose and render.
|
||||
isPublished: true
|
||||
publishedOn: 2025-10-06
|
||||
thumbnail: /images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg
|
||||
slug: turn-images-into-objects
|
||||
aliases: turn-images-into-3d-objects
|
||||
category: 3D
|
||||
videoUrl: https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa
|
||||
---
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parseFrontmatter } from "./markdown";
|
||||
|
||||
// Markdown content loaders, ported from @storyteller/markdown-content.
|
||||
//
|
||||
// Platform difference: the Vite site pulled these files in with
|
||||
// `import.meta.glob(..., { eager: true })`, which bundles every article into
|
||||
// the client. Next has no glob import; instead these run on the server (build
|
||||
// time for the static pages, via generateStaticParams) and read the files
|
||||
// straight from disk. SERVER ONLY — never import this module from a
|
||||
// "use client" component; pass the resulting plain objects down as props.
|
||||
|
||||
const CONTENT_ROOT = join(process.cwd(), "src", "content");
|
||||
|
||||
export type NewsPost = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
body: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
|
||||
export type FaqItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
body: string;
|
||||
isPublished: boolean;
|
||||
thumbnail?: string;
|
||||
};
|
||||
|
||||
export type TutorialItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
abstract: string;
|
||||
category?: string;
|
||||
thumbnail?: string;
|
||||
videoUrl?: string;
|
||||
youtubeId?: string;
|
||||
aliases: string[];
|
||||
body: string;
|
||||
isPublished: boolean;
|
||||
};
|
||||
|
||||
export function getNewsPosts(): NewsPost[] {
|
||||
return readCollection("news")
|
||||
.map(({ slug, frontmatter, body }) => ({
|
||||
slug,
|
||||
title: frontmatter.title || slug,
|
||||
description: frontmatter.abstract || "",
|
||||
date: frontmatter.date || "",
|
||||
thumbnail: frontmatter.thumbnail,
|
||||
body,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.date && b.date) {
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function getNewsPostBySlug(slug: string): NewsPost | null {
|
||||
return getNewsPosts().find((post) => post.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function getFaqItems(): FaqItem[] {
|
||||
return readCollection("faq")
|
||||
.map(({ slug, frontmatter, body }) => ({
|
||||
slug,
|
||||
title: frontmatter.title || slug,
|
||||
description: frontmatter.abstract || "",
|
||||
body,
|
||||
isPublished: frontmatter.isPublished !== "false",
|
||||
thumbnail: frontmatter.thumbnail,
|
||||
}))
|
||||
.filter((item) => item.isPublished);
|
||||
}
|
||||
|
||||
export function getFaqItemBySlug(slug: string): FaqItem | null {
|
||||
return getFaqItems().find((item) => item.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function getTutorialItems(): TutorialItem[] {
|
||||
return readCollection("tutorials")
|
||||
.map(({ slug, frontmatter, body }) => ({
|
||||
slug: (frontmatter.slug || slug).trim().toLowerCase(),
|
||||
title: frontmatter.title || slug,
|
||||
abstract: frontmatter.abstract || "",
|
||||
category: frontmatter.category,
|
||||
thumbnail: frontmatter.thumbnail,
|
||||
videoUrl: frontmatter.videoUrl,
|
||||
youtubeId: frontmatter.youtubeId,
|
||||
aliases: frontmatter.aliases
|
||||
? frontmatter.aliases
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
body,
|
||||
isPublished: frontmatter.isPublished !== "false",
|
||||
}))
|
||||
.filter((item) => item.isPublished);
|
||||
}
|
||||
|
||||
export function getTutorialItemBySlug(slug: string): TutorialItem | null {
|
||||
const wanted = slug.toLowerCase();
|
||||
return (
|
||||
getTutorialItems().find(
|
||||
(item) => item.slug === wanted || item.aliases.includes(wanted),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Reads every .md file in a content folder, sorted by filename so output is
|
||||
// deterministic across platforms (readdir order is not guaranteed).
|
||||
function readCollection(folder: "news" | "faq" | "tutorials") {
|
||||
const dir = join(CONTENT_ROOT, folder);
|
||||
return readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort()
|
||||
.map((file) => {
|
||||
const { frontmatter, body } = parseFrontmatter(
|
||||
readFileSync(join(dir, file), "utf8"),
|
||||
);
|
||||
return { slug: file.replace(/\.md$/, ""), frontmatter, body };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Desktop installer links, ported from the Vite site's
|
||||
// config/github_download_links.ts. Keep previous releases in the history
|
||||
// when adding new downloads, newest first.
|
||||
const DOWNLOAD_HISTORY = {
|
||||
"0.37.0": githubDownloadLinks("0.37.0"),
|
||||
"0.32.0": githubDownloadLinks("0.32.0"),
|
||||
"0.28.0": githubDownloadLinks("0.28.0"),
|
||||
"0.23.0": githubDownloadLinks("0.23.0"), // 2026-04-03 Characters + prompt box
|
||||
"0.20.0": githubDownloadLinks("0.20.0"), // 2026-03-24 Reprompt, performance
|
||||
"0.14.0": githubDownloadLinks("0.14.0"), // 2026-03-10 WorldLabs native, Angles
|
||||
"0.11.0": githubDownloadLinks("0.11.0"), // 2026-03-02 Credits accounting
|
||||
"0.7.0": githubDownloadLinks("0.7.0"), // 2026-02-23 Seedance
|
||||
} 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";
|
||||
|
||||
// Set false to temporarily hide desktop downloads throughout the website.
|
||||
export const DOWNLOADS_ENABLED = true;
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
WINDOWS: DOWNLOAD_HISTORY[WINDOWS_VERSION].WINDOWS,
|
||||
MACOS: DOWNLOAD_HISTORY[MAC_VERSION].MACOS,
|
||||
} as const;
|
||||
|
||||
export const DOWNLOAD_VERSIONS = {
|
||||
WINDOWS: WINDOWS_VERSION,
|
||||
MACOS: MAC_VERSION,
|
||||
} as const;
|
||||
|
||||
function githubDownloadLinks(version: string) {
|
||||
const baseUrl = `https://github.com/storytold/artcraft/releases/download/artcraft-v${version}`;
|
||||
return {
|
||||
WINDOWS: `${baseUrl}/ArtCraft_${version}_x64-setup.exe`,
|
||||
MACOS: `${baseUrl}/ArtCraft_${version}_universal.dmg`,
|
||||
} as const;
|
||||
}
|
||||
@@ -114,6 +114,16 @@ export const RULER_SECTIONS: RulerSection[] = [
|
||||
{ id: "ownership", label: "OWNERSHIP" },
|
||||
{ id: "made-with", label: "COMMUNITY" },
|
||||
{ id: "start", label: "GET STARTED" },
|
||||
// Interior marketing pages. Each page mounts only its own ids; the rest
|
||||
// are skipped, so one roster serves the whole site.
|
||||
{ id: "plans", label: "PLANS" },
|
||||
{ id: "enterprise", label: "ENTERPRISE" },
|
||||
{ id: "credits", label: "CREDITS" },
|
||||
{ id: "platforms", label: "PLATFORMS" },
|
||||
{ id: "quick-start", label: "QUICK START" },
|
||||
{ id: "articles", label: "ARTICLES" },
|
||||
{ id: "contact", label: "CONTACT" },
|
||||
{ id: "apply", label: "APPLY" },
|
||||
];
|
||||
|
||||
export const MADE_WITH_YOUTUBE_IDS = [
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Minimal markdown renderer, ported from @storyteller/markdown-content. It
|
||||
// covers exactly the subset our content files use (ATX headings, paragraphs,
|
||||
// "- " lists, blockquotes, bold/italic, links, images, and the @youtube /
|
||||
// @video / @loop_autoplay / @image directives). Output is styled by the
|
||||
// `.article-content` rules in globals.css — no inline styling here, so the
|
||||
// same markup renders correctly in both themes.
|
||||
|
||||
export type Frontmatter = Record<string, string>;
|
||||
|
||||
export type FrontmatterResult = {
|
||||
frontmatter: Frontmatter;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".m4v", ".avi"];
|
||||
|
||||
const YOUTUBE_PATTERNS = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
|
||||
];
|
||||
|
||||
export function parseFrontmatter(raw: string): FrontmatterResult {
|
||||
const text = raw.replace(/\r\n/g, "\n");
|
||||
if (!text.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: text };
|
||||
}
|
||||
const end = text.indexOf("\n---\n", 4);
|
||||
if (end === -1) return { frontmatter: {}, body: text };
|
||||
const header = text.slice(4, end);
|
||||
const body = text.slice(end + 5);
|
||||
const frontmatter: Frontmatter = {};
|
||||
for (const line of header.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line
|
||||
.slice(idx + 1)
|
||||
.trim()
|
||||
.replace(/^"|^'|"$|'$/g, "");
|
||||
if (key) frontmatter[key] = value;
|
||||
}
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
// Extracts a YouTube video id from a bare id or any common YouTube URL form.
|
||||
export function extractYouTubeId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
|
||||
for (const pattern of YOUTUBE_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function markdownToHtml(markdown: string, basePath = ""): string {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
let blockquoteLines: string[] = [];
|
||||
let paragraphLines: string[] = [];
|
||||
|
||||
const isAbsolute = (url: string) => /^(?:[a-z]+:)?\/\//i.test(url);
|
||||
const normalizeLink = (url: string): string => {
|
||||
if (!basePath || !url) return url;
|
||||
if (url.startsWith("#") || url.startsWith("/") || isAbsolute(url)) {
|
||||
return url;
|
||||
}
|
||||
return basePath + url.replace(/^\.\.?\//, "");
|
||||
};
|
||||
|
||||
const isVideoUrl = (url: string) => {
|
||||
const lower = url.toLowerCase().split("?")[0];
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
};
|
||||
|
||||
const videoMimeType = (url: string) => {
|
||||
const lower = url.toLowerCase().split("?")[0];
|
||||
if (lower.endsWith(".webm")) return "webm";
|
||||
if (lower.endsWith(".ogg")) return "ogg";
|
||||
return "mp4";
|
||||
};
|
||||
|
||||
const escapeAttr = (value: string) => value.replace(/"/g, """);
|
||||
|
||||
const renderYouTube = (videoId: string) =>
|
||||
`<div class="video-embed youtube-embed"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div>`;
|
||||
|
||||
const renderVideo = (url: string, loop: boolean) => {
|
||||
const src = escapeAttr(normalizeLink(url));
|
||||
const attrs = loop
|
||||
? "autoplay loop muted playsinline"
|
||||
: 'controls preload="metadata"';
|
||||
return `<div class="video-embed"><video ${attrs}><source src="${src}" type="video/${videoMimeType(url)}">Your browser does not support the video tag.</video></div>`;
|
||||
};
|
||||
|
||||
// Optional "WIDTHxHEIGHT" / "WIDTH" size suffix (from ``
|
||||
// or `@image(url, 300)`); bare numbers are pixels.
|
||||
const renderImage = (url: string, alt = "", sizeStr = "") => {
|
||||
const src = escapeAttr(normalizeLink(url.trim()));
|
||||
const [width, height] = sizeStr.toLowerCase().split("x");
|
||||
const toCss = (v?: string) =>
|
||||
v ? (v.endsWith("%") || v.endsWith("px") ? v : `${v}px`) : undefined;
|
||||
const style = [
|
||||
toCss(width) && `width:${toCss(width)}`,
|
||||
toCss(height) && `height:${toCss(height)}`,
|
||||
width && width !== "100%" && "margin-left:auto;margin-right:auto",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
return `<img src="${src}" alt="${escapeAttr(alt)}" loading="lazy"${style ? ` style="${style}"` : ""} />`;
|
||||
};
|
||||
|
||||
const renderInline = (text: string): string => {
|
||||
let out = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, urlAndSize) => {
|
||||
const [url, size] = String(urlAndSize).split("|");
|
||||
const safeUrl = String(url ?? "").trim();
|
||||
return isVideoUrl(safeUrl)
|
||||
? renderVideo(safeUrl, false)
|
||||
: renderImage(safeUrl, alt, size ?? "");
|
||||
});
|
||||
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
||||
const href = escapeAttr(normalizeLink(String(url ?? "")));
|
||||
const external = isAbsolute(href);
|
||||
return `<a href="${href}"${external ? ' target="_blank" rel="noopener noreferrer"' : ""}>${escapeAttr(String(label ?? ""))}</a>`;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
const flushParagraph = () => {
|
||||
if (paragraphLines.length) {
|
||||
html.push(`<p>${paragraphLines.map(renderInline).join(" ")}</p>`);
|
||||
paragraphLines = [];
|
||||
}
|
||||
};
|
||||
const flushBlockquote = () => {
|
||||
if (blockquoteLines.length) {
|
||||
html.push(`<blockquote>${renderInline(blockquoteLines.join(" "))}</blockquote>`);
|
||||
blockquoteLines = [];
|
||||
}
|
||||
};
|
||||
const startBlock = () => {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
const ltrim = line.replace(/^\s+/, "");
|
||||
|
||||
const blockquote = line.match(/^>\s?(.*)$/);
|
||||
if (blockquote) {
|
||||
startBlock();
|
||||
blockquoteLines.push(blockquote[1]);
|
||||
continue;
|
||||
}
|
||||
flushBlockquote();
|
||||
|
||||
const youtube = ltrim.match(/^@youtube\(([^)]+)\)\s*$/);
|
||||
if (youtube) {
|
||||
startBlock();
|
||||
const id = extractYouTubeId(youtube[1]);
|
||||
html.push(id ? renderYouTube(id) : `<p>Invalid YouTube video: ${youtube[1]}</p>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const video = ltrim.match(/^@video\(([^)]+)\)\s*$/);
|
||||
if (video) {
|
||||
startBlock();
|
||||
html.push(renderVideo(video[1], false));
|
||||
continue;
|
||||
}
|
||||
|
||||
const loop = ltrim.match(/^@loop_autoplay\(([^)]+)\)\s*$/);
|
||||
if (loop) {
|
||||
startBlock();
|
||||
html.push(renderVideo(loop[1], true));
|
||||
continue;
|
||||
}
|
||||
|
||||
const image = ltrim.match(/^@(image|gif)\(([^,)]+)(?:,\s*([^)]+))?\)\s*$/);
|
||||
if (image) {
|
||||
startBlock();
|
||||
html.push(renderImage(image[2], "", image[3]?.trim() ?? ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = ltrim.match(/^(#{1,6})\s*(.*)$/);
|
||||
if (heading) {
|
||||
startBlock();
|
||||
const level = Math.min(heading[1].length, 6);
|
||||
html.push(`<h${level}>${renderInline(heading[2] ?? "")}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const standaloneImage = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||
if (standaloneImage) {
|
||||
startBlock();
|
||||
const [url, size] = standaloneImage[2].split("|");
|
||||
const safeUrl = String(url ?? "").trim();
|
||||
html.push(
|
||||
isVideoUrl(safeUrl)
|
||||
? renderVideo(safeUrl, false)
|
||||
: renderImage(safeUrl, standaloneImage[1] ?? "", size ?? ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("- ")) {
|
||||
flushParagraph();
|
||||
if (!inList) {
|
||||
inList = true;
|
||||
html.push("<ul>");
|
||||
}
|
||||
html.push(`<li>${renderInline(line.slice(2))}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
startBlock();
|
||||
continue;
|
||||
}
|
||||
|
||||
closeList();
|
||||
paragraphLines.push(line);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushBlockquote();
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Press kit assets, ported from the Vite site. To add one, copy an existing
|
||||
// entry:
|
||||
// - type: "video" | "image" | "embed" | "link"
|
||||
// - thumbnail: site-relative image path (served via mediaUrl) or absolute URL
|
||||
// - embedUrl: YouTube embed URL for type "embed"
|
||||
// - videoUrl: direct video URL for type "video" (defaults to downloadUrl)
|
||||
// - downloadUrl: the downloadable file (e.g. Cloudflare R2 URL)
|
||||
// - containThumbnail: letterbox the thumbnail with padding (good for logos)
|
||||
|
||||
export type PressKitAssetType = "video" | "image" | "embed" | "link";
|
||||
|
||||
export type PressKitAsset = {
|
||||
type: PressKitAssetType;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
embedUrl?: string;
|
||||
videoUrl?: string;
|
||||
downloadUrl: string;
|
||||
downloadLabel?: string;
|
||||
fileSize?: string;
|
||||
containThumbnail?: boolean;
|
||||
};
|
||||
|
||||
export type PressKitCategory = {
|
||||
name: string;
|
||||
description?: string;
|
||||
assets: PressKitAsset[];
|
||||
};
|
||||
|
||||
export const PRESS_CONTACT_PHONE = { display: "(678) 744-6080", tel: "678-744-6080" };
|
||||
|
||||
const R2 = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev";
|
||||
|
||||
export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
|
||||
{
|
||||
name: "Promotional videos",
|
||||
description: "High-quality promotional videos for press coverage",
|
||||
assets: [
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft Commercial",
|
||||
description: "Official ArtCraft commercial showcasing the app",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-commercial.png",
|
||||
downloadUrl: `${R2}/artcraft_website_v2.mp4`,
|
||||
fileSize: "125 MB",
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft 3D Sci-Fi Animation",
|
||||
description: "3D pre-viz workflow demonstrates precision control",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-sci-fi.png",
|
||||
downloadUrl: `${R2}/ArtCraft_SciFi_Horror.mp4`,
|
||||
fileSize: "14.5 MB",
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft x WorldLabs Pirate Demo",
|
||||
description: "ArtCraft with Gaussian splats can be used to quickly create sets",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-pirate.png",
|
||||
downloadUrl: `${R2}/ArtCraft_World_Pirate.mp4`,
|
||||
fileSize: "88.5 MB",
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft Posing and Blocking",
|
||||
description: "More examples of detailed posing and blocking in 3D",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-knight-pose.png",
|
||||
downloadUrl: `${R2}/ArtCraft_Pose_Ad.mp4`,
|
||||
fileSize: "60.6 MB",
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
title: "ArtCraft x WorldLabs Ad",
|
||||
description: "Several shots crafted using WorldLabs' advanced Marble model",
|
||||
thumbnail: "/images/video-thumbnails/artcraft-worldlabs-ad.png",
|
||||
downloadUrl: `${R2}/ArtCraft_WorldLabs_Ad.mp4`,
|
||||
fileSize: "23.7 MB",
|
||||
},
|
||||
{
|
||||
type: "embed",
|
||||
title: "Grinch: The Anime",
|
||||
description: "Made using ArtCraft",
|
||||
thumbnail: "https://img.youtube.com/vi/oqoCWdOwr2U/maxresdefault.jpg",
|
||||
embedUrl: "https://www.youtube-nocookie.com/embed/oqoCWdOwr2U",
|
||||
downloadUrl: `${R2}/ArtCraft_Grinch_Anime.mp4`,
|
||||
fileSize: "56.3 MB",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Logos & branding",
|
||||
description: "Official ArtCraft logos and branding assets",
|
||||
assets: [
|
||||
{
|
||||
type: "image",
|
||||
title: "ArtCraft Logo (PNG)",
|
||||
thumbnail: "/images/artcraft-logo.png",
|
||||
downloadUrl: "/images/artcraft-logo.png",
|
||||
containThumbnail: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Screenshots & media",
|
||||
description: "High-resolution screenshots and promotional images",
|
||||
assets: [],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
// Subscription plan roster, ported from @storyteller/subscription and the
|
||||
// pricing table's promo math. Prices and features are the approved
|
||||
// marketing numbers — keep them in sync with the webapp's plan data.
|
||||
//
|
||||
// Checkout itself (Stripe, session detection, plan switching) lives in the
|
||||
// webapp; every plan CTA here links there.
|
||||
|
||||
export type BillingCadence = "monthly" | "yearly";
|
||||
|
||||
export type PlanFeature = {
|
||||
text: string;
|
||||
seedanceOnly?: boolean;
|
||||
};
|
||||
|
||||
export type SubscriptionPlan = {
|
||||
slug: string;
|
||||
name: string;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
features: PlanFeature[];
|
||||
/** Callout tab rendered above the card. */
|
||||
highlight?: "Most popular" | "Best value";
|
||||
};
|
||||
|
||||
// Every plan is shown at this single discount off a higher "before" price, on
|
||||
// BOTH cadences. The charged prices are unchanged — this only drives the
|
||||
// "% off" pills, the crossed-out anchor, and the banner copy.
|
||||
export const PROMO_PCT = 20;
|
||||
|
||||
export const CONTACT_EMAIL = "hello@storyteller.ai";
|
||||
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
slug: "artcraft_basic",
|
||||
name: "Basic",
|
||||
monthlyPrice: 10,
|
||||
yearlyPrice: 96,
|
||||
features: [
|
||||
{ text: "1,000 credits / month" },
|
||||
{ text: "~250 Nano Banana images" },
|
||||
{ text: "~67 Nano Banana Pro images" },
|
||||
{ text: "~33 Nano Banana Pro 4K images" },
|
||||
{ text: "~200 GPT-Image-1.5 images" },
|
||||
{ text: "~63 seconds Seedance 2.0 video", seedanceOnly: true },
|
||||
{ text: "~45 seconds Kling 3.0 Pro video" },
|
||||
{ text: "You own ArtCraft forever" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "artcraft_pro",
|
||||
name: "Pro",
|
||||
monthlyPrice: 35,
|
||||
yearlyPrice: 336,
|
||||
highlight: "Most popular",
|
||||
features: [
|
||||
{ text: "3,750 credits / month" },
|
||||
{ text: "~938 Nano Banana images" },
|
||||
{ text: "~250 Nano Banana Pro images" },
|
||||
{ text: "~125 Nano Banana Pro 4K images" },
|
||||
{ text: "~750 GPT-Image-1.5 images" },
|
||||
{ text: "~234 seconds Seedance 2.0 video", seedanceOnly: true },
|
||||
{ text: "~167 seconds Kling 3.0 Pro video" },
|
||||
{ text: "You own ArtCraft forever" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "artcraft_max",
|
||||
name: "Max",
|
||||
monthlyPrice: 60,
|
||||
yearlyPrice: 576,
|
||||
highlight: "Best value",
|
||||
features: [
|
||||
{ text: "6,600 credits / month" },
|
||||
{ text: "~1,650 Nano Banana images" },
|
||||
{ text: "~440 Nano Banana Pro images" },
|
||||
{ text: "~220 Nano Banana Pro 4K images" },
|
||||
{ text: "~1,320 GPT-Image-1.5 images" },
|
||||
{ text: "~413 seconds Seedance 2.0 video", seedanceOnly: true },
|
||||
{ text: "~295 seconds Kling 3.0 Pro video" },
|
||||
{ text: "You own ArtCraft forever" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ENTERPRISE_FEATURES = [
|
||||
"Bespoke credit allocation",
|
||||
"Secure models",
|
||||
"Support SLAs",
|
||||
"Custom integrations",
|
||||
];
|
||||
|
||||
export type PlanPricing = {
|
||||
/** Displayed per-month price for the cadence, whole dollars. */
|
||||
current: number;
|
||||
/** Crossed-out "before" price the discount is applied to, whole dollars. */
|
||||
basePrice: number;
|
||||
};
|
||||
|
||||
// The displayed price is the real charge; the anchor is current ÷ (1 − promo),
|
||||
// rounded so no price ever shows cents.
|
||||
export function planPricing(
|
||||
plan: SubscriptionPlan,
|
||||
cadence: BillingCadence,
|
||||
): PlanPricing {
|
||||
const current =
|
||||
cadence === "yearly" ? Math.round(plan.yearlyPrice / 12) : plan.monthlyPrice;
|
||||
return {
|
||||
current,
|
||||
basePrice: Math.round(current / (1 - PROMO_PCT / 100)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user