Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add implementation plan for ionosphere.tv

12 tasks across 5 active chunks plus a deferred enrichment chunk.
Covers: workspace scaffold, lexicons, format-lexicon, appview with
SQLite, data ingest pipeline, Next.js frontend with video player,
transcription pipeline, document assembly, and timestamp sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+2497
+2497
docs/superpowers/plans/2026-03-30-ionosphere-implementation.md
··· 1 + # Ionosphere Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Build ionosphere.tv — a semantically enriched AT Protocol conference video archive for ATmosphereConf 2026 VODs. 6 + 7 + **Architecture:** Pannacotta-pattern pipeline: ingest source AT Protocol records (Streamplace VODs + ATmosphereConf schedule) → correlate → transcribe → assemble RelationalText documents → LLM enrichment → SQLite appview → Next.js SSG frontend with synchronized video+transcript playback. 8 + 9 + **Tech Stack:** TypeScript, pnpm workspaces, Next.js 15, React 18, Tailwind CSS, Hono, better-sqlite3, relational-text, @atproto/api, vitest. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-03-30-ionosphere-design.md` 12 + 13 + --- 14 + 15 + ## Chunk 1: Project Scaffold & Lexicons 16 + 17 + Sets up the monorepo workspace, defines all AT Protocol lexicons, and creates the format-lexicon with facet type definitions. After this chunk, the project structure exists and the data model is formalized. 18 + 19 + ### Task 1: Initialize pnpm workspace 20 + 21 + **Files:** 22 + - Create: `package.json` 23 + - Create: `pnpm-workspace.yaml` 24 + - Create: `tsconfig.json` 25 + - Create: `.gitignore` 26 + 27 + - [ ] **Step 1: Create root package.json** 28 + 29 + ```json 30 + { 31 + "name": "ionosphere-workspace", 32 + "private": true, 33 + "scripts": { 34 + "dev": "pnpm --filter ionosphere dev", 35 + "build": "pnpm --filter ionosphere build", 36 + "appview": "pnpm --filter ionosphere-appview appview" 37 + }, 38 + "devDependencies": {} 39 + } 40 + ``` 41 + 42 + - [ ] **Step 2: Create pnpm-workspace.yaml** 43 + 44 + ```yaml 45 + packages: 46 + - 'apps/*' 47 + - 'formats/*' 48 + ``` 49 + 50 + - [ ] **Step 3: Create tsconfig.json** 51 + 52 + Base TypeScript config for the workspace. ESM, strict, Node 20+ target. 53 + 54 + ```json 55 + { 56 + "compilerOptions": { 57 + "target": "ES2022", 58 + "module": "ESNext", 59 + "moduleResolution": "bundler", 60 + "strict": true, 61 + "esModuleInterop": true, 62 + "skipLibCheck": true, 63 + "forceConsistentCasingInFileNames": true, 64 + "resolveJsonModule": true, 65 + "declaration": true, 66 + "declarationMap": true, 67 + "sourceMap": true, 68 + "outDir": "dist" 69 + }, 70 + "exclude": ["node_modules", "dist"] 71 + } 72 + ``` 73 + 74 + - [ ] **Step 4: Create .gitignore** 75 + 76 + ``` 77 + node_modules/ 78 + dist/ 79 + .next/ 80 + data/audio/ 81 + data/transcripts/ 82 + *.sqlite 83 + *.sqlite-journal 84 + *.sqlite-wal 85 + .env 86 + .env.local 87 + ``` 88 + 89 + - [ ] **Step 5: Commit** 90 + 91 + ```bash 92 + git add package.json pnpm-workspace.yaml tsconfig.json .gitignore 93 + git commit -m "chore: initialize pnpm workspace" 94 + ``` 95 + 96 + ### Task 2: Define AT Protocol lexicons 97 + 98 + **Files:** 99 + - Create: `lexicons/tv/ionosphere/talk.json` 100 + - Create: `lexicons/tv/ionosphere/speaker.json` 101 + - Create: `lexicons/tv/ionosphere/concept.json` 102 + - Create: `lexicons/tv/ionosphere/event.json` 103 + 104 + - [ ] **Step 1: Create talk lexicon** 105 + 106 + ```json 107 + { 108 + "lexicon": 1, 109 + "$type": "com.atproto.lexicon.schema", 110 + "id": "tv.ionosphere.talk", 111 + "revision": 1, 112 + "description": "A conference talk with video reference and enriched transcript document.", 113 + "defs": { 114 + "main": { 115 + "type": "record", 116 + "key": "any", 117 + "record": { 118 + "type": "object", 119 + "required": ["title", "eventUri"], 120 + "properties": { 121 + "title": { 122 + "type": "string", 123 + "description": "Talk title." 124 + }, 125 + "document": { 126 + "type": "ref", 127 + "ref": "org.relationaltext.richtext.document", 128 + "description": "Enriched transcript document with temporal and semantic facets." 129 + }, 130 + "speakerUris": { 131 + "type": "array", 132 + "items": { "type": "string", "format": "at-uri" }, 133 + "description": "AT URIs of tv.ionosphere.speaker records." 134 + }, 135 + "videoUri": { 136 + "type": "string", 137 + "format": "at-uri", 138 + "description": "AT URI to place.stream.video record." 139 + }, 140 + "scheduleUri": { 141 + "type": "string", 142 + "format": "at-uri", 143 + "description": "AT URI to source community.lexicon.calendar.event record." 144 + }, 145 + "eventUri": { 146 + "type": "string", 147 + "format": "at-uri", 148 + "description": "AT URI to tv.ionosphere.event record." 149 + }, 150 + "room": { 151 + "type": "string", 152 + "description": "Room or track name." 153 + }, 154 + "category": { 155 + "type": "string", 156 + "description": "Talk category from schedule." 157 + }, 158 + "talkType": { 159 + "type": "string", 160 + "description": "Type: presentation, lightning-talk, panel, workshop, etc." 161 + }, 162 + "startsAt": { 163 + "type": "string", 164 + "format": "datetime", 165 + "description": "Scheduled start time (ISO 8601)." 166 + }, 167 + "endsAt": { 168 + "type": "string", 169 + "format": "datetime", 170 + "description": "Scheduled end time (ISO 8601)." 171 + }, 172 + "duration": { 173 + "type": "integer", 174 + "description": "Video duration in nanoseconds (from VOD record)." 175 + }, 176 + "description": { 177 + "type": "string", 178 + "description": "Talk description/abstract from schedule." 179 + } 180 + } 181 + } 182 + } 183 + } 184 + } 185 + ``` 186 + 187 + - [ ] **Step 2: Create speaker lexicon** 188 + 189 + ```json 190 + { 191 + "lexicon": 1, 192 + "$type": "com.atproto.lexicon.schema", 193 + "id": "tv.ionosphere.speaker", 194 + "revision": 1, 195 + "description": "A conference speaker.", 196 + "defs": { 197 + "main": { 198 + "type": "record", 199 + "key": "any", 200 + "record": { 201 + "type": "object", 202 + "required": ["name"], 203 + "properties": { 204 + "name": { 205 + "type": "string", 206 + "description": "Speaker display name." 207 + }, 208 + "handle": { 209 + "type": "string", 210 + "description": "AT Protocol handle (e.g., 'signez.fr')." 211 + }, 212 + "did": { 213 + "type": "string", 214 + "format": "did", 215 + "description": "Speaker's DID, if known." 216 + }, 217 + "bio": { 218 + "type": "string", 219 + "description": "Speaker bio." 220 + }, 221 + "affiliations": { 222 + "type": "array", 223 + "items": { "type": "string" }, 224 + "description": "Organizations, projects, or affiliations." 225 + } 226 + } 227 + } 228 + } 229 + } 230 + } 231 + ``` 232 + 233 + - [ ] **Step 3: Create concept lexicon** 234 + 235 + ```json 236 + { 237 + "lexicon": 1, 238 + "$type": "com.atproto.lexicon.schema", 239 + "id": "tv.ionosphere.concept", 240 + "revision": 1, 241 + "description": "A knowledge entity referenced in talk transcripts.", 242 + "defs": { 243 + "main": { 244 + "type": "record", 245 + "key": "any", 246 + "record": { 247 + "type": "object", 248 + "required": ["name"], 249 + "properties": { 250 + "name": { 251 + "type": "string", 252 + "description": "Canonical concept name." 253 + }, 254 + "aliases": { 255 + "type": "array", 256 + "items": { "type": "string" }, 257 + "description": "Alternative names for matching." 258 + }, 259 + "description": { 260 + "type": "string", 261 + "description": "Brief description of the concept." 262 + }, 263 + "wikidataId": { 264 + "type": "string", 265 + "description": "Wikidata Q-identifier (e.g., 'Q123456')." 266 + }, 267 + "url": { 268 + "type": "string", 269 + "format": "uri", 270 + "description": "Canonical external URL for the concept." 271 + } 272 + } 273 + } 274 + } 275 + } 276 + } 277 + ``` 278 + 279 + - [ ] **Step 4: Create event lexicon** 280 + 281 + ```json 282 + { 283 + "lexicon": 1, 284 + "$type": "com.atproto.lexicon.schema", 285 + "id": "tv.ionosphere.event", 286 + "revision": 1, 287 + "description": "A conference or event whose talks are archived.", 288 + "defs": { 289 + "main": { 290 + "type": "record", 291 + "key": "any", 292 + "record": { 293 + "type": "object", 294 + "required": ["name", "startsAt", "endsAt"], 295 + "properties": { 296 + "name": { 297 + "type": "string", 298 + "description": "Event name." 299 + }, 300 + "description": { 301 + "type": "string", 302 + "description": "Event description." 303 + }, 304 + "location": { 305 + "type": "string", 306 + "description": "Venue and city." 307 + }, 308 + "startsAt": { 309 + "type": "string", 310 + "format": "datetime", 311 + "description": "Event start date (ISO 8601)." 312 + }, 313 + "endsAt": { 314 + "type": "string", 315 + "format": "datetime", 316 + "description": "Event end date (ISO 8601)." 317 + }, 318 + "tracks": { 319 + "type": "array", 320 + "items": { "type": "string" }, 321 + "description": "Room/track names." 322 + }, 323 + "scheduleRepo": { 324 + "type": "string", 325 + "format": "did", 326 + "description": "DID of the repo containing schedule records." 327 + }, 328 + "vodRepo": { 329 + "type": "string", 330 + "format": "did", 331 + "description": "DID of the repo containing VOD records." 332 + } 333 + } 334 + } 335 + } 336 + } 337 + } 338 + ``` 339 + 340 + - [ ] **Step 5: Commit** 341 + 342 + ```bash 343 + git add lexicons/ 344 + git commit -m "feat: define AT Protocol lexicons for talk, speaker, concept, event" 345 + ``` 346 + 347 + ### Task 3: Create format-lexicon and facet type definitions 348 + 349 + **Files:** 350 + - Create: `formats/tv.ionosphere/package.json` 351 + - Create: `formats/tv.ionosphere/tsconfig.json` 352 + - Create: `formats/tv.ionosphere/ionosphere.lexicon.json` 353 + 354 + - [ ] **Step 1: Create format package.json** 355 + 356 + ```json 357 + { 358 + "name": "@ionosphere/format", 359 + "version": "0.1.0", 360 + "type": "module", 361 + "main": "ts/index.ts", 362 + "exports": { 363 + ".": "./ts/index.ts", 364 + "./assemble": "./ts/assemble.ts" 365 + }, 366 + "dependencies": { 367 + "relational-text": "^0.1.1" 368 + }, 369 + "devDependencies": { 370 + "typescript": "^5", 371 + "vitest": "^3.0.0" 372 + }, 373 + "scripts": { 374 + "test": "vitest run", 375 + "typecheck": "tsc --noEmit" 376 + } 377 + } 378 + ``` 379 + 380 + - [ ] **Step 2: Create format tsconfig.json** 381 + 382 + ```json 383 + { 384 + "extends": "../../tsconfig.json", 385 + "compilerOptions": { 386 + "rootDir": "ts", 387 + "outDir": "dist" 388 + }, 389 + "include": ["ts"] 390 + } 391 + ``` 392 + 393 + - [ ] **Step 3: Create ionosphere.lexicon.json** 394 + 395 + This defines the RelationalText facet types, not AT Protocol record types. 396 + 397 + Follows pannacotta's `$type: "org.relationaltext.format-lexicon"` schema exactly. 398 + 399 + ```json 400 + { 401 + "$type": "org.relationaltext.format-lexicon", 402 + "id": "tv.ionosphere.facet", 403 + "name": "Ionosphere Talk Annotations", 404 + "description": "Semantic annotations for conference talk transcripts: timestamps, speakers, concepts, cross-references", 405 + "features": [ 406 + { 407 + "typeId": "tv.ionosphere.facet#speaker-segment", 408 + "featureClass": "block", 409 + "expandStart": false, 410 + "expandEnd": false 411 + }, 412 + { 413 + "typeId": "tv.ionosphere.facet#concept-ref", 414 + "featureClass": "inline", 415 + "expandStart": false, 416 + "expandEnd": false 417 + }, 418 + { 419 + "typeId": "tv.ionosphere.facet#speaker-ref", 420 + "featureClass": "inline", 421 + "expandStart": false, 422 + "expandEnd": false 423 + }, 424 + { 425 + "typeId": "tv.ionosphere.facet#talk-xref", 426 + "featureClass": "inline", 427 + "expandStart": false, 428 + "expandEnd": false 429 + }, 430 + { 431 + "typeId": "tv.ionosphere.facet#link", 432 + "featureClass": "inline", 433 + "expandStart": false, 434 + "expandEnd": false 435 + }, 436 + { 437 + "typeId": "tv.ionosphere.facet#timestamp", 438 + "featureClass": "meta", 439 + "expandStart": false, 440 + "expandEnd": false 441 + } 442 + ] 443 + } 444 + ``` 445 + 446 + Feature property schemas (speakerUri, conceptUri, startTime/endTime, etc.) are defined in TypeScript code, not in the format-lexicon JSON — matching pannacotta's pattern. 447 + 448 + - [ ] **Step 4: Create format entry point** 449 + 450 + Create `formats/tv.ionosphere/ts/index.ts`: 451 + 452 + ```typescript 453 + export const NAMESPACE = "tv.ionosphere"; 454 + 455 + // Shared types used across packages 456 + export interface WordTimestamp { 457 + word: string; 458 + start: number; // seconds 459 + end: number; // seconds 460 + confidence: number; 461 + } 462 + 463 + export interface TranscriptResult { 464 + text: string; 465 + words: WordTimestamp[]; 466 + } 467 + ``` 468 + 469 + Canonical location for shared types. The appview's `transcribe.ts` imports these rather than defining its own. 470 + 471 + - [ ] **Step 5: Run pnpm install** 472 + 473 + ```bash 474 + pnpm install 475 + ``` 476 + 477 + - [ ] **Step 6: Commit** 478 + 479 + ```bash 480 + git add formats/ 481 + git commit -m "feat: create format-lexicon with facet type definitions" 482 + ``` 483 + 484 + --- 485 + 486 + ### Deferred from Chunk 1 487 + 488 + - **Lens files** (`schedule-to-talk.lens.json`, `transcript-to-document.lens.json`): The lens transformation logic is currently implemented procedurally in `ingest.ts` and `assemble.ts`. Declarative lens JSON specs will be formalized once the transformation rules are stable. This matches pannacotta's development pattern where lenses were extracted from working code. 489 + - **panproto**: Schema versioning will be integrated once lexicons stabilize past their initial revision. 490 + - **Pretext**: Transcript layout integration depends on evaluating Pretext's available API surface area, which is currently undocumented. The plan uses a custom `TranscriptView` component that can be swapped for Pretext later. 491 + 492 + --- 493 + 494 + ## Chunk 2: Appview Scaffold & Data Ingest 495 + 496 + Creates the appview app, sets up SQLite schema, and implements ingestion of source data from Streamplace VODs and ATmosphereConf schedule records. 497 + 498 + ### Task 4: Scaffold the appview app 499 + 500 + **Files:** 501 + - Create: `apps/ionosphere-appview/package.json` 502 + - Create: `apps/ionosphere-appview/tsconfig.json` 503 + - Create: `apps/ionosphere-appview/src/appview.ts` 504 + - Create: `apps/ionosphere-appview/src/db.ts` 505 + - Create: `apps/ionosphere-appview/src/routes.ts` 506 + 507 + - [ ] **Step 1: Create appview package.json** 508 + 509 + ```json 510 + { 511 + "name": "ionosphere-appview", 512 + "version": "0.1.0", 513 + "type": "module", 514 + "dependencies": { 515 + "@atproto/api": "^0.15.0", 516 + "@hono/node-server": "^1.13.0", 517 + "@ionosphere/format": "workspace:*", 518 + "better-sqlite3": "^12.8.0", 519 + "hono": "^4.7.0", 520 + "relational-text": "^0.1.1", 521 + "tsx": "^4.19.0" 522 + }, 523 + "devDependencies": { 524 + "@types/better-sqlite3": "^7.6.0", 525 + "typescript": "^5.7.0", 526 + "vitest": "^3.0.0" 527 + }, 528 + "scripts": { 529 + "appview": "tsx src/appview.ts", 530 + "ingest": "tsx src/ingest.ts", 531 + "test": "vitest run", 532 + "typecheck": "tsc --noEmit" 533 + } 534 + } 535 + ``` 536 + 537 + - [ ] **Step 2: Create appview tsconfig.json** 538 + 539 + ```json 540 + { 541 + "extends": "../../tsconfig.json", 542 + "compilerOptions": { 543 + "rootDir": "src", 544 + "outDir": "dist" 545 + }, 546 + "include": ["src"] 547 + } 548 + ``` 549 + 550 + - [ ] **Step 3: Create db.ts with SQLite schema** 551 + 552 + ```typescript 553 + import Database from "better-sqlite3"; 554 + import path from "node:path"; 555 + 556 + const DB_PATH = path.resolve( 557 + import.meta.dirname, 558 + "../../data/ionosphere.sqlite" 559 + ); 560 + 561 + export function openDb(): Database.Database { 562 + const db = new Database(DB_PATH); 563 + db.pragma("journal_mode = WAL"); 564 + db.pragma("foreign_keys = ON"); 565 + return db; 566 + } 567 + 568 + export function migrate(db: Database.Database): void { 569 + db.exec(` 570 + CREATE TABLE IF NOT EXISTS events ( 571 + uri TEXT PRIMARY KEY, 572 + did TEXT NOT NULL, 573 + rkey TEXT NOT NULL, 574 + name TEXT NOT NULL, 575 + description TEXT, 576 + location TEXT, 577 + starts_at TEXT NOT NULL, 578 + ends_at TEXT NOT NULL, 579 + tracks TEXT, -- JSON array 580 + schedule_repo TEXT, 581 + vod_repo TEXT, 582 + created_at TEXT DEFAULT CURRENT_TIMESTAMP 583 + ); 584 + 585 + CREATE TABLE IF NOT EXISTS speakers ( 586 + uri TEXT PRIMARY KEY, 587 + did TEXT, 588 + rkey TEXT NOT NULL, 589 + name TEXT NOT NULL, 590 + handle TEXT, 591 + speaker_did TEXT, 592 + bio TEXT, 593 + affiliations TEXT, -- JSON array 594 + created_at TEXT DEFAULT CURRENT_TIMESTAMP 595 + ); 596 + 597 + CREATE TABLE IF NOT EXISTS talks ( 598 + uri TEXT PRIMARY KEY, 599 + did TEXT NOT NULL, 600 + rkey TEXT NOT NULL, 601 + title TEXT NOT NULL, 602 + description TEXT, 603 + document TEXT, -- JSON: RelationalText document 604 + video_uri TEXT, 605 + schedule_uri TEXT, 606 + event_uri TEXT NOT NULL, 607 + room TEXT, 608 + category TEXT, 609 + talk_type TEXT, 610 + starts_at TEXT, 611 + ends_at TEXT, 612 + duration INTEGER, -- nanoseconds 613 + created_at TEXT DEFAULT CURRENT_TIMESTAMP, 614 + FOREIGN KEY (event_uri) REFERENCES events(uri) ON DELETE CASCADE 615 + ); 616 + 617 + CREATE TABLE IF NOT EXISTS talk_speakers ( 618 + talk_uri TEXT NOT NULL, 619 + speaker_uri TEXT NOT NULL, 620 + PRIMARY KEY (talk_uri, speaker_uri), 621 + FOREIGN KEY (talk_uri) REFERENCES talks(uri) ON DELETE CASCADE, 622 + FOREIGN KEY (speaker_uri) REFERENCES speakers(uri) ON DELETE CASCADE 623 + ); 624 + 625 + CREATE TABLE IF NOT EXISTS concepts ( 626 + uri TEXT PRIMARY KEY, 627 + did TEXT NOT NULL, 628 + rkey TEXT NOT NULL, 629 + name TEXT NOT NULL, 630 + aliases TEXT, -- JSON array 631 + description TEXT, 632 + wikidata_id TEXT, 633 + url TEXT, 634 + created_at TEXT DEFAULT CURRENT_TIMESTAMP 635 + ); 636 + 637 + CREATE TABLE IF NOT EXISTS talk_concepts ( 638 + talk_uri TEXT NOT NULL, 639 + concept_uri TEXT NOT NULL, 640 + mention_count INTEGER DEFAULT 1, 641 + PRIMARY KEY (talk_uri, concept_uri), 642 + FOREIGN KEY (talk_uri) REFERENCES talks(uri) ON DELETE CASCADE, 643 + FOREIGN KEY (concept_uri) REFERENCES concepts(uri) ON DELETE CASCADE 644 + ); 645 + 646 + CREATE TABLE IF NOT EXISTS talk_crossrefs ( 647 + from_talk_uri TEXT NOT NULL, 648 + to_talk_uri TEXT NOT NULL, 649 + PRIMARY KEY (from_talk_uri, to_talk_uri), 650 + FOREIGN KEY (from_talk_uri) REFERENCES talks(uri) ON DELETE CASCADE, 651 + FOREIGN KEY (to_talk_uri) REFERENCES talks(uri) ON DELETE CASCADE 652 + ); 653 + 654 + -- Track pipeline status per talk 655 + CREATE TABLE IF NOT EXISTS pipeline_status ( 656 + talk_uri TEXT PRIMARY KEY, 657 + ingested INTEGER DEFAULT 0, 658 + transcribed INTEGER DEFAULT 0, 659 + assembled INTEGER DEFAULT 0, 660 + enriched INTEGER DEFAULT 0, 661 + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 662 + FOREIGN KEY (talk_uri) REFERENCES talks(uri) ON DELETE CASCADE 663 + ); 664 + `); 665 + } 666 + ``` 667 + 668 + - [ ] **Step 4: Create routes.ts with basic API** 669 + 670 + ```typescript 671 + import { Hono } from "hono"; 672 + import type Database from "better-sqlite3"; 673 + 674 + export function createRoutes(db: Database.Database): Hono { 675 + const app = new Hono(); 676 + 677 + app.get("/health", (c) => c.json({ status: "ok" })); 678 + 679 + app.get("/talks", (c) => { 680 + const talks = db 681 + .prepare( 682 + `SELECT t.*, GROUP_CONCAT(s.name) as speaker_names 683 + FROM talks t 684 + LEFT JOIN talk_speakers ts ON t.uri = ts.talk_uri 685 + LEFT JOIN speakers s ON ts.speaker_uri = s.uri 686 + GROUP BY t.uri 687 + ORDER BY t.starts_at ASC` 688 + ) 689 + .all(); 690 + return c.json({ talks }); 691 + }); 692 + 693 + app.get("/talks/:rkey", (c) => { 694 + const { rkey } = c.req.param(); 695 + const talk = db 696 + .prepare("SELECT * FROM talks WHERE rkey = ?") 697 + .get(rkey); 698 + if (!talk) return c.json({ error: "not found" }, 404); 699 + 700 + const speakers = db 701 + .prepare( 702 + `SELECT s.* FROM speakers s 703 + JOIN talk_speakers ts ON s.uri = ts.speaker_uri 704 + WHERE ts.talk_uri = ?` 705 + ) 706 + .all((talk as any).uri); 707 + 708 + const concepts = db 709 + .prepare( 710 + `SELECT c.* FROM concepts c 711 + JOIN talk_concepts tc ON c.uri = tc.concept_uri 712 + WHERE tc.talk_uri = ?` 713 + ) 714 + .all((talk as any).uri); 715 + 716 + return c.json({ talk, speakers, concepts }); 717 + }); 718 + 719 + app.get("/speakers", (c) => { 720 + const speakers = db.prepare("SELECT * FROM speakers ORDER BY name ASC").all(); 721 + return c.json({ speakers }); 722 + }); 723 + 724 + app.get("/speakers/:rkey", (c) => { 725 + const { rkey } = c.req.param(); 726 + const speaker = db.prepare("SELECT * FROM speakers WHERE rkey = ?").get(rkey); 727 + if (!speaker) return c.json({ error: "not found" }, 404); 728 + 729 + const talks = db 730 + .prepare( 731 + `SELECT t.* FROM talks t 732 + JOIN talk_speakers ts ON t.uri = ts.talk_uri 733 + WHERE ts.speaker_uri = ? 734 + ORDER BY t.starts_at ASC` 735 + ) 736 + .all((speaker as any).uri); 737 + 738 + return c.json({ speaker, talks }); 739 + }); 740 + 741 + app.get("/concepts", (c) => { 742 + const concepts = db 743 + .prepare("SELECT * FROM concepts ORDER BY name ASC") 744 + .all(); 745 + return c.json({ concepts }); 746 + }); 747 + 748 + app.get("/concepts/:rkey", (c) => { 749 + const { rkey } = c.req.param(); 750 + const concept = db.prepare("SELECT * FROM concepts WHERE rkey = ?").get(rkey); 751 + if (!concept) return c.json({ error: "not found" }, 404); 752 + 753 + const talks = db 754 + .prepare( 755 + `SELECT t.* FROM talks t 756 + JOIN talk_concepts tc ON t.uri = tc.talk_uri 757 + WHERE tc.concept_uri = ? 758 + ORDER BY t.starts_at ASC` 759 + ) 760 + .all((concept as any).uri); 761 + 762 + return c.json({ concept, talks }); 763 + }); 764 + 765 + return app; 766 + } 767 + ``` 768 + 769 + - [ ] **Step 5: Create appview.ts entry point** 770 + 771 + ```typescript 772 + import { serve } from "@hono/node-server"; 773 + import { openDb, migrate } from "./db.js"; 774 + import { createRoutes } from "./routes.js"; 775 + 776 + const db = openDb(); 777 + migrate(db); 778 + 779 + const app = createRoutes(db); 780 + 781 + const port = parseInt(process.env.PORT || "3001", 10); 782 + serve({ fetch: app.fetch, port }, (info) => { 783 + console.log(`Ionosphere appview running on http://localhost:${info.port}`); 784 + }); 785 + ``` 786 + 787 + - [ ] **Step 6: Run pnpm install and verify** 788 + 789 + ```bash 790 + pnpm install 791 + ``` 792 + 793 + - [ ] **Step 7: Commit** 794 + 795 + ```bash 796 + git add apps/ionosphere-appview/ 797 + git commit -m "feat: scaffold appview with SQLite schema and REST API" 798 + ``` 799 + 800 + ### Task 5: Implement data ingest from AT Protocol 801 + 802 + **Files:** 803 + - Create: `apps/ionosphere-appview/src/ingest.ts` 804 + - Create: `apps/ionosphere-appview/src/correlate.ts` 805 + 806 + - [ ] **Step 1: Write test for correlation logic** 807 + 808 + Create `apps/ionosphere-appview/src/correlate.test.ts`: 809 + 810 + ```typescript 811 + import { describe, it, expect } from "vitest"; 812 + import { correlate, type ScheduleEvent, type VodRecord } from "./correlate.js"; 813 + 814 + describe("correlate", () => { 815 + const schedule: ScheduleEvent[] = [ 816 + { 817 + uri: "at://did:plc:test/community.lexicon.calendar.event/abc", 818 + name: "Building Cirrus: a single-user, serverless PDS", 819 + startsAt: "2026-03-28T16:15:00.000Z", 820 + endsAt: "2026-03-28T16:45:00.000Z", 821 + type: "presentation", 822 + room: "Great Hall South", 823 + category: "Development and Protocol", 824 + speakers: [{ id: "test.bsky.social", name: "Test Speaker" }], 825 + description: "A test talk.", 826 + }, 827 + ]; 828 + 829 + const vods: VodRecord[] = [ 830 + { 831 + uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/123", 832 + title: "Building Cirrus: a single-user, serverless PDS", 833 + creator: "did:plc:7tattzlorncahxgtdiuci7x7", 834 + duration: 2238000000000, 835 + createdAt: "2026-03-28T16:50:00Z", 836 + }, 837 + { 838 + uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/456", 839 + title: "lunch", 840 + creator: "did:plc:7tattzlorncahxgtdiuci7x7", 841 + duration: 4308000000000, 842 + createdAt: "2026-03-28T19:30:00Z", 843 + }, 844 + ]; 845 + 846 + it("matches VODs to schedule events by title", () => { 847 + const matches = correlate(schedule, vods); 848 + expect(matches).toHaveLength(1); 849 + expect(matches[0].schedule.name).toBe( 850 + "Building Cirrus: a single-user, serverless PDS" 851 + ); 852 + expect(matches[0].vod.uri).toContain("123"); 853 + }); 854 + 855 + it("filters out noise titles", () => { 856 + const matches = correlate(schedule, vods); 857 + expect(matches.every((m) => m.vod.title !== "lunch")).toBe(true); 858 + }); 859 + }); 860 + ``` 861 + 862 + - [ ] **Step 2: Run test to verify it fails** 863 + 864 + Run: `cd apps/ionosphere-appview && pnpm test` 865 + Expected: FAIL — correlate module doesn't exist yet. 866 + 867 + - [ ] **Step 3: Implement correlate.ts** 868 + 869 + ```typescript 870 + export interface ScheduleEvent { 871 + uri: string; 872 + name: string; 873 + startsAt: string; 874 + endsAt: string; 875 + type: string; 876 + room: string; 877 + category: string; 878 + speakers: Array<{ id: string; name: string }>; 879 + description: string; 880 + } 881 + 882 + export interface VodRecord { 883 + uri: string; 884 + title: string; 885 + creator: string; 886 + duration: number; // nanoseconds 887 + createdAt: string; 888 + } 889 + 890 + export interface Match { 891 + schedule: ScheduleEvent; 892 + vod: VodRecord; 893 + confidence: number; // 0-1 894 + } 895 + 896 + const NOISE_TITLES = new Set([ 897 + "lunch", 898 + "lunch break", 899 + "break", 900 + "doors open", 901 + "starting soon", 902 + "join us tomorrow", 903 + "lunch day", 904 + "breakfast", 905 + "coffee break", 906 + "irl only", 907 + "no stream", 908 + ]); 909 + 910 + function isNoise(title: string): boolean { 911 + const lower = title.toLowerCase().trim(); 912 + if (NOISE_TITLES.has(lower)) return true; 913 + if (lower.startsWith("lunch")) return true; 914 + if (lower.startsWith("doors open")) return true; 915 + if (lower.startsWith("atmoshereconf starting")) return true; 916 + if (lower.startsWith("atmosphereconf starting")) return true; 917 + if (lower.startsWith("join us")) return true; 918 + if (lower.startsWith("please join")) return true; 919 + if (lower.startsWith("follow @")) return true; 920 + if (lower.includes("starting soon")) return true; 921 + return false; 922 + } 923 + 924 + function normalizeTitle(title: string): string { 925 + return title 926 + .toLowerCase() 927 + .replace(/[^a-z0-9\s]/g, "") 928 + .replace(/\s+/g, " ") 929 + .trim(); 930 + } 931 + 932 + function titleSimilarity(a: string, b: string): number { 933 + const na = normalizeTitle(a); 934 + const nb = normalizeTitle(b); 935 + if (na === nb) return 1; 936 + if (na.includes(nb) || nb.includes(na)) return 0.9; 937 + 938 + const wordsA = new Set(na.split(" ")); 939 + const wordsB = new Set(nb.split(" ")); 940 + const intersection = new Set([...wordsA].filter((w) => wordsB.has(w))); 941 + const union = new Set([...wordsA, ...wordsB]); 942 + return intersection.size / union.size; 943 + } 944 + 945 + export function correlate( 946 + schedule: ScheduleEvent[], 947 + vods: VodRecord[] 948 + ): Match[] { 949 + const matches: Match[] = []; 950 + const usedVods = new Set<string>(); 951 + 952 + // Filter noise VODs 953 + const realVods = vods.filter((v) => !isNoise(v.title)); 954 + 955 + for (const event of schedule) { 956 + let bestMatch: VodRecord | null = null; 957 + let bestScore = 0; 958 + 959 + for (const vod of realVods) { 960 + if (usedVods.has(vod.uri)) continue; 961 + const score = titleSimilarity(event.name, vod.title); 962 + if (score > bestScore) { 963 + bestScore = score; 964 + bestMatch = vod; 965 + } 966 + } 967 + 968 + if (bestMatch && bestScore >= 0.5) { 969 + matches.push({ 970 + schedule: event, 971 + vod: bestMatch, 972 + confidence: bestScore, 973 + }); 974 + usedVods.add(bestMatch.uri); 975 + } 976 + } 977 + 978 + return matches.sort( 979 + (a, b) => 980 + new Date(a.schedule.startsAt).getTime() - 981 + new Date(b.schedule.startsAt).getTime() 982 + ); 983 + } 984 + ``` 985 + 986 + - [ ] **Step 4: Run test to verify it passes** 987 + 988 + Run: `cd apps/ionosphere-appview && pnpm test` 989 + Expected: PASS 990 + 991 + - [ ] **Step 5: Implement ingest.ts** 992 + 993 + This fetches source records from AT Protocol and writes correlated talks + speakers to SQLite. 994 + 995 + ```typescript 996 + import { openDb, migrate } from "./db.js"; 997 + import { correlate, type ScheduleEvent, type VodRecord } from "./correlate.js"; 998 + 999 + const SCHEDULE_DID = "did:plc:3xewinw4wtimo2lqfy5fm5sw"; 1000 + const SCHEDULE_COLLECTION = "community.lexicon.calendar.event"; 1001 + const VOD_DID = "did:plc:rbvrr34edl5ddpuwcubjiost"; 1002 + const VOD_COLLECTION = "place.stream.video"; 1003 + const VOD_PDS = "https://iameli.com"; 1004 + const BSKY_API = "https://bsky.social"; 1005 + 1006 + // ionosphere.tv's own DID — placeholder until the real DID is created. 1007 + // All ionosphere domain records use this as their repo DID. 1008 + const IONOSPHERE_DID = "did:plc:ionosphere-placeholder"; 1009 + 1010 + const EVENT_URI = `at://${IONOSPHERE_DID}/tv.ionosphere.event/atmosphereconf-2026`; 1011 + 1012 + async function fetchAllRecords( 1013 + baseUrl: string, 1014 + repo: string, 1015 + collection: string 1016 + ): Promise<any[]> { 1017 + const records: any[] = []; 1018 + let cursor: string | undefined; 1019 + 1020 + do { 1021 + const params = new URLSearchParams({ 1022 + repo, 1023 + collection, 1024 + limit: "100", 1025 + }); 1026 + if (cursor) params.set("cursor", cursor); 1027 + 1028 + const res = await fetch( 1029 + `${baseUrl}/xrpc/com.atproto.repo.listRecords?${params}` 1030 + ); 1031 + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); 1032 + const data = await res.json(); 1033 + records.push(...data.records); 1034 + cursor = data.cursor; 1035 + } while (cursor); 1036 + 1037 + return records; 1038 + } 1039 + 1040 + function parseScheduleEvent(record: any): ScheduleEvent | null { 1041 + const v = record.value; 1042 + const ad = v.additionalData; 1043 + if (!ad?.isAtmosphereconf) return null; 1044 + if (v.status === "community.lexicon.calendar.event#cancelled") return null; 1045 + // Skip non-talk types 1046 + const type = ad?.type || ""; 1047 + if (["info", "food"].includes(type)) return null; 1048 + 1049 + return { 1050 + uri: record.uri, 1051 + name: v.name, 1052 + startsAt: v.startsAt, 1053 + endsAt: v.endsAt, 1054 + type, 1055 + room: ad?.room || "", 1056 + category: ad?.category || "", 1057 + speakers: ad?.speakers || [], 1058 + description: v.description || "", 1059 + }; 1060 + } 1061 + 1062 + function parseVodRecord(record: any): VodRecord { 1063 + return { 1064 + uri: record.uri, 1065 + title: record.value.title, 1066 + creator: record.value.creator, 1067 + duration: record.value.duration, 1068 + createdAt: record.value.createdAt, 1069 + }; 1070 + } 1071 + 1072 + function slugify(name: string): string { 1073 + return name 1074 + .toLowerCase() 1075 + .replace(/[^a-z0-9]+/g, "-") 1076 + .replace(/^-|-$/g, ""); 1077 + } 1078 + 1079 + async function main() { 1080 + console.log("Fetching schedule events..."); 1081 + const scheduleRaw = await fetchAllRecords( 1082 + BSKY_API, 1083 + SCHEDULE_DID, 1084 + SCHEDULE_COLLECTION 1085 + ); 1086 + const schedule = scheduleRaw 1087 + .map(parseScheduleEvent) 1088 + .filter((e): e is ScheduleEvent => e !== null); 1089 + console.log(` ${schedule.length} schedule events (filtered from ${scheduleRaw.length})`); 1090 + 1091 + console.log("Fetching VOD records..."); 1092 + const vodRaw = await fetchAllRecords(VOD_PDS, VOD_DID, VOD_COLLECTION); 1093 + const vods = vodRaw.map(parseVodRecord); 1094 + console.log(` ${vods.length} VOD records`); 1095 + 1096 + console.log("Correlating..."); 1097 + const matches = correlate(schedule, vods); 1098 + console.log(` ${matches.length} matches`); 1099 + 1100 + const db = openDb(); 1101 + migrate(db); 1102 + 1103 + // Insert event 1104 + db.prepare( 1105 + `INSERT OR REPLACE INTO events (uri, did, rkey, name, description, location, starts_at, ends_at, tracks, schedule_repo, vod_repo) 1106 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 1107 + ).run( 1108 + EVENT_URI, 1109 + SCHEDULE_DID, 1110 + "atmosphereconf-2026", 1111 + "ATmosphereConf 2026", 1112 + "The global gathering for the AT Protocol community.", 1113 + "AMS Student Nest, UBC, Vancouver, BC, Canada", 1114 + "2026-03-26T00:00:00Z", 1115 + "2026-03-29T23:59:59Z", 1116 + JSON.stringify(["Great Hall South", "Performance Theatre", "Room 2301"]), 1117 + SCHEDULE_DID, 1118 + VOD_DID 1119 + ); 1120 + 1121 + // Collect unique speakers, insert them 1122 + const speakerMap = new Map<string, { name: string; handle: string }>(); 1123 + for (const m of matches) { 1124 + for (const s of m.schedule.speakers) { 1125 + if (!speakerMap.has(s.id)) { 1126 + speakerMap.set(s.id, { name: s.name, handle: s.id }); 1127 + } 1128 + } 1129 + } 1130 + 1131 + const insertSpeaker = db.prepare( 1132 + `INSERT OR REPLACE INTO speakers (uri, did, rkey, name, handle) 1133 + VALUES (?, ?, ?, ?, ?)` 1134 + ); 1135 + for (const [handle, speaker] of speakerMap) { 1136 + const rkey = slugify(handle); 1137 + const uri = `at://${IONOSPHERE_DID}/tv.ionosphere.speaker/${rkey}`; 1138 + insertSpeaker.run(uri, IONOSPHERE_DID, rkey, speaker.name, speaker.handle); 1139 + } 1140 + console.log(` ${speakerMap.size} speakers`); 1141 + 1142 + // Insert talks 1143 + const insertTalk = db.prepare( 1144 + `INSERT OR REPLACE INTO talks (uri, did, rkey, title, description, video_uri, schedule_uri, event_uri, room, category, talk_type, starts_at, ends_at, duration) 1145 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 1146 + ); 1147 + const insertTalkSpeaker = db.prepare( 1148 + `INSERT OR REPLACE INTO talk_speakers (talk_uri, speaker_uri) 1149 + VALUES (?, ?)` 1150 + ); 1151 + const insertPipelineStatus = db.prepare( 1152 + `INSERT OR REPLACE INTO pipeline_status (talk_uri, ingested) 1153 + VALUES (?, 1)` 1154 + ); 1155 + 1156 + for (const m of matches) { 1157 + const rkey = m.schedule.uri.split("/").pop()!; 1158 + const talkUri = `at://${IONOSPHERE_DID}/tv.ionosphere.talk/${rkey}`; 1159 + 1160 + insertTalk.run( 1161 + talkUri, 1162 + IONOSPHERE_DID, 1163 + rkey, 1164 + m.schedule.name, 1165 + m.schedule.description, 1166 + m.vod.uri, 1167 + m.schedule.uri, 1168 + EVENT_URI, 1169 + m.schedule.room, 1170 + m.schedule.category, 1171 + m.schedule.type, 1172 + m.schedule.startsAt, 1173 + m.schedule.endsAt, 1174 + m.vod.duration 1175 + ); 1176 + 1177 + for (const s of m.schedule.speakers) { 1178 + const speakerRkey = slugify(s.id); 1179 + const speakerUri = `at://ionosphere.tv/tv.ionosphere.speaker/${speakerRkey}`; 1180 + insertTalkSpeaker.run(talkUri, speakerUri); 1181 + } 1182 + 1183 + insertPipelineStatus.run(talkUri); 1184 + } 1185 + 1186 + console.log(`\nIngested ${matches.length} talks into database.`); 1187 + 1188 + // Report unmatched 1189 + const unmatchedSchedule = schedule.filter( 1190 + (s) => !matches.some((m) => m.schedule.uri === s.uri) 1191 + ); 1192 + if (unmatchedSchedule.length > 0) { 1193 + console.log(`\nUnmatched schedule events (${unmatchedSchedule.length}):`); 1194 + for (const s of unmatchedSchedule) { 1195 + console.log(` - ${s.name} (${s.type})`); 1196 + } 1197 + } 1198 + 1199 + db.close(); 1200 + } 1201 + 1202 + main().catch(console.error); 1203 + ``` 1204 + 1205 + - [ ] **Step 6: Run ingest and verify** 1206 + 1207 + ```bash 1208 + mkdir -p data 1209 + cd apps/ionosphere-appview && pnpm ingest 1210 + ``` 1211 + 1212 + Verify output shows correlated talks, speakers, and any unmatched events. 1213 + 1214 + - [ ] **Step 7: Start appview and test API** 1215 + 1216 + ```bash 1217 + cd apps/ionosphere-appview && pnpm appview & 1218 + curl http://localhost:3001/health 1219 + curl http://localhost:3001/talks | python3 -m json.tool | head -30 1220 + curl http://localhost:3001/speakers | python3 -m json.tool | head -20 1221 + ``` 1222 + 1223 + - [ ] **Step 8: Commit** 1224 + 1225 + ```bash 1226 + git add apps/ionosphere-appview/src/ingest.ts apps/ionosphere-appview/src/correlate.ts apps/ionosphere-appview/src/correlate.test.ts data/ 1227 + git commit -m "feat: ingest VOD and schedule data, correlate talks to videos" 1228 + ``` 1229 + 1230 + --- 1231 + 1232 + ## Chunk 3: Next.js Frontend Scaffold 1233 + 1234 + Sets up the Next.js SSG frontend with basic page routes and a working video player. After this chunk, you can browse talks and watch videos. 1235 + 1236 + ### Task 6: Scaffold Next.js app 1237 + 1238 + **Files:** 1239 + - Create: `apps/ionosphere/package.json` 1240 + - Create: `apps/ionosphere/tsconfig.json` 1241 + - Create: `apps/ionosphere/next.config.ts` 1242 + - Create: `apps/ionosphere/tailwind.config.ts` 1243 + - Create: `apps/ionosphere/postcss.config.mjs` 1244 + - Create: `apps/ionosphere/src/app/layout.tsx` 1245 + - Create: `apps/ionosphere/src/app/page.tsx` 1246 + - Create: `apps/ionosphere/src/lib/api.ts` 1247 + 1248 + - [ ] **Step 1: Create package.json** 1249 + 1250 + ```json 1251 + { 1252 + "name": "ionosphere", 1253 + "version": "0.1.0", 1254 + "type": "module", 1255 + "dependencies": { 1256 + "@ionosphere/format": "workspace:*", 1257 + "next": "^15", 1258 + "react": "^18", 1259 + "react-dom": "^18", 1260 + "relational-text": "^0.1.1" 1261 + }, 1262 + "devDependencies": { 1263 + "@tailwindcss/typography": "^0.5.19", 1264 + "@types/node": "^22", 1265 + "@types/react": "^18", 1266 + "@types/react-dom": "^18", 1267 + "autoprefixer": "^10.4.20", 1268 + "postcss": "^8.4.49", 1269 + "tailwindcss": "^3.4.19", 1270 + "typescript": "^5" 1271 + }, 1272 + "scripts": { 1273 + "dev": "next dev --port 3002", 1274 + "build": "next build", 1275 + "start": "next start" 1276 + } 1277 + } 1278 + ``` 1279 + 1280 + - [ ] **Step 2: Create next.config.ts** 1281 + 1282 + ```typescript 1283 + import type { NextConfig } from "next"; 1284 + 1285 + const nextConfig: NextConfig = { 1286 + output: "export", 1287 + }; 1288 + 1289 + export default nextConfig; 1290 + ``` 1291 + 1292 + - [ ] **Step 3: Create tailwind.config.ts** 1293 + 1294 + ```typescript 1295 + import type { Config } from "tailwindcss"; 1296 + import typography from "@tailwindcss/typography"; 1297 + 1298 + const config: Config = { 1299 + content: ["./src/**/*.{ts,tsx}"], 1300 + theme: { 1301 + extend: {}, 1302 + }, 1303 + plugins: [typography], 1304 + }; 1305 + 1306 + export default config; 1307 + ``` 1308 + 1309 + - [ ] **Step 4: Create postcss.config.mjs** 1310 + 1311 + ```javascript 1312 + export default { 1313 + plugins: { 1314 + tailwindcss: {}, 1315 + autoprefixer: {}, 1316 + }, 1317 + }; 1318 + ``` 1319 + 1320 + - [ ] **Step 5: Create tsconfig.json** 1321 + 1322 + ```json 1323 + { 1324 + "compilerOptions": { 1325 + "target": "ES2017", 1326 + "lib": ["dom", "dom.iterable", "esnext"], 1327 + "allowJs": true, 1328 + "skipLibCheck": true, 1329 + "strict": true, 1330 + "noEmit": true, 1331 + "esModuleInterop": true, 1332 + "module": "esnext", 1333 + "moduleResolution": "bundler", 1334 + "resolveJsonModule": true, 1335 + "isolatedModules": true, 1336 + "jsx": "preserve", 1337 + "incremental": true, 1338 + "plugins": [{ "name": "next" }], 1339 + "paths": { 1340 + "@/*": ["./src/*"] 1341 + } 1342 + }, 1343 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 1344 + "exclude": ["node_modules"] 1345 + } 1346 + ``` 1347 + 1348 + - [ ] **Step 6: Create src/lib/api.ts** 1349 + 1350 + API client that reads from the appview (at build time for SSG, at runtime for dev). 1351 + 1352 + ```typescript 1353 + const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; 1354 + 1355 + async function fetchApi<T>(path: string): Promise<T> { 1356 + const res = await fetch(`${API_BASE}${path}`, { next: { revalidate: false } }); 1357 + if (!res.ok) throw new Error(`API error: ${res.status} ${path}`); 1358 + return res.json(); 1359 + } 1360 + 1361 + export async function getTalks() { 1362 + return fetchApi<{ talks: any[] }>("/talks"); 1363 + } 1364 + 1365 + export async function getTalk(rkey: string) { 1366 + return fetchApi<{ talk: any; speakers: any[]; concepts: any[] }>( 1367 + `/talks/${rkey}` 1368 + ); 1369 + } 1370 + 1371 + export async function getSpeakers() { 1372 + return fetchApi<{ speakers: any[] }>("/speakers"); 1373 + } 1374 + 1375 + export async function getSpeaker(rkey: string) { 1376 + return fetchApi<{ speaker: any; talks: any[] }>(`/speakers/${rkey}`); 1377 + } 1378 + 1379 + export async function getConcepts() { 1380 + return fetchApi<{ concepts: any[] }>("/concepts"); 1381 + } 1382 + 1383 + export async function getConcept(rkey: string) { 1384 + return fetchApi<{ concept: any; talks: any[] }>(`/concepts/${rkey}`); 1385 + } 1386 + ``` 1387 + 1388 + - [ ] **Step 7: Create src/app/globals.css** 1389 + 1390 + ```css 1391 + @tailwind base; 1392 + @tailwind components; 1393 + @tailwind utilities; 1394 + ``` 1395 + 1396 + - [ ] **Step 8: Create layout.tsx** 1397 + 1398 + ```tsx 1399 + import type { Metadata } from "next"; 1400 + import "./globals.css"; 1401 + 1402 + export const metadata: Metadata = { 1403 + title: "Ionosphere", 1404 + description: 1405 + "Semantically enriched conference video archive for ATmosphereConf 2026", 1406 + }; 1407 + 1408 + export default function RootLayout({ 1409 + children, 1410 + }: { 1411 + children: React.ReactNode; 1412 + }) { 1413 + return ( 1414 + <html lang="en"> 1415 + <body className="bg-neutral-950 text-neutral-100 min-h-screen"> 1416 + <header className="border-b border-neutral-800 px-6 py-4"> 1417 + <nav className="max-w-6xl mx-auto flex items-center gap-6"> 1418 + <a href="/" className="text-xl font-bold tracking-tight"> 1419 + Ionosphere 1420 + </a> 1421 + <a href="/talks" className="text-neutral-400 hover:text-neutral-100"> 1422 + Talks 1423 + </a> 1424 + <a 1425 + href="/speakers" 1426 + className="text-neutral-400 hover:text-neutral-100" 1427 + > 1428 + Speakers 1429 + </a> 1430 + <a 1431 + href="/concepts" 1432 + className="text-neutral-400 hover:text-neutral-100" 1433 + > 1434 + Concepts 1435 + </a> 1436 + </nav> 1437 + </header> 1438 + <main className="max-w-6xl mx-auto px-6 py-8">{children}</main> 1439 + </body> 1440 + </html> 1441 + ); 1442 + } 1443 + ``` 1444 + 1445 + - [ ] **Step 9: Create page.tsx (home)** 1446 + 1447 + ```tsx 1448 + import { getTalks } from "@/lib/api"; 1449 + 1450 + export default async function Home() { 1451 + const { talks } = await getTalks(); 1452 + 1453 + return ( 1454 + <div> 1455 + <h1 className="text-4xl font-bold mb-2">ATmosphereConf 2026</h1> 1456 + <p className="text-neutral-400 mb-8"> 1457 + Semantically enriched conference archive. {talks.length} talks. 1458 + </p> 1459 + <div className="grid gap-4"> 1460 + {talks.slice(0, 20).map((talk: any) => ( 1461 + <a 1462 + key={talk.rkey} 1463 + href={`/talks/${talk.rkey}`} 1464 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1465 + > 1466 + <h2 className="font-semibold">{talk.title}</h2> 1467 + <div className="text-sm text-neutral-400 mt-1"> 1468 + {talk.speaker_names} &middot; {talk.room} &middot; {talk.talk_type} 1469 + </div> 1470 + </a> 1471 + ))} 1472 + </div> 1473 + </div> 1474 + ); 1475 + } 1476 + ``` 1477 + 1478 + - [ ] **Step 10: Install and verify** 1479 + 1480 + ```bash 1481 + pnpm install 1482 + cd apps/ionosphere && pnpm dev 1483 + ``` 1484 + 1485 + Visit http://localhost:3002 — should show talk list (requires appview running on 3001). 1486 + 1487 + - [ ] **Step 11: Commit** 1488 + 1489 + ```bash 1490 + git add apps/ionosphere/ 1491 + git commit -m "feat: scaffold Next.js frontend with talk listing" 1492 + ``` 1493 + 1494 + ### Task 7: Talk page with video player 1495 + 1496 + **Files:** 1497 + - Create: `apps/ionosphere/src/app/talks/page.tsx` 1498 + - Create: `apps/ionosphere/src/app/talks/[rkey]/page.tsx` 1499 + - Create: `apps/ionosphere/src/app/components/VideoPlayer.tsx` 1500 + 1501 + - [ ] **Step 1: Create talks index page** 1502 + 1503 + `apps/ionosphere/src/app/talks/page.tsx`: 1504 + 1505 + ```tsx 1506 + import { getTalks } from "@/lib/api"; 1507 + 1508 + export default async function TalksPage() { 1509 + const { talks } = await getTalks(); 1510 + 1511 + // Group by day 1512 + const byDay = new Map<string, any[]>(); 1513 + for (const talk of talks) { 1514 + const day = talk.starts_at?.slice(0, 10) || "unknown"; 1515 + if (!byDay.has(day)) byDay.set(day, []); 1516 + byDay.get(day)!.push(talk); 1517 + } 1518 + 1519 + return ( 1520 + <div> 1521 + <h1 className="text-3xl font-bold mb-6">All Talks</h1> 1522 + {[...byDay.entries()].map(([day, dayTalks]) => ( 1523 + <section key={day} className="mb-8"> 1524 + <h2 className="text-xl font-semibold text-neutral-300 mb-4"> 1525 + {new Date(day + "T00:00:00Z").toLocaleDateString("en-US", { 1526 + weekday: "long", 1527 + month: "long", 1528 + day: "numeric", 1529 + })} 1530 + </h2> 1531 + <div className="grid gap-3"> 1532 + {dayTalks.map((talk: any) => ( 1533 + <a 1534 + key={talk.rkey} 1535 + href={`/talks/${talk.rkey}`} 1536 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1537 + > 1538 + <h3 className="font-semibold">{talk.title}</h3> 1539 + <div className="text-sm text-neutral-400 mt-1"> 1540 + {talk.speaker_names} &middot; {talk.room} &middot;{" "} 1541 + {talk.talk_type} 1542 + </div> 1543 + </a> 1544 + ))} 1545 + </div> 1546 + </section> 1547 + ))} 1548 + </div> 1549 + ); 1550 + } 1551 + ``` 1552 + 1553 + - [ ] **Step 2: Create VideoPlayer component** 1554 + 1555 + `apps/ionosphere/src/app/components/VideoPlayer.tsx`: 1556 + 1557 + ```tsx 1558 + "use client"; 1559 + 1560 + import { useRef, useEffect } from "react"; 1561 + 1562 + interface VideoPlayerProps { 1563 + videoUri: string; 1564 + onTimeUpdate?: (timeNs: number) => void; 1565 + } 1566 + 1567 + const VOD_ENDPOINT = "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist"; 1568 + 1569 + export default function VideoPlayer({ videoUri, onTimeUpdate }: VideoPlayerProps) { 1570 + const videoRef = useRef<HTMLVideoElement>(null); 1571 + 1572 + const playlistUrl = `${VOD_ENDPOINT}?uri=${encodeURIComponent(videoUri)}`; 1573 + 1574 + useEffect(() => { 1575 + const video = videoRef.current; 1576 + if (!video) return; 1577 + 1578 + // HLS.js for browsers that don't support HLS natively 1579 + let hls: any; 1580 + 1581 + async function setupHls() { 1582 + if (video!.canPlayType("application/vnd.apple.mpegurl")) { 1583 + // Safari supports HLS natively 1584 + video!.src = playlistUrl; 1585 + } else { 1586 + const { default: Hls } = await import("hls.js"); 1587 + if (Hls.isSupported()) { 1588 + hls = new Hls(); 1589 + hls.loadSource(playlistUrl); 1590 + hls.attachMedia(video!); 1591 + } 1592 + } 1593 + } 1594 + 1595 + setupHls(); 1596 + 1597 + return () => { 1598 + if (hls) hls.destroy(); 1599 + }; 1600 + }, [playlistUrl]); 1601 + 1602 + useEffect(() => { 1603 + const video = videoRef.current; 1604 + if (!video || !onTimeUpdate) return; 1605 + 1606 + const handler = () => { 1607 + // Convert seconds to nanoseconds for consistency with VOD duration format 1608 + onTimeUpdate(video.currentTime * 1e9); 1609 + }; 1610 + 1611 + video.addEventListener("timeupdate", handler); 1612 + return () => video.removeEventListener("timeupdate", handler); 1613 + }, [onTimeUpdate]); 1614 + 1615 + return ( 1616 + <video 1617 + ref={videoRef} 1618 + controls 1619 + className="w-full rounded-lg bg-black aspect-video" 1620 + /> 1621 + ); 1622 + } 1623 + ``` 1624 + 1625 + - [ ] **Step 3: Create talk detail page** 1626 + 1627 + `apps/ionosphere/src/app/talks/[rkey]/page.tsx`: 1628 + 1629 + ```tsx 1630 + import { getTalk, getTalks } from "@/lib/api"; 1631 + import VideoPlayer from "@/app/components/VideoPlayer"; 1632 + 1633 + export async function generateStaticParams() { 1634 + const { talks } = await getTalks(); 1635 + return talks.map((t: any) => ({ rkey: t.rkey })); 1636 + } 1637 + 1638 + export default async function TalkPage({ 1639 + params, 1640 + }: { 1641 + params: Promise<{ rkey: string }>; 1642 + }) { 1643 + const { rkey } = await params; 1644 + const { talk, speakers, concepts } = await getTalk(rkey); 1645 + 1646 + const durationMin = talk.duration ? (talk.duration / 1e9 / 60).toFixed(0) : null; 1647 + 1648 + return ( 1649 + <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> 1650 + <div className="lg:col-span-2"> 1651 + {talk.video_uri && <VideoPlayer videoUri={talk.video_uri} />} 1652 + <h1 className="text-2xl font-bold mt-4">{talk.title}</h1> 1653 + <div className="text-neutral-400 mt-1"> 1654 + {speakers.map((s: any) => s.name).join(", ")} 1655 + {durationMin && <> &middot; {durationMin} min</>} 1656 + {talk.room && <> &middot; {talk.room}</>} 1657 + </div> 1658 + {talk.description && ( 1659 + <p className="text-neutral-300 mt-4 leading-relaxed"> 1660 + {talk.description} 1661 + </p> 1662 + )} 1663 + {/* Transcript will go here in a later task */} 1664 + <div className="mt-8 p-6 rounded-lg border border-neutral-800 text-neutral-500 text-sm"> 1665 + Transcript not yet available. 1666 + </div> 1667 + </div> 1668 + <aside className="space-y-6"> 1669 + <section> 1670 + <h2 className="text-sm font-semibold text-neutral-400 uppercase tracking-wide mb-2"> 1671 + Speakers 1672 + </h2> 1673 + {speakers.map((s: any) => ( 1674 + <a 1675 + key={s.rkey} 1676 + href={`/speakers/${s.rkey}`} 1677 + className="block text-neutral-200 hover:text-white" 1678 + > 1679 + {s.name} 1680 + {s.handle && ( 1681 + <span className="text-neutral-500 ml-1">@{s.handle}</span> 1682 + )} 1683 + </a> 1684 + ))} 1685 + </section> 1686 + {talk.category && ( 1687 + <section> 1688 + <h2 className="text-sm font-semibold text-neutral-400 uppercase tracking-wide mb-2"> 1689 + Category 1690 + </h2> 1691 + <span className="text-neutral-300">{talk.category}</span> 1692 + </section> 1693 + )} 1694 + {talk.talk_type && ( 1695 + <section> 1696 + <h2 className="text-sm font-semibold text-neutral-400 uppercase tracking-wide mb-2"> 1697 + Type 1698 + </h2> 1699 + <span className="text-neutral-300">{talk.talk_type}</span> 1700 + </section> 1701 + )} 1702 + </aside> 1703 + </div> 1704 + ); 1705 + } 1706 + ``` 1707 + 1708 + - [ ] **Step 4: Add hls.js dependency** 1709 + 1710 + ```bash 1711 + cd apps/ionosphere && pnpm add hls.js 1712 + ``` 1713 + 1714 + - [ ] **Step 5: Verify talk page with video playback** 1715 + 1716 + With appview running on :3001 and frontend on :3002, navigate to a talk page and verify the video loads and plays from Streamplace. 1717 + 1718 + - [ ] **Step 6: Commit** 1719 + 1720 + ```bash 1721 + git add apps/ionosphere/src/app/talks/ apps/ionosphere/src/app/components/VideoPlayer.tsx 1722 + git commit -m "feat: talk pages with HLS video player" 1723 + ``` 1724 + 1725 + ### Task 8: Speaker and concept pages 1726 + 1727 + **Files:** 1728 + - Create: `apps/ionosphere/src/app/speakers/page.tsx` 1729 + - Create: `apps/ionosphere/src/app/speakers/[rkey]/page.tsx` 1730 + - Create: `apps/ionosphere/src/app/concepts/page.tsx` 1731 + - Create: `apps/ionosphere/src/app/concepts/[rkey]/page.tsx` 1732 + 1733 + - [ ] **Step 1: Create speakers index page** 1734 + 1735 + ```tsx 1736 + import { getSpeakers } from "@/lib/api"; 1737 + 1738 + export default async function SpeakersPage() { 1739 + const { speakers } = await getSpeakers(); 1740 + 1741 + return ( 1742 + <div> 1743 + <h1 className="text-3xl font-bold mb-6">Speakers</h1> 1744 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> 1745 + {speakers.map((s: any) => ( 1746 + <a 1747 + key={s.rkey} 1748 + href={`/speakers/${s.rkey}`} 1749 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1750 + > 1751 + <div className="font-semibold">{s.name}</div> 1752 + {s.handle && ( 1753 + <div className="text-sm text-neutral-400">@{s.handle}</div> 1754 + )} 1755 + </a> 1756 + ))} 1757 + </div> 1758 + </div> 1759 + ); 1760 + } 1761 + ``` 1762 + 1763 + - [ ] **Step 2: Create speaker detail page** 1764 + 1765 + ```tsx 1766 + import { getSpeaker, getSpeakers } from "@/lib/api"; 1767 + 1768 + export async function generateStaticParams() { 1769 + const { speakers } = await getSpeakers(); 1770 + return speakers.map((s: any) => ({ rkey: s.rkey })); 1771 + } 1772 + 1773 + export default async function SpeakerPage({ 1774 + params, 1775 + }: { 1776 + params: Promise<{ rkey: string }>; 1777 + }) { 1778 + const { rkey } = await params; 1779 + const { speaker, talks } = await getSpeaker(rkey); 1780 + 1781 + return ( 1782 + <div> 1783 + <h1 className="text-3xl font-bold">{speaker.name}</h1> 1784 + {speaker.handle && ( 1785 + <div className="text-neutral-400 mt-1">@{speaker.handle}</div> 1786 + )} 1787 + {speaker.bio && ( 1788 + <p className="text-neutral-300 mt-4">{speaker.bio}</p> 1789 + )} 1790 + <h2 className="text-xl font-semibold mt-8 mb-4">Talks</h2> 1791 + <div className="grid gap-3"> 1792 + {talks.map((t: any) => ( 1793 + <a 1794 + key={t.rkey} 1795 + href={`/talks/${t.rkey}`} 1796 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1797 + > 1798 + <div className="font-semibold">{t.title}</div> 1799 + <div className="text-sm text-neutral-400 mt-1"> 1800 + {t.room} &middot; {t.talk_type} 1801 + </div> 1802 + </a> 1803 + ))} 1804 + </div> 1805 + </div> 1806 + ); 1807 + } 1808 + ``` 1809 + 1810 + - [ ] **Step 3: Create concepts index page** 1811 + 1812 + ```tsx 1813 + import { getConcepts } from "@/lib/api"; 1814 + 1815 + export default async function ConceptsPage() { 1816 + const { concepts } = await getConcepts(); 1817 + 1818 + if (concepts.length === 0) { 1819 + return ( 1820 + <div> 1821 + <h1 className="text-3xl font-bold mb-6">Concepts</h1> 1822 + <p className="text-neutral-400"> 1823 + Concepts will appear here after transcript enrichment. 1824 + </p> 1825 + </div> 1826 + ); 1827 + } 1828 + 1829 + return ( 1830 + <div> 1831 + <h1 className="text-3xl font-bold mb-6">Concepts</h1> 1832 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> 1833 + {concepts.map((c: any) => ( 1834 + <a 1835 + key={c.rkey} 1836 + href={`/concepts/${c.rkey}`} 1837 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1838 + > 1839 + <div className="font-semibold">{c.name}</div> 1840 + {c.description && ( 1841 + <div className="text-sm text-neutral-400 mt-1 line-clamp-2"> 1842 + {c.description} 1843 + </div> 1844 + )} 1845 + </a> 1846 + ))} 1847 + </div> 1848 + </div> 1849 + ); 1850 + } 1851 + ``` 1852 + 1853 + - [ ] **Step 4: Create concept detail page** 1854 + 1855 + ```tsx 1856 + import { getConcept, getConcepts } from "@/lib/api"; 1857 + 1858 + export async function generateStaticParams() { 1859 + const { concepts } = await getConcepts(); 1860 + return concepts.map((c: any) => ({ rkey: c.rkey })); 1861 + } 1862 + 1863 + export default async function ConceptPage({ 1864 + params, 1865 + }: { 1866 + params: Promise<{ rkey: string }>; 1867 + }) { 1868 + const { rkey } = await params; 1869 + const { concept, talks } = await getConcept(rkey); 1870 + 1871 + return ( 1872 + <div> 1873 + <h1 className="text-3xl font-bold">{concept.name}</h1> 1874 + {concept.description && ( 1875 + <p className="text-neutral-300 mt-4">{concept.description}</p> 1876 + )} 1877 + {concept.wikidata_id && ( 1878 + <a 1879 + href={`https://www.wikidata.org/wiki/${concept.wikidata_id}`} 1880 + className="text-blue-400 hover:underline text-sm mt-2 inline-block" 1881 + target="_blank" 1882 + rel="noopener" 1883 + > 1884 + Wikidata 1885 + </a> 1886 + )} 1887 + <h2 className="text-xl font-semibold mt-8 mb-4"> 1888 + Mentioned in {talks.length} talk{talks.length !== 1 ? "s" : ""} 1889 + </h2> 1890 + <div className="grid gap-3"> 1891 + {talks.map((t: any) => ( 1892 + <a 1893 + key={t.rkey} 1894 + href={`/talks/${t.rkey}`} 1895 + className="block p-4 rounded-lg border border-neutral-800 hover:border-neutral-600 transition-colors" 1896 + > 1897 + <div className="font-semibold">{t.title}</div> 1898 + </a> 1899 + ))} 1900 + </div> 1901 + </div> 1902 + ); 1903 + } 1904 + ``` 1905 + 1906 + - [ ] **Step 5: Verify all pages** 1907 + 1908 + Navigate to /speakers, /speakers/:rkey, /concepts, verify rendering. 1909 + 1910 + - [ ] **Step 6: Commit** 1911 + 1912 + ```bash 1913 + git add apps/ionosphere/src/app/speakers/ apps/ionosphere/src/app/concepts/ 1914 + git commit -m "feat: speaker and concept pages" 1915 + ``` 1916 + 1917 + --- 1918 + 1919 + ## Chunk 4: Transcription Pipeline 1920 + 1921 + Implements audio extraction from HLS streams and transcription with word-level timestamps. After this chunk, talks have transcripts stored in the database. 1922 + 1923 + ### Task 9: Audio extraction from HLS 1924 + 1925 + **Files:** 1926 + - Create: `apps/ionosphere-appview/src/extract-audio.ts` 1927 + 1928 + - [ ] **Step 1: Write test for audio extraction** 1929 + 1930 + Create `apps/ionosphere-appview/src/extract-audio.test.ts`: 1931 + 1932 + ```typescript 1933 + import { describe, it, expect } from "vitest"; 1934 + import { buildPlaylistUrl } from "./extract-audio.js"; 1935 + 1936 + describe("extract-audio", () => { 1937 + it("builds correct playlist URL from video URI", () => { 1938 + const uri = 1939 + "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi5stzyxji2e"; 1940 + const url = buildPlaylistUrl(uri); 1941 + expect(url).toBe( 1942 + "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist?uri=at%3A%2F%2Fdid%3Aplc%3Arbvrr34edl5ddpuwcubjiost%2Fplace.stream.video%2F3mi5stzyxji2e" 1943 + ); 1944 + }); 1945 + }); 1946 + ``` 1947 + 1948 + - [ ] **Step 2: Run test to verify it fails** 1949 + 1950 + Run: `cd apps/ionosphere-appview && pnpm test` 1951 + 1952 + - [ ] **Step 3: Implement extract-audio.ts** 1953 + 1954 + Uses ffmpeg to extract audio from the HLS stream. ffmpeg must be installed on the system. 1955 + 1956 + ```typescript 1957 + import { execSync } from "node:child_process"; 1958 + import { existsSync, mkdirSync } from "node:fs"; 1959 + import path from "node:path"; 1960 + 1961 + const VOD_ENDPOINT = 1962 + "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist"; 1963 + const AUDIO_DIR = path.resolve(import.meta.dirname, "../../data/audio"); 1964 + 1965 + export function buildPlaylistUrl(videoUri: string): string { 1966 + return `${VOD_ENDPOINT}?uri=${encodeURIComponent(videoUri)}`; 1967 + } 1968 + 1969 + export function extractAudio( 1970 + videoUri: string, 1971 + talkRkey: string 1972 + ): string { 1973 + mkdirSync(AUDIO_DIR, { recursive: true }); 1974 + 1975 + const outputPath = path.join(AUDIO_DIR, `${talkRkey}.wav`); 1976 + if (existsSync(outputPath)) { 1977 + console.log(` Audio already exists: ${talkRkey}.wav`); 1978 + return outputPath; 1979 + } 1980 + 1981 + const playlistUrl = buildPlaylistUrl(videoUri); 1982 + console.log(` Extracting audio for ${talkRkey}...`); 1983 + 1984 + execSync( 1985 + `ffmpeg -i "${playlistUrl}" -vn -acodec pcm_s16le -ar 16000 -ac 1 "${outputPath}"`, 1986 + { stdio: "inherit", timeout: 600_000 } 1987 + ); 1988 + 1989 + return outputPath; 1990 + } 1991 + ``` 1992 + 1993 + - [ ] **Step 4: Run test to verify it passes** 1994 + 1995 + Run: `cd apps/ionosphere-appview && pnpm test` 1996 + 1997 + - [ ] **Step 5: Commit** 1998 + 1999 + ```bash 2000 + git add apps/ionosphere-appview/src/extract-audio.ts apps/ionosphere-appview/src/extract-audio.test.ts 2001 + git commit -m "feat: audio extraction from HLS streams via ffmpeg" 2002 + ``` 2003 + 2004 + ### Task 10: Transcription integration 2005 + 2006 + **Files:** 2007 + - Create: `apps/ionosphere-appview/src/transcribe.ts` 2008 + 2009 + This task is a skeleton — the actual transcription provider is pluggable. Start with a file-based interface that can wrap any provider. 2010 + 2011 + - [ ] **Step 1: Define transcription types and interface** 2012 + 2013 + ```typescript 2014 + import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; 2015 + import path from "node:path"; 2016 + import { extractAudio } from "./extract-audio.js"; 2017 + import { openDb } from "./db.js"; 2018 + 2019 + const TRANSCRIPT_DIR = path.resolve( 2020 + import.meta.dirname, 2021 + "../../data/transcripts" 2022 + ); 2023 + 2024 + export interface WordTimestamp { 2025 + word: string; 2026 + start: number; // seconds 2027 + end: number; // seconds 2028 + confidence: number; 2029 + } 2030 + 2031 + export interface TranscriptResult { 2032 + text: string; 2033 + words: WordTimestamp[]; 2034 + } 2035 + 2036 + export type TranscriptionProvider = ( 2037 + audioPath: string 2038 + ) => Promise<TranscriptResult>; 2039 + 2040 + // Placeholder provider — replace with real implementation 2041 + async function placeholderProvider( 2042 + audioPath: string 2043 + ): Promise<TranscriptResult> { 2044 + throw new Error( 2045 + `No transcription provider configured. Audio file: ${audioPath}` 2046 + ); 2047 + } 2048 + 2049 + export async function transcribeTalk( 2050 + talkRkey: string, 2051 + videoUri: string, 2052 + provider: TranscriptionProvider = placeholderProvider 2053 + ): Promise<TranscriptResult> { 2054 + mkdirSync(TRANSCRIPT_DIR, { recursive: true }); 2055 + 2056 + const cachedPath = path.join(TRANSCRIPT_DIR, `${talkRkey}.json`); 2057 + 2058 + // Check cache 2059 + if (existsSync(cachedPath)) { 2060 + console.log(` Transcript cached: ${talkRkey}`); 2061 + return JSON.parse(readFileSync(cachedPath, "utf-8")); 2062 + } 2063 + 2064 + // Extract audio 2065 + const audioPath = extractAudio(videoUri, talkRkey); 2066 + 2067 + // Transcribe 2068 + console.log(` Transcribing ${talkRkey}...`); 2069 + const result = await provider(audioPath); 2070 + 2071 + // Cache result 2072 + writeFileSync(cachedPath, JSON.stringify(result, null, 2)); 2073 + console.log(` Saved transcript: ${cachedPath}`); 2074 + 2075 + return result; 2076 + } 2077 + 2078 + // CLI entry point: transcribe all talks that have a video but no transcript 2079 + async function main() { 2080 + const db = openDb(); 2081 + const talks = db 2082 + .prepare( 2083 + `SELECT t.rkey, t.video_uri FROM talks t 2084 + JOIN pipeline_status ps ON t.uri = ps.talk_uri 2085 + WHERE t.video_uri IS NOT NULL AND ps.transcribed = 0 2086 + LIMIT 5` 2087 + ) 2088 + .all() as Array<{ rkey: string; video_uri: string }>; 2089 + 2090 + console.log(`${talks.length} talks to transcribe`); 2091 + 2092 + for (const talk of talks) { 2093 + try { 2094 + await transcribeTalk(talk.rkey, talk.video_uri); 2095 + db.prepare( 2096 + `UPDATE pipeline_status SET transcribed = 1, updated_at = CURRENT_TIMESTAMP 2097 + WHERE talk_uri = (SELECT uri FROM talks WHERE rkey = ?)` 2098 + ).run(talk.rkey); 2099 + } catch (err) { 2100 + console.error(` Failed: ${talk.rkey}:`, (err as Error).message); 2101 + } 2102 + } 2103 + 2104 + db.close(); 2105 + } 2106 + 2107 + // Only run main when executed directly 2108 + if (import.meta.url === `file://${process.argv[1]}`) { 2109 + main().catch(console.error); 2110 + } 2111 + ``` 2112 + 2113 + - [ ] **Step 2: Add transcribe script to appview package.json** 2114 + 2115 + Add to scripts: 2116 + ```json 2117 + "transcribe": "tsx src/transcribe.ts" 2118 + ``` 2119 + 2120 + - [ ] **Step 3: Commit** 2121 + 2122 + ```bash 2123 + git add apps/ionosphere-appview/src/transcribe.ts 2124 + git commit -m "feat: transcription pipeline skeleton with provider interface and caching" 2125 + ``` 2126 + 2127 + --- 2128 + 2129 + ## Chunk 5: Document Assembly & Timestamp Provider 2130 + 2131 + Converts raw transcripts into RelationalText documents with timestamp facets, and implements the frontend timestamp sync. 2132 + 2133 + ### Task 11: Document assembly — transcript to RelationalText 2134 + 2135 + **Files:** 2136 + - Create: `formats/tv.ionosphere/ts/assemble.ts` 2137 + - Create: `formats/tv.ionosphere/ts/assemble.test.ts` 2138 + 2139 + - [ ] **Step 1: Write test for document assembly** 2140 + 2141 + ```typescript 2142 + import { describe, it, expect } from "vitest"; 2143 + import { assembleDocument, type TranscriptInput } from "./assemble.js"; 2144 + 2145 + describe("assembleDocument", () => { 2146 + const transcript: TranscriptInput = { 2147 + text: "Hello world this is a test", 2148 + words: [ 2149 + { word: "Hello", start: 0.0, end: 0.5, confidence: 0.99 }, 2150 + { word: "world", start: 0.5, end: 1.0, confidence: 0.98 }, 2151 + { word: "this", start: 1.0, end: 1.3, confidence: 0.97 }, 2152 + { word: "is", start: 1.3, end: 1.5, confidence: 0.99 }, 2153 + { word: "a", start: 1.5, end: 1.6, confidence: 0.99 }, 2154 + { word: "test", start: 1.6, end: 2.0, confidence: 0.95 }, 2155 + ], 2156 + }; 2157 + 2158 + it("creates a document with text matching the transcript", () => { 2159 + const doc = assembleDocument(transcript); 2160 + expect(doc.text).toBe("Hello world this is a test"); 2161 + }); 2162 + 2163 + it("creates timestamp facets for each word", () => { 2164 + const doc = assembleDocument(transcript); 2165 + const timestampFacets = doc.facets.filter((f: any) => 2166 + f.features.some((feat: any) => feat.$type === "tv.ionosphere.facet#timestamp") 2167 + ); 2168 + expect(timestampFacets).toHaveLength(6); 2169 + }); 2170 + 2171 + it("timestamp facets have correct byte ranges", () => { 2172 + const doc = assembleDocument(transcript); 2173 + const first = doc.facets.find((f: any) => 2174 + f.features.some( 2175 + (feat: any) => 2176 + feat.$type === "tv.ionosphere.facet#timestamp" && 2177 + feat.startTime === 0 2178 + ) 2179 + ); 2180 + expect(first).toBeDefined(); 2181 + expect(first!.index.byteStart).toBe(0); 2182 + expect(first!.index.byteEnd).toBe(5); // "Hello" = 5 bytes 2183 + }); 2184 + 2185 + it("timestamp times are in nanoseconds", () => { 2186 + const doc = assembleDocument(transcript); 2187 + const first = doc.facets[0]; 2188 + const ts = first.features.find( 2189 + (f: any) => f.$type === "tv.ionosphere.facet#timestamp" 2190 + ); 2191 + expect(ts.startTime).toBe(0); 2192 + expect(ts.endTime).toBe(500_000_000); // 0.5s in ns 2193 + }); 2194 + }); 2195 + ``` 2196 + 2197 + - [ ] **Step 2: Run test to verify it fails** 2198 + 2199 + Run: `cd formats/tv.ionosphere && pnpm test` 2200 + 2201 + - [ ] **Step 3: Implement assemble.ts** 2202 + 2203 + ```typescript 2204 + export interface WordTimestamp { 2205 + word: string; 2206 + start: number; // seconds 2207 + end: number; // seconds 2208 + confidence: number; 2209 + } 2210 + 2211 + export interface TranscriptInput { 2212 + text: string; 2213 + words: WordTimestamp[]; 2214 + } 2215 + 2216 + export interface Facet { 2217 + index: { byteStart: number; byteEnd: number }; 2218 + features: Array<Record<string, any>>; 2219 + } 2220 + 2221 + export interface Document { 2222 + text: string; 2223 + facets: Facet[]; 2224 + } 2225 + 2226 + function secondsToNs(s: number): number { 2227 + return Math.round(s * 1e9); 2228 + } 2229 + 2230 + export function assembleDocument(transcript: TranscriptInput): Document { 2231 + const encoder = new TextEncoder(); 2232 + const facets: Facet[] = []; 2233 + 2234 + // Build byte offset map by finding each word in the text 2235 + let searchFrom = 0; 2236 + for (const word of transcript.words) { 2237 + const idx = transcript.text.indexOf(word.word, searchFrom); 2238 + if (idx === -1) continue; 2239 + 2240 + const byteStart = encoder.encode(transcript.text.slice(0, idx)).length; 2241 + const byteEnd = 2242 + encoder.encode(transcript.text.slice(0, idx + word.word.length)).length; 2243 + 2244 + facets.push({ 2245 + index: { byteStart, byteEnd }, 2246 + features: [ 2247 + { 2248 + $type: "tv.ionosphere.facet#timestamp", 2249 + startTime: secondsToNs(word.start), 2250 + endTime: secondsToNs(word.end), 2251 + }, 2252 + ], 2253 + }); 2254 + 2255 + searchFrom = idx + word.word.length; 2256 + } 2257 + 2258 + return { text: transcript.text, facets }; 2259 + } 2260 + ``` 2261 + 2262 + - [ ] **Step 4: Run test to verify it passes** 2263 + 2264 + Run: `cd formats/tv.ionosphere && pnpm test` 2265 + 2266 + - [ ] **Step 5: Commit** 2267 + 2268 + ```bash 2269 + git add formats/tv.ionosphere/ts/assemble.ts formats/tv.ionosphere/ts/assemble.test.ts 2270 + git commit -m "feat: assemble RelationalText documents from transcripts with timestamp facets" 2271 + ``` 2272 + 2273 + ### Task 12: Timestamp provider and transcript sync in frontend 2274 + 2275 + **Files:** 2276 + - Create: `apps/ionosphere/src/app/components/TimestampProvider.tsx` 2277 + - Create: `apps/ionosphere/src/app/components/TranscriptView.tsx` 2278 + 2279 + - [ ] **Step 1: Create TimestampProvider** 2280 + 2281 + ```tsx 2282 + "use client"; 2283 + 2284 + import { 2285 + createContext, 2286 + useContext, 2287 + useState, 2288 + useCallback, 2289 + type ReactNode, 2290 + } from "react"; 2291 + 2292 + interface TimestampContextValue { 2293 + currentTimeNs: number; 2294 + setCurrentTimeNs: (ns: number) => void; 2295 + seekTo: (ns: number) => void; 2296 + onSeek: (handler: (ns: number) => void) => () => void; 2297 + } 2298 + 2299 + const TimestampContext = createContext<TimestampContextValue | null>(null); 2300 + 2301 + export function useTimestamp() { 2302 + const ctx = useContext(TimestampContext); 2303 + if (!ctx) throw new Error("useTimestamp must be used within TimestampProvider"); 2304 + return ctx; 2305 + } 2306 + 2307 + export function TimestampProvider({ children }: { children: ReactNode }) { 2308 + const [currentTimeNs, setCurrentTimeNs] = useState(0); 2309 + const [seekHandlers] = useState<Set<(ns: number) => void>>(new Set()); 2310 + 2311 + const seekTo = useCallback( 2312 + (ns: number) => { 2313 + for (const handler of seekHandlers) { 2314 + handler(ns); 2315 + } 2316 + }, 2317 + [seekHandlers] 2318 + ); 2319 + 2320 + const onSeek = useCallback( 2321 + (handler: (ns: number) => void) => { 2322 + seekHandlers.add(handler); 2323 + return () => seekHandlers.delete(handler); 2324 + }, 2325 + [seekHandlers] 2326 + ); 2327 + 2328 + return ( 2329 + <TimestampContext.Provider 2330 + value={{ currentTimeNs, setCurrentTimeNs, seekTo, onSeek }} 2331 + > 2332 + {children} 2333 + </TimestampContext.Provider> 2334 + ); 2335 + } 2336 + ``` 2337 + 2338 + - [ ] **Step 2: Create TranscriptView** 2339 + 2340 + ```tsx 2341 + "use client"; 2342 + 2343 + import { useTimestamp } from "./TimestampProvider"; 2344 + import { useRef, useEffect } from "react"; 2345 + 2346 + interface TranscriptFacet { 2347 + index: { byteStart: number; byteEnd: number }; 2348 + features: Array<{ 2349 + $type: string; 2350 + startTime?: number; 2351 + endTime?: number; 2352 + [key: string]: any; 2353 + }>; 2354 + } 2355 + 2356 + interface TranscriptDocument { 2357 + text: string; 2358 + facets: TranscriptFacet[]; 2359 + } 2360 + 2361 + interface TranscriptViewProps { 2362 + document: TranscriptDocument; 2363 + } 2364 + 2365 + interface WordSpan { 2366 + text: string; 2367 + startTime: number; 2368 + endTime: number; 2369 + byteStart: number; 2370 + byteEnd: number; 2371 + } 2372 + 2373 + function extractWordSpans(doc: TranscriptDocument): WordSpan[] { 2374 + const encoder = new TextEncoder(); 2375 + const textBytes = encoder.encode(doc.text); 2376 + const decoder = new TextDecoder(); 2377 + 2378 + return doc.facets 2379 + .filter((f) => 2380 + f.features.some((feat) => feat.$type === "tv.ionosphere.facet#timestamp") 2381 + ) 2382 + .map((f) => { 2383 + const ts = f.features.find( 2384 + (feat) => feat.$type === "tv.ionosphere.facet#timestamp" 2385 + )!; 2386 + return { 2387 + text: decoder.decode(textBytes.slice(f.index.byteStart, f.index.byteEnd)), 2388 + startTime: ts.startTime!, 2389 + endTime: ts.endTime!, 2390 + byteStart: f.index.byteStart, 2391 + byteEnd: f.index.byteEnd, 2392 + }; 2393 + }) 2394 + .sort((a, b) => a.byteStart - b.byteStart); 2395 + } 2396 + 2397 + export default function TranscriptView({ document }: TranscriptViewProps) { 2398 + const { currentTimeNs, seekTo } = useTimestamp(); 2399 + const containerRef = useRef<HTMLDivElement>(null); 2400 + const activeRef = useRef<HTMLSpanElement>(null); 2401 + 2402 + const words = extractWordSpans(document); 2403 + 2404 + // Auto-scroll to active word 2405 + useEffect(() => { 2406 + if (activeRef.current) { 2407 + activeRef.current.scrollIntoView({ 2408 + behavior: "smooth", 2409 + block: "center", 2410 + }); 2411 + } 2412 + }, [currentTimeNs]); 2413 + 2414 + return ( 2415 + <div 2416 + ref={containerRef} 2417 + className="mt-8 p-6 rounded-lg border border-neutral-800 max-h-96 overflow-y-auto leading-relaxed" 2418 + > 2419 + {words.map((word, i) => { 2420 + const isActive = 2421 + currentTimeNs >= word.startTime && currentTimeNs < word.endTime; 2422 + 2423 + return ( 2424 + <span 2425 + key={i} 2426 + ref={isActive ? activeRef : undefined} 2427 + onClick={() => seekTo(word.startTime)} 2428 + className={`cursor-pointer transition-colors ${ 2429 + isActive 2430 + ? "bg-blue-500/30 text-white rounded px-0.5" 2431 + : "text-neutral-300 hover:text-white" 2432 + }`} 2433 + > 2434 + {word.text}{" "} 2435 + </span> 2436 + ); 2437 + })} 2438 + </div> 2439 + ); 2440 + } 2441 + ``` 2442 + 2443 + - [ ] **Step 3: Update VideoPlayer to integrate with TimestampProvider** 2444 + 2445 + Update `apps/ionosphere/src/app/components/VideoPlayer.tsx` to use the timestamp context: 2446 + 2447 + Add to the component, after existing imports: 2448 + ```tsx 2449 + import { useTimestamp } from "./TimestampProvider"; 2450 + ``` 2451 + 2452 + Replace the `onTimeUpdate` prop pattern with context-based time broadcasting and seek listening. The component should call `setCurrentTimeNs` on video timeupdate events, and listen for `onSeek` calls to seek the video element. 2453 + 2454 + - [ ] **Step 4: Update talk page to use TimestampProvider and TranscriptView** 2455 + 2456 + Wrap the talk page content in `<TimestampProvider>` and conditionally render `<TranscriptView>` when a document is available. 2457 + 2458 + - [ ] **Step 5: Verify end-to-end** 2459 + 2460 + With a talk that has a transcript in the database, verify: 2461 + 1. Video plays 2462 + 2. Transcript words highlight as video plays 2463 + 3. Clicking a word seeks the video 2464 + 2465 + - [ ] **Step 6: Commit** 2466 + 2467 + ```bash 2468 + git add apps/ionosphere/src/app/components/ 2469 + git commit -m "feat: timestamp provider and synchronized transcript view" 2470 + ``` 2471 + 2472 + --- 2473 + 2474 + ## Chunk 6: LLM Enrichment Pipeline (Future) 2475 + 2476 + This chunk covers LLM-assisted semantic enrichment of transcripts. It is deferred until transcription is working and validated on the corpus. The implementation will: 2477 + 2478 + 1. Pass transcript text + talk context to an LLM 2479 + 2. Extract concept mentions, speaker references, talk cross-references, and links 2480 + 3. Create `tv.ionosphere.concept` records and annotation layers 2481 + 4. Store enrichment results as `pub.layers.annotation` layers on the document 2482 + 5. Update the appview index with concept/speaker/crossref join tables 2483 + 2484 + This is documented but not planned in detail yet — the exact approach depends on transcript quality and cost evaluation. 2485 + 2486 + --- 2487 + 2488 + ## Summary 2489 + 2490 + | Chunk | Tasks | What it delivers | 2491 + |-------|-------|------------------| 2492 + | 1: Scaffold & Lexicons | 1-3 | Workspace, lexicons, format-lexicon | 2493 + | 2: Appview & Ingest | 4-5 | SQLite schema, REST API, data ingest pipeline | 2494 + | 3: Frontend | 6-8 | Next.js SSG, talk/speaker/concept pages, video player | 2495 + | 4: Transcription | 9-10 | Audio extraction, transcription pipeline skeleton | 2496 + | 5: Document Assembly | 11-12 | RelationalText documents, timestamp sync, transcript view | 2497 + | 6: Enrichment | (future) | LLM annotation, concept extraction, knowledge graph |