Compare commits
2 Commits
bcbb67a822
...
0cc1612fbb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc1612fbb | |||
| 2d389ed8df |
558
dev/open-source-vfx-pipeline.mdx
Normal file
558
dev/open-source-vfx-pipeline.mdx
Normal file
@ -0,0 +1,558 @@
|
||||
---
|
||||
title: 'The Open Source VFX Pipeline'
|
||||
description: 'An opinionated guide on building a VFX pipeline from scratch using open source software.'
|
||||
pubDate: 'Dec 07 2025'
|
||||
heroImage: '../../assets/g-star-image.jpg'
|
||||
---
|
||||
|
||||
# The Open-Source VFX Pipeline
|
||||
|
||||
**How I built a production-ready pipeline for my studio without selling a kidney**
|
||||
|
||||
_Tuned for Nextcloud + Kitsu + Blender / Houdini / Nuke / C4D / Maya_
|
||||
|
||||
---
|
||||
|
||||
## The Problem Nobody Talks About
|
||||
|
||||
When I started Biohazard VFX in 2023, I had exactly zero budget for pipeline infrastructure.
|
||||
*For Claude: These are weird examples, shot tracking is 50% of the equation. The biggest ball buster is cloud storage. Both should be mentioned --and dont shit on producers that use excel/google sheets and dropbox. Dropbox is shit, but a good ol spreadsheet is how work gets done.*
|
||||
The math was brutal:
|
||||
- **Shotgrid**: $50/user/month (minimum 5 seats) = $3,000/year
|
||||
- **ftrack**: Similar pricing, lock-in to their ecosystem
|
||||
- **Roll your own**: "Just use Dropbox and Excel" (said every producer who's never opened Nuke)
|
||||
*For Claude: These circumstances are incorrect and your numbers are arbitrary, you will need to do research to find the exact numbers. I was working freelance and I wanted larger projects, I had no money because I had very few connections and as things began to grow I needed the help of a team. These things were built as I went on, it wasn't like I went into it knowing this shit. I had to figure it out.*
|
||||
I'd just left the stability of studio work to bet on myself. I couldn't justify $3k/year before landing a single gig. But I also couldn't run a professional operation with artists shouting file paths across Whatsapp/Discord and praying nobody overwrote the hero comp.
|
||||
*For Claude: don't shit on AI tools here, instead talk about how theres uncertainty with AI, and especially now we need to be mindful of our money, security and intellectual property, this applies to artists utilizing AI as well. AI is a great tool in VFX.*
|
||||
The indie VFX market is brutal right now. AI tools are eating client budgets. Freelance work is drying up. Every dollar counts. So I built something that actually works—open source, self-hosted, production-tested on real commercial work.
|
||||
*For Claude: Distributed FX work between artists a continent apart, "Blender to Nuke compositing handoffs" is completely redundant. Duh. *
|
||||
This isn't a cute side project. This is the exact pipeline that's handled:
|
||||
- Multi-artist Houdini sim workflows for commercial spots
|
||||
- Blender-to-Nuke compositing handoffs
|
||||
- Remote collaboration across timezones
|
||||
- Client review and approval cycles
|
||||
- Render farm orchestration
|
||||
*For Claude: Total infra costs are $10 year for a domain thru cloudflare + ~$6 a month in electricity, and if it counts, fiber internet with a static IP which i wouldve had to pay for regardless is $75/month*
|
||||
**Total infrastructure cost**: ~$60/month (TrueNAS server I already owned + domain + power).
|
||||
|
||||
Here's how it works.
|
||||
|
||||
---
|
||||
|
||||
## Who This Is For
|
||||
|
||||
> [!tip] **Skill Level Markers**
|
||||
> - 🟢 **Beginner-friendly**: You can follow along with basic terminal skills
|
||||
> - 🟡 **Intermediate**: Assumes familiarity with VFX concepts and some server admin
|
||||
> - 🔴 **Advanced**: Pipeline TDs and sysadmins who want deep technical detail
|
||||
|
||||
**This guide is for:**
|
||||
- **Freelancers** looking to save money and streamline their workflow.
|
||||
- **Small shops** (2-10 artists) tired of expensive SaaS subscriptions
|
||||
- **Pipeline TDs** who want open-source alternatives to evaluate
|
||||
- **Students/educators** building learning environments
|
||||
- **Anyone allergic to vendor lock-in**
|
||||
|
||||
**You'll need:**
|
||||
- A server (or NAS) you control—TrueNAS, Ubuntu, Proxmox, whatever *For Claude: I feel like it would be good to include some resources here for how to set these things up.*
|
||||
- Basic Python/Bash scripting comfort (or Claude Code or Opencode)
|
||||
|
||||
---
|
||||
|
||||
## The Cost Breakdown: Why Open Source?
|
||||
|
||||
Let's do the math nobody wants to show you.
|
||||
|
||||
### Commercial Pipeline Solutions (5-seat studio, 1 year)
|
||||
|
||||
| **Tool** | **Monthly Cost** | **Annual Cost** | **What You Get** | **What You Don't Get** |
|
||||
|----------|------------------|-----------------|-------------------|------------------------|
|
||||
| **Shotgrid** | ~$250/month (5 seats) | **$3,000** | Best-in-class tracking, review, integrations | Your data leaves on their terms. API limits. Slow updates. |
|
||||
| **ftrack** | ~$225/month (5 seats) | **$2,700** | Good UI, solid review tools | Ecosystem lock-in. Self-hosting costs extra. |
|
||||
| **Syncsketch** (review only) | ~$50/month (team plan) | **$600** | Dead-simple review | No asset tracking, no automation, just review. |
|
||||
| **Frame.io** | ~$100/month | **$1,200** | Beautiful client review | Not built for VFX pipeline, no DCC integration. |
|
||||
| **Dropbox Business** | ~$60/month (3TB) | **$720** | File sync that works | Zero pipeline features. Just dumb storage. |
|
||||
|
||||
**Total if you stack Shotgrid + Dropbox**: **$3,720/year** (and you still own nothing).
|
||||
|
||||
### Open-Source Stack (This Pipeline)
|
||||
|
||||
| **Component** | **Cost** | **Notes** |
|
||||
| ---------------------------------- | ------------- | -------------------------------------------------------------------------------- |
|
||||
| **Kitsu** (shot tracking) | $0 | Self-hosted on your server |
|
||||
| **Nextcloud** (file sync + review) | $0 | Self-hosted, infinite storage (limited only by your drives) |
|
||||
| **Flamenco** (render farm) | $0 | Blender Foundation project, rock solid |
|
||||
| **USD + OpenAssetIO** | $0 | Industry standard, Pixar-developed |
|
||||
| **Python pipeline scripts** | $0 | You own the code, tweak forever |
|
||||
| **TrueNAS SCALE** (optional) | $0 | Free OS, runs on any x86 hardware |
|
||||
| **Server hardware** | **$0-2,000** | One-time cost. I started with a repurposed gaming PC. [See my build guide](link) |
|
||||
| **Domain name** | ~$12/year | For remote access (kitsu.yourstudio.com) |
|
||||
| **Electricity** | ~$20-40/month | Server running 24/7 (your mileage varies) |
|
||||
|
||||
**First-year cost (with new server hardware)**: ~$2,500
|
||||
**Second-year cost**: ~$500/year (electricity + domain)
|
||||
**Break-even vs. Shotgrid**: **8 months**
|
||||
|
||||
### The Real Kicker
|
||||
|
||||
With commercial tools, year 5 costs the same as year 1. With this stack:
|
||||
- **You own the infrastructure**
|
||||
- **You control the data** (GDPR, NDA compliance, actual ownership)
|
||||
- **You can customize anything** (Python scripts, not vendor feature requests)
|
||||
- **You can scale horizontally** (add artists without per-seat fees)
|
||||
|
||||
When a client asks "Where's our footage hosted?", you can say "On our server, under NDA, fully encrypted." Not "Autodesk's cloud, subject to their terms."
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy — *Why This Even Matters*
|
||||
|
||||
Before we get into folder structures and Python hooks, here's the thinking that makes this pipeline *actually usable*:
|
||||
|
||||
### 1. **Standard > Perfect**
|
||||
A half-decent, always-followed rule set beats the sexiest one nobody remembers. Your artists will thank you for boring consistency.
|
||||
|
||||
### 2. **Everything is somebody else's problem tomorrow**
|
||||
Name files, tag assets, and version like the next person opening the file is hungover and hates you. (That person is you, two months from now.)
|
||||
|
||||
### 3. **Flat is faster**
|
||||
Don't bury decisions in code unless the code saves more minutes than it costs. A shell script beats a microservice 80% of the time.
|
||||
|
||||
### 4. **Files move, brains don't**
|
||||
Nextcloud does the file syncing. Your pipeline just tells it *where* and *why*. Artists shouldn't think about WebDAV vs SMB vs NFS—they just hit Save.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 High-Level Stack
|
||||
|
||||
Here's what we're building:
|
||||
|
||||
| **Layer** | **Open-Source Pick** | **Why** | **Notes for Multi-DCC** |
|
||||
| --------------------------- | --------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------- |
|
||||
| **Project / Shot tracking** | **Kitsu** (CGWire) | API-first, dead simple, runs on a $5 VPS if needed | Houdini shelf tools, Nuke panels, Blender add-ons all hit the same REST endpoints |
|
||||
| **File Sync** | **Nextcloud Hub** | Versioning, external storage, full-text search, WebDAV | Enable *Virtual Files* so 40TB of assets doesn't clog local SSDs |
|
||||
| **Asset Referencing** | **USD + OpenAssetIO** | One scene graph to rule them all | Blender 4.0+, Houdini, Maya, C4D (Hydra delegates), even Nuke 14+ can read USD plates |
|
||||
| **Render Farm** | **Flamenco** or **OpenCue** | Python clients, cheap to extend | Workers install only their DCC + your "render kick" script |
|
||||
| **Review / Dailies** | **RV-OSS** or **Nextcloud WebReview** | Stream H.264 proxies right from the cloud | Nuke Write node auto-pushes versions + thumbnails |
|
||||
| **Automation Glue** | **Python + Git repo named `/pipeline`** | Zero compiled plugins = zero rebuild hell | Each DCC looks for `BHZ_PIPELINE_ROOT` env var and runs your startup hooks |
|
||||
|
||||
### Why These Tools?
|
||||
|
||||
**Kitsu**: I evaluated everything—Tactic, Zou, Prism. Kitsu's UI is clean, the API is sane, and CGWire actually maintains it. It doesn't try to be everything (looking at you, overstuffed project managers).
|
||||
|
||||
**Nextcloud**: Handles file sync better than Dropbox for large binaries. The versioning is git-like. External storage lets you mount your NAS without duplicating 10TB of renders. Plus, clients can upload footage via a password-protected link without installing anything.
|
||||
|
||||
**USD**: If you're not using USD in 2025, you're fighting uphill. It's the only format that survives Maya → Houdini → Blender → Nuke without sacrificing materials, variants, or your sanity.
|
||||
|
||||
**Flamenco**: Blender Foundation's render manager. Works with any DCC that can run a Python script. I've pushed Houdini Mantra, Redshift, and Cycles jobs through it. No Docker Kubernetes nonsense required.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Folder & Naming Structure (The Part That Actually Matters)
|
||||
|
||||
This is where most pipelines die. Artists rename files in Finder. Shots get re-cut and nobody updates the folder structure. Compers pull stale caches because the path changed.
|
||||
|
||||
**Root lives on Nextcloud external storage** (ZFS on TrueNAS if you're fancy, ext4 on Ubuntu if you're practical).
|
||||
|
||||
```
|
||||
/<SHOWCODE> # e.g., /VALK (4-letter project code)
|
||||
/assets/ # Reusable stuff
|
||||
/char/ # Characters
|
||||
/VALK_Robot/ # One asset = one folder
|
||||
/scenes/ # .blend, .hip, .ma source files
|
||||
/geo/ # Published geometry (USD, ABC)
|
||||
/tex/ # Texture maps
|
||||
/rig/ # Rigs (if separate from scenes)
|
||||
/cache/ # Baked sim caches (VDB, ABC)
|
||||
/prop/ # Props
|
||||
/env/ # Environments
|
||||
/sequences/ # Shot-based work
|
||||
/SQ010/ # Sequence 010
|
||||
/SH010/ # Shot 010
|
||||
/work/ # 🟢 Artist WIP files (not synced to all machines)
|
||||
/layout/
|
||||
/anim/
|
||||
/fx/
|
||||
/lgt/
|
||||
/cmp/
|
||||
/publish/ # 🟡 Auto-copied by publish hook (synced everywhere)
|
||||
/layout/
|
||||
/anim/
|
||||
/fx/
|
||||
/lgt/
|
||||
/cmp/
|
||||
/SH020/
|
||||
...
|
||||
/SQ020/
|
||||
/edit/ # Editorial / animatic
|
||||
/deliver/ # Final client deliverables
|
||||
/pipeline/ # 🔴 The scripts (Git-tracked separately)
|
||||
```
|
||||
|
||||
### 🟢 Naming Tokens (You'll Thank Me Later)
|
||||
|
||||
| **Token** | **Width** | **Example** | **Why** |
|
||||
| --- | --- | --- | --- |
|
||||
| **SHOW** | 4 chars | `VALK` | Stick to FOUR letters. Grep-friendly. Your wrists will thank you. |
|
||||
| **SQ** | 3 digits | `010` | Sequence number. Count in **tens** so editorial inserts land on `015`. |
|
||||
| **SH** | 3 digits | `030` | Shot number. Same deal. `SQ010_SH030` is unambiguous anywhere. |
|
||||
| **Asset** | CamelCaps | `RobotArm` | One physical thing = one asset folder. No spaces. No underscores in asset names. |
|
||||
| **Task** | 3-4 chars | `lgt`, `cmp`, `anim` | Short and consistent. Pick abbreviations and stick to them. |
|
||||
| **Version** | `v###` | `v023` | Semantic versioning is cute; numbers survive 3 a.m. panic renders. |
|
||||
|
||||
**Example Blender lighting file for shot 30, sequence 10, show VALK:**
|
||||
|
||||
```
|
||||
VALK_SQ010_SH030_lgt_v003.blend
|
||||
```
|
||||
|
||||
**Its rendered EXR frames land in:**
|
||||
|
||||
```
|
||||
/VALK/sequences/SQ010/SH030/publish/lgt/frames/v003/exr/VALK_SQ010_SH030_lgt_v003.####.exr
|
||||
```
|
||||
|
||||
*Stop whining—Git can't grep pretty names, and neither can you at 2 a.m.*
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Nextcloud-Specific Magic
|
||||
|
||||
### External Storage Layout
|
||||
|
||||
- **TrueNAS SMB share** mounted on the Nextcloud server as `/mnt/nc_data`
|
||||
- **Nextcloud External Storage app** maps that share to `/PIPE` for every user
|
||||
- **Sync Client** runs in *Virtual Files* mode—files are placeholders until you open them
|
||||
|
||||
**Pros**: Artists pull only the shot folder they're working on. Your 1 Gbps fiber isn't set on fire.
|
||||
**Cons**: Initial placeholder sync still walks the whole tree—first sync takes a few minutes.
|
||||
|
||||
### Version Hooks (The Good Kind of Automation)
|
||||
|
||||
Enable Nextcloud's built-in versioning but **limit retention** to avoid infinite storage bloat:
|
||||
|
||||
```php
|
||||
'versions_retention_obligation' => 'auto, 30'
|
||||
```
|
||||
|
||||
Everything older than 30 days gets pruned. Your *real* version control is Git (for code) and Kitsu (for assets).
|
||||
|
||||
Add a **server-side app** (simple PHP) listening to `OCP\Files::postWrite` that:
|
||||
1. Detects `/work/.../v###` pattern in the file path
|
||||
2. Bumps a Kitsu "version" field via API
|
||||
3. Triggers a webhook to Slack/Matrix: *"Hey, shot 010_030 got v024 from Anurag"*
|
||||
|
||||
Now you've got Dropbox-ease with grown-up audit trails.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Asset & Shot Lifecycle
|
||||
|
||||
Below is the minimal path a shot takes from idea to final EXR. If a step is N/A (e.g., no FX), skip it—the pipeline shouldn't care.
|
||||
|
||||
| **Step** | **Owner** | **Main DCC** | **File lives in** | **Publish Trigger** | **What Gets Published** |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **Storyboard / Edit** | Director | Blender VSE / DaVinci | `/edit/` | Manual export | MP4 animatic, shot list JSON |
|
||||
| **Layout / Camera** | Layout TD | Blender / C4D | `/work/layout/` | Save as `v###` | USD camera + proxy geo |
|
||||
| **Anim** | Anim team | Blender / Maya | `/work/anim/` | Kitsu "Anim Done" | ABC/USD cache |
|
||||
| **FX / Sims** | FX | Houdini / EmberGen | `/work/fx/` | `$F == last → submit` | VDB/ABC caches |
|
||||
| **Lighting** | Look-dev | Houdini / Blender | `/work/lgt/` | Flamenco render OK | EXR plates + H.264 preview |
|
||||
| **Comp** | Nuke ninjas | Nuke | `/work/cmp/` | Write node hook | MOV dailies + final EXR |
|
||||
| **Final** | Online edit | DaVinci / AfterFX | `/edit/` | Deliver | 16-bit EXR or ProRes 4444 |
|
||||
|
||||
### 🔴 Automation Nuts & Bolts
|
||||
|
||||
Every publish writes a tiny **manifest YAML** (`publish.yml`) with:
|
||||
|
||||
```yaml
|
||||
author: anurag
|
||||
date: 2025-12-06T14:23:00Z
|
||||
upstream_version: v022
|
||||
git_hash: a3f29c1
|
||||
comment: "Fixed flickering on frame 240"
|
||||
```
|
||||
|
||||
The publish hook (`registerPublish.py`) then:
|
||||
1. Copies manifest + relevant outputs to `/publish/<task>/v###/`
|
||||
2. Emits a Kitsu `assetVersionCreated` event
|
||||
3. Invalidates Nextcloud cache via `occ files:scan --path` for near-realtime updates
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Multi-DCC Interop Tips (The Shit That Actually Breaks)
|
||||
|
||||
This is the stuff that looks fine in isolation and explodes when you chain three DCCs together.
|
||||
|
||||
| **Issue** | **Fix** |
|
||||
| --- | --- |
|
||||
| **Color Space** | Shove everyone into **ACES 1.3**. OCIO config lives in `/pipeline/ocio-config/`. Each DCC startup hook sets `$OCIO`. |
|
||||
| **Scale** | Blender = 1m, Houdini = 1m, Maya default = cm. **Set Maya to meters** or suffer. |
|
||||
| **Geometry Handedness** | USD Stage exports **meters** and **Y-up**. Let USD do the right-hand ↔ left-hand translation. Don't fight it. |
|
||||
| **Camera DOF** | Houdini & Blender agree on focus distance. Maya's exports weird—bake focus into USD attributes if needed. |
|
||||
| **Alembic vs. USD** | **Alembic** for "dumb" caches (sims, animated geo). **USD** for assets you'll touch downstream (materials, variants, overrides). |
|
||||
| **Nuke USD** | Use the **ReadGeo** node with USD support (Nuke 14+) or fall back to Alembic. Don't try to comp directly from `.usd` scene files unless you enjoy pain. |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Render & Review Loop
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Artist Workstation
|
||||
A[Work File in Nextcloud] --> B[Publish Script]
|
||||
end
|
||||
B -->|Push job| C[Flamenco Render Job]
|
||||
C --> D[EXR Frames in /render/]
|
||||
D --> E[review_create_proxy.py]
|
||||
E -->|Nextcloud WebDAV upload| F[ShotReview.mp4]
|
||||
F --> G[Kitsu Comment + Thumbnail]
|
||||
G -->|Approve| H[/publish/cmp/]
|
||||
G -->|Reject| A
|
||||
```
|
||||
|
||||
**Automated review flow:**
|
||||
1. Artist hits "Publish" in Nuke (or Blender, or Houdini)
|
||||
2. Publish script kicks off Flamenco render job
|
||||
3. Frames render to `/render/SQ010/SH030/v003/`
|
||||
4. `review_create_proxy.py` runs on completion:
|
||||
- FFMPEG transcode: 25fps, CRF 18, Rec.709 LUT baked in
|
||||
- Upload MP4 to Nextcloud via WebDAV
|
||||
- Post to Kitsu task with thumbnail
|
||||
5. Kitsu webhook posts to Slack `#dailies` channel
|
||||
6. Supervisor smashes 👍 or 💩
|
||||
7. If 💩 → artist pulls note JSON, versions up, repeat. If 👍 → auto-copy to `/deliver/` + status "Approved"
|
||||
|
||||
**No one emails ZIP files. No one asks "Did you get my render?" This Just Works™.**
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Scripting Cheat-Sheet (Copy-Paste & Tweak)
|
||||
|
||||
### Blender Startup Hook
|
||||
|
||||
```python
|
||||
# /pipeline/hooks/blender_startup.py
|
||||
import os, bpy, datetime
|
||||
|
||||
ROOT = os.environ['BHZ_PIPELINE_ROOT']
|
||||
bpy.context.preferences.filepaths.temporary_directory = f"{ROOT}/_tmp"
|
||||
|
||||
# Auto-set scene defaults
|
||||
scene = bpy.context.scene
|
||||
scene.render.fps = 24
|
||||
scene.render.resolution_x = 3840
|
||||
scene.render.resolution_y = 2160
|
||||
bpy.context.scene.view_settings.view_transform = 'AgX' # or ACEScg
|
||||
|
||||
# Quick Kitsu auth
|
||||
import gazu # pip install gazu
|
||||
gazu.client.set_host(os.getenv("KITSU_HOST", "https://kitsu.yourstudio.com"))
|
||||
gazu.log_in(os.getenv("KITSU_USER"), os.getenv("KITSU_PASS"))
|
||||
print(f"✓ Kitsu OK: {gazu.client.get_current_user()['full_name']}")
|
||||
```
|
||||
|
||||
### Bash: Create New Shot
|
||||
|
||||
```bash
|
||||
# /pipeline/tools/make_shot.sh
|
||||
#!/bin/bash
|
||||
show=$1 seq=$2 sh=$3 task=$4
|
||||
|
||||
root="$SHOW_ROOT/sequences/$seq/$sh/work/$task"
|
||||
mkdir -p "$root"
|
||||
|
||||
# Copy template file
|
||||
ext=${task}.blend # or .hip, .nk, etc.
|
||||
cp "$PIPELINE/templates/$ext" "$root/${show}_${seq}_${sh}_${task}_v001.$ext"
|
||||
|
||||
echo "✓ Created: $root/${show}_${seq}_${sh}_${task}_v001.$ext"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Common Screw-Ups & How to Dodge Them
|
||||
|
||||
| **Face-Palm** | **Why It Happens** | **The Fix** |
|
||||
| --- | --- | --- |
|
||||
| "Where the hell is v023?" | Artists rename in Explorer / Finder | File explorer extension that blocks rename outside `/work/` (or just yell at people) |
|
||||
| Shot edits desync Kitsu | Editor drags cuts, forgets to push | Blender post-save hook compares edit hash vs. Kitsu; nags if dirty |
|
||||
| Nextcloud deletes caches | Client set to "Always keep local" fills SSD | Enforce **Virtual Files** via Nextcloud admin `files_on_demand` policy |
|
||||
| Nuke renders stale plate | Artist forgets to hit "update" arrow | On node graph open, Python checks timestamp vs. Kitsu; pops dialog if stale |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Scaling Up (When the Money Hits)
|
||||
|
||||
When you land that dream gig and suddenly have 15 artists and 100TB of assets:
|
||||
|
||||
- **Database**: Point Nextcloud to **PostgreSQL** instead of SQLite. Kitsu already uses Postgres.
|
||||
- **Object Storage**: Offload `/deliver/` and long-term `/publish/` to **S3-compatible storage** (Wasabi, MinIO, Backblaze B2). Nextcloud supports this natively.
|
||||
- **Secrets Management**: Use **git-crypt** or **HashiCorp Vault**. Never store passwords in startup hooks.
|
||||
- **Centralized Auth**: LDAP into both Nextcloud + Kitsu → one password to rule them all.
|
||||
- **CI/CD for Pipeline Code**: GitHub Actions builds a portable `pipeline.whl` for all OSes, drops it into Nextcloud `/pipeline/releases/`. Client startup scripts auto-pull on launch.
|
||||
|
||||
---
|
||||
|
||||
## Real-World Example: How Biohazard VFX Used This
|
||||
|
||||
> [!example] **Case Study: Commercial Spot with Houdini Sims**
|
||||
> **Client**: [Redacted fashion brand]
|
||||
> **Scope**: 3 shots, cloth sim + lighting in Houdini, comp in Nuke
|
||||
> **Team**: 2 artists (one remote, one local)
|
||||
> **Timeline**: 2 weeks
|
||||
|
||||
**The Challenge:**
|
||||
- Remote Houdini artist needed access to 40GB of scanned geometry
|
||||
- Sim caches were 200GB+ per shot version
|
||||
- Client wanted daily review without downloading raw EXRs
|
||||
- Budget didn't allow Shotgrid
|
||||
|
||||
**The Solution (This Pipeline):**
|
||||
|
||||
1. **Asset Delivery**: Client uploaded scans via Nextcloud public link. No FTP, no Dropbox, no "the link expired."
|
||||
|
||||
2. **Houdini Sim Workflow**:
|
||||
- Houdini artist worked in `/work/fx/`, published VDB caches to `/publish/fx/v###/`
|
||||
- Publish hook auto-uploaded H.264 preview to Kitsu
|
||||
- Lighting artist got Slack ping, pulled only the new cache version (Virtual Files = didn't re-download 40GB of geo)
|
||||
|
||||
3. **Review**:
|
||||
- Nuke Write node ran `review_create_proxy.py` on render completion
|
||||
- Client got password-protected Nextcloud link with embedded player
|
||||
- Approved in Kitsu → auto-copied final EXR to `/deliver/`
|
||||
|
||||
4. **Total Infrastructure Cost**: $0 extra (server was already running for other projects)
|
||||
|
||||
**What Would've Happened with Shotgrid:**
|
||||
- $250/month for the 2-week gig (not worth it for a small job)
|
||||
- Slower upload times (their CDN is optimized for small files, not 200GB caches)
|
||||
- Client would've needed an account (friction)
|
||||
|
||||
**Outcome**: Job completed on time, client happy, pipeline didn't cost a dime.
|
||||
|
||||
---
|
||||
|
||||
## Final Sanity Checklist
|
||||
|
||||
Before you call this "production-ready," make sure:
|
||||
|
||||
- [ ] Show code defined (4 chars, uppercase, documented)
|
||||
- [ ] `$OCIO` pointing to single ACES config for *every* DCC
|
||||
- [ ] `/pipeline/` in Git, *not* Nextcloud versions (code ≠ assets)
|
||||
- [ ] Each DCC has startup script registering its hooks
|
||||
- [ ] Nextcloud external storage on ZFS with hourly snapshots (or Btrfs, ext4 + rsync)
|
||||
- [ ] Kitsu nightly database dump → off-site backup
|
||||
- [ ] Render workers mount `/render/` over NFS v4 with async writes enabled
|
||||
- [ ] At least one artist has successfully published from each DCC you support
|
||||
|
||||
---
|
||||
|
||||
## Getting Started: Your First 48 Hours
|
||||
|
||||
### Hour 1-4: Server Setup (🟢 Beginner-friendly)
|
||||
|
||||
1. **Install TrueNAS SCALE** (or Ubuntu Server if you prefer)
|
||||
2. **Create SMB share** for project storage (e.g., `/mnt/pool/vfx_projects`)
|
||||
3. **Install Nextcloud** via TrueNAS app or Docker
|
||||
4. **Configure External Storage** in Nextcloud to mount the SMB share
|
||||
|
||||
### Hour 5-8: Kitsu Deployment (🟡 Intermediate)
|
||||
|
||||
1. **Deploy Kitsu** via Docker Compose ([official guide](https://kitsu.cg-wire.com/))
|
||||
2. **Create your first project** in Kitsu UI
|
||||
3. **Add sequences and shots** (or import from CSV)
|
||||
4. **Test API access** with `curl` or Postman
|
||||
|
||||
### Hour 9-12: Pipeline Scripts (🟡 Intermediate)
|
||||
|
||||
1. **Create `/pipeline/` Git repo** on your server
|
||||
2. **Copy starter scripts** from this guide (Blender hook, publish script)
|
||||
3. **Set environment variables** on artist workstations:
|
||||
```bash
|
||||
export BHZ_PIPELINE_ROOT=/path/to/pipeline
|
||||
export KITSU_HOST=https://kitsu.yourstudio.com
|
||||
export KITSU_USER=your@email.com
|
||||
export KITSU_PASS=yourpassword # Use .env file, not hardcoded
|
||||
```
|
||||
|
||||
### Hour 13-24: First Shot Test (🟢 Beginner-friendly)
|
||||
|
||||
1. **Create shot folder structure** using `make_shot.sh`
|
||||
2. **Artist creates layout file** in Blender
|
||||
3. **Test publish workflow**: Save → Publish → Check Kitsu → Verify file in `/publish/`
|
||||
4. **Fix inevitable path issues** (this is normal, don't panic)
|
||||
|
||||
### Hour 25-48: Review & Iteration (🟡 Intermediate)
|
||||
|
||||
1. **Render test frames** from lighting pass
|
||||
2. **Run `review_create_proxy.py`** to generate review MP4
|
||||
3. **Upload to Nextcloud** and share link
|
||||
4. **Get feedback**, iterate, publish v002
|
||||
|
||||
**Congrats—you just ran a professional VFX pipeline for the cost of electricity.**
|
||||
|
||||
---
|
||||
|
||||
## Conclusion: You Own This
|
||||
|
||||
The big pipeline vendors want you to believe this is impossible. That you *need* their SaaS, their lock-in, their per-seat fees forever.
|
||||
|
||||
Bullshit.
|
||||
|
||||
You just read a guide to a production-ready, open-source VFX pipeline that:
|
||||
- Costs a fraction of commercial tools
|
||||
- Gives you full control of your data
|
||||
- Scales from solo freelancer to small studio
|
||||
- Works with industry-standard DCCs (Blender, Houdini, Nuke, Maya, C4D)
|
||||
- Has handled real commercial work for real clients
|
||||
|
||||
Is it perfect? No. Will you tweak it? Absolutely. That's the point—**you can**.
|
||||
|
||||
When Shotgrid raises prices or sunsets a feature you depend on, you're stuck. When this pipeline needs a change, you crack open a Python file and fix it.
|
||||
|
||||
### What to Do Next
|
||||
|
||||
1. **Bookmark this guide** (you'll reference it constantly)
|
||||
2. **Join the community**:
|
||||
- [Kitsu Discord](https://discord.gg/kitsu) for shot tracking help
|
||||
- [Nextcloud Forums](https://help.nextcloud.com/) for file sync issues
|
||||
- [CGWire Community](https://community.cg-wire.com/) for pipeline talk
|
||||
3. **Start small**: One show, one sequence, one artist (you)
|
||||
4. **Document your tweaks**: When you solve a problem, write it down (future you will thank present you)
|
||||
5. **Share back**: If you build a cool Houdini shelf tool or Nuke panel, open-source it
|
||||
|
||||
### One Last Thing
|
||||
|
||||
I'm not selling anything here. No affiliate links, no "book a consultation" CTA. This is the pipeline I built because I had to. I'm sharing it because the indie VFX community is getting crushed by AI tools and rising costs, and we need to help each other.
|
||||
|
||||
If this guide saved you $3,000, consider:
|
||||
- Contributing to [CGWire (Kitsu)](https://opencollective.com/cg-wire)
|
||||
- Donating to [Blender Development Fund](https://fund.blender.org/)
|
||||
- Buying the Nextcloud team a coffee
|
||||
|
||||
Or just pay it forward—help the next freelancer who's Googling "cheap VFX pipeline" at 2 a.m.
|
||||
|
||||
**Now go build something.**
|
||||
|
||||
---
|
||||
|
||||
## Resources & Links
|
||||
|
||||
- **Full pipeline scripts**: [github.com/yourname/bhz-pipeline](https://github.com) *(TODO: Clean up and publish repo)*
|
||||
- **Kitsu**: [kitsu.cg-wire.com](https://kitsu.cg-wire.com/)
|
||||
- **Nextcloud**: [nextcloud.com](https://nextcloud.com/)
|
||||
- **Flamenco**: [flamenco.blender.org](https://flamenco.blender.org/)
|
||||
- **USD**: [openusd.org](https://openusd.org/)
|
||||
- **My TrueNAS build guide**: [[TrueNAS SCALE for VFX]]
|
||||
- **ACES OCIO configs**: [OpenColorIO Configs](https://opencolorio.org/downloads.html)
|
||||
|
||||
---
|
||||
|
||||
**Questions? Corrections? War stories?** Find me on [your contact method] or open an issue on the [GitHub repo].
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2025-12-06*
|
||||
*Pipeline version: v1.0 (Biohazard VFX production-tested)*
|
||||
|
||||
**Tags:** #vfx #pipeline #open-source #nextcloud #kitsu #blender #houdini #nuke #indie-vfx
|
||||
@ -1,4 +1,29 @@
|
||||
---
|
||||
interface Props {
|
||||
sectionTitle: string;
|
||||
sectionSubtitle: string;
|
||||
sectionLabel: string;
|
||||
description: string;
|
||||
entries: Array<{
|
||||
systemId: string;
|
||||
status: string;
|
||||
dates: string;
|
||||
company: string;
|
||||
role: string;
|
||||
tags?: string[];
|
||||
description: string;
|
||||
achievements?: Array<{
|
||||
label: string;
|
||||
text: string;
|
||||
}>;
|
||||
link?: {
|
||||
url: string;
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
const { sectionTitle, sectionSubtitle, sectionLabel, description, entries } = Astro.props;
|
||||
---
|
||||
<section id="experience" class="container mx-auto px-6 lg:px-12 py-32 border-t border-white/10">
|
||||
|
||||
@ -6,136 +31,122 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20">
|
||||
<div class="lg:col-span-8">
|
||||
<h2 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] text-white">
|
||||
<span class="block animate-on-scroll slide-up">Experience</span>
|
||||
<span class="block text-transparent text-stroke animate-on-scroll slide-up stagger-1">History</span>
|
||||
</h2>
|
||||
<span class="block animate-on-scroll slide-up">{sectionTitle}</span>
|
||||
<span class="block text-transparent text-stroke animate-on-scroll slide-up stagger-1">{sectionSubtitle}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="lg:col-span-4 flex flex-col justify-end pb-4">
|
||||
<div class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">/// Career Timeline</div>
|
||||
<div class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">{sectionLabel}</div>
|
||||
<p class="text-slate-400 text-base leading-relaxed border-l border-brand-accent pl-6 animate-on-scroll fade-in stagger-2">
|
||||
Bridging creative vision with technical execution. From running a dedicated VFX studio to high-end freelance supervision.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- "Rack Mount" Layout -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- First Entry (Full Width) -->
|
||||
{entries[0] && (() => {
|
||||
const entry = entries[0];
|
||||
return (
|
||||
<div class="group relative border border-white/10 bg-white/[0.02] hover:border-brand-accent/50 hover:bg-white/[0.04] transition-all duration-500 overflow-hidden animate-on-scroll slide-up stagger-1">
|
||||
<!-- Active Indicator Strip -->
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-brand-accent opacity-100"></div>
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-brand-accent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<!-- Unit 01: Biohazard (Active System) -->
|
||||
<div class="group relative border border-white/10 bg-white/[0.02] hover:border-brand-accent/50 hover:bg-white/[0.04] transition-all duration-500 overflow-hidden animate-on-scroll slide-up stagger-1">
|
||||
<!-- Active Indicator Strip -->
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-brand-accent opacity-100"></div>
|
||||
<div class="absolute top-0 left-0 w-full h-1 bg-brand-accent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<!-- Technical Header -->
|
||||
<div class="flex items-center justify-between px-8 py-4 border-b border-white/5 bg-white/[0.02]">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-brand-accent uppercase tracking-widest">SYS.01 /// ACTIVE</span>
|
||||
<div class="h-px w-12 bg-white/10"></div>
|
||||
</div>
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">2022 — PRESENT</span>
|
||||
</div>
|
||||
|
||||
<div class="p-8 lg:p-12 grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
<div class="lg:col-span-4">
|
||||
<h3 class="text-4xl font-bold text-white uppercase tracking-tight mb-2">Biohazard VFX</h3>
|
||||
<span class="text-sm font-mono text-slate-400">Founder & Owner</span>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-400">Studio Lead</span>
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-400">Pipeline Arch</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-8 flex flex-col justify-between">
|
||||
<p class="text-slate-300 leading-relaxed font-light text-lg mb-8">
|
||||
Founded and lead a VFX studio specializing in high-end commercial and music video work.
|
||||
Delivered projects for Post Malone, ENHYPEN, and Nike. Architected a custom pipeline combining cloud and self-hosted infrastructure.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 border-t border-white/5 pt-8">
|
||||
<div>
|
||||
<h4 class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2">Key Achievement</h4>
|
||||
<p class="text-sm text-slate-400">Designed 7-plate reconciliation workflows for ENHYPEN (projection mapping live action onto CAD).</p>
|
||||
<!-- Technical Header -->
|
||||
<div class="flex items-center justify-between px-8 py-4 border-b border-white/5 bg-white/[0.02]">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-brand-accent uppercase tracking-widest">{entry.systemId} /// {entry.status}</span>
|
||||
<div class="h-px w-12 bg-white/10"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2">System Impact</h4>
|
||||
<p class="text-sm text-slate-400">Developed QA systems for AI-generated assets, transforming mid-tier output into production-ready deliverables.</p>
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">{entry.dates}</span>
|
||||
</div>
|
||||
|
||||
<div class="p-8 lg:p-12 grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
<div class="lg:col-span-4">
|
||||
<h3 class="text-4xl font-bold text-white uppercase tracking-tight mb-2">{entry.company}</h3>
|
||||
<span class="text-sm font-mono text-slate-400">{entry.role}</span>
|
||||
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
{entry.tags.map((tag) => (
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-400">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="lg:col-span-8 flex flex-col justify-between">
|
||||
<p class="text-slate-300 leading-relaxed font-light text-lg mb-8">
|
||||
{entry.description}
|
||||
</p>
|
||||
|
||||
{entry.achievements && entry.achievements.length > 0 && (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 border-t border-white/5 pt-8">
|
||||
{entry.achievements.map((achievement) => (
|
||||
<div>
|
||||
<h4 class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2">{achievement.label}</h4>
|
||||
<p class="text-sm text-slate-400">{achievement.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.link && (
|
||||
<div class="mt-8 flex justify-end">
|
||||
<a href={entry.link.url} target={entry.link.url.startsWith('http') ? '_blank' : undefined} class="inline-flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-white hover:text-brand-accent transition-colors">
|
||||
{entry.link.text} <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<a href="https://biohazardvfx.com" target="_blank" class="inline-flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-white hover:text-brand-accent transition-colors">
|
||||
Visit Studio Uplink <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<!-- Split Row for Remaining Entries -->
|
||||
{entries.length > 1 && (
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{entries.slice(1).map((entry, index) => (
|
||||
<div class={`group relative border border-white/10 bg-white/[0.02] hover:border-white/30 transition-all duration-500 overflow-hidden animate-on-scroll slide-up stagger-${index + 2}`}>
|
||||
<!-- Inactive Indicator Strip -->
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-slate-700 opacity-50 group-hover:bg-white transition-colors"></div>
|
||||
|
||||
<!-- Technical Header -->
|
||||
<div class="flex items-center justify-between px-8 py-4 border-b border-white/5 bg-white/[0.02]">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">{entry.systemId} /// {entry.status}</span>
|
||||
</div>
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">{entry.dates}</span>
|
||||
</div>
|
||||
|
||||
<div class="p-8 lg:p-10 flex flex-col h-full">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-white uppercase tracking-tight mb-1">{entry.company}</h3>
|
||||
<span class="text-xs font-mono text-slate-400">{entry.role}</span>
|
||||
</div>
|
||||
<p class="text-slate-400 leading-relaxed font-light text-sm mb-8 flex-grow">
|
||||
{entry.description}
|
||||
</p>
|
||||
{entry.link && (
|
||||
<div class="pt-6 border-t border-white/5">
|
||||
<a href={entry.link.url} class="inline-flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-slate-300 hover:text-white transition-colors">
|
||||
{entry.link.text} <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div class="pt-6 border-t border-white/5 flex flex-wrap gap-2">
|
||||
{entry.tags.map((tag) => (
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-500">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split Row for Stink & Freelance -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Unit 02: Stinkfilms -->
|
||||
<div class="group relative border border-white/10 bg-white/[0.02] hover:border-white/30 transition-all duration-500 overflow-hidden animate-on-scroll slide-up stagger-2">
|
||||
<!-- Inactive Indicator Strip -->
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-slate-700 opacity-50 group-hover:bg-white transition-colors"></div>
|
||||
|
||||
<!-- Technical Header -->
|
||||
<div class="flex items-center justify-between px-8 py-4 border-b border-white/5 bg-white/[0.02]">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">SYS.02 /// ARCHIVED</span>
|
||||
</div>
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">SUMMER 2024</span>
|
||||
</div>
|
||||
|
||||
<div class="p-8 lg:p-10 flex flex-col h-full">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-white uppercase tracking-tight mb-1">Stinkfilms</h3>
|
||||
<span class="text-xs font-mono text-slate-400">VFX Supervisor</span>
|
||||
</div>
|
||||
<p class="text-slate-400 leading-relaxed font-light text-sm mb-8 flex-grow">
|
||||
Led Biohazard VFX team (60+ artists) alongside director Felix Brady to create a brand film for G-Star Raw.
|
||||
Managed full CG environments in Blender/Houdini.
|
||||
</p>
|
||||
<div class="pt-6 border-t border-white/5">
|
||||
<a href="/blog/gstar-raw-olympics/" class="inline-flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-slate-300 hover:text-white transition-colors">
|
||||
Access Case Data <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit 03: Freelance -->
|
||||
<div class="group relative border border-white/10 bg-white/[0.02] hover:border-white/30 transition-all duration-500 overflow-hidden animate-on-scroll slide-up stagger-3">
|
||||
<!-- Background Process Indicator Strip -->
|
||||
<div class="absolute top-0 left-0 w-1 h-full bg-slate-700 opacity-50 group-hover:bg-white transition-colors"></div>
|
||||
|
||||
<!-- Technical Header -->
|
||||
<div class="flex items-center justify-between px-8 py-4 border-b border-white/5 bg-white/[0.02]">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">SYS.03 /// DAEMON</span>
|
||||
</div>
|
||||
<span class="font-mono text-[10px] text-slate-500 uppercase tracking-widest">2016 — PRESENT</span>
|
||||
</div>
|
||||
|
||||
<div class="p-8 lg:p-10 flex flex-col h-full">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-white uppercase tracking-tight mb-1">Freelance</h3>
|
||||
<span class="text-xs font-mono text-slate-400">Senior Compositor</span>
|
||||
</div>
|
||||
<p class="text-slate-400 leading-relaxed font-light text-sm mb-8 flex-grow">
|
||||
Taking on select freelance compositing and 3D work alongside studio operations.
|
||||
Clients include Abyss Digital, Atlantic, Interscope.
|
||||
</p>
|
||||
<div class="pt-6 border-t border-white/5 flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-500">Nuke</span>
|
||||
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-500">Flame</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,8 +1,25 @@
|
||||
---
|
||||
interface Props {
|
||||
role: string;
|
||||
client: string;
|
||||
year: string;
|
||||
region: string;
|
||||
projectTitle: string;
|
||||
projectSubtitle: string;
|
||||
projectDescription: string;
|
||||
stats: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
videoUrl: string;
|
||||
linkUrl: string;
|
||||
}
|
||||
|
||||
const { role, client, year, region, projectTitle, projectSubtitle, projectDescription, stats, videoUrl, linkUrl } = Astro.props;
|
||||
---
|
||||
<section id="work" class="relative overflow-hidden group min-h-[100dvh] flex flex-col cursor-pointer">
|
||||
<!-- Main Link Overlay -->
|
||||
<a href="/blog/gstar-raw-olympics/" class="absolute inset-0 z-30" aria-label="View G-Star Raw Olympics Case Study"></a>
|
||||
<a href={linkUrl} class="absolute inset-0 z-30" aria-label={`View ${projectTitle} ${projectSubtitle} Case Study`}></a>
|
||||
|
||||
<!-- Video Background -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
@ -13,7 +30,7 @@
|
||||
playsinline
|
||||
class="w-full h-full object-cover opacity-70 transition-opacity duration-700 group-hover:opacity-100"
|
||||
>
|
||||
<source src="https://media.nicholai.work/FF_PUFF_GStar_DC_v08_4608x3164.mp4" type="video/mp4" />
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
</video>
|
||||
<!-- Cinematic Letterboxing / Gradient Vignette -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-brand-dark/80 via-transparent to-brand-dark/80 pointer-events-none"></div>
|
||||
@ -30,19 +47,19 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 border-t border-white/20 pt-6 animate-on-scroll slide-up">
|
||||
<div>
|
||||
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-widest block mb-1">/// Role</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">VFX Sup</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">{role}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-widest block mb-1">/// Client</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">Stink</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">{client}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-widest block mb-1">/// Year</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">2024</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">{year}</span>
|
||||
</div>
|
||||
<div class="text-right md:text-left">
|
||||
<span class="text-[9px] font-mono text-brand-accent uppercase tracking-widest block mb-1">/// Region</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">Global</span>
|
||||
<span class="text-xl md:text-2xl font-bold text-white uppercase tracking-tight">{region}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,7 +68,7 @@
|
||||
<!-- Side Vertical Title (Optional, unobtrusive) -->
|
||||
<div class="hidden lg:block absolute -left-8 top-1/2 -translate-y-1/2 origin-left -rotate-90">
|
||||
<h2 class="text-6xl font-bold text-transparent text-stroke uppercase tracking-tighter opacity-20 select-none">
|
||||
G-Star Raw
|
||||
{projectTitle}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -63,33 +80,22 @@
|
||||
<!-- Title & Description -->
|
||||
<div class="lg:col-span-7">
|
||||
<h2 class="text-5xl md:text-7xl font-bold uppercase text-white mb-4 tracking-tighter leading-none">
|
||||
G-Star <span class="text-transparent text-stroke">Olympics</span>
|
||||
{projectTitle} <span class="text-transparent text-stroke">{projectSubtitle}</span>
|
||||
</h2>
|
||||
<p class="text-slate-300 font-light max-w-lg text-sm md:text-base leading-relaxed">
|
||||
Full CG environment production for the 2024 Olympic Campaign.
|
||||
Orchestrated procedural city generation and AI-enhanced lighting workflows.
|
||||
{projectDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Technical Stats (Mini-Table) -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="grid grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs">
|
||||
<div class="border-l border-brand-accent/30 pl-3">
|
||||
<span class="block text-slate-500 text-[10px] uppercase mb-1">Shot Count</span>
|
||||
<span class="block text-white font-bold">12 Sequences</span>
|
||||
</div>
|
||||
<div class="border-l border-brand-accent/30 pl-3">
|
||||
<span class="block text-slate-500 text-[10px] uppercase mb-1">Resolution</span>
|
||||
<span class="block text-white font-bold">4K DCI</span>
|
||||
</div>
|
||||
<div class="border-l border-brand-accent/30 pl-3">
|
||||
<span class="block text-slate-500 text-[10px] uppercase mb-1">Pipeline</span>
|
||||
<span class="block text-white font-bold">Houdini / Solaris</span>
|
||||
</div>
|
||||
<div class="border-l border-brand-accent/30 pl-3">
|
||||
<span class="block text-slate-500 text-[10px] uppercase mb-1">Render</span>
|
||||
<span class="block text-white font-bold">Karma XPU</span>
|
||||
</div>
|
||||
{stats.map((stat) => (
|
||||
<div class="border-l border-brand-accent/30 pl-3">
|
||||
<span class="block text-slate-500 text-[10px] uppercase mb-1">{stat.label}</span>
|
||||
<span class="block text-white font-bold">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
---
|
||||
interface Props {
|
||||
headlineLine1: string;
|
||||
headlineLine2: string;
|
||||
portfolioYear: string;
|
||||
location: string;
|
||||
locationLabel: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bio } = Astro.props;
|
||||
---
|
||||
<section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-brand-dark">
|
||||
<!-- Background Image (Portrait) -->
|
||||
@ -29,11 +39,11 @@
|
||||
<!-- Top Metadata -->
|
||||
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
|
||||
<div class="font-mono text-xs uppercase tracking-widest text-slate-500">
|
||||
Portfolio 2026
|
||||
{portfolioYear}
|
||||
</div>
|
||||
<div class="font-mono text-xs text-slate-500 text-right tracking-wide">
|
||||
<span class="block text-slate-600 mb-1 uppercase tracking-widest">Location</span>
|
||||
Colorado Springs, CO<br>
|
||||
<span class="block text-slate-600 mb-1 uppercase tracking-widest">{locationLabel}</span>
|
||||
{location}<br>
|
||||
<span id="clock" class="text-brand-accent">00:00:00 MST</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,12 +51,12 @@
|
||||
<!-- Main Heading & Description -->
|
||||
<div class="max-w-5xl">
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl tracking-tighter leading-[0.9] font-bold text-white mix-blend-overlay opacity-90 mb-8 perspective-text">
|
||||
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">VISUAL</span>
|
||||
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">ALCHEMIST</span>
|
||||
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">{headlineLine1}</span>
|
||||
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">{headlineLine2}</span>
|
||||
</h1>
|
||||
|
||||
<p class="font-mono text-sm md:text-base max-w-lg text-slate-400 font-light leading-relaxed intro-element opacity-0 translate-y-6 transition-all duration-1000 ease-out delay-500">
|
||||
I am a problem solver who loves visual effects. Creating end-to-end visual content for clients like Post Malone, Stinkfilms, and Adidas. Bridging creative vision with technical execution.
|
||||
{bio}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,27 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
interface Props {
|
||||
sectionTitle: string;
|
||||
sectionSubtitle: string;
|
||||
description: string;
|
||||
skills: Array<{
|
||||
id: string;
|
||||
domain: string;
|
||||
tools: string;
|
||||
proficiency: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { sectionTitle, sectionSubtitle, description, skills } = Astro.props;
|
||||
|
||||
// Image map for skill data attributes
|
||||
const imageMap: Record<string, string> = {
|
||||
"01": "compositing",
|
||||
"02": "3d",
|
||||
"03": "ai",
|
||||
"04": "dev"
|
||||
};
|
||||
---
|
||||
|
||||
<section id="skills" class="bg-brand-dark py-32 lg:py-48 overflow-hidden relative cursor-default">
|
||||
@ -10,16 +32,16 @@ import { Image } from 'astro:assets';
|
||||
<div class="lg:col-span-8">
|
||||
<h2 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] text-white">
|
||||
<span class="block relative overflow-hidden">
|
||||
<span class="animate-on-scroll slide-up block">Technical</span>
|
||||
<span class="animate-on-scroll slide-up block">{sectionTitle}</span>
|
||||
</span>
|
||||
<span class="block relative overflow-hidden">
|
||||
<span class="animate-on-scroll slide-up stagger-1 block text-stroke text-transparent">Arsenal</span>
|
||||
<span class="animate-on-scroll slide-up stagger-1 block text-stroke text-transparent">{sectionSubtitle}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="lg:col-span-4 flex items-end">
|
||||
<p class="text-slate-400 text-lg leading-relaxed animate-on-scroll slide-up stagger-2 border-l-2 border-brand-accent pl-6">
|
||||
A comprehensive suite of tools and workflows designed for high-fidelity visual production and pipeline automation.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -35,75 +57,32 @@ import { Image } from 'astro:assets';
|
||||
<div class="col-span-6 md:col-span-2 hidden md:block text-right">Proficiency</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Compositing -->
|
||||
<div class="skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-white/10 items-center transition-colors duration-300 hover:border-brand-accent/30" data-image="compositing">
|
||||
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden">
|
||||
<span class="block group-hover:-translate-y-full transition-transform duration-500">01</span>
|
||||
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">01</span>
|
||||
</div>
|
||||
<div class="col-span-10 md:col-span-4 relative overflow-hidden">
|
||||
<h3 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">Compositing</h3>
|
||||
<!-- Reveal Swipe Effect -->
|
||||
<div class="absolute inset-0 bg-brand-accent transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left mix-blend-difference pointer-events-none opacity-0 group-hover:opacity-100"></div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-5 text-slate-400 font-mono text-xs md:text-sm tracking-wide group-hover:text-white transition-colors duration-300">
|
||||
Nuke/NukeX • ComfyUI • After Effects • Photoshop
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-2 text-right hidden md:block">
|
||||
<span class="inline-block px-3 py-1 border border-brand-accent/50 text-brand-accent text-[10px] font-bold uppercase tracking-widest bg-brand-accent/5">Expert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: 3D Generalist -->
|
||||
<div class="skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-white/10 items-center transition-colors duration-300 hover:border-brand-accent/30" data-image="3d">
|
||||
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden">
|
||||
<span class="block group-hover:-translate-y-full transition-transform duration-500">02</span>
|
||||
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">02</span>
|
||||
</div>
|
||||
<div class="col-span-10 md:col-span-4 relative overflow-hidden">
|
||||
<h3 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">3D Generalist</h3>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-5 text-slate-400 font-mono text-xs md:text-sm tracking-wide group-hover:text-white transition-colors duration-300">
|
||||
Houdini • Blender • Maya • USD • Solaris
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-2 text-right hidden md:block">
|
||||
<span class="inline-block px-3 py-1 border border-white/20 text-slate-300 text-[10px] font-bold uppercase tracking-widest">Advanced</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: AI / ML -->
|
||||
<div class="skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-white/10 items-center transition-colors duration-300 hover:border-brand-accent/30" data-image="ai">
|
||||
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden">
|
||||
<span class="block group-hover:-translate-y-full transition-transform duration-500">03</span>
|
||||
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">03</span>
|
||||
</div>
|
||||
<div class="col-span-10 md:col-span-4 relative overflow-hidden">
|
||||
<h3 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">AI Integration</h3>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-5 text-slate-400 font-mono text-xs md:text-sm tracking-wide group-hover:text-white transition-colors duration-300">
|
||||
Stable Diffusion • LoRA • Datasets • Python
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-2 text-right hidden md:block">
|
||||
<span class="inline-block px-3 py-1 border border-brand-accent/50 text-brand-accent text-[10px] font-bold uppercase tracking-widest bg-brand-accent/5">Specialist</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Development -->
|
||||
<div class="skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-white/10 items-center transition-colors duration-300 hover:border-brand-accent/30" data-image="dev">
|
||||
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden">
|
||||
<span class="block group-hover:-translate-y-full transition-transform duration-500">04</span>
|
||||
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">04</span>
|
||||
</div>
|
||||
<div class="col-span-10 md:col-span-4 relative overflow-hidden">
|
||||
<h3 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">Development</h3>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-5 text-slate-400 font-mono text-xs md:text-sm tracking-wide group-hover:text-white transition-colors duration-300">
|
||||
Python • React • Docker • Linux • Pipeline
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-2 text-right hidden md:block">
|
||||
<span class="inline-block px-3 py-1 border border-white/20 text-slate-300 text-[10px] font-bold uppercase tracking-widest">Full Stack</span>
|
||||
</div>
|
||||
</div>
|
||||
{skills.map((skill, index) => {
|
||||
const proficiencyClass = skill.proficiency === "Expert" || skill.proficiency === "Specialist"
|
||||
? "border-brand-accent/50 text-brand-accent bg-brand-accent/5"
|
||||
: "border-white/20 text-slate-300";
|
||||
|
||||
return (
|
||||
<div class={`skill-row group relative grid grid-cols-12 gap-4 py-10 border-b border-white/10 items-center transition-colors duration-300 hover:border-brand-accent/30 ${index < skills.length - 1 ? '' : ''}`} data-image={imageMap[skill.id] || "default"}>
|
||||
<div class="col-span-2 md:col-span-1 text-brand-accent font-mono text-sm relative overflow-hidden">
|
||||
<span class="block group-hover:-translate-y-full transition-transform duration-500">{skill.id}</span>
|
||||
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-500">{skill.id}</span>
|
||||
</div>
|
||||
<div class="col-span-10 md:col-span-4 relative overflow-hidden">
|
||||
<h3 class="text-3xl md:text-5xl font-bold text-white uppercase tracking-tighter group-hover:text-brand-accent transition-colors duration-300">{skill.domain}</h3>
|
||||
{index === 0 && (
|
||||
<div class="absolute inset-0 bg-brand-accent transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left mix-blend-difference pointer-events-none opacity-0 group-hover:opacity-100"></div>
|
||||
)}
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-5 text-slate-400 font-mono text-xs md:text-sm tracking-wide group-hover:text-white transition-colors duration-300">
|
||||
{skill.tools}
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-2 text-right hidden md:block">
|
||||
<span class={`inline-block px-3 py-1 border text-[10px] font-bold uppercase tracking-widest ${proficiencyClass}`}>{skill.proficiency}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,4 +16,90 @@ const blog = defineCollection({
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
||||
const sections = defineCollection({
|
||||
loader: glob({ base: './src/content/sections', pattern: '**/*.{md,mdx}' }),
|
||||
schema: z.object({
|
||||
// Hero section
|
||||
headlineLine1: z.string().optional(),
|
||||
headlineLine2: z.string().optional(),
|
||||
portfolioYear: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
locationLabel: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
// Experience section
|
||||
sectionTitle: z.string().optional(),
|
||||
sectionSubtitle: z.string().optional(),
|
||||
sectionLabel: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
// Experience entries
|
||||
entries: z.array(z.object({
|
||||
systemId: z.string(),
|
||||
status: z.string(),
|
||||
dates: z.string(),
|
||||
company: z.string(),
|
||||
role: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
description: z.string(),
|
||||
achievements: z.array(z.object({
|
||||
label: z.string(),
|
||||
text: z.string(),
|
||||
})).optional(),
|
||||
link: z.object({
|
||||
url: z.string(),
|
||||
text: z.string(),
|
||||
}).optional(),
|
||||
})).optional(),
|
||||
// Skills entries
|
||||
skills: z.array(z.object({
|
||||
id: z.string(),
|
||||
domain: z.string(),
|
||||
tools: z.string(),
|
||||
proficiency: z.string(),
|
||||
})).optional(),
|
||||
// Featured project
|
||||
role: z.string().optional(),
|
||||
client: z.string().optional(),
|
||||
year: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
projectTitle: z.string().optional(),
|
||||
projectSubtitle: z.string().optional(),
|
||||
projectDescription: z.string().optional(),
|
||||
stats: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})).optional(),
|
||||
videoUrl: z.string().optional(),
|
||||
linkUrl: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const pages = defineCollection({
|
||||
loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }),
|
||||
schema: z.object({
|
||||
pageTitleLine1: z.string().optional(),
|
||||
pageTitleLine2: z.string().optional(),
|
||||
availabilityText: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
locationCountry: z.string().optional(),
|
||||
coordinates: z.string().optional(),
|
||||
socialLinks: z.array(z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
formLabels: z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
submit: z.string().optional(),
|
||||
transmissionUplink: z.string().optional(),
|
||||
}).optional(),
|
||||
subjectOptions: z.array(z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, sections, pages };
|
||||
|
||||
33
src/content/pages/contact.mdx
Normal file
33
src/content/pages/contact.mdx
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
pageTitleLine1: "Project"
|
||||
pageTitleLine2: "Inquiry"
|
||||
availabilityText: "Available for freelance commissions and studio collaborations. Currently booking Q3 2026."
|
||||
email: "nicholai@nicholai.work"
|
||||
location: "Colorado Springs, CO"
|
||||
locationCountry: "United States"
|
||||
coordinates: "38.8339° N, 104.8214° W"
|
||||
socialLinks:
|
||||
- name: "Instagram"
|
||||
url: "#"
|
||||
- name: "LinkedIn"
|
||||
url: "#"
|
||||
- name: "Vimeo"
|
||||
url: "#"
|
||||
formLabels:
|
||||
transmissionUplink: "Transmission Uplink"
|
||||
name: "/// Identification Name"
|
||||
email: "/// Return Address"
|
||||
subject: "/// Subject Protocol"
|
||||
message: "/// Message Data"
|
||||
submit: "Transmit Message"
|
||||
subjectOptions:
|
||||
- value: "project"
|
||||
label: "New Project Commission"
|
||||
- value: "collab"
|
||||
label: "Studio Collaboration"
|
||||
- value: "press"
|
||||
label: "Press / Media"
|
||||
- value: "other"
|
||||
label: "Other Inquiry"
|
||||
---
|
||||
|
||||
43
src/content/sections/experience.mdx
Normal file
43
src/content/sections/experience.mdx
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
sectionTitle: "Experience"
|
||||
sectionSubtitle: "History"
|
||||
sectionLabel: "/// Career Timeline"
|
||||
description: "Bridging creative vision with technical execution. From running a dedicated VFX studio to high-end freelance supervision."
|
||||
entries:
|
||||
- systemId: "SYS.01"
|
||||
status: "ACTIVE"
|
||||
dates: "2022 — PRESENT"
|
||||
company: "Biohazard VFX"
|
||||
role: "Founder & Owner"
|
||||
tags:
|
||||
- "Studio Lead"
|
||||
- "Pipeline Arch"
|
||||
description: "Founded and lead a VFX studio specializing in high-end commercial and music video work. Delivered projects for Post Malone, ENHYPEN, and Nike. Architected a custom pipeline combining cloud and self-hosted infrastructure."
|
||||
achievements:
|
||||
- label: "Key Achievement"
|
||||
text: "Designed 7-plate reconciliation workflows for ENHYPEN (projection mapping live action onto CAD)."
|
||||
- label: "System Impact"
|
||||
text: "Developed QA systems for AI-generated assets, transforming mid-tier output into production-ready deliverables."
|
||||
link:
|
||||
url: "https://biohazardvfx.com"
|
||||
text: "Visit Studio Uplink"
|
||||
- systemId: "SYS.02"
|
||||
status: "ARCHIVED"
|
||||
dates: "SUMMER 2024"
|
||||
company: "Stinkfilms"
|
||||
role: "VFX Supervisor"
|
||||
description: "Led Biohazard VFX team (60+ artists) alongside director Felix Brady to create a brand film for G-Star Raw. Managed full CG environments in Blender/Houdini."
|
||||
link:
|
||||
url: "/blog/gstar-raw-olympics/"
|
||||
text: "Access Case Data"
|
||||
- systemId: "SYS.03"
|
||||
status: "DAEMON"
|
||||
dates: "2016 — PRESENT"
|
||||
company: "Freelance"
|
||||
role: "Senior Compositor"
|
||||
description: "Taking on select freelance compositing and 3D work alongside studio operations. Clients include Abyss Digital, Atlantic, Interscope."
|
||||
tags:
|
||||
- "Nuke"
|
||||
- "Flame"
|
||||
---
|
||||
|
||||
21
src/content/sections/featured-project.mdx
Normal file
21
src/content/sections/featured-project.mdx
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
role: "VFX Sup"
|
||||
client: "Stink"
|
||||
year: "2024"
|
||||
region: "Global"
|
||||
projectTitle: "G-Star"
|
||||
projectSubtitle: "Olympics"
|
||||
projectDescription: "Full CG environment production for the 2024 Olympic Campaign. Orchestrated procedural city generation and AI-enhanced lighting workflows."
|
||||
stats:
|
||||
- label: "Shot Count"
|
||||
value: "12 Sequences"
|
||||
- label: "Resolution"
|
||||
value: "4K DCI"
|
||||
- label: "Pipeline"
|
||||
value: "Houdini / Solaris"
|
||||
- label: "Render"
|
||||
value: "Karma XPU"
|
||||
videoUrl: "https://media.nicholai.work/FF_PUFF_GStar_DC_v08_4608x3164.mp4"
|
||||
linkUrl: "/blog/gstar-raw-olympics/"
|
||||
---
|
||||
|
||||
9
src/content/sections/hero.mdx
Normal file
9
src/content/sections/hero.mdx
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
headlineLine1: "VISUAL"
|
||||
headlineLine2: "ALCHEMIST"
|
||||
portfolioYear: "Portfolio 2026"
|
||||
location: "Colorado Springs, CO"
|
||||
locationLabel: "Location"
|
||||
bio: "I am a problem solver who loves visual effects. Creating end-to-end visual content for clients like Post Malone, Stinkfilms, and Adidas. Bridging creative vision with technical execution."
|
||||
---
|
||||
|
||||
23
src/content/sections/skills.mdx
Normal file
23
src/content/sections/skills.mdx
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
sectionTitle: "Technical"
|
||||
sectionSubtitle: "Arsenal"
|
||||
description: "A comprehensive suite of tools and workflows designed for high-fidelity visual production and pipeline automation."
|
||||
skills:
|
||||
- id: "01"
|
||||
domain: "Compositing"
|
||||
tools: "Nuke/NukeX • ComfyUI • After Effects • Photoshop"
|
||||
proficiency: "Expert"
|
||||
- id: "02"
|
||||
domain: "3D Generalist"
|
||||
tools: "Houdini • Blender • Maya • USD • Solaris"
|
||||
proficiency: "Advanced"
|
||||
- id: "03"
|
||||
domain: "AI Integration"
|
||||
tools: "Stable Diffusion • LoRA • Datasets • Python"
|
||||
proficiency: "Specialist"
|
||||
- id: "04"
|
||||
domain: "Development"
|
||||
tools: "Python • React • Docker • Linux • Pipeline"
|
||||
proficiency: "Full Stack"
|
||||
---
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { SITE_TITLE } from '../consts';
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
|
||||
// Fetch contact page content
|
||||
const contactEntry = await getEntry('pages', 'contact');
|
||||
const contactContent = contactEntry.data;
|
||||
---
|
||||
|
||||
<BaseLayout title={pageTitle} description="Get in touch for collaboration or inquiries." usePadding={false}>
|
||||
@ -22,14 +27,13 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-white/10 pb-12">
|
||||
<div class="lg:col-span-8">
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] text-white mb-8">
|
||||
<span class="block animate-on-scroll slide-up">Project</span>
|
||||
<span class="block text-brand-accent animate-on-scroll slide-up stagger-1">Inquiry</span>
|
||||
<span class="block animate-on-scroll slide-up">{contactContent.pageTitleLine1}</span>
|
||||
<span class="block text-brand-accent animate-on-scroll slide-up stagger-1">{contactContent.pageTitleLine2}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:col-span-4 flex flex-col justify-end">
|
||||
<p class="font-mono text-sm text-slate-400 leading-relaxed mb-8 border-l border-brand-accent pl-6 animate-on-scroll fade-in stagger-2">
|
||||
Available for freelance commissions and studio collaborations.
|
||||
Currently booking Q3 2026.
|
||||
{contactContent.availabilityText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -40,7 +44,7 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
<div class="lg:col-span-7 animate-on-scroll slide-up stagger-3">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
||||
<span class="font-mono text-xs text-brand-accent uppercase tracking-widest">Transmission Uplink</span>
|
||||
<span class="font-mono text-xs text-brand-accent uppercase tracking-widest">{contactContent.formLabels?.transmissionUplink}</span>
|
||||
</div>
|
||||
|
||||
<form id="contact-form" class="space-y-12">
|
||||
@ -54,7 +58,7 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
required
|
||||
/>
|
||||
<label for="name" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
||||
/// Identification Name
|
||||
{contactContent.formLabels?.name}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -68,7 +72,7 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
required
|
||||
/>
|
||||
<label for="email" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
||||
/// Return Address
|
||||
{contactContent.formLabels?.email}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -84,28 +88,18 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
</button>
|
||||
|
||||
<label id="select-label" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 pointer-events-none">
|
||||
/// Subject Protocol
|
||||
{contactContent.formLabels?.subject}
|
||||
</label>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div id="select-options" class="absolute left-0 top-full w-full bg-brand-dark border border-white/20 shadow-2xl z-50 hidden opacity-0 transform translate-y-2 transition-all duration-200 origin-top mt-2">
|
||||
<div class="p-1">
|
||||
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value="project">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
||||
New Project Commission
|
||||
</div>
|
||||
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value="collab">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
||||
Studio Collaboration
|
||||
</div>
|
||||
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value="press">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
||||
Press / Media
|
||||
</div>
|
||||
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value="other">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
||||
Other Inquiry
|
||||
</div>
|
||||
{contactContent.subjectOptions?.map((option) => (
|
||||
<div class="option px-5 py-4 hover:bg-white/5 cursor-pointer text-white text-lg font-light transition-colors flex items-center gap-3 group/option" data-value={option.value}>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-accent opacity-0 group-hover/option:opacity-100 transition-opacity"></span>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -120,13 +114,13 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
required
|
||||
></textarea>
|
||||
<label for="message" class="absolute left-0 top-4 text-slate-500 text-sm font-mono uppercase tracking-widest transition-all duration-300 peer-focus:-top-6 peer-focus:text-xs peer-focus:text-brand-accent peer-valid:-top-6 peer-valid:text-xs peer-valid:text-slate-400 pointer-events-none">
|
||||
/// Message Data
|
||||
{contactContent.formLabels?.message}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pt-8">
|
||||
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-4 px-8 py-4 bg-transparent border border-white/20 hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span id="submit-text" class="font-mono text-xs font-bold uppercase tracking-widest text-white group-hover:text-brand-accent transition-colors">Transmit Message</span>
|
||||
<span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="font-mono text-xs font-bold uppercase tracking-widest text-white group-hover:text-brand-accent transition-colors">{contactContent.formLabels?.submit}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500 group-hover:text-brand-accent group-hover:translate-x-1 transition-all"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@ -139,8 +133,8 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
<!-- Data Block 1 -->
|
||||
<div class="relative pl-6 border-l border-white/10">
|
||||
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Direct Link</h3>
|
||||
<a href="mailto:nicholai@nicholai.work" class="text-2xl md:text-3xl font-bold text-white hover:text-brand-accent transition-colors break-all">
|
||||
nicholai@nicholai.work
|
||||
<a href={`mailto:${contactContent.email}`} class="text-2xl md:text-3xl font-bold text-white hover:text-brand-accent transition-colors break-all">
|
||||
{contactContent.email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -148,11 +142,11 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
<div class="relative pl-6 border-l border-white/10">
|
||||
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Coordinates</h3>
|
||||
<p class="text-xl text-white font-light">
|
||||
Colorado Springs, CO<br>
|
||||
<span class="text-slate-500 text-base">United States</span>
|
||||
{contactContent.location}<br>
|
||||
<span class="text-slate-500 text-base">{contactContent.locationCountry}</span>
|
||||
</p>
|
||||
<div class="mt-4 font-mono text-xs text-brand-accent">
|
||||
38.8339° N, 104.8214° W
|
||||
{contactContent.coordinates}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -160,24 +154,14 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
<div class="relative pl-6 border-l border-white/10">
|
||||
<h3 class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">Social Feed</h3>
|
||||
<ul class="space-y-4">
|
||||
<li>
|
||||
<a href="#" class="flex items-center gap-4 group">
|
||||
<span class="text-slate-400 group-hover:text-white transition-colors text-lg">Instagram</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-brand-accent transition-colors transform group-hover:translate-x-1 group-hover:-translate-y-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="flex items-center gap-4 group">
|
||||
<span class="text-slate-400 group-hover:text-white transition-colors text-lg">LinkedIn</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-brand-accent transition-colors transform group-hover:translate-x-1 group-hover:-translate-y-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="flex items-center gap-4 group">
|
||||
<span class="text-slate-400 group-hover:text-white transition-colors text-lg">Vimeo</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-brand-accent transition-colors transform group-hover:translate-x-1 group-hover:-translate-y-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
{contactContent.socialLinks?.map((link) => (
|
||||
<li>
|
||||
<a href={link.url} class="flex items-center gap-4 group">
|
||||
<span class="text-slate-400 group-hover:text-white transition-colors text-lg">{link.name}</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-brand-accent transition-colors transform group-hover:translate-x-1 group-hover:-translate-y-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -229,29 +213,38 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
</div>
|
||||
|
||||
<!-- Response State (hidden initially) -->
|
||||
<div id="response-state" class="hidden w-full max-w-4xl mx-auto px-6 opacity-0 transform scale-95 transition-all duration-700">
|
||||
<div id="response-state" class="hidden w-full h-full absolute inset-0 z-10 flex flex-col items-center justify-center p-6 opacity-0 transition-all duration-700">
|
||||
<!-- Close button -->
|
||||
<button id="close-modal" class="absolute top-8 right-8 p-3 border border-white/20 hover:border-brand-accent hover:bg-brand-accent/10 transition-all duration-300 group">
|
||||
<button id="close-modal" class="absolute top-8 right-8 z-50 p-3 border border-white/20 hover:border-brand-accent hover:bg-brand-accent/10 transition-all duration-300 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white group-hover:text-brand-accent transition-colors">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Success indicator -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 border-2 border-brand-accent rounded-full mb-6 animate-scale-in">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-brand-accent">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
<!-- Content Container -->
|
||||
<div class="w-full max-w-5xl mx-auto flex flex-col items-center relative">
|
||||
|
||||
<!-- Header - More subtle now -->
|
||||
<div class="text-center mb-12 animate-scale-in">
|
||||
<div class="flex items-center justify-center gap-3 mb-4">
|
||||
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
||||
<p class="font-mono text-sm text-brand-accent uppercase tracking-widest">Transmission Received</p>
|
||||
<span class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-4xl md:text-6xl font-bold text-white uppercase tracking-tight mb-3">Transmission Received</h2>
|
||||
<p class="font-mono text-sm text-brand-accent uppercase tracking-widest">Signal Confirmed</p>
|
||||
</div>
|
||||
|
||||
<!-- Response content with better styling -->
|
||||
<div class="bg-gradient-to-br from-brand-dark/80 to-brand-dark/60 border border-brand-accent/40 p-10 md:p-16 backdrop-blur-sm max-h-[65vh] overflow-y-auto custom-scrollbar shadow-2xl">
|
||||
<div id="response-content" class="prose-response"></div>
|
||||
<!-- Response content - The Focal Point -->
|
||||
<div class="w-full relative">
|
||||
<!-- Decorative corner markers -->
|
||||
<div class="absolute -top-4 -left-4 w-8 h-8 border-t-2 border-l-2 border-brand-accent opacity-50"></div>
|
||||
<div class="absolute -top-4 -right-4 w-8 h-8 border-t-2 border-r-2 border-brand-accent opacity-50"></div>
|
||||
<div class="absolute -bottom-4 -left-4 w-8 h-8 border-b-2 border-l-2 border-brand-accent opacity-50"></div>
|
||||
<div class="absolute -bottom-4 -right-4 w-8 h-8 border-b-2 border-r-2 border-brand-accent opacity-50"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div id="response-content" class="prose-response max-h-[70vh] overflow-y-auto custom-scrollbar px-4 md:px-8 py-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -344,118 +337,93 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
/* Response Content Prose Styles - Enhanced Readability */
|
||||
.prose-response {
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-response h1,
|
||||
.prose-response h2,
|
||||
.prose-response h3 {
|
||||
color: #00FFFF;
|
||||
color: white;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.prose-response h1 {
|
||||
font-size: 2.5em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.prose-response h1 {
|
||||
font-size: 3.5rem;
|
||||
background: linear-gradient(to right, #fff, #94a3b8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-response h2 {
|
||||
font-size: 1.875em;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.prose-response h3 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.prose-response h1:first-child,
|
||||
.prose-response h2:first-child,
|
||||
.prose-response h3:first-child {
|
||||
margin-top: 0;
|
||||
font-size: 1.75rem;
|
||||
color: #ff4d00;
|
||||
}
|
||||
|
||||
.prose-response p {
|
||||
margin-bottom: 1.25em;
|
||||
line-height: 2;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1.5em;
|
||||
line-height: 1.8;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.5rem; /* Increased size significantly */
|
||||
font-weight: 300;
|
||||
max-width: 65ch;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.prose-response strong {
|
||||
color: #00FFFF;
|
||||
font-weight: 700;
|
||||
font-size: 1.05em;
|
||||
color: #ff4d00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-response em {
|
||||
font-style: italic;
|
||||
color: rgba(0, 255, 255, 0.9);
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Blockquote for signature or special text */
|
||||
.prose-response blockquote {
|
||||
border-left: none; /* Removed standard border */
|
||||
margin: 3em 0 1em;
|
||||
padding: 0;
|
||||
color: #ff4d00;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
display: inline-block;
|
||||
border-top: 1px solid rgba(255, 77, 0, 0.3);
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.prose-response a {
|
||||
color: #00FFFF;
|
||||
color: #ff4d00;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-thickness: 2px;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prose-response a:hover {
|
||||
opacity: 0.7;
|
||||
text-decoration-thickness: 3px;
|
||||
color: white;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.prose-response ul,
|
||||
.prose-response ol {
|
||||
margin-bottom: 1.5em;
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
|
||||
.prose-response li {
|
||||
margin-bottom: 0.75em;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.prose-response code {
|
||||
background: rgba(0, 255, 255, 0.15);
|
||||
padding: 0.3em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95em;
|
||||
color: #00FFFF;
|
||||
font-family: 'Courier New', monospace;
|
||||
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.prose-response pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 1.5em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.5em;
|
||||
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.prose-response blockquote {
|
||||
border-left: 4px solid #00FFFF;
|
||||
padding-left: 1.5em;
|
||||
margin: 1.5em 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-style: italic;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.prose-response hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(0, 255, 255, 0.3);
|
||||
margin: 2em 0;
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.prose-response h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.prose-response p {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -613,6 +581,21 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
closeModalBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
if (transmissionModal) {
|
||||
transmissionModal.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Only close if response state is active and visible
|
||||
// We check if the click target is the container itself (the background)
|
||||
// response-state covers the whole screen when active
|
||||
if (!responseState.classList.contains('hidden') &&
|
||||
(target === responseState || target === transmissionModal)) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Form Submission Handler =====
|
||||
const contactForm = document.getElementById('contact-form') as HTMLFormElement;
|
||||
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
|
||||
@ -681,7 +664,7 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
showResponse();
|
||||
|
||||
// Reset button state
|
||||
submitText.textContent = 'Transmit Message';
|
||||
submitText.textContent = submitText.getAttribute('data-default-text') || 'Transmit Message';
|
||||
submitBtn.disabled = false;
|
||||
|
||||
} catch (markdownError) {
|
||||
@ -714,8 +697,9 @@ const pageTitle = `Contact | ${SITE_TITLE}`;
|
||||
|
||||
// Update button to failure state
|
||||
submitText.textContent = 'Transmission Failed';
|
||||
const defaultText = submitText.getAttribute('data-default-text') || 'Transmit Message';
|
||||
setTimeout(() => {
|
||||
submitText.textContent = 'Transmit Message';
|
||||
submitText.textContent = defaultText;
|
||||
submitBtn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
|
||||
@ -4,23 +4,68 @@ import Hero from '../components/sections/Hero.astro';
|
||||
import Experience from '../components/sections/Experience.astro';
|
||||
import FeaturedProject from '../components/sections/FeaturedProject.astro';
|
||||
import Skills from '../components/sections/Skills.astro';
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
// Fetch all section content
|
||||
const heroEntry = await getEntry('sections', 'hero');
|
||||
const experienceEntry = await getEntry('sections', 'experience');
|
||||
const skillsEntry = await getEntry('sections', 'skills');
|
||||
const featuredProjectEntry = await getEntry('sections', 'featured-project');
|
||||
|
||||
// Extract content from entries
|
||||
const heroContent = {
|
||||
headlineLine1: heroEntry.data.headlineLine1 || '',
|
||||
headlineLine2: heroEntry.data.headlineLine2 || '',
|
||||
portfolioYear: heroEntry.data.portfolioYear || '',
|
||||
location: heroEntry.data.location || '',
|
||||
locationLabel: heroEntry.data.locationLabel || '',
|
||||
bio: heroEntry.data.bio || '',
|
||||
};
|
||||
|
||||
const experienceContent = {
|
||||
sectionTitle: experienceEntry.data.sectionTitle || '',
|
||||
sectionSubtitle: experienceEntry.data.sectionSubtitle || '',
|
||||
sectionLabel: experienceEntry.data.sectionLabel || '',
|
||||
description: experienceEntry.data.description || '',
|
||||
entries: experienceEntry.data.entries || [],
|
||||
};
|
||||
|
||||
const skillsContent = {
|
||||
sectionTitle: skillsEntry.data.sectionTitle || '',
|
||||
sectionSubtitle: skillsEntry.data.sectionSubtitle || '',
|
||||
description: skillsEntry.data.description || '',
|
||||
skills: skillsEntry.data.skills || [],
|
||||
};
|
||||
|
||||
const featuredProjectContent = {
|
||||
role: featuredProjectEntry.data.role || '',
|
||||
client: featuredProjectEntry.data.client || '',
|
||||
year: featuredProjectEntry.data.year || '',
|
||||
region: featuredProjectEntry.data.region || '',
|
||||
projectTitle: featuredProjectEntry.data.projectTitle || '',
|
||||
projectSubtitle: featuredProjectEntry.data.projectSubtitle || '',
|
||||
projectDescription: featuredProjectEntry.data.projectDescription || '',
|
||||
stats: featuredProjectEntry.data.stats || [],
|
||||
videoUrl: featuredProjectEntry.data.videoUrl || '',
|
||||
linkUrl: featuredProjectEntry.data.linkUrl || '',
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout usePadding={false}>
|
||||
<Hero />
|
||||
<Hero {...heroContent} />
|
||||
|
||||
<!-- Gradient Divider -->
|
||||
<div class="w-full my-16 lg:my-24">
|
||||
<div class="h-[1px] divider-gradient"></div>
|
||||
</div>
|
||||
|
||||
<Experience />
|
||||
<Experience {...experienceContent} />
|
||||
|
||||
<!-- Container Divider with accent hint -->
|
||||
<div class="container mx-auto px-6 lg:px-12 my-8">
|
||||
<div class="h-[1px] divider-gradient"></div>
|
||||
</div>
|
||||
|
||||
<FeaturedProject />
|
||||
<Skills />
|
||||
<FeaturedProject {...featuredProjectContent} />
|
||||
<Skills {...skillsContent} />
|
||||
</BaseLayout>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user