Initial ttvfx.com site: Next.js static export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-05 18:17:41 +02:00
commit 1f3af8f7f5
33 changed files with 8003 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ttvfx-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "-p", "3210"],
"port": 3210
}
]
}
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+64
View File
@@ -0,0 +1,64 @@
# Deploying ttvfx.com
The site is a fully static export, so there is no Node process to run in production.
## 1. Build
```bash
npm ci
npm run build # outputs ./out
```
## 2. DNS
`ttvfx.com` does not resolve yet. At the registrar or DNS host (Cloudflare, if
you want it alongside twotalesvfx.com), add:
| Type | Name | Value |
|------|------|-------|
| A | `@` | `<VPS IPv4>` |
| A | `www`| `<VPS IPv4>` |
## 3. Upload
```bash
rsync -avz --delete out/ user@your-vps:/var/www/ttvfx.com/
```
## 4. nginx
`/etc/nginx/sites-available/ttvfx.com`:
```nginx
server {
listen 80;
server_name ttvfx.com www.ttvfx.com;
root /var/www/ttvfx.com;
# www -> apex
if ($host = www.ttvfx.com) { return 301 https://ttvfx.com$request_uri; }
location / {
try_files $uri $uri/ $uri.html =404;
}
# Hashed build assets can be cached forever
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /404.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml application/json;
}
```
```bash
sudo ln -s /etc/nginx/sites-available/ttvfx.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ttvfx.com -d www.ttvfx.com # HTTPS via Let's Encrypt
```
(For Apache, set `DocumentRoot` to the same folder plus `ErrorDocument 404 /404.html`.
Because the build uses `trailingSlash`, every page is a folder with its own `index.html`.)
+36
View File
@@ -0,0 +1,36 @@
# ttvfx.com
The marketing site for **Two Tales VFX**: Next.js 16 (App Router), Tailwind CSS v4, exported as static HTML.
```bash
npm install
npm run dev # http://localhost:3000
npm run build # static site in ./out
```
## Editing content
All copy (studio info, services, projects, contact email) lives in
[`content/site.ts`](content/site.ts). Items marked `TODO` are placeholders.
- **Projects:** add an entry to `projects`. Put stills in `public/work/` and set
`image: "/work/name.jpg"` (2.39:1 looks best). `video` accepts an MP4 path
or a Vimeo/YouTube embed URL. Each project gets a page at `/work/<slug>/`.
- **Showreel:** drop `public/reel.mp4` (muted, looping, ideally under 10 MB) and
`public/reel-poster.jpg`, then set `site.reel` and `site.reelPoster`.
- **Logo:** the fox mark is a vector in `components/Fox.tsx` / `public/fox.svg`,
traced from `G:\_ADMIN\TWOTALESLOGO.png`. Favicons are `app/icon.png` and `app/apple-icon.png`.
## Structure
```
app/
page.tsx home: hero, selected work, services, studio, contact
work/page.tsx project index
work/[slug]/ project detail pages (statically generated)
sitemap.ts, robots.ts
components/ Header, Footer, Hero, Frame, ProjectCard, Reveal, Timecode…
content/site.ts all site content
```
Deployment is covered in [DEPLOY.md](DEPLOY.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+87
View File
@@ -0,0 +1,87 @@
@import "tailwindcss";
:root {
--ink: #0a0a0b;
--ink-2: #111113;
--line: rgb(255 255 255 / 0.1);
--fg: #ecebe8;
--muted: #8d8c88;
--accent: #e8572a;
}
@theme inline {
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-line: var(--line);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-accent: var(--accent);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-display: var(--font-josefin);
}
html {
color-scheme: dark;
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
background: var(--ink);
color: var(--fg);
}
::selection {
background: var(--accent);
color: var(--ink);
}
/* Film grain overlay */
.grain::after {
content: "";
position: fixed;
inset: -50%;
pointer-events: none;
z-index: 60;
opacity: 0.07;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grain 0.8s steps(4) infinite;
}
@keyframes grain {
0% { transform: translate(0, 0); }
25% { transform: translate(-3%, 2%); }
50% { transform: translate(2%, -3%); }
75% { transform: translate(-2%, -1%); }
100% { transform: translate(1%, 3%); }
}
/* Scroll reveal (toggled by <Reveal>) */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.9s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
@keyframes drift {
0%, 100% { transform: translate3d(-6%, -4%, 0) scale(1); }
50% { transform: translate3d(6%, 4%, 0) scale(1.15); }
}
.animate-drift { animation: drift 18s ease-in-out infinite; }
@keyframes scan {
from { transform: translateY(-10vh); }
to { transform: translateY(110vh); }
}
.animate-scan { animation: scan 7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.grain::after, .animate-drift, .animate-scan { animation: none; }
.reveal { opacity: 1; transform: none; transition: none; }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+50
View File
@@ -0,0 +1,50 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Josefin_Sans } from "next/font/google";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { site } from "@/content/site";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
const josefin = Josefin_Sans({
variable: "--font-josefin",
subsets: ["latin"],
weight: ["100", "300", "400"],
});
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: {
default: `${site.name}: Visual Effects Studio`,
template: `%s | ${site.name}`,
},
description: site.description,
openGraph: {
type: "website",
siteName: site.name,
title: `${site.name}: Visual Effects Studio`,
description: site.description,
url: site.url,
},
twitter: { card: "summary_large_image" },
};
export const viewport: Viewport = {
themeColor: "#0a0a0b",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} ${josefin.variable} antialiased`}
>
<body className="grain flex min-h-dvh flex-col font-sans">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import Fox from "@/components/Fox";
export default function NotFound() {
return (
<div className="flex min-h-[80vh] flex-col items-center justify-center px-5 text-center">
<Fox className="h-16 w-16 text-white/20" />
<p className="mt-8 font-mono text-xs tracking-[0.3em] text-accent uppercase">Error 404 · Frame missing</p>
<h1 className="mt-4 font-display text-5xl font-thin uppercase md:text-7xl">Shot not found</h1>
<Link
href="/"
className="mt-10 border border-white/25 px-6 py-3.5 font-mono text-xs tracking-[0.2em] uppercase hover:border-fg"
>
Back to home
</Link>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import Link from "next/link";
import Hero from "@/components/Hero";
import ProjectCard from "@/components/ProjectCard";
import Reveal from "@/components/Reveal";
import SectionLabel from "@/components/SectionLabel";
import { projects, services, site, stats } from "@/content/site";
const pipeline = [
{ step: "Breakdown", body: "Script and plate breakdowns, bids and a shot list everyone agrees on." },
{ step: "Build", body: "Assets, environments and lookdev, approved in context, not in isolation." },
{ step: "Shot", body: "Artists work in tracked versions, reviewed daily on our own review platform." },
{ step: "QC", body: "Every EXR is checked automatically for frame range, NaNs, dead pixels and colour space before a human signs off." },
{ step: "Deliver", body: "Spec-correct, frame-accurate deliveries with full shot metadata." },
];
export default function Home() {
return (
<>
<Hero />
{/* Selected work */}
<section id="work" className="mx-auto max-w-7xl px-5 py-24 md:px-8 md:py-36">
<SectionLabel index="01">Selected work</SectionLabel>
<div className="grid gap-x-8 gap-y-16 md:grid-cols-2">
{projects.slice(0, 4).map((p, i) => (
<Reveal key={p.slug} delay={(i % 2) * 120} className={i % 2 === 1 ? "md:mt-24" : ""}>
<ProjectCard project={p} />
</Reveal>
))}
</div>
<div className="mt-20 flex justify-center">
<Link
href="/work/"
className="border border-white/25 px-8 py-4 font-mono text-xs tracking-[0.2em] uppercase transition-colors hover:border-accent hover:text-accent"
>
All projects →
</Link>
</div>
</section>
{/* Services */}
<section id="services" className="border-t border-line bg-ink-2">
<div className="mx-auto max-w-7xl px-5 py-24 md:px-8 md:py-36">
<SectionLabel index="02">Services</SectionLabel>
<Reveal>
<h2 className="max-w-4xl font-display text-4xl leading-tight font-thin uppercase md:text-6xl">
From first breakdown to final <span className="text-accent">delivery</span>.
</h2>
</Reveal>
<div className="mt-16 grid border-t border-l border-line sm:grid-cols-2 lg:grid-cols-3">
{services.map((s, i) => (
<Reveal key={s.code} delay={(i % 3) * 100} className="border-r border-b border-line">
<div className="group h-full p-8 transition-colors hover:bg-white/[0.02] md:p-10">
<span className="font-mono text-xs text-accent">{s.code}</span>
<h3 className="mt-6 font-display text-2xl font-light tracking-wide uppercase">{s.title}</h3>
<p className="mt-4 text-sm leading-relaxed text-muted">{s.body}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
{/* Studio / pipeline */}
<section id="studio" className="mx-auto max-w-7xl px-5 py-24 md:px-8 md:py-36">
<SectionLabel index="03">Studio</SectionLabel>
<div className="grid gap-16 lg:grid-cols-2">
<Reveal>
<h2 className="font-display text-4xl leading-tight font-thin uppercase md:text-6xl">
Two tales. <br />
One <span className="font-light">obsession</span>.
</h2>
<div className="mt-8 space-y-5 text-base leading-relaxed text-muted md:text-lg">
<p>
Two Tales started in {site.founded} as an animation studio. A decade later we&apos;re a visual
effects house that still thinks like animators: every frame is performance, every shot is story.
</p>
<p>
We&apos;re small on purpose. Senior artists, direct lines to supervisors, and an in-house pipeline
(review, shot tracking and automated QC) that lets a tight team deliver like a big one.
</p>
</div>
</Reveal>
<ol className="relative border-l border-line">
{pipeline.map((p, i) => (
<Reveal key={p.step} delay={i * 80}>
<li className="relative pb-10 pl-10 last:pb-0">
<span className="absolute top-1.5 -left-[5px] h-[9px] w-[9px] rotate-45 bg-accent" />
<div className="flex items-baseline gap-4">
<span className="font-mono text-xs text-muted">{String(i + 1).padStart(2, "0")}</span>
<h3 className="font-display text-xl font-light tracking-[0.15em] uppercase">{p.step}</h3>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted">{p.body}</p>
</li>
</Reveal>
))}
</ol>
</div>
<div className="mt-24 grid grid-cols-2 border-t border-line md:grid-cols-4">
{stats.map((s) => (
<div key={s.label} className="border-b border-line py-10 pr-6 md:border-b-0">
<p className="font-display text-5xl font-thin md:text-6xl">{s.value}</p>
<p className="mt-3 font-mono text-[11px] tracking-[0.2em] text-muted uppercase">{s.label}</p>
</div>
))}
</div>
</section>
{/* Contact */}
<section id="contact" className="relative overflow-hidden border-t border-line">
<div
className="pointer-events-none absolute -bottom-1/2 left-1/2 h-[80vmax] w-[80vmax] -translate-x-1/2 rounded-full bg-[radial-gradient(circle,rgb(232_87_42/0.14),transparent_60%)]"
aria-hidden="true"
/>
<div className="relative mx-auto max-w-7xl px-5 py-28 text-center md:px-8 md:py-44">
<SectionLabel index="04" className="justify-center">
Contact
</SectionLabel>
<Reveal>
<h2 className="font-display text-5xl leading-[0.95] font-thin uppercase md:text-8xl">
Got a story
<br />
to tell?
</h2>
<p className="mx-auto mt-8 max-w-lg text-muted md:text-lg">
Send us a script, a breakdown or just an idea. We&apos;ll come back with a plan and a bid.
</p>
<a
href={`mailto:${site.email}?subject=New%20project%20enquiry`}
className="mt-12 inline-block border-b border-accent pb-2 font-display text-2xl font-light tracking-wider break-all transition-colors hover:text-accent md:text-4xl"
>
{site.email}
</a>
</Reveal>
</div>
</section>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
import { site } from "@/content/site";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: `${site.url}/sitemap.xml`,
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { projects, site } from "@/content/site";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: `${site.url}/`, priority: 1 },
{ url: `${site.url}/work/`, priority: 0.8 },
...projects.map((p) => ({ url: `${site.url}/work/${p.slug}/`, priority: 0.6 })),
];
}
+110
View File
@@ -0,0 +1,110 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import Frame from "@/components/Frame";
import { projects, site } from "@/content/site";
export const dynamicParams = false;
export function generateStaticParams() {
return projects.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: PageProps<"/work/[slug]">): Promise<Metadata> {
const { slug } = await params;
const project = projects.find((p) => p.slug === slug);
if (!project) return {};
return { title: project.title, description: project.summary };
}
function Video({ src, title }: { src: string; title: string }) {
if (/vimeo\.com|youtube\.com|youtu\.be/.test(src)) {
return (
<iframe
src={src}
title={title}
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
);
}
return <video src={src} className="aspect-video w-full bg-black" controls playsInline />;
}
export default async function ProjectPage({ params }: PageProps<"/work/[slug]">) {
const { slug } = await params;
const index = projects.findIndex((p) => p.slug === slug);
if (index === -1) notFound();
const project = projects[index];
const next = projects[(index + 1) % projects.length];
const meta = [
{ k: "Client", v: project.client },
{ k: "Year", v: String(project.year) },
{ k: "Type", v: project.category },
...(project.shots ? [{ k: "Shots", v: String(project.shots) }] : []),
];
return (
<article className="pt-28 md:pt-36">
<div className="mx-auto max-w-7xl px-5 md:px-8">
<Link
href="/work/"
className="font-mono text-xs tracking-[0.2em] text-muted uppercase transition-colors hover:text-fg"
>
← All work
</Link>
<h1 className="mt-8 font-display text-5xl leading-[0.9] font-thin uppercase md:text-8xl">{project.title}</h1>
</div>
<div className="mx-auto mt-12 max-w-7xl px-5 md:px-8">
{project.video ? <Video src={project.video} title={project.title} /> : <Frame project={project} priority />}
</div>
<div className="mx-auto grid max-w-7xl gap-12 px-5 py-20 md:grid-cols-[1fr_2fr] md:px-8 md:py-28">
<dl className="grid grid-cols-2 gap-6 self-start md:grid-cols-1">
{meta.map((m) => (
<div key={m.k}>
<dt className="font-mono text-[11px] tracking-[0.2em] text-muted uppercase">{m.k}</dt>
<dd className="mt-1 text-lg">{m.v}</dd>
</div>
))}
</dl>
<div>
<p className="text-xl leading-relaxed font-light md:text-2xl">{project.summary}</p>
<ul className="mt-10 flex flex-wrap gap-2">
{project.credits.map((c) => (
<li
key={c}
className="border border-line px-3 py-1.5 font-mono text-[11px] tracking-[0.15em] text-muted uppercase"
>
{c}
</li>
))}
</ul>
<a
href={`mailto:${site.email}?subject=${encodeURIComponent(`Enquiry: ${project.title}`)}`}
className="mt-12 inline-block font-mono text-xs tracking-[0.2em] text-accent uppercase hover:text-fg"
>
Talk to us about a project like this →
</a>
</div>
</div>
<Link href={`/work/${next.slug}/`} className="group block border-t border-line">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-6 px-5 py-14 md:px-8 md:py-20">
<div>
<p className="font-mono text-xs tracking-[0.3em] text-muted uppercase">Next project</p>
<p className="mt-3 font-display text-3xl font-thin uppercase transition-colors group-hover:text-accent md:text-6xl">
{next.title}
</p>
</div>
<span className="font-display text-4xl font-thin transition-transform group-hover:translate-x-2 md:text-6xl">
→
</span>
</div>
</Link>
</article>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import ProjectCard from "@/components/ProjectCard";
import Reveal from "@/components/Reveal";
import SectionLabel from "@/components/SectionLabel";
import { projects } from "@/content/site";
export const metadata: Metadata = {
title: "Work",
description: "Selected visual effects work by Two Tales VFX across series, film, commercials and animation.",
};
export default function WorkPage() {
return (
<div className="mx-auto max-w-7xl px-5 pt-36 pb-24 md:px-8 md:pt-48 md:pb-36">
<SectionLabel index="—">Work</SectionLabel>
<h1 className="font-display text-6xl leading-[0.9] font-thin uppercase md:text-9xl">
Selected <span className="font-light">work</span>
</h1>
<div className="mt-20 grid gap-x-8 gap-y-16 md:grid-cols-2">
{projects.map((p, i) => (
<Reveal key={p.slug} delay={(i % 2) * 120}>
<ProjectCard project={p} />
</Reveal>
))}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import Fox from "./Fox";
import { nav, site } from "@/content/site";
export default function Footer() {
return (
<footer className="border-t border-line">
<div className="mx-auto grid max-w-7xl gap-10 px-5 py-14 md:grid-cols-3 md:px-8">
<div className="flex items-start gap-4">
<Fox className="h-10 w-10 shrink-0" />
<div>
<p className="font-display text-lg font-light tracking-[0.3em] uppercase">Two Tales VFX</p>
<p className="mt-1 font-mono text-xs tracking-widest text-muted uppercase">Est. {site.founded}</p>
</div>
</div>
<nav className="flex flex-col gap-2" aria-label="Footer">
{nav.map((item) => (
<Link key={item.href} href={item.href} className="text-sm text-muted hover:text-fg">
{item.label}
</Link>
))}
</nav>
<div className="flex flex-col gap-2 text-sm md:items-end">
<a href={`mailto:${site.email}`} className="hover:text-accent">
{site.email}
</a>
<span className="text-muted">{site.location}</span>
{site.socials.map((s) => (
<a key={s.href} href={s.href} target="_blank" rel="noreferrer" className="text-muted hover:text-fg">
{s.label}
</a>
))}
</div>
</div>
<div className="mx-auto flex max-w-7xl justify-between border-t border-line px-5 py-6 font-mono text-[11px] tracking-widest text-muted uppercase md:px-8">
<span>
© {new Date().getFullYear()} {site.name}
</span>
<span>ttvfx.com</span>
</div>
</footer>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function Fox({ className = "" }: { className?: string }) {
return (
<svg viewBox="252 0 406 404" className={className} fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M252 1 252 275 517 403 517 176 528 152 495 174 517 121 466 176 332 176Z M369 248 453 247 478 302Z"
/>
<path d="M549 303 657 248 572 248Z" />
</svg>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Project } from "@/content/site";
// A project still. Uses the project image when present, otherwise renders a
// stylised "viewer" frame (gradient plate + safe-area guide + slate overlay).
export default function Frame({ project, priority = false }: { project: Project; priority?: boolean }) {
const h = project.hue;
return (
<div className="relative aspect-[2.39/1] w-full overflow-hidden bg-ink-2">
{project.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={project.image}
alt={project.title}
loading={priority ? "eager" : "lazy"}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-[1.04]"
/>
) : (
<div
className="absolute inset-0 transition-transform duration-[1.2s] ease-out group-hover:scale-[1.06]"
style={{
background: `radial-gradient(ellipse 60% 80% at 70% 60%, hsl(${h} 70% 45% / .55), transparent 70%),
radial-gradient(ellipse 50% 60% at 20% 30%, hsl(${(h + 40) % 360} 60% 30% / .5), transparent 70%),
linear-gradient(180deg, hsl(${h} 25% 9%), hsl(${h} 30% 4%))`,
}}
>
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/70 to-transparent" />
</div>
)}
<div className="pointer-events-none absolute inset-[6%] border border-white/10" />
<div className="pointer-events-none absolute top-3 left-4 font-mono text-[10px] tracking-widest text-white/50 uppercase">
{project.category} · {project.year}
</div>
<div className="pointer-events-none absolute top-3 right-4 font-mono text-[10px] tracking-widest text-white/50">
{project.shots ? `${project.shots} SHOTS` : "2.39:1"}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import Fox from "./Fox";
import { nav } from "@/content/site";
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
}, [open]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-500 ${
scrolled || open ? "border-b border-line bg-ink/80 backdrop-blur-md" : "border-b border-transparent"
}`}
>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 md:h-20 md:px-8">
<Link href="/" className="flex items-center gap-3" onClick={() => setOpen(false)}>
<Fox className="h-7 w-7 text-fg md:h-8 md:w-8" />
<span className="font-display text-base font-light tracking-[0.3em] uppercase md:text-lg">
Two Tales <span className="text-accent">VFX</span>
</span>
</Link>
<nav className="hidden items-center gap-10 md:flex" aria-label="Main">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="font-mono text-xs tracking-[0.2em] text-muted uppercase transition-colors hover:text-fg"
>
{item.label}
</Link>
))}
</nav>
<button
type="button"
className="relative h-10 w-10 md:hidden"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span
className={`absolute right-2 left-2 h-px bg-fg transition-transform duration-300 ${open ? "rotate-45" : "-translate-y-1.5"}`}
/>
<span
className={`absolute right-2 left-2 h-px bg-fg transition-transform duration-300 ${open ? "-rotate-45" : "translate-y-1.5"}`}
/>
</button>
</div>
{open && (
<nav className="flex h-[calc(100dvh-4rem)] flex-col gap-2 px-5 pt-10 md:hidden" aria-label="Mobile">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className="font-display text-4xl font-light tracking-wider uppercase"
>
{item.label}
</Link>
))}
</nav>
)}
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import Fox from "./Fox";
import Timecode from "./Timecode";
import { site } from "@/content/site";
export default function Hero() {
return (
<section className="relative flex min-h-[100svh] items-end overflow-hidden pt-32 md:pt-44">
{/* Plate: showreel if configured, otherwise a moving light field */}
{site.reel ? (
<video
className="absolute inset-0 h-full w-full object-cover opacity-60"
src={site.reel}
poster={site.reelPoster ?? undefined}
autoPlay
muted
loop
playsInline
/>
) : (
<div className="absolute inset-0" aria-hidden="true">
<div className="animate-drift absolute top-[10%] left-[45%] h-[70vmax] w-[70vmax] rounded-full bg-[radial-gradient(circle,rgb(232_87_42/0.28),transparent_60%)] blur-2xl" />
<div
className="animate-drift absolute top-[-20%] left-[-20%] h-[60vmax] w-[60vmax] rounded-full bg-[radial-gradient(circle,rgb(70_110_160/0.22),transparent_60%)] blur-2xl"
style={{ animationDelay: "-9s" }}
/>
<Fox className="absolute top-1/2 right-[-8vw] h-[85vh] w-auto -translate-y-1/2 text-white/[0.035]" />
<div className="animate-scan absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-ink via-ink/40 to-ink/30" aria-hidden="true" />
{/* Viewer HUD */}
<div
className="pointer-events-none absolute inset-x-5 top-24 bottom-6 hidden font-mono text-[10px] tracking-[0.25em] text-white/40 uppercase md:inset-x-8 md:top-28 md:block"
aria-hidden="true"
>
<span className="absolute top-0 left-0 h-4 w-4 border-t border-l border-white/30" />
<span className="absolute top-0 right-0 h-4 w-4 border-t border-r border-white/30" />
<span className="absolute bottom-0 left-0 h-4 w-4 border-b border-l border-white/30" />
<span className="absolute right-0 bottom-0 h-4 w-4 border-r border-b border-white/30" />
<span className="absolute top-3 left-6">TTVFX_REEL_v{String(new Date().getFullYear()).slice(2)}1</span>
<span className="absolute top-3 right-6">ACEScg · 24 fps</span>
<Timecode className="absolute right-6 bottom-3" />
</div>
<div className="relative mx-auto w-full max-w-7xl px-5 pb-20 md:px-8 md:pb-28">
<p className="mb-6 font-mono text-xs tracking-[0.3em] text-accent uppercase">
Visual effects studio · Est. {site.founded}
</p>
<h1 className="font-display text-[9.5vw] leading-[0.95] font-thin tracking-tight uppercase md:text-[8.5vw] xl:text-[8rem]">
Invisible work.
<br />
<span className="font-light">Unforgettable</span> frames.
</h1>
<div className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<p className="max-w-md text-base leading-relaxed text-muted md:text-lg">
Compositing, CG and finishing for series, film and commercials, built on a pipeline we wrote ourselves.
</p>
<div className="flex gap-3">
<Link
href="/work/"
className="bg-fg px-5 py-3.5 whitespace-nowrap md:px-6 font-mono text-xs tracking-[0.2em] text-ink uppercase transition-colors hover:bg-accent"
>
View work
</Link>
<Link
href="/#contact"
className="border border-white/25 px-5 py-3.5 font-mono text-xs tracking-[0.2em] whitespace-nowrap uppercase transition-colors hover:border-fg md:px-6"
>
Start a project
</Link>
</div>
</div>
</div>
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link";
import Frame from "./Frame";
import type { Project } from "@/content/site";
export default function ProjectCard({ project }: { project: Project }) {
return (
<Link href={`/work/${project.slug}/`} className="group block">
<Frame project={project} />
<div className="mt-5 flex items-start justify-between gap-6">
<div>
<h3 className="font-display text-2xl font-light tracking-wide uppercase transition-colors group-hover:text-accent md:text-3xl">
{project.title}
</h3>
<p className="mt-1 text-sm text-muted">{project.client}</p>
</div>
<p className="shrink-0 pt-2 text-right font-mono text-[11px] tracking-widest text-muted uppercase">
{project.credits.slice(0, 2).join(" / ")}
</p>
</div>
</Link>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export default function Reveal({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add("is-visible");
io.disconnect();
}
},
{ rootMargin: "0px 0px -10% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div ref={ref} className={`reveal ${className}`} style={{ transitionDelay: `${delay}ms` }}>
{children}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
export default function SectionLabel({
index,
children,
className = "",
}: {
index: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`mb-10 flex items-center gap-4 font-mono text-xs tracking-[0.3em] text-muted uppercase md:mb-14 ${className}`}>
<span className="text-accent">{index}</span>
<span className="h-px w-10 bg-line" />
<span>{children}</span>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef } from "react";
const FPS = 24;
const pad = (n: number) => String(n).padStart(2, "0");
// A running SMPTE-style timecode, written straight to the DOM to avoid re-renders.
export default function Timecode({ className = "" }: { className?: string }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const start = performance.now() - 1001 * (1000 / FPS);
const id = window.setInterval(() => {
const f = Math.floor(((performance.now() - start) / 1000) * FPS);
const s = Math.floor(f / FPS);
if (ref.current) {
ref.current.textContent = `01:${pad(Math.floor(s / 60) % 60)}:${pad(s % 60)}:${pad(f % FPS)}`;
}
}, 1000 / FPS);
return () => window.clearInterval(id);
}, []);
return (
<span ref={ref} className={`tabular-nums ${className}`}>
01:00:41:17
</span>
);
}
+138
View File
@@ -0,0 +1,138 @@
// All editable site copy lives here. Anything marked TODO is placeholder
// content that should be replaced with real studio information before launch.
export const site = {
name: "Two Tales VFX",
shortName: "TTVFX",
url: "https://ttvfx.com",
founded: 2015,
tagline: "Invisible work. Unforgettable frames.",
description:
"Two Tales VFX is an independent visual effects studio crafting compositing, CG and finishing for series, film and commercials.",
// TODO: confirm the public contact address and location.
email: "hello@ttvfx.com",
location: "South Africa",
// Optional showreel. Drop an MP4 at public/reel.mp4 (and a poster frame at
// public/reel-poster.jpg) and set this to "/reel.mp4" to play it in the hero.
reel: null as string | null,
reelPoster: null as string | null,
socials: [
// TODO: add real profiles, e.g. { label: "Vimeo", href: "https://vimeo.com/..." }
] as { label: string; href: string }[],
};
export const nav = [
{ label: "Work", href: "/work/" },
{ label: "Services", href: "/#services" },
{ label: "Studio", href: "/#studio" },
{ label: "Contact", href: "/#contact" },
];
export const services = [
{
code: "01",
title: "Compositing",
body: "Plate-driven, photoreal integration. Keying, set extensions, CG integration, sky replacements and the hundreds of invisible fixes that hold a cut together.",
},
{
code: "02",
title: "CG & Environments",
body: "Assets, look development, lighting and rendering, from hero props to full digital environments built to sit inside live-action photography.",
},
{
code: "03",
title: "Paint, Roto & Cleanup",
body: "Rig and wire removal, beauty work, logo replacement and precise roto. Tracked, QC'd and delivered to spec.",
},
{
code: "04",
title: "Animation",
body: "Character and motion work rooted in a decade of animation production as Two Tales Animation.",
},
{
code: "05",
title: "Supervision",
body: "On-set and remote VFX supervision, bidding and breakdowns, so problems are solved before the camera rolls.",
},
{
code: "06",
title: "Pipeline & Delivery",
body: "In-house review, tracking and automated QC tooling. ACES colour-managed EXR workflows and frame-accurate, spec-checked deliveries.",
},
];
export const stats = [
{ value: `${new Date().getFullYear() - 2015}+`, label: "Years in production" },
{ value: "ACES", label: "Colour-managed pipeline" },
{ value: "EXR", label: "Frame-accurate delivery" },
{ value: "QC", label: "Automated shot checks" },
];
export type Project = {
slug: string;
title: string;
client: string;
year: number;
category: string;
summary: string;
// Path under /public, e.g. "/work/my-show.jpg". If missing, a generated
// gradient frame is shown instead.
image?: string;
// Optional MP4 under /public, or an external Vimeo/YouTube embed URL.
video?: string;
credits: string[];
shots?: number;
// Accent hue (0-360) used for the generated placeholder frame.
hue: number;
};
// TODO: replace these placeholders with real projects (client approval permitting).
export const projects: Project[] = [
{
slug: "project-one",
title: "Episodic Drama",
client: "Placeholder Network",
year: 2026,
category: "Series",
summary:
"Placeholder: a full season of invisible effects: set extensions, screen replacements, period cleanup and digital weather across every episode.",
credits: ["Compositing", "Set Extension", "Cleanup"],
shots: 420,
hue: 22,
},
{
slug: "project-two",
title: "Feature Film",
client: "Placeholder Pictures",
year: 2025,
category: "Film",
summary:
"Placeholder: CG environments and hero compositing for a feature, including a fully digital third act location.",
credits: ["CG Environments", "Lighting", "Compositing"],
shots: 180,
hue: 205,
},
{
slug: "project-three",
title: "Automotive Spot",
client: "Placeholder Agency",
year: 2025,
category: "Commercial",
summary:
"Placeholder: a CG car integrated into live-action plates with full reflections, road replacement and beauty finishing.",
credits: ["CG", "Lookdev", "Compositing"],
shots: 24,
hue: 350,
},
{
slug: "project-four",
title: "Animated Short",
client: "Two Tales Animation",
year: 2024,
category: "Animation",
summary:
"Placeholder: a stylised animated short produced end to end, from design through animation, lighting and final grade.",
credits: ["Animation", "Lighting", "Compositing"],
hue: 140,
},
];
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Static HTML export -> `out/`, served by nginx on the VPS (see DEPLOY.md).
output: "export",
trailingSlash: true,
images: { unoptimized: true },
reactCompiler: true,
};
export default nextConfig;
+6792
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "ttvfx",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.8",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.3.8",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="252 0 406 404" fill="#fff"><path fill-rule="evenodd" d="M252 1 252 275 517 403 517 176 528 152 495 174 517 121 466 176 332 176Z M369 248 453 247 478 302Z"/><path d="M549 303 657 248 572 248Z"/></svg>

After

Width:  |  Height:  |  Size: 249 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}