Initial ttvfx.com site: Next.js static export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ttvfx-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "-p", "3210"],
|
||||
"port": 3210
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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`.)
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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're a visual
|
||||
effects house that still thinks like animators: every frame is performance, every shot is story.
|
||||
</p>
|
||||
<p>
|
||||
We'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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 })),
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+6792
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user