vod frog, frog with the vods
5
fork

Configure Feed

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

caption editor with import/export JSON, Constellation backlink lookup for existing captions, PDS fix for upload, edit/upload buttons inline with title, seek from debug overlay, spacebar fix for inputs

+829 -16
spec/edit_caption.png

This is a binary file and will not be displayed.

spec/upload_captions.png

This is a binary file and will not be displayed.

+429
src/lib/CaptionEditor.svelte
··· 1 + <!-- 2 + CaptionEditor: Modal for reviewing, editing, and uploading captions. 3 + Two modes: 4 + - "edit": Edit the single caption currently showing on screen 5 + - "full": Full caption list editor with timestamps, text, and speakers 6 + --> 7 + <script lang="ts"> 8 + import { getAuthState } from './auth.svelte'; 9 + import { putCaptions, toCaptionEntries, type CaptionEntry } from './caption-records'; 10 + import WavyBorder from './WavyBorder.svelte'; 11 + import WavyButton from './WavyButton.svelte'; 12 + import { playCroak } from './croak'; 13 + 14 + let { 15 + captions, 16 + currentTime = 0, 17 + videoUri = '', 18 + initialMode = 'edit', 19 + onClose, 20 + onUpdate 21 + }: { 22 + captions: { text: string; start: number; end: number }[]; 23 + currentTime?: number; 24 + videoUri?: string; 25 + initialMode?: 'edit' | 'full'; 26 + onClose: () => void; 27 + onUpdate?: (captions: { text: string; start: number; end: number }[]) => void; 28 + } = $props(); 29 + 30 + const auth = getAuthState(); 31 + 32 + let mode = $state<'edit' | 'full'>(initialMode); 33 + let editCaptions = $state<{ text: string; start: string; end: string; speaker: string }[]>([]); 34 + let saving = $state(false); 35 + let saveResult = $state(''); 36 + let initialized = false; 37 + 38 + // Initialize editable copy once on open, not on every captions change 39 + $effect(() => { 40 + if (!initialized && captions.length > 0) { 41 + editCaptions = captions.map(c => ({ 42 + text: c.text, 43 + start: c.start.toFixed(2), 44 + end: c.end.toFixed(2), 45 + speaker: '' 46 + })); 47 + initialized = true; 48 + } 49 + }); 50 + 51 + // Find the caption currently showing 52 + let currentCaptionIdx = $derived( 53 + captions.findIndex(c => currentTime >= c.start && currentTime <= c.end) 54 + ); 55 + 56 + function updateCaption(idx: number, field: 'text' | 'start' | 'end' | 'speaker', value: string) { 57 + editCaptions = editCaptions.map((c, i) => i === idx ? { ...c, [field]: value } : c); 58 + } 59 + 60 + function deleteCaption(idx: number) { 61 + editCaptions = editCaptions.filter((_, i) => i !== idx); 62 + } 63 + 64 + function applyEdits() { 65 + const updated = editCaptions 66 + .filter(c => c.text.trim()) 67 + .map(c => ({ 68 + text: c.text.trim(), 69 + start: parseFloat(c.start) || 0, 70 + end: parseFloat(c.end) || 0 71 + })) 72 + .sort((a, b) => a.start - b.start); 73 + onUpdate?.(updated); 74 + playCroak(); 75 + saveResult = 'Edits applied'; 76 + setTimeout(() => saveResult = '', 2000); 77 + } 78 + 79 + async function uploadCaptions() { 80 + if (!auth.session || !videoUri) return; 81 + saving = true; 82 + saveResult = ''; 83 + 84 + const entries: CaptionEntry[] = editCaptions.map(c => ({ 85 + timestamp: c.start, 86 + text: c.text, 87 + ...(c.speaker ? { speaker: c.speaker } : {}) 88 + })); 89 + 90 + const uri = await putCaptions(videoUri, entries); 91 + saving = false; 92 + 93 + if (uri) { 94 + saveResult = 'Captions uploaded!'; 95 + playCroak(); 96 + } else { 97 + saveResult = 'Upload failed'; 98 + } 99 + } 100 + 101 + let fileInput: HTMLInputElement | undefined = $state(); 102 + 103 + function downloadJson() { 104 + const record = { 105 + subject: videoUri, 106 + captions: editCaptions.map(c => ({ 107 + timestamp: c.start, 108 + text: c.text, 109 + ...(c.speaker ? { speaker: c.speaker } : {}) 110 + })), 111 + createdAt: new Date().toISOString() 112 + }; 113 + const json = JSON.stringify({ collection: 'boo.sky.vods.captions', record }, null, 4); 114 + const blob = new Blob([json], { type: 'application/json' }); 115 + const url = URL.createObjectURL(blob); 116 + const a = document.createElement('a'); 117 + a.href = url; 118 + a.download = `captions-${Date.now()}.json`; 119 + a.click(); 120 + URL.revokeObjectURL(url); 121 + playCroak(); 122 + } 123 + 124 + function importJson() { 125 + fileInput?.click(); 126 + } 127 + 128 + function handleFileImport(e: Event) { 129 + const file = (e.target as HTMLInputElement).files?.[0]; 130 + if (!file) return; 131 + const reader = new FileReader(); 132 + reader.onload = () => { 133 + try { 134 + const data = JSON.parse(reader.result as string); 135 + // Support both { record: { captions } } and { captions } formats 136 + const caps = data.record?.captions || data.captions; 137 + if (!Array.isArray(caps)) { 138 + saveResult = 'Invalid JSON format'; 139 + return; 140 + } 141 + editCaptions = caps.map((c: any) => ({ 142 + text: c.text || '', 143 + start: String(c.timestamp ?? c.start ?? '0'), 144 + end: String(c.end ?? '0'), 145 + speaker: c.speaker || '' 146 + })); 147 + // Auto-compute end times from next caption's start 148 + for (let i = 0; i < editCaptions.length - 1; i++) { 149 + if (parseFloat(editCaptions[i].end) === 0) { 150 + editCaptions[i].end = editCaptions[i + 1].start; 151 + } 152 + } 153 + applyEdits(); 154 + playCroak(); 155 + saveResult = `Imported ${caps.length} captions`; 156 + setTimeout(() => saveResult = '', 3000); 157 + } catch { 158 + saveResult = 'Failed to parse JSON'; 159 + } 160 + }; 161 + reader.readAsText(file); 162 + // Reset so same file can be re-imported 163 + if (fileInput) fileInput.value = ''; 164 + } 165 + 166 + function formatTime(s: string): string { 167 + const n = parseFloat(s); 168 + if (isNaN(n)) return s; 169 + const mins = Math.floor(n / 60); 170 + const secs = (n % 60).toFixed(1); 171 + return `${mins}:${secs.padStart(4, '0')}`; 172 + } 173 + 174 + function onBackdropClick(e: MouseEvent) { 175 + if (e.target === e.currentTarget) onClose(); 176 + } 177 + </script> 178 + 179 + <!-- svelte-ignore a11y_no_static_element_interactions --> 180 + <div class="editor-backdrop" onclick={onBackdropClick}> 181 + <div class="editor-modal"> 182 + <WavyBorder seed="caption-editor" fill="#39FF44" strokeColor="#0A182B" strokeWidth={2.5} padding={32}> 183 + <div class="editor-body"> 184 + <div class="editor-header"> 185 + <h2 class="editor-title">captions</h2> 186 + <div class="mode-tabs"> 187 + <WavyButton seed="tab-full" fill={mode === 'full' ? '#0A182B' : '#FFDEED'} textColor={mode === 'full' ? '#FFDEED' : '#0A182B'} onclick={() => mode = 'full'}>all</WavyButton> 188 + {#if currentCaptionIdx >= 0} 189 + <WavyButton seed="tab-edit" fill={mode === 'edit' ? '#0A182B' : '#FFDEED'} textColor={mode === 'edit' ? '#FFDEED' : '#0A182B'} onclick={() => mode = 'edit'}>current</WavyButton> 190 + {/if} 191 + </div> 192 + </div> 193 + 194 + {#if mode === 'edit' && currentCaptionIdx >= 0} 195 + <div class="single-edit"> 196 + <label class="field-label"> 197 + <span>time</span> 198 + <div class="time-row"> 199 + <input class="time-input" value={editCaptions[currentCaptionIdx]?.start} oninput={(e) => updateCaption(currentCaptionIdx, 'start', e.currentTarget.value)} /> 200 + <span class="time-sep">—</span> 201 + <input class="time-input" value={editCaptions[currentCaptionIdx]?.end} oninput={(e) => updateCaption(currentCaptionIdx, 'end', e.currentTarget.value)} /> 202 + </div> 203 + </label> 204 + <label class="field-label"> 205 + <span>text</span> 206 + <textarea class="text-input" rows="3" value={editCaptions[currentCaptionIdx]?.text} oninput={(e) => updateCaption(currentCaptionIdx, 'text', e.currentTarget.value)}></textarea> 207 + </label> 208 + <label class="field-label"> 209 + <span>speaker (DID)</span> 210 + <input class="speaker-input" value={editCaptions[currentCaptionIdx]?.speaker} placeholder="optional" oninput={(e) => updateCaption(currentCaptionIdx, 'speaker', e.currentTarget.value)} /> 211 + </label> 212 + </div> 213 + {:else} 214 + <div class="caption-list"> 215 + {#each editCaptions as cap, i} 216 + <div class="caption-row" class:active={i === currentCaptionIdx}> 217 + <div class="caption-times"> 218 + <input class="time-input-sm" value={cap.start} oninput={(e) => updateCaption(i, 'start', e.currentTarget.value)} /> 219 + <span class="time-sep-sm">-</span> 220 + <input class="time-input-sm" value={cap.end} oninput={(e) => updateCaption(i, 'end', e.currentTarget.value)} /> 221 + </div> 222 + <input class="text-input-sm" value={cap.text} oninput={(e) => updateCaption(i, 'text', e.currentTarget.value)} /> 223 + <button class="delete-btn" onclick={() => deleteCaption(i)} title="Delete">x</button> 224 + </div> 225 + {/each} 226 + </div> 227 + {/if} 228 + 229 + {#if saveResult} 230 + <p class="save-result">{saveResult}</p> 231 + {/if} 232 + 233 + <input bind:this={fileInput} type="file" accept=".json" onchange={handleFileImport} class="hidden-input" /> 234 + <div class="editor-actions"> 235 + <WavyButton seed="import-json" fill="#FFA639" textColor="#0A182B" onclick={importJson}>import</WavyButton> 236 + <WavyButton seed="export-json" fill="#FFA639" textColor="#0A182B" onclick={downloadJson}>export</WavyButton> 237 + <WavyButton seed="apply-edits" fill="#0A182B" textColor="#FFDEED" onclick={applyEdits}>apply</WavyButton> 238 + {#if auth.session && videoUri} 239 + <WavyButton seed="upload-caps" fill="#3992FF" textColor="#FFDEED" disabled={saving} onclick={uploadCaptions}> 240 + {saving ? '...' : 'upload'} 241 + </WavyButton> 242 + {/if} 243 + <WavyButton seed="close-editor" fill="#FF3992" textColor="#FFDEED" onclick={onClose}>close</WavyButton> 244 + </div> 245 + </div> 246 + </WavyBorder> 247 + </div> 248 + </div> 249 + 250 + <style> 251 + .editor-backdrop { 252 + position: fixed; 253 + inset: 0; 254 + background: rgba(10, 24, 43, 0.7); 255 + z-index: 1000; 256 + display: flex; 257 + align-items: center; 258 + justify-content: center; 259 + padding: 20px; 260 + } 261 + 262 + .editor-modal { 263 + width: min(700px, 95vw); 264 + max-height: 85vh; 265 + overflow-y: auto; 266 + } 267 + 268 + .editor-body { 269 + display: flex; 270 + flex-direction: column; 271 + gap: 16px; 272 + } 273 + 274 + .editor-header { 275 + display: flex; 276 + align-items: center; 277 + justify-content: space-between; 278 + flex-wrap: wrap; 279 + gap: 8px; 280 + } 281 + 282 + .editor-title { 283 + font-family: 'PicNic', cursive, system-ui; 284 + font-size: clamp(1.4rem, 3vw, 2rem); 285 + color: #0A182B; 286 + margin: 0; 287 + } 288 + 289 + .mode-tabs { 290 + display: flex; 291 + gap: 6px; 292 + } 293 + 294 + /* Single edit mode */ 295 + .single-edit { 296 + display: flex; 297 + flex-direction: column; 298 + gap: 12px; 299 + } 300 + 301 + .field-label { 302 + display: flex; 303 + flex-direction: column; 304 + gap: 4px; 305 + font-family: 'PicNic', cursive, system-ui; 306 + font-size: 0.9rem; 307 + color: #0A182B; 308 + } 309 + 310 + .time-row { 311 + display: flex; 312 + align-items: center; 313 + gap: 8px; 314 + } 315 + 316 + .time-input { 317 + font-family: 'Fang', system-ui, sans-serif; 318 + font-size: 0.9rem; 319 + padding: 6px 10px; 320 + border: 2px solid #0A182B; 321 + background: #FFDEED; 322 + color: #0A182B; 323 + width: 100px; 324 + outline: none; 325 + } 326 + 327 + .time-sep { color: #0A182B; opacity: 0.5; } 328 + 329 + .text-input, .speaker-input { 330 + font-family: 'Fang', system-ui, sans-serif; 331 + font-size: 0.9rem; 332 + padding: 8px 10px; 333 + border: 2px solid #0A182B; 334 + background: #FFDEED; 335 + color: #0A182B; 336 + width: 100%; 337 + outline: none; 338 + resize: vertical; 339 + } 340 + 341 + /* Full list mode */ 342 + .caption-list { 343 + max-height: 50vh; 344 + overflow-y: auto; 345 + display: flex; 346 + flex-direction: column; 347 + gap: 4px; 348 + } 349 + 350 + .caption-row { 351 + display: flex; 352 + align-items: center; 353 + gap: 6px; 354 + padding: 4px; 355 + border-bottom: 1px solid rgba(10, 24, 43, 0.1); 356 + } 357 + 358 + .caption-row.active { 359 + background: rgba(57, 146, 255, 0.15); 360 + } 361 + 362 + .caption-times { 363 + display: flex; 364 + align-items: center; 365 + gap: 2px; 366 + flex-shrink: 0; 367 + } 368 + 369 + .time-input-sm { 370 + font-family: monospace; 371 + font-size: 0.75rem; 372 + padding: 3px 4px; 373 + border: 1px solid rgba(10, 24, 43, 0.3); 374 + background: #FFDEED; 375 + color: #0A182B; 376 + width: 55px; 377 + outline: none; 378 + } 379 + 380 + .time-sep-sm { font-size: 0.7rem; opacity: 0.4; } 381 + 382 + .text-input-sm { 383 + font-family: 'Fang', system-ui, sans-serif; 384 + font-size: 0.8rem; 385 + padding: 3px 6px; 386 + border: 1px solid rgba(10, 24, 43, 0.3); 387 + background: #FFDEED; 388 + color: #0A182B; 389 + flex: 1; 390 + min-width: 0; 391 + outline: none; 392 + } 393 + 394 + .delete-btn { 395 + all: unset; 396 + font-family: monospace; 397 + font-size: 0.8rem; 398 + color: #FF3992; 399 + cursor: pointer; 400 + padding: 2px 6px; 401 + opacity: 0.6; 402 + transition: opacity 0.15s; 403 + } 404 + 405 + .delete-btn:hover { opacity: 1; } 406 + 407 + .save-result { 408 + font-family: 'Fang', system-ui, sans-serif; 409 + font-size: 0.85rem; 410 + color: #0A182B; 411 + text-align: center; 412 + margin: 0; 413 + } 414 + 415 + .hidden-input { 416 + position: absolute; 417 + width: 0; 418 + height: 0; 419 + opacity: 0; 420 + pointer-events: none; 421 + } 422 + 423 + .editor-actions { 424 + display: flex; 425 + justify-content: center; 426 + gap: 8px; 427 + flex-wrap: wrap; 428 + } 429 + </style>
+45 -7
src/lib/VideoPlayer.svelte
··· 3 3 import Hls from "hls.js"; 4 4 import WavyBorder from "./WavyBorder.svelte"; 5 5 import { playCroak } from "./croak"; 6 - import { getModelStatus, getCaptionsEnabled, getCurrentCaption, toggleCaptionsDisplay, updateCaptionForTime, destroyCaptions, getCaptionCount, getDebugState, precomputeCaptions, getIsProcessing, getProcessProgress } from "./captions.svelte"; 6 + import { getModelStatus, getCaptionsEnabled, getCurrentCaption, toggleCaptionsDisplay, updateCaptionForTime, destroyCaptions, getCaptionCount, getDebugState, precomputeCaptions, getIsProcessing, getProcessProgress, getAllCaptions, getProcessedRanges, loadExistingCaptions } from "./captions.svelte"; 7 + import { fetchCaptionsForVideo } from "./caption-records"; 8 + import CaptionEditor from "./CaptionEditor.svelte"; 7 9 8 10 // HLS video source URL (m3u8 playlist) and AT URI for caption pre-computation 9 11 let { src, atUri = '' }: { src: string; atUri?: string } = $props(); ··· 11 13 let videoEl: HTMLVideoElement | undefined = $state(); 12 14 let hls: Hls | null = null; 13 15 let errorMsg = $state(""); 16 + 17 + // Caption editor 18 + let showEditor = $state(false); 19 + 20 + export function openEditor() { showEditor = true; } 21 + 22 + function onCaptionUpdate(updated: { text: string; start: number; end: number }[]) { 23 + console.log('[CC] Captions updated:', updated.length); 24 + } 14 25 15 26 // CC debug overlay — toggle with Shift+D 16 27 let ccDebug = $state(false); ··· 90 101 hls = new Hls({ enableWorker: true, lowLatencyMode: false }); 91 102 hls.loadSource(src); 92 103 hls.attachMedia(videoEl); 93 - hls.on(Hls.Events.MANIFEST_PARSED, () => { 104 + hls.on(Hls.Events.MANIFEST_PARSED, async () => { 94 105 videoEl?.play().catch(() => {}); 95 - if (atUri) precomputeCaptions(atUri); 106 + if (atUri) { 107 + // Check for existing captions first 108 + console.log('[CC] Checking for existing captions...'); 109 + const existing = await fetchCaptionsForVideo(atUri); 110 + if (existing?.record?.captions?.length) { 111 + loadExistingCaptions(existing.record.captions); 112 + console.log(`[CC] Loaded ${existing.record.captions.length} existing captions from ${existing.did}`); 113 + } else { 114 + console.log('[CC] No existing captions found, starting Whisper...'); 115 + precomputeCaptions(atUri); 116 + } 117 + } 96 118 }); 97 119 hls.on(Hls.Events.ERROR, (_event, data) => { 98 120 console.error("HLS error:", data); ··· 283 305 } 284 306 285 307 function onKeyDown(e: KeyboardEvent) { 286 - if (e.code === "Space" && videoEl) { 308 + const tag = (e.target as HTMLElement)?.tagName; 309 + if (e.code === "Space" && videoEl && tag !== 'INPUT' && tag !== 'TEXTAREA') { 287 310 e.preventDefault(); 288 311 togglePlay(); 289 312 } ··· 428 451 {#each (debugInfo.captions ?? []) as cap} 429 452 {@const vt = debugInfo.videoTime ?? 0} 430 453 {@const active = vt >= cap.start && vt <= cap.end} 431 - <div class="cc-debug-chunk" class:active> 454 + <!-- svelte-ignore a11y_no_static_element_interactions --> 455 + <div class="cc-debug-chunk" class:active onclick={() => { if (videoEl) { videoEl.currentTime = cap.start; currentTime = cap.start; } }}> 432 456 <span class="cc-debug-time">{cap.start?.toFixed(1)}s-{cap.end?.toFixed(1)}s</span> 433 457 <span class="cc-debug-text">{cap.text}</span> 434 458 {#if active}<span class="cc-debug-active">NOW</span>{/if} ··· 443 467 {/if} 444 468 </div> 445 469 </WavyBorder> 446 - > 470 + 471 + {#if showEditor} 472 + <CaptionEditor 473 + captions={getAllCaptions()} 474 + currentTime={currentTime} 475 + videoUri={atUri} 476 + onClose={() => showEditor = false} 477 + onUpdate={onCaptionUpdate} 478 + /> 479 + {/if} 447 480 448 481 <style> 449 482 .player-wrapper { ··· 669 702 .cc-debug-chunk { 670 703 display: flex; 671 704 gap: 8px; 672 - padding: 1px 0; 705 + padding: 2px 4px; 673 706 border-bottom: 1px solid rgba(57, 255, 68, 0.1); 707 + cursor: pointer; 708 + } 709 + 710 + .cc-debug-chunk:hover { 711 + background: rgba(57, 255, 68, 0.1); 674 712 } 675 713 676 714 .cc-debug-chunk.active {
+5
src/lib/api.ts
··· 298 298 } 299 299 } 300 300 301 + /** Resolve a DID to its identity (handle + PDS) */ 302 + export async function resolveIdentity(did: string): Promise<MiniDoc | null> { 303 + return resolveMiniDoc(did); 304 + } 305 + 301 306 /** Resolve a DID to a human-readable @handle */ 302 307 export async function resolveHandle(did: string): Promise<string> { 303 308 const doc = await resolveMiniDoc(did);
+3 -3
src/lib/auth.svelte.ts
··· 49 49 _client = new BrowserOAuthClient({ 50 50 handleResolver: 'https://bsky.social', 51 51 clientMetadata: { 52 - client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent('atproto repo:sky.boo.vods.watchlist')}`, 52 + client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent('atproto repo:sky.boo.vods.watchlist repo:boo.sky.vods.captions')}`, 53 53 client_name: 'Vod Frog (dev)', 54 54 client_uri: `http://localhost:${port}`, 55 55 redirect_uris: [redirect], 56 - scope: 'atproto repo:sky.boo.vods.watchlist', 56 + scope: 'atproto repo:sky.boo.vods.watchlist repo:boo.sky.vods.captions', 57 57 grant_types: ['authorization_code', 'refresh_token'], 58 58 response_types: ['code'], 59 59 token_endpoint_auth_method: 'none', ··· 98 98 try { 99 99 const client = await getClient(); 100 100 await client.signIn(input, { 101 - scope: 'atproto repo:sky.boo.vods.watchlist' 101 + scope: 'atproto repo:sky.boo.vods.watchlist repo:boo.sky.vods.captions' 102 102 }); 103 103 // signIn will redirect the browser — execution won't continue past here 104 104 } catch (e: any) {
+174
src/lib/caption-records.ts
··· 1 + /** 2 + * AT Protocol caption records — read/write boo.sky.vods.captions records. 3 + * 4 + * Lexicon: boo.sky.vods.captions 5 + * Each record contains captions for a specific video, identified by its AT URI. 6 + */ 7 + 8 + import { getAuthState } from './auth.svelte'; 9 + 10 + const COLLECTION = 'boo.sky.vods.captions'; 11 + const CONSTELLATION_API = 'https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks'; 12 + 13 + export interface CaptionEntry { 14 + timestamp: string; // seconds as string, e.g. "123.45" 15 + text: string; 16 + speaker?: string; // DID of speaker 17 + } 18 + 19 + export interface CaptionRecord { 20 + subject: string; // at:// URI of the video 21 + captions: CaptionEntry[]; 22 + createdAt: string; // ISO datetime 23 + } 24 + 25 + /** Fetch captions for a video by querying Constellation for backlinks */ 26 + export async function fetchCaptionsForVideo(videoUri: string): Promise<{ uri: string; did: string; record: CaptionRecord } | null> { 27 + try { 28 + // Query Constellation for any boo.sky.vods.captions records linking to this video 29 + const params = new URLSearchParams({ 30 + subject: videoUri, 31 + source: `${COLLECTION}:subject`, 32 + limit: '5' 33 + }); 34 + const res = await fetch(`${CONSTELLATION_API}?${params}`); 35 + if (!res.ok) return null; 36 + 37 + const data = await res.json(); 38 + if (!data.records?.length) return null; 39 + 40 + // Fetch the first matching record from the author's PDS 41 + for (const ref of data.records) { 42 + try { 43 + const { resolveIdentity } = await import('./api'); 44 + const identity = await resolveIdentity(ref.did); 45 + const pds = identity?.pds || 'https://bsky.social'; 46 + 47 + const recordRes = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?repo=${ref.did}&collection=${ref.collection}&rkey=${ref.rkey}`); 48 + if (!recordRes.ok) continue; 49 + 50 + const recordData = await recordRes.json(); 51 + if (recordData.value?.captions?.length) { 52 + console.log(`[Captions] Found ${recordData.value.captions.length} captions from ${ref.did}`); 53 + return { 54 + uri: recordData.uri, 55 + did: ref.did, 56 + record: recordData.value as CaptionRecord 57 + }; 58 + } 59 + } catch { 60 + continue; 61 + } 62 + } 63 + } catch (err) { 64 + console.warn('[Captions] Constellation lookup failed:', err); 65 + } 66 + 67 + return null; 68 + } 69 + 70 + /** List all caption records from a specific user */ 71 + export async function listUserCaptions(did: string): Promise<{ uri: string; did: string; record: CaptionRecord }[]> { 72 + try { 73 + // Resolve PDS 74 + const { resolveIdentity } = await import('./api'); 75 + const identity = await resolveIdentity(did); 76 + const pds = identity?.pds || 'https://bsky.social'; 77 + 78 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${COLLECTION}&limit=100`); 79 + if (!res.ok) return []; 80 + const data = await res.json(); 81 + return (data.records || []).map((r: any) => ({ 82 + uri: r.uri, 83 + did, 84 + record: r.value as CaptionRecord 85 + })); 86 + } catch { 87 + return []; 88 + } 89 + } 90 + 91 + /** Create or update a caption record for a video */ 92 + export async function putCaptions(videoUri: string, captions: CaptionEntry[]): Promise<string | null> { 93 + const auth = getAuthState(); 94 + if (!auth.session || !auth.did) { 95 + console.error('[Captions] Not logged in'); 96 + return null; 97 + } 98 + 99 + // Use a deterministic rkey based on the video URI 100 + const rkey = generateRkey(videoUri); 101 + 102 + const record: CaptionRecord = { 103 + subject: videoUri, 104 + captions, 105 + createdAt: new Date().toISOString() 106 + }; 107 + 108 + try { 109 + // Resolve the user's PDS — OAuth tokens only work against the user's own PDS 110 + const { resolveIdentity } = await import('./api'); 111 + const identity = await resolveIdentity(auth.did); 112 + const pds = identity?.pds || 'https://bsky.social'; 113 + 114 + const res = await auth.session.fetchHandler( 115 + new URL(`${pds}/xrpc/com.atproto.repo.putRecord`), 116 + { 117 + method: 'POST', 118 + headers: { 'Content-Type': 'application/json' }, 119 + body: JSON.stringify({ 120 + repo: auth.did, 121 + collection: COLLECTION, 122 + rkey, 123 + record 124 + }) 125 + } 126 + ); 127 + 128 + if (res.status >= 200 && res.status < 300) { 129 + const data = await res.json(); 130 + console.log('[Captions] Saved:', data.uri); 131 + return data.uri; 132 + } else { 133 + const text = await res.text(); 134 + console.error('[Captions] Save failed:', res.status, text); 135 + return null; 136 + } 137 + } catch (err: any) { 138 + console.error('[Captions] Save error:', err); 139 + return null; 140 + } 141 + } 142 + 143 + /** Generate a deterministic rkey from a video URI */ 144 + function generateRkey(videoUri: string): string { 145 + // Simple hash to create a short deterministic key 146 + let hash = 0; 147 + for (let i = 0; i < videoUri.length; i++) { 148 + const c = videoUri.charCodeAt(i); 149 + hash = ((hash << 5) - hash) + c; 150 + hash |= 0; 151 + } 152 + return `cap-${Math.abs(hash).toString(36)}`; 153 + } 154 + 155 + /** Convert our internal Caption format to CaptionEntry format */ 156 + export function toCaptionEntries(captions: { text: string; start: number; end: number }[]): CaptionEntry[] { 157 + return captions.map(c => ({ 158 + timestamp: c.start.toFixed(2), 159 + text: c.text 160 + })); 161 + } 162 + 163 + /** Convert CaptionEntry format back to internal format */ 164 + export function fromCaptionEntries(entries: CaptionEntry[]): { text: string; start: number; end: number }[] { 165 + return entries.map((e, i) => { 166 + const start = parseFloat(e.timestamp); 167 + const nextStart = i < entries.length - 1 ? parseFloat(entries[i + 1].timestamp) : start + estimateDuration(e.text); 168 + return { text: e.text, start, end: nextStart }; 169 + }); 170 + } 171 + 172 + function estimateDuration(text: string): number { 173 + return Math.max(2, text.split(/\s+/).length * 0.35); 174 + }
+32 -4
src/lib/captions.svelte.ts
··· 40 40 let captionsEnabled = $state(false); 41 41 let currentCaption = $state(''); 42 42 let captions = $state<Caption[]>([]); 43 + let lastVideoTime = $state(0); 43 44 let isProcessing = $state(false); 44 45 let processProgress = $state(0); 45 46 ··· 58 59 export function getIsProcessing() { return isProcessing; } 59 60 export function getProcessProgress() { return processProgress; } 60 61 export function getAllCaptions() { return captions; } 62 + 63 + /** Replace captions with user-edited versions. Stops further processing to prevent overwrites. */ 64 + export function setCaptions(newCaptions: Caption[]) { 65 + abortCtrl?.abort(); // Stop processing so it doesn't overwrite edits 66 + captions = newCaptions; 67 + rawCaptionBuffer = newCaptions; // Keep in sync 68 + console.log('[CC] Captions updated by user:', newCaptions.length); 69 + } 61 70 export function getProcessedRanges() { return processedRanges; } 71 + export function getVideoTime() { return lastVideoTime; } 72 + 73 + /** Load captions from an existing AT Proto record (from Constellation lookup) */ 74 + export function loadExistingCaptions(entries: { timestamp: string; text: string; speaker?: string }[]) { 75 + const loaded: Caption[] = []; 76 + for (let i = 0; i < entries.length; i++) { 77 + const start = parseFloat(entries[i].timestamp) || 0; 78 + const nextStart = i < entries.length - 1 ? parseFloat(entries[i + 1].timestamp) || 0 : start + estimateDuration(entries[i].text); 79 + if (entries[i].text?.trim()) { 80 + loaded.push({ text: entries[i].text.trim(), start, end: nextStart }); 81 + } 82 + } 83 + captions = loaded; 84 + processedRanges = loaded.length > 0 85 + ? [{ start: loaded[0].start, end: loaded[loaded.length - 1].end }] 86 + : []; 87 + console.log(`[CC] Loaded ${loaded.length} captions from existing record`); 88 + } 89 + 90 + function estimateDuration(text: string): number { 91 + return Math.max(2, (text?.split(/\s+/).length || 1) * 0.35); 92 + } 62 93 63 94 export function getDebugState(videoTime: number) { 64 95 return { ··· 242 273 return result; 243 274 } 244 275 245 - function estimateDuration(text: string): number { 246 - return Math.max(2, text.split(/\s+/).length * 0.35); 247 - } 248 - 249 276 // ---- Pre-computation ---- 250 277 export async function precomputeCaptions(atUri: string) { 251 278 if (modelStatus !== 'ready' || !worker) { ··· 362 389 } 363 390 364 391 export function updateCaptionForTime(time: number) { 392 + lastVideoTime = time; 365 393 if (!captionsEnabled || captions.length === 0) { 366 394 currentCaption = ''; 367 395 return;
+70 -1
src/routes/+page.svelte
··· 16 16 import WavyBorder from "$lib/WavyBorder.svelte"; 17 17 import WavyCircle from "$lib/WavyCircle.svelte"; 18 18 import WavyButton from "$lib/WavyButton.svelte"; 19 + import CaptionEditor from "$lib/CaptionEditor.svelte"; 20 + import { getAuthState } from "$lib/auth.svelte"; 21 + import { getCaptionCount, getIsProcessing, getProcessProgress, getAllCaptions, getVideoTime, setCaptions } from "$lib/captions.svelte"; 22 + import { playCroak } from "$lib/croak"; 19 23 24 + const auth = getAuthState(); 20 25 const PAGE_SIZE = 9; 21 26 22 27 let videos: VideoRecord[] = $state([]); ··· 25 30 let selectedHandle = $state(""); 26 31 let selectedAvatar = $state(""); 27 32 let error = $state(""); 33 + let showCaptionEditor = $state(false); 34 + let captionEditorMode = $state<'edit' | 'full'>('edit'); 28 35 29 36 // Pagination state — store cursor history so we can go back 30 37 let cursorHistory: (string | undefined)[] = $state([undefined]); ··· 132 139 <VideoPlayer src={getPlaylistUrl(selectedVideo.uri)} atUri={selectedVideo.uri} /> 133 140 <div class="player-info"> 134 141 <WavyBorder seed="player-info" fill="#39FF44" strokeColor="#0A182B" strokeWidth={1.8} padding="32px clamp(40px, 6vw, 80px)"> 135 - <h2 class="player-title">{selectedVideo.value.title}</h2> 142 + <div class="title-row"> 143 + <h2 class="player-title">{selectedVideo.value.title}</h2> 144 + {#if auth.session && getCaptionCount() > 0} 145 + <div class="caption-buttons"> 146 + <button class="caption-btn" onclick={() => { playCroak(); captionEditorMode = 'edit'; showCaptionEditor = true; }} title="Edit current caption"> 147 + <img src="/edit_caption.png" alt="edit captions" class="caption-btn-icon" /> 148 + </button> 149 + <button class="caption-btn" onclick={() => { playCroak(); captionEditorMode = 'full'; showCaptionEditor = true; }} title="Review all captions"> 150 + <img src="/upload_captions.png" alt="upload captions" class="caption-btn-icon" /> 151 + </button> 152 + </div> 153 + {:else if getIsProcessing()} 154 + <span class="caption-progress">captions {getProcessProgress().toFixed(0)}%</span> 155 + {/if} 156 + </div> 136 157 <div class="player-meta-row"> 137 158 {#if selectedAvatar} 138 159 <a href="/profile/{selectedHandle?.replace('@', '') || selectedVideo.value.creator}" class="creator-avatar-link"> ··· 178 199 </div> 179 200 </div> 180 201 202 + {#if showCaptionEditor && selectedVideo} 203 + <CaptionEditor 204 + captions={getAllCaptions()} 205 + currentTime={getVideoTime()} 206 + videoUri={selectedVideo.uri} 207 + initialMode={captionEditorMode} 208 + onClose={() => showCaptionEditor = false} 209 + onUpdate={setCaptions} 210 + /> 211 + {/if} 212 + 181 213 <style> 182 214 .app { 183 215 max-width: 1300px; ··· 211 243 transform: scale(1.1); 212 244 } 213 245 246 + .title-row { 247 + display: flex; 248 + align-items: center; 249 + gap: 12px; 250 + flex-wrap: wrap; 251 + } 252 + 214 253 .player-title { 215 254 margin: 0; 216 255 font-family: "PicNic", cursive, system-ui; 217 256 font-size: clamp(1.4rem, 3vw, 2rem); 218 257 color: #0a182b; 258 + } 259 + 260 + .caption-buttons { 261 + display: flex; 262 + gap: 6px; 263 + align-items: center; 264 + } 265 + 266 + .caption-btn { 267 + all: unset; 268 + cursor: pointer; 269 + transition: transform 0.2s ease; 270 + } 271 + 272 + .caption-btn:hover { 273 + transform: scale(1.12); 274 + } 275 + 276 + .caption-btn-icon { 277 + width: 32px; 278 + height: auto; 279 + filter: drop-shadow(1px 2px 3px rgba(10, 24, 43, 0.2)); 280 + } 281 + 282 + .caption-progress { 283 + font-family: 'Fang', system-ui, sans-serif; 284 + font-size: 0.75rem; 285 + color: #0A182B; 286 + opacity: 0.6; 287 + font-style: italic; 219 288 } 220 289 221 290 .player-meta {
+71 -1
src/routes/profile/[handle]/+page.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { page } from '$app/stores'; 4 4 import { getProfile, listVideosByCreator, getPlaylistUrl, formatDuration, formatDate, type BskyProfile, type VideoRecord } from '$lib/api'; 5 + import { getAuthState } from '$lib/auth.svelte'; 6 + import { getCaptionCount, getIsProcessing, getProcessProgress, getAllCaptions, getVideoTime, setCaptions } from '$lib/captions.svelte'; 7 + import CaptionEditor from '$lib/CaptionEditor.svelte'; 8 + import { playCroak } from '$lib/croak'; 9 + 10 + const auth = getAuthState(); 5 11 import WavyBorder from '$lib/WavyBorder.svelte'; 6 12 import WavyCircle from '$lib/WavyCircle.svelte'; 7 13 import WavyButton from '$lib/WavyButton.svelte'; ··· 17 23 let videosLoading = $state(false); 18 24 let error = $state(''); 19 25 let selectedVideo: VideoRecord | null = $state(null); 26 + let showCaptionEditor = $state(false); 27 + let captionEditorMode = $state<'edit' | 'full'>('edit'); 20 28 21 29 // Pagination 22 30 let cursorHistory: (string | undefined)[] = $state([undefined]); ··· 156 164 <VideoPlayer src={getPlaylistUrl(selectedVideo.uri)} atUri={selectedVideo.uri} /> 157 165 <div class="player-info"> 158 166 <WavyBorder seed="profile-player-info" fill="#39FF44" strokeColor="#0A182B" strokeWidth={1.8} padding={48}> 159 - <h2 class="player-title">{selectedVideo.value.title}</h2> 167 + <div class="title-row"> 168 + <h2 class="player-title">{selectedVideo.value.title}</h2> 169 + {#if auth.session && getCaptionCount() > 0} 170 + <div class="caption-buttons"> 171 + <button class="caption-btn" onclick={() => { playCroak(); captionEditorMode = 'edit'; showCaptionEditor = true; }} title="Edit current caption"> 172 + <img src="/edit_caption.png" alt="edit captions" class="caption-btn-icon" /> 173 + </button> 174 + <button class="caption-btn" onclick={() => { playCroak(); captionEditorMode = 'full'; showCaptionEditor = true; }} title="Review all captions"> 175 + <img src="/upload_captions.png" alt="upload captions" class="caption-btn-icon" /> 176 + </button> 177 + </div> 178 + {:else if getIsProcessing()} 179 + <span class="caption-progress">captions {getProcessProgress().toFixed(0)}%</span> 180 + {/if} 181 + </div> 160 182 <p class="player-meta"> 161 183 {formatDate(selectedVideo.value.createdAt)} 162 184 <span class="dot">·</span> ··· 196 218 <a href="/">← back to vods</a> 197 219 </div> 198 220 </div> 221 + 222 + {#if showCaptionEditor && selectedVideo} 223 + <CaptionEditor 224 + captions={getAllCaptions()} 225 + currentTime={getVideoTime()} 226 + videoUri={selectedVideo.uri} 227 + initialMode={captionEditorMode} 228 + onClose={() => showCaptionEditor = false} 229 + onUpdate={setCaptions} 230 + /> 231 + {/if} 199 232 200 233 <style> 201 234 .profile-page { ··· 340 373 padding: 20px 4px; 341 374 } 342 375 376 + .title-row { 377 + display: flex; 378 + align-items: center; 379 + gap: 12px; 380 + flex-wrap: wrap; 381 + } 382 + 343 383 .player-title { 344 384 margin: 0; 345 385 font-family: 'PicNic', cursive, system-ui; 346 386 font-size: clamp(1.4rem, 3vw, 2rem); 347 387 color: #0A182B; 388 + } 389 + 390 + .caption-buttons { 391 + display: flex; 392 + gap: 6px; 393 + align-items: center; 394 + } 395 + 396 + .caption-btn { 397 + all: unset; 398 + cursor: pointer; 399 + transition: transform 0.2s ease; 400 + } 401 + 402 + .caption-btn:hover { 403 + transform: scale(1.12); 404 + } 405 + 406 + .caption-btn-icon { 407 + width: 32px; 408 + height: auto; 409 + filter: drop-shadow(1px 2px 3px rgba(10, 24, 43, 0.2)); 410 + } 411 + 412 + .caption-progress { 413 + font-family: 'Fang', system-ui, sans-serif; 414 + font-size: 0.75rem; 415 + color: #0A182B; 416 + opacity: 0.6; 417 + font-style: italic; 348 418 } 349 419 350 420 .player-meta {
static/edit_caption.png

This is a binary file and will not be displayed.

static/upload_captions.png

This is a binary file and will not be displayed.