From 253e1d13f6ba653a578512e30709f399182563d5 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 15 Jul 2026 16:09:24 -0700 Subject: [PATCH] media: begin thespianjas digital twin pipeline --- marketing/essay-reels/SCORE.md | 33 +++ marketing/essay-reels/bin/render.mjs | 188 ++++++++++++++++++ package.json | 3 + thespianjas/.gitignore | 5 + thespianjas/SCORE.md | 48 +++++ thespianjas/assets/versions/.gitkeep | 1 + .../assets/versions/v001/manifest.json | 39 ++++ thespianjas/bin/generate.mjs | 83 ++++++++ thespianjas/bin/serve.mjs | 15 ++ thespianjas/identity.json | 12 ++ thespianjas/studio/index.html | 14 ++ 11 files changed, 441 insertions(+) create mode 100644 marketing/essay-reels/SCORE.md create mode 100644 marketing/essay-reels/bin/render.mjs create mode 100644 thespianjas/.gitignore create mode 100644 thespianjas/SCORE.md create mode 100644 thespianjas/assets/versions/.gitkeep create mode 100644 thespianjas/assets/versions/v001/manifest.json create mode 100644 thespianjas/bin/generate.mjs create mode 100644 thespianjas/bin/serve.mjs create mode 100644 thespianjas/identity.json create mode 100644 thespianjas/studio/index.html diff --git a/marketing/essay-reels/SCORE.md b/marketing/essay-reels/SCORE.md new file mode 100644 index 000000000..675fe7a99 --- /dev/null +++ b/marketing/essay-reels/SCORE.md @@ -0,0 +1,33 @@ +# Essay reels — podcast readings → Reel / Story + +This lane turns a produced episode in `marketing/podcast/out/` into a vertical +social excerpt. It uses the actual mastered Jeffrey reading, transcribes the +chosen excerpt for word timing, and renders a furniture-free 1080×1920 video. + +The visual form is deliberately native to the reading series: the square +episode cover becomes a slowly breathing paper field; the current phrase sits +large and legible in the safe center; each spoken word fills green as it lands; +and a small audio-reactive ring makes the recording feel alive without turning +the essay into a slideshow. + +```fish +node marketing/essay-reels/bin/render.mjs granularity \ + --start 7 --duration 45 --open +``` + +Inputs (by slug): + +- `marketing/podcast/out/.mp3` +- `marketing/podcast/out/.json` +- `marketing/podcast/out/-cover.png` + +Outputs: + +- `marketing/essay-reels/out/-reel.mp4` +- `marketing/essay-reels/out/-story.mp4` (same master; named for upload) +- `marketing/essay-reels/out/-words.json` + +Flags: `--start N`, `--duration N` (default 45), `--out path`, `--open`. +Keep essential type inside the central safe area: Instagram overlays controls at +the top and bottom. There is no baked progress bar or timecode; the host app +supplies its own furniture. diff --git a/marketing/essay-reels/bin/render.mjs b/marketing/essay-reels/bin/render.mjs new file mode 100644 index 000000000..3a05df8f4 --- /dev/null +++ b/marketing/essay-reels/bin/render.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// Podcast reading → word-timed 9:16 Instagram Reel / Story. + +import { spawn, spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createCanvas, loadImage } from "canvas"; +import { wordsFromWhisper } from "../../lib/words.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", "..", ".."); +const POD = resolve(REPO, "marketing", "podcast", "out"); +const OUT_DIR = resolve(HERE, "..", "out"); +const MODEL = resolve(REPO, "recap", "models", "ggml-large-v3-turbo.bin"); +const W = 1080; +const H = 1920; +const FPS = 30; + +const argv = process.argv.slice(2); +const slug = argv.find((a) => !a.startsWith("--")); +const flag = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback; +}; +const has = (name) => argv.includes(`--${name}`); +if (!slug) { + console.error("usage: render.mjs [--start 7] [--duration 45] [--open]"); + process.exit(2); +} + +const audio = resolve(POD, `${slug}.mp3`); +const coverPath = resolve(POD, `${slug}-cover.png`); +const metaPath = resolve(POD, `${slug}.json`); +if (![audio, coverPath, metaPath, MODEL].every(existsSync)) { + console.error(`✗ missing podcast audio, cover, metadata, or Whisper model for ${slug}`); + process.exit(1); +} + +mkdirSync(OUT_DIR, { recursive: true }); +const work = resolve(OUT_DIR, `.work-${slug}`); +mkdirSync(work, { recursive: true }); +const start = Number(flag("start", "7")); +const duration = Number(flag("duration", "45")); +const out = resolve(flag("out", resolve(OUT_DIR, `${slug}-reel.mp4`))); +const story = resolve(OUT_DIR, `${slug}-story.mp4`); +const wav = resolve(work, "excerpt.wav"); +const mono = resolve(work, "excerpt-16k.wav"); +const whisperBase = resolve(work, "words"); +const whisperJson = `${whisperBase}.json`; +const meta = JSON.parse(readFileSync(metaPath, "utf8")); + +const run = (cmd, args, opts = {}) => { + const r = spawnSync(cmd, args, { encoding: "utf8", ...opts }); + if (r.status !== 0) throw new Error(`${cmd}: ${r.stderr?.slice(-800)}`); + return r.stdout; +}; + +console.log(`· excerpt ${start}s → ${start + duration}s`); +run("ffmpeg", ["-y", "-ss", String(start), "-t", String(duration), "-i", audio, + "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", wav], { stdio: "ignore" }); +run("ffmpeg", ["-y", "-i", wav, "-ar", "16000", "-ac", "1", mono], { stdio: "ignore" }); + +console.log("· transcribing word timing"); +run("whisper-cli", ["-m", MODEL, "-f", mono, "-oj", "-ojf", "-ml", "1", "-l", "en", "-ng", "-of", whisperBase]); +const words = wordsFromWhisper(whisperJson); +writeFileSync(resolve(OUT_DIR, `${slug}-words.json`), JSON.stringify(words, null, 2) + "\n"); + +// Decode a light-weight mono envelope for the pulse. +const pcm = spawnSync("ffmpeg", ["-v", "error", "-i", wav, "-f", "s16le", "-ac", "1", "-ar", "8000", "-"], { encoding: null }).stdout; +const samples = new Int16Array(pcm.buffer, pcm.byteOffset, Math.floor(pcm.byteLength / 2)); +const energyAt = (sec) => { + const a = Math.max(0, Math.floor(sec * 8000)); + const b = Math.min(samples.length, a + 640); + let sum = 0; + for (let i = a; i < b; i++) sum += (samples[i] / 32768) ** 2; + return Math.min(1, Math.sqrt(sum / Math.max(1, b - a)) * 5.5); +}; + +const cover = await loadImage(coverPath); +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +const frames = Math.round(duration * FPS); + +// Group words into short readable phrases, never more than seven words. +const phrases = []; +for (let i = 0; i < words.length;) { + const group = []; + const fromMs = words[i].fromMs; + while (i < words.length && group.length < 7) { + group.push(words[i++]); + if (/[.!?]$/.test(group.at(-1).text) && group.length >= 3) break; + } + phrases.push({ words: group, fromMs, toMs: group.at(-1).toMs + 280 }); +} + +const roundRect = (x, y, w, h, r) => { + ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); +}; +const wrap = (items, maxWidth) => { + const lines = [[]]; + for (const item of items) { + const test = [...lines.at(-1), item].map((w) => w.text).join(" "); + if (ctx.measureText(test).width > maxWidth && lines.at(-1).length) lines.push([item]); + else lines.at(-1).push(item); + } + return lines; +}; + +const enc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "-", + "-i", wav, "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", + "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", + "-movflags", "+faststart", out], { stdio: ["pipe", "inherit", "inherit"] }); +const write = (buf) => new Promise((ok) => enc.stdin.write(buf) ? ok() : enc.stdin.once("drain", ok)); + +console.log(`· rendering ${frames} frames`); +for (let f = 0; f < frames; f++) { + const t = f / FPS; + const ms = t * 1000; + const zoom = 1.12 + 0.025 * Math.sin(t * 0.16); + const cw = W * zoom; + const ch = cw; + ctx.fillStyle = "#fff9fc"; + ctx.fillRect(0, 0, W, H); + ctx.globalAlpha = 0.23; + ctx.drawImage(cover, (W - cw) / 2, (H - ch) / 2, cw, ch); + ctx.globalAlpha = 1; + const fade = ctx.createLinearGradient(0, 0, 0, H); + fade.addColorStop(0, "rgba(255,249,252,.94)"); + fade.addColorStop(.35, "rgba(255,249,252,.72)"); + fade.addColorStop(.75, "rgba(255,249,252,.78)"); + fade.addColorStop(1, "rgba(255,249,252,.96)"); + ctx.fillStyle = fade; ctx.fillRect(0, 0, W, H); + + ctx.textAlign = "center"; + ctx.fillStyle = "#777"; + ctx.font = "700 30px Arial"; + ctx.fillText("A READING FROM AESTHETIC.COMPUTER", W / 2, 150); + ctx.fillStyle = "#40384a"; + ctx.font = "700 48px Arial"; + ctx.fillText(meta.title, W / 2, 225); + + const e = energyAt(t); + ctx.strokeStyle = "#b44887"; + ctx.lineWidth = 10; + ctx.beginPath(); ctx.arc(W / 2, 390, 58 + e * 28, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(W / 2, 390, 20, 0, Math.PI * 2); ctx.fill(); + + const phrase = phrases.find((p) => ms >= p.fromMs - 180 && ms <= p.toMs) || phrases.find((p) => ms < p.fromMs) || phrases.at(-1); + ctx.font = "700 78px Arial"; + const lines = wrap(phrase.words, 820); + const lineH = 102; + const boxH = lines.length * lineH + 120; + ctx.fillStyle = "rgba(64,56,74,.92)"; + roundRect(90, 680 - boxH / 2, 900, boxH, 36); + let y = 680 - ((lines.length - 1) * lineH) / 2 + 26; + for (const line of lines) { + const widths = line.map((w) => ctx.measureText(w.text).width); + const total = widths.reduce((a, b) => a + b, 0) + (line.length - 1) * 24; + let x = (W - total) / 2; + ctx.textAlign = "left"; + line.forEach((word, i) => { + ctx.fillStyle = ms >= word.fromMs ? "#46c85a" : "#fff9fc"; + ctx.fillText(word.text, x, y); + x += widths[i] + 24; + }); + y += lineH; + } + + ctx.textAlign = "center"; + ctx.fillStyle = "#b44887"; + ctx.font = "700 42px Arial"; + ctx.fillText("PLAYBACK CAN BE AN INSTRUMENT", W / 2, 1570); + ctx.fillStyle = "#40384a"; + ctx.font = "32px Arial"; + ctx.fillText("The Record Is a Better Interface · @jeffrey", W / 2, 1640); + ctx.fillStyle = "#777"; + ctx.font = "26px Arial"; + ctx.fillText("read the essay · listen to the episode", W / 2, 1700); + await write(canvas.toBuffer("raw")); + if (f % 150 === 0) process.stdout.write(`\r ${f}/${frames}`); +} +enc.stdin.end(); +await new Promise((ok, fail) => enc.on("close", (code) => code === 0 ? ok() : fail(new Error(`ffmpeg ${code}`)))); +copyFileSync(out, story); +process.stdout.write(`\r ${frames}/${frames}\n✓ ${out}\n✓ ${story}\n`); +if (has("open")) spawnSync("open", [out]); diff --git a/package.json b/package.json index 3be7c8b67..fa66e2d9f 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,9 @@ "podcast:feed": "node marketing/podcast/bin/feed.mjs", "podcast:publish": "node marketing/podcast/bin/publish.mjs", "podcast:publish:push": "node marketing/podcast/bin/publish.mjs --push", + "essay:reel": "node marketing/essay-reels/bin/render.mjs", + "thespianjas:generate": "node thespianjas/bin/generate.mjs", + "thespianjas:studio": "node thespianjas/bin/serve.mjs", "publish:m4l": "node ac-m4l/publish.mjs", "session:reset": "f() { cd session-server; npx jamsocket backend terminate $1 };f", "session:alive": "cd session-server; npx jamsocket backend list", diff --git a/thespianjas/.gitignore b/thespianjas/.gitignore new file mode 100644 index 000000000..a0c8578b2 --- /dev/null +++ b/thespianjas/.gitignore @@ -0,0 +1,5 @@ +assets/versions/*/*.glb +assets/versions/*/*.fbx +assets/versions/*/*.png +out/ +!.gitkeep diff --git a/thespianjas/SCORE.md b/thespianjas/SCORE.md new file mode 100644 index 000000000..a916fc994 --- /dev/null +++ b/thespianjas/SCORE.md @@ -0,0 +1,48 @@ +# thespianjas + +`thespianjas` is the global Jeffrey digital-twin subsystem for the AC monorepo. +It is not owned by one podcast or campaign. Podcast, Reel, lecture, piece, and +live-performance pipelines consume versioned assets from here. + +## Architecture + +1. **Identity** — canonical studio references resolve from + `papers/jeffrey-platter` through `identity.json`. +2. **Reconstruction** — provider adapters turn those references into a textured, + relightable GLB. Meshy 6 multi-image is the first adapter; Tripo H3.1 and SAM + 3D Body are named benchmark candidates. +3. **Studio** — `studio/index.html` loads the GLB locally in A-Frame. Key, fill, + rim, ground, camera, and idle motion remain ours to direct. +4. **Performance** — podcast audio drives energy, breathing, head emphasis, and + eventually facial blendshapes. A video lipsync provider may polish exported + shots, but it is downstream of the canonical relightable twin. +5. **Compositing** — `marketing/essay-reels` adds word-timed captions and social + safe-area layout to a rendered thespianjas performance. + +The canonical slug is globally unique: `thespianjas`. Generated binaries are +large and versioned locally under `assets/versions/vNNN/`; manifests and recipes +are tracked in git. + +## First build + +```fish +node thespianjas/bin/generate.mjs --version v001 --provider meshy +node thespianjas/bin/serve.mjs +# open http://127.0.0.1:4177/thespianjas/studio/?version=v001 +``` + +Meshy accepts one to four views and returns a textured PBR GLB. The initial +studio platter has a strong face view and two seated body views; a dedicated +front/side/back neutral-pose capture will materially improve the next rig. + +## Provider notes (July 2026) + +- **Meshy 6 multi-image:** 1–4 views, PBR textures, A/T pose options, humanoid + auto-rig, GLB/FBX/USDZ/OBJ. Best first complete asset lane. +- **Tripo H3.1:** useful single-image benchmark for likeness/geometry. +- **SAM 3D Body:** inexpensive human-specific reconstruction benchmark. +- **Kling / Sync lipsync:** video finishing layers, not substitutes for the + relightable GLB. They accept rendered video or a portrait plus audio. + +Identity outputs are publishable only in Jeffrey-operated AC surfaces. Never +silently substitute another person's photographs or train from private imagery. diff --git a/thespianjas/assets/versions/.gitkeep b/thespianjas/assets/versions/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/thespianjas/assets/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/thespianjas/assets/versions/v001/manifest.json b/thespianjas/assets/versions/v001/manifest.json new file mode 100644 index 000000000..1130faab5 --- /dev/null +++ b/thespianjas/assets/versions/v001/manifest.json @@ -0,0 +1,39 @@ +{ + "slug": "thespianjas", + "version": "v001", + "created": "2026-07-15T23:05:08.109Z", + "provider": "meshy", + "endpoint": "fal-ai/meshy/v6/multi-image-to-3d", + "references": [ + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--01.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--04.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--07.jpg" + ], + "requestId": "019f6801-450b-77a1-a108-86cd2a13f233", + "input": { + "image_urls": [ + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--01.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--04.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--07.jpg" + ], + "topology": "quad", + "target_polycount": 30000, + "symmetry_mode": "auto", + "should_remesh": true, + "should_texture": true, + "enable_pbr": true, + "pose_mode": "a-pose", + "texture_prompt": "faithful natural studio portrait texture, white layered shirt with small yellow tiger patch, coral undershirt, blue wide-leg shorts, dark shoes, natural skin and medium brown hair", + "enable_rigging": true, + "rigging_height_meters": 1.82, + "enable_animation": true, + "animation_action_id": 0, + "enable_safety_checker": true + }, + "outputs": { + "model.glb": "https://v3b.fal.media/files/b/0aa2682c/cEwqhXoFHqUqTV6verT2n_model.glb", + "rigged.glb": "https://v3b.fal.media/files/b/0aa26833/JEkh0BXKGkN4YvkEvbGA6_rigged_character.glb", + "idle.glb": "https://v3b.fal.media/files/b/0aa26836/WJ7gBQmbVC7QtHvYeBwl7_animation.glb", + "preview.png": "https://v3b.fal.media/files/b/0aa2682d/-B2Ck7Fm9PnuFQcyNhXTo_preview.png" + } +} diff --git a/thespianjas/bin/generate.mjs b/thespianjas/bin/generate.mjs new file mode 100644 index 000000000..3465922d2 --- /dev/null +++ b/thespianjas/bin/generate.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Canonical Jeffrey platter refs → versioned relightable digital twin. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { falKey, dataUri } from "../../pop/lib/fal.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const REPO = resolve(ROOT, ".."); +const argv = process.argv.slice(2); +const flag = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback; +}; +const provider = flag("provider", "meshy"); +const version = flag("version", "v001"); +if (provider !== "meshy") throw new Error(`provider ${provider} not implemented yet`); + +const identity = JSON.parse(readFileSync(resolve(ROOT, "identity.json"), "utf8")); +const refs = identity.references.map((p) => resolve(REPO, p)); +if (!refs.every(existsSync)) throw new Error("one or more canonical platter references are missing"); +const outDir = resolve(ROOT, "assets", "versions", version); +mkdirSync(outDir, { recursive: true }); + +const endpoint = "fal-ai/meshy/v6/multi-image-to-3d"; +const input = { + image_urls: refs.map(dataUri), + topology: "quad", + target_polycount: 30000, + symmetry_mode: "auto", + should_remesh: true, + should_texture: true, + enable_pbr: true, + pose_mode: "a-pose", + texture_prompt: "faithful natural studio portrait texture, white layered shirt with small yellow tiger patch, coral undershirt, blue wide-leg shorts, dark shoes, natural skin and medium brown hair", + enable_rigging: true, + rigging_height_meters: 1.82, + enable_animation: true, + animation_action_id: 0, + enable_safety_checker: true +}; + +const auth = { Authorization: `Key ${falKey()}`, "Content-Type": "application/json" }; +const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms)); +console.log(`thespianjas ${version} · ${endpoint} · ${refs.length} canonical refs`); +const sub = await fetch(`https://queue.fal.run/${endpoint}`, { method: "POST", headers: auth, body: JSON.stringify(input) }); +if (!sub.ok) throw new Error(`submit ${sub.status}: ${(await sub.text()).slice(0, 500)}`); +const queued = await sub.json(); +let state = ""; +while (state !== "COMPLETED") { + await sleep(5000); + const status = await (await fetch(queued.status_url, { headers: auth })).json(); + if (status.status !== state) { state = status.status; console.log(` ${state.toLowerCase()}`); } + if (state === "FAILED" || status.error) throw new Error(JSON.stringify(status).slice(0, 800)); +} +const result = await (await fetch(queued.response_url, { headers: auth })).json(); +const files = { + "model.glb": result.model_glb?.url || result.model_urls?.glb?.url, + "rigged.glb": result.rigged_character_glb?.url, + "idle.glb": result.animation_glb?.url, + "preview.png": result.thumbnail?.url, +}; +for (const [name, url] of Object.entries(files)) { + if (!url) continue; + const bytes = Buffer.from(await (await fetch(url)).arrayBuffer()); + writeFileSync(resolve(outDir, name), bytes); + console.log(` ${name} ${(bytes.length / 1024 / 1024).toFixed(1)} MB`); +} +const manifest = { + slug: identity.slug, + version, + created: new Date().toISOString(), + provider, + endpoint, + references: identity.references, + requestId: queued.request_id, + input: { ...input, image_urls: identity.references }, + outputs: Object.fromEntries(Object.entries(files).filter(([, url]) => url)), +}; +writeFileSync(resolve(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +console.log(`✓ ${outDir}`); diff --git a/thespianjas/bin/serve.mjs b/thespianjas/bin/serve.mjs new file mode 100644 index 000000000..ddd0a8619 --- /dev/null +++ b/thespianjas/bin/serve.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { createServer } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { extname, join, normalize, resolve } from "node:path"; + +const root = resolve(new URL("../..", import.meta.url).pathname); +const types = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".glb": "model/gltf-binary", ".png": "image/png", ".json": "application/json" }; +createServer((req, res) => { + const rel = normalize(decodeURIComponent((req.url || "/").split("?")[0])).replace(/^\/+/, ""); + let file = join(root, rel); + if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html"); + if (!file.startsWith(root) || !existsSync(file)) { res.writeHead(404); res.end("not found"); return; } + res.writeHead(200, { "content-type": types[extname(file)] || "application/octet-stream", "cache-control": "no-store" }); + createReadStream(file).pipe(res); +}).listen(4177, "127.0.0.1", () => console.log("thespianjas studio · http://127.0.0.1:4177/thespianjas/studio/")); diff --git a/thespianjas/identity.json b/thespianjas/identity.json new file mode 100644 index 000000000..f57be4010 --- /dev/null +++ b/thespianjas/identity.json @@ -0,0 +1,12 @@ +{ + "slug": "thespianjas", + "subject": "Jeffrey Alan Scudder", + "source": "papers/jeffrey-platter", + "consent": "subject-operated AC publishing surfaces", + "references": [ + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--01.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--04.jpg", + "portraits/jeffrey/corpus/shoot-2k/jeffery-av--07.jpg" + ], + "notes": "Canonical studio face plus two full-body seated views in the usual outfit. Add neutral front, profile, and back captures for v002." +} diff --git a/thespianjas/studio/index.html b/thespianjas/studio/index.html new file mode 100644 index 000000000..7a5a2b087 --- /dev/null +++ b/thespianjas/studio/index.html @@ -0,0 +1,14 @@ + +thespianjas studio + + + + + + + + + + + + -- 2.51.2