Portfolio site for See Through Lab, an experimental art & technology studio.
Built with Astro 7, deployed to GitHub Pages. Live at https://seethroughlab.com.
- Astro 7 — static output,
compressHTML: true(astro.config.mjs) - Tailwind CSS 4 — via
@tailwindcss/viteplugin (notailwind.config; theme insrc/styles/global.css) - MDX — content authoring (
@astrojs/mdx) - TypeScript (strict)
- Node 22 (
.nvmrc)
npm run dev # local dev server
npm run build # production build
npm run preview # preview production buildsrc/
components/ # Nav, Footer, ProjectCard, ResearchCard, VideoPlayer,
# ImageCarousel, InstagramEmbed
content/
projects/ # 26 MDX files
research/ # 7 MDX files
layouts/ # Base.astro (only layout)
pages/
index.astro # homepage
projects/[slug].astro # project detail
research/index.astro # research listing
research/[slug].astro # research detail
styles/
global.css # Tailwind @theme tokens + prose styles
content.config.ts # collection schemas
public/
images/projects/<slug>/ # project images
home/ # WebGPU homepage animation (JS + WGSL shaders)
lab/ # interactive experiments (ambient-generator, interactive-frank-2, shaderlm);
# cassvision/ holds the Cassvision viewer's assets (synced, see below)
scripts/
process-video.sh # HLS encoding (ffmpeg): [--grain] <input> <slug>; quality-based, capped per rendition
upload-video.js # S3 upload
sync-cassvision.sh # copy Cassvision's STLs, pipeline JS, clips, carrier renders from ~/Developer/cassvision [--rebuild]
cassvision-form-drawing.py # regenerate the Cassvision Form drawing from the CAD (Cassvision's enclosure venv)
scrape-squarespace.js # original site scraper (migration)
enrich-frontmatter.js # MDX frontmatter enrichment (migration)
Schemas defined in src/content.config.ts.
| Field | Type | Notes |
|---|---|---|
| title | string |
required |
| slug | string |
required |
| client | string |
defaults '' |
| year | number |
optional |
| tags | string[] |
defaults [] |
| coverImage | string |
defaults '' |
| videoSrc | string |
CloudFront HLS URL; defaults '' |
| featured | boolean |
defaults false |
| order | number |
sort key; defaults 99 |
| role | string |
defaults '' |
| tech | string[] |
defaults [] |
| Field | Type | Notes |
|---|---|---|
| title | string |
required |
| slug | string |
required |
| subtitle | string |
defaults '' |
| status | 'active' | 'archived' | 'experiment' |
defaults 'active' |
| githubUrl | string |
defaults '' |
| coverImage | string |
defaults '' |
| labUrl | string |
defaults '' |
| videoSrc | string |
CloudFront HLS URL; replaces the cover image as hero; defaults '' |
| videoAspect | string |
CSS aspect-ratio for the hero video (e.g. 1920 / 888); defaults to 16:9 |
| heroViewer | '' | 'cassvision-case' |
interactive hero in place of the video/cover (Cassvision's 3D case: src/components/cassvision/, scene in public/lab/cassvision/cassvision.js); defaults '' |
| instagramPosts | string[] |
defaults [] |
| credits | { role, name, url? }[] |
shown as "Team" below the body; defaults [] |
The Cassvision page's model comes from the Cassvision repo (~/Developer/cassvision, CAD in enclosure/pxl_case.py).
After a CAD, pipeline or clip change there, run scripts/sync-cassvision.sh (add --rebuild to regenerate
there first), check the page locally, and commit. A part added or renamed in the CAD also needs its name in
the script's PARTS, in cassvision.js (CASE_PARTS or CASE_INTERNALS) and, for a printed part, a button in
CaseViewer.astro. Details the CAD only cuts holes for (screen glass, lens, USB-C, switch, screws, LED,
window disc) are drawn in cassvision.js from the CAD's numbers: update them there if those numbers change.
The Form section's dimensioned drawing is generated by scripts/cassvision-form-drawing.py (run it with the
Cassvision enclosure's .venv/bin/python); rerun it when the outline changes.
The repo is public and GitHub Pages has no auth, so a project with private: true is never pushed in plain form:
- its
.mdxandpublic/images/projects/<slug>/are listed in.gitignore; - it is excluded from the homepage, tag pages, BTS and the normal build;
node scripts/build-private.mjsrenders it locally, inlines CSS and images, encrypts the content (AES-GCM / PBKDF2) and writespublic/projects/<slug>/index.html, the only file committed. Rerun it after editing the project.- Password:
.private-password(gitignored) orPRIVATE_PAGE_PASSWORD. - To publish normally: remove
private: true, its.gitignorelines andpublic/projects/<slug>/, then commit the.mdxand images.
Currently private: none.
Video → Image carousel → Cover image (first available wins).
- Dark theme: black bg (
#000), white primary text, muted gray (#666) for body/secondary - Theme tokens in
src/styles/global.css@themeblock — colors, fonts, font sizes, container width - Prose styles for MDX content in
@layer base(.proseclass) .prose-no-imageshides MDX inline images when anImageCarouselis present.logo-text— chromatic aberration text effect for homepage logo
- Source:
public/home/js/(modules) +public/home/shaders/(WGSL) - Loaded by
Nav.astrovia<script is:inline type="module" src="/home/js/main.js"> - CRT scanline background effect + interactive logo rendering
- Falls back gracefully when WebGPU is unavailable
GitHub Actions (.github/workflows/deploy.yml):
- Trigger: push to
masteror manual dispatch - Uses
withastro/action@v3(Node 20) →actions/deploy-pages@v4 - Concurrency group
pages, does not cancel in-progress deploys
Project videos are self-hosted on S3/CloudFront with HLS adaptive streaming.
- S3 Bucket:
seethroughlab-media(us-east-1) - CloudFront Distribution:
E3HHODJ5DZ82EV—d13tobysqmg65w.cloudfront.net - IAM User:
s3_user(has S3 + CloudFront permissions) - OAC ID:
E3LVSUOS6K0DW2
videos/<slug>/
├── original.mp4 ← source file from Vimeo (for future re-encoding)
├── index.m3u8 ← master HLS playlist
├── 1080p/index.m3u8 + seg_*.ts
├── 720p/index.m3u8 + seg_*.ts
├── 480p/index.m3u8 + seg_*.ts
└── poster.jpg ← frame extracted at 2s
| Slug | Original Vimeo ID |
|---|---|
| cosmic-crisis | 878052416 |
| pandora-sounds-like-you | 259486511 |
| intel-mega-experience | 259486965 |
| one-time-in-new-orleans | 259501609 |
| driven-by-emotion | 187023351 |
| endec | 168886039 |
| light-echoes | 134693404 |
| acer-predator | 130166758 |
Research: tidewalker (sizzle reel, from the Tidewalker repo's Builds/Reel/post/Tidewalker.mp4), antiphon (sizzle reel 16x9, from ~/Music/Antiphon/common-tone/videos/).
To replace a published video, upload to a new path (e.g. videos/antiphon/v2/ via process-video.sh <in> antiphon/v2 + upload-video.js antiphon/v2) rather than overwriting: segments are cached as immutable for a year. Then delete the old files.
scripts/process-video.sh [--grain] <input.mp4> <slug>— Encodes HLS in 3 renditions, quality-based with a ceiling (1080p CRF 18 ≤ 12 Mbps, 720p CRF 19 ≤ 6 Mbps, 480p CRF 20 ≤ 2.5 Mbps; keyframes every 2 s, aligned across renditions; landscape fits within 1920x1080 etc., output always 4:2:0 limited range) + poster; the master playlist states each rendition's measured peak and average bitrate.--grainadds x264 grain tuning for grainy, noisy or shader-textured footage. Output:processed/videos/<slug>/.scripts/upload-video.js <slug>— Uploads processed video to S3 with correct MIME types and cache headers. SetCF_DOMAINenv var to override CloudFront domain.
# 1. Download original from S3 (or use local copy in raw/videos/)
aws s3 cp s3://seethroughlab-media/videos/<slug>/original.mp4 raw/videos/<slug>.mp4
# 2. Process
./scripts/process-video.sh raw/videos/<slug>.mp4 <slug>
# 3. Upload
node scripts/upload-video.js <slug>VideoPlayer.astro detects .m3u8 URLs and uses:
- Native HLS in Safari
- hls.js (loaded via CDN) in Chrome/Firefox
MDX frontmatter videoSrc field points to the CloudFront master playlist URL.