diff --git a/src/exportHandler/exportHandler.ts b/src/exportHandler/exportHandler.ts index 30884e550..7d197a537 100644 --- a/src/exportHandler/exportHandler.ts +++ b/src/exportHandler/exportHandler.ts @@ -255,6 +255,7 @@ export interface ExportOptions { removeIds?: boolean; includeAudio?: boolean; includeTimestamps?: boolean; + excludeLabels?: boolean; /** Per-file 0-based milestone indices to include when exporting audio. An empty array skips that file entirely. Files omitted from this map are exported in full (no milestone step). */ selectedMilestonesByFile?: Record; } @@ -2066,8 +2067,15 @@ export const exportCodexContentAsSubtitlesVtt = async ( totalCells += cells.length; debug(`File has ${cells.length} active cells`); - const vttContent = generateVttData(cells, includeStyles, cueSplitting, file.fsPath); - debug({ vttContent, cells, includeStyles }); + // Generate VTT content + const vttContent = generateVttData( + cells, + includeStyles, + cueSplitting, + file.fsPath, + options?.excludeLabels === true + ); + debug({ vttContent, cells, includeStyles }); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = basename(file.fsPath).replace(".codex", "") || "unknown"; diff --git a/src/exportHandler/vttUtils.ts b/src/exportHandler/vttUtils.ts index bd7e3fdf7..04c5b1d00 100644 --- a/src/exportHandler/vttUtils.ts +++ b/src/exportHandler/vttUtils.ts @@ -71,7 +71,8 @@ export const generateVttData = ( cells: CodexNotebookAsJSONData["cells"], includeStyles: boolean, cueSplitting: boolean, - filePath: string + filePath: string, + excludeLabels: boolean = false ): string => { if (!cells.length) return ""; @@ -95,7 +96,7 @@ export const generateVttData = ( const text = includeStyles ? processVttContent(unit.value) : removeHtmlTags(unit.value); const finalText = ensureDialogueLineBreaks(text); - const rawLabel = unit.metadata?.cellLabel?.trim(); + const rawLabel = excludeLabels ? undefined : unit.metadata?.cellLabel?.trim(); const payload = rawLabel ? `${finalText}` : finalText; diff --git a/src/extension.ts b/src/extension.ts index e8c8a4060..b1908a21d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -353,6 +353,15 @@ export async function activate(context: vscode.ExtensionContext) { initToolPreferences(context); + // Clear the stream-only video session cache (stored outside the project) so + // "Loaded" videos re-stream after a reload, like the in-memory audio cache. + try { + const { clearVideoStreamCache } = await import("./utils/videoStreamCache"); + await clearVideoStreamCache(context); + } catch (e) { + console.warn("[Extension] Could not clear video stream cache:", e); + } + // Per-user audio preferences (autoDownloadAudioOnOpen, // autoRecordOnMicClick, recordingCountdownSeconds) live in globalState. // Initialize before any provider reads them; the per-workspace migration diff --git a/src/projectManager/projectExportView.ts b/src/projectManager/projectExportView.ts index 0e65c5d71..512d9f6cb 100644 --- a/src/projectManager/projectExportView.ts +++ b/src/projectManager/projectExportView.ts @@ -696,6 +696,21 @@ function getWebviewContent( .format-option-row[data-option].hidden { display: none !important; } .format-option p, .format-option-content p { line-height: 1.45; margin: 4px 0 0 0; } .format-option-content { display: flex; flex-direction: column; gap: 4px; } + .format-option-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; + } + .format-option-toggle input[type="checkbox"] { margin: 0; cursor: pointer; } + .format-section-suboption { + padding: 10px 12px; + background-color: var(--vscode-editor-background); + border-top: 1px solid var(--vscode-input-border); + } .format-tag { display: inline-block; padding: 1px 4px; @@ -1248,6 +1263,12 @@ function getWebviewContent( +
+ +
@@ -2954,6 +2975,10 @@ function getWebviewContent( options.includeAudio = true; options.includeTimestamps = selectedAudioMode === 'audio-timestamps'; } + if (selectedFormat && selectedFormat.startsWith('subtitles-vtt-')) { + const cb = document.getElementById('vttExcludeLabelsCb'); + if (cb && cb.checked) options.excludeLabels = true; + } const selectedMilestones = buildSelectedMilestonesPayload(); if (selectedMilestones) { options.selectedMilestonesByFile = selectedMilestones; diff --git a/src/projectManager/syncManager.ts b/src/projectManager/syncManager.ts index 8e5b6f527..59ec1d288 100644 --- a/src/projectManager/syncManager.ts +++ b/src/projectManager/syncManager.ts @@ -1299,6 +1299,19 @@ export class SyncManager { // Don't fail sync completion due to cleanup errors } + // Refresh the chapter video reference status in open editors so a video + // just uploaded to LFS this sync now offers the "Free up space" action. + try { + const { GlobalProvider } = await import("../globalProvider"); + const provider = GlobalProvider.getInstance().getProvider("codex-cell-editor") as any; + if (provider && typeof provider.refreshVideoReferenceStatusAfterSync === "function") { + await provider.refreshVideoReferenceStatusAfterSync(); + } + } catch (error) { + console.error("[SyncManager] Error refreshing video reference status after sync:", error); + // Don't fail sync completion due to video status refresh errors + } + // Update sync stage and splash screen this.currentSyncStage = "Sync complete!"; this.notifySyncStatusListeners(); @@ -1682,6 +1695,11 @@ export class SyncManager { return 'Uploading changes...'; } if (stage.includes('Uploading media')) { + // Surface connection/retry detail verbatim so the user + // can see what's happening (e.g. "retrying in 9s"). + if (/retry|retrying|waiting|interrupted|stalled|connection/i.test(stage)) { + return stage; + } const pctMatch = stage.match(/(\d+)%/); if (pctMatch) { return `Uploading media... ${pctMatch[1]}%`; diff --git a/src/providers/StartupFlow/StartupFlowProvider.ts b/src/providers/StartupFlow/StartupFlowProvider.ts index b68a352ef..9d1f7d28f 100644 --- a/src/providers/StartupFlow/StartupFlowProvider.ts +++ b/src/providers/StartupFlow/StartupFlowProvider.ts @@ -3264,17 +3264,29 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { if (flags?.lastModeRun === mediaStrategy && !switchStarted) { debugLog(`Switching back to last applied strategy "${mediaStrategy}" - auto-applying without dialog`); - // Clear keepFilesOnStreamAndSave if switching away from stream-and-save - if (mediaStrategy !== "stream-and-save") { - try { - const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); - const settings = await readLocalProjectSettings(projectUri); - if (settings.keepFilesOnStreamAndSave !== undefined) { - settings.keepFilesOnStreamAndSave = undefined; - await writeLocalProjectSettings(settings, projectUri); - } - } catch (e) { /* ignore */ } - } + // Clear stale switch-choice flags; switching back to the last + // applied strategy touches no files, so no choice should linger. + try { + const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); + const settings = await readLocalProjectSettings(projectUri); + const keepFlag = mediaStrategy === "stream-and-save" ? settings.keepFilesOnStreamAndSave : undefined; + const keepVideoFlag = mediaStrategy === "stream-and-save" ? settings.keepVideoOnStreamAndSave : undefined; + const keepAudioFlag = mediaStrategy === "stream-and-save" ? settings.keepAudioOnStreamAndSave : undefined; + if ( + settings.keepFilesOnStreamAndSave !== keepFlag || + settings.keepVideoOnStreamAndSave !== keepVideoFlag || + settings.keepAudioOnStreamAndSave !== keepAudioFlag || + settings.streamOnlyVideoChoice !== undefined || + settings.streamAndSavePreserveVideos !== undefined + ) { + settings.keepFilesOnStreamAndSave = keepFlag; + settings.keepVideoOnStreamAndSave = keepVideoFlag; + settings.keepAudioOnStreamAndSave = keepAudioFlag; + settings.streamOnlyVideoChoice = undefined; + settings.streamAndSavePreserveVideos = undefined; + await writeLocalProjectSettings(settings, projectUri); + } + } catch (e) { /* ignore */ } // Just update the stored strategy without touching files await setMediaFilesStrategy(mediaStrategy, projectUri); @@ -3339,57 +3351,192 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { if (selection === switchButton) { // Switch but do not open; mark changesApplied=false and only update strategy - // Only ask about keeping files when: auto-download → stream-and-save - // For stream-only: always convert to pointers (no prompt) - // From stream-only: nothing to keep (no prompt) - if (currentStrategy === "auto-download" && mediaStrategy === "stream-and-save") { - const { countDownloadedMediaFiles } = await import("../../utils/mediaStrategyManager"); - const downloadedCount = await countDownloadedMediaFiles(projectPath); + // Decide how media is handled for this transition. Cancelling + // any prompt reverts the selection. Choices are persisted and + // applied by applyMediaStrategy when the project opens. + // - auto-download -> stream-and-save: Keep Files / Free Space + // - stream-only -> stream-and-save: Preserve Videos? (only if local videos) + // - * -> stream-only: Keep Video / Free Space (if local videos); + // also reports synced audio that will be freed (confirm if no videos) + // - * -> auto-download / stream-only->auto-download: preserve all (no prompt) + // + // IMPORTANT: branch on the last *applied* strategy (lastModeRun), + // NOT currentMediaFilesStrategy. A switch-only selection overwrites + // currentMediaFilesStrategy without touching files, so if the user + // selects a strategy and then picks a different one before opening, + // the on-disk files still reflect lastModeRun. Using it means an + // abandoned selection is forgotten and the new prompt matches what's + // actually on disk (e.g. auto-download -> [stream-only, not applied] + // -> stream-and-save prompts as auto-download -> stream-and-save). + const appliedStrategy = flags?.lastModeRun ?? currentStrategy; + const { countDownloadedMediaFiles, countLocalVideoFiles, countSyncedDeletableAudioFiles } = await import("../../utils/mediaStrategyManager"); + const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); + + let keepFilesChoice: boolean | undefined; + let keepVideoChoice: boolean | undefined; + let keepAudioChoice: boolean | undefined; + let streamOnlyVideoChoice: "keep-video" | "free-all" | undefined; + let streamAndSavePreserveVideos: boolean | undefined; + + const cancelSwitch = () => { + this.safeSendMessage({ + command: "project.setMediaStrategyResult", + success: false, + projectPath, + mediaStrategy, + } as any); + }; + if (mediaStrategy === "stream-and-save" && appliedStrategy === "auto-download") { + const downloadedCount = await countDownloadedMediaFiles(projectPath); if (downloadedCount > 0) { - const keepOrFreeChoice = await vscode.window.showInformationMessage( - `${downloadedCount} media file(s) stored locally. Keep or free up space?`, + const videoCount = await countLocalVideoFiles(projectPath); + const audioCount = Math.max(downloadedCount - videoCount, 0); + if (videoCount > 0 && audioCount > 0) { + // Video present alongside audio: let the user decide + // about each independently in a single modal. + const KEEP_BOTH = "Keep Both"; + const VIDEO_ONLY = "Keep Video Only"; + const AUDIO_ONLY = "Keep Audio Only"; + const FREE_ALL = "Free All"; + const choice = await vscode.window.showInformationMessage( + `${videoCount} video(s) and ${audioCount} audio file(s) stored locally. What should be kept?`, + { + modal: true, + detail: "Keep Video Only frees audio. Keep Audio Only frees video. Free All streams everything on demand.", + }, + KEEP_BOTH, + VIDEO_ONLY, + AUDIO_ONLY, + FREE_ALL + ); + if (!choice) { + debugLog("User cancelled keep/free choice (auto-download -> stream-and-save, video+audio)"); + cancelSwitch(); + return; + } + keepVideoChoice = choice === KEEP_BOTH || choice === VIDEO_ONLY; + keepAudioChoice = choice === KEEP_BOTH || choice === AUDIO_ONLY; + } else if (videoCount > 0) { + // Video present but no local audio: video-only keep/free. + const choice = await vscode.window.showInformationMessage( + `${videoCount} video(s) stored locally. Keep or free up space?`, + { modal: true }, + "Keep Video", + "Free Space" + ); + if (!choice) { + debugLog("User cancelled keep/free choice (auto-download -> stream-and-save, video-only)"); + cancelSwitch(); + return; + } + keepVideoChoice = choice === "Keep Video"; + } else { + // No video: combined media prompt (preserves audio only). + const choice = await vscode.window.showInformationMessage( + `${downloadedCount} media file(s) stored locally. Keep or free up space?`, + { modal: true }, + "Keep Files", + "Free Space" + ); + if (!choice) { + debugLog("User cancelled keep/free choice (auto-download -> stream-and-save)"); + cancelSwitch(); + return; + } + keepFilesChoice = choice === "Keep Files"; + } + } + } else if (mediaStrategy === "stream-and-save" && appliedStrategy === "stream-only") { + const videoCount = await countLocalVideoFiles(projectPath); + if (videoCount > 0) { + const choice = await vscode.window.showInformationMessage( + `${videoCount} video(s) stored locally. Preserve them or free up space?`, { modal: true }, - "Keep Files", + "Preserve Videos", "Free Space" ); - - if (!keepOrFreeChoice) { - // User cancelled - revert selection - debugLog("User cancelled keep/free choice for media strategy change"); - this.safeSendMessage({ - command: "project.setMediaStrategyResult", - success: false, - projectPath, - mediaStrategy, - } as any); + if (!choice) { + debugLog("User cancelled preserve-videos choice (stream-only -> stream-and-save)"); + cancelSwitch(); return; } - - // Store choice to apply when project opens - const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); - const settings = await readLocalProjectSettings(projectUri); - settings.keepFilesOnStreamAndSave = (keepOrFreeChoice === "Keep Files"); - await writeLocalProjectSettings(settings, projectUri); - - vscode.window.showInformationMessage("Changes apply when project opens."); + streamAndSavePreserveVideos = choice === "Preserve Videos"; } - } else if (mediaStrategy !== "stream-and-save") { - // Clear keepFilesOnStreamAndSave if switching away from stream-and-save - // (e.g., user switched to stream-and-save, then back to auto-download) - try { - const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); - const settings = await readLocalProjectSettings(projectUri); - if (settings.keepFilesOnStreamAndSave !== undefined) { - settings.keepFilesOnStreamAndSave = undefined; - await writeLocalProjectSettings(settings, projectUri); - debugLog("Cleared keepFilesOnStreamAndSave as strategy changed away from stream-and-save"); + } else if (mediaStrategy === "stream-only") { + const videoCount = await countLocalVideoFiles(projectPath); + // Stream-only always frees synced audio (audio is never kept + // locally in this mode); surface how much will be removed. + const audioCount = await countSyncedDeletableAudioFiles(projectPath); + const audioNote = + audioCount > 0 + ? ` ${audioCount} synced audio file(s) will be removed to free space (they will stream on demand).` + : ""; + if (videoCount > 0) { + const choice = await vscode.window.showInformationMessage( + `${videoCount} video(s) stored locally. Keep them or free up space?`, + { + modal: true, + detail: `Stream-only keeps media as references and streams it on demand.${audioNote}`, + }, + "Keep Video", + "Free Space" + ); + if (!choice) { + debugLog("User cancelled keep-video choice (-> stream-only)"); + cancelSwitch(); + return; + } + streamOnlyVideoChoice = choice === "Keep Video" ? "keep-video" : "free-all"; + } else if (audioCount > 0) { + // No local videos, but synced audio will be removed — + // confirm so the deletion isn't silent. + const PROCEED = "Switch to Stream-only"; + const choice = await vscode.window.showInformationMessage( + `${audioCount} synced audio file(s) will be removed to free space.`, + { + modal: true, + detail: "They will stream on demand. Locally recorded audio that hasn't synced yet is kept.", + }, + PROCEED + ); + if (choice !== PROCEED) { + debugLog("User cancelled stream-only switch (audio removal)"); + cancelSwitch(); + return; } - } catch (clearErr) { - debugLog("Failed to clear keepFilesOnStreamAndSave", clearErr); } } + // Persist the choices for this transition and clear any choice + // flags that don't apply, so a stale choice from an earlier + // (unapplied) switch can't leak into this one. + try { + const settings = await readLocalProjectSettings(projectUri); + settings.keepFilesOnStreamAndSave = + mediaStrategy === "stream-and-save" ? keepFilesChoice : undefined; + settings.keepVideoOnStreamAndSave = + mediaStrategy === "stream-and-save" ? keepVideoChoice : undefined; + settings.keepAudioOnStreamAndSave = + mediaStrategy === "stream-and-save" ? keepAudioChoice : undefined; + settings.streamAndSavePreserveVideos = + mediaStrategy === "stream-and-save" ? streamAndSavePreserveVideos : undefined; + settings.streamOnlyVideoChoice = + mediaStrategy === "stream-only" ? streamOnlyVideoChoice : undefined; + await writeLocalProjectSettings(settings, projectUri); + if ( + keepFilesChoice !== undefined || + keepVideoChoice !== undefined || + keepAudioChoice !== undefined || + streamOnlyVideoChoice !== undefined || + streamAndSavePreserveVideos !== undefined + ) { + vscode.window.showInformationMessage("Changes apply when project opens."); + } + } catch (persistErr) { + debugLog("Failed to persist media strategy switch choices", persistErr); + } + // Initialize switchStarted flag if missing (one-time initialization on strategy change) try { const { readLocalProjectSettings, writeLocalProjectSettings } = await import("../../utils/localProjectSettings"); @@ -3581,7 +3728,9 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { // 1) Replace downloaded media bytes in files/ with pointers (only for LFS-tracked media) try { const { replaceFilesWithPointers } = await import("../../utils/mediaStrategyManager"); - await replaceFilesWithPointers(projectPath); + // Explicit user-initiated "Clean media files" reclaims space for + // everything, including videos the user had saved via "Save to project". + await replaceFilesWithPointers(projectPath, { ignorePersisted: true }); } catch (e) { debugLog("Error replacing files with pointers during cleanup:", e); } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 39a560b99..0935e006a 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -22,6 +22,8 @@ import { toPosixPath } from "../../utils/pathUtils"; import { revalidateCellMissingFlags, clearMissingFlagAfterSuccess } from "../../utils/audioMissingUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; +import { deleteLocalVideoFiles, isHttpVideoUrl, processVideoUrl, getVideoWorkspaceRelativePath, resolveVideoAvailability, type VideoAvailability } from "./utils/videoUtils"; +import { parsePointerFile, isPointerFile } from "../../utils/lfsHelpers"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -140,6 +142,323 @@ function getDocumentSegment(document: CodexCellDocument): string { return "UNKNOWN"; } +type VideoKind = "url" | "local" | "none"; + +/** Classify a stored video reference as a remote URL, a local file, or empty. */ +function classifyVideo(videoUrl: string | undefined | null): VideoKind { + if (!videoUrl) { + return "none"; + } + return isHttpVideoUrl(videoUrl) ? "url" : "local"; +} + +/** + * Show the appropriate replace/delete confirmation when the current video is a + * local file. Returns true to proceed. URL/empty sources need no confirmation + * (there is no local file to delete), so they return true immediately. + */ +async function confirmVideoReplacement( + oldKind: VideoKind, + newKind: VideoKind +): Promise { + // Nothing to confirm when there was no video to begin with. + if (oldKind === "none") { + return true; + } + + const removing = newKind === "none"; + const confirmLabel = removing ? "Remove" : "Replace"; + + let detail: string; + if (oldKind === "local") { + // A local file will actually be deleted from disk — always warn. + detail = removing + ? "Remove the current video? The local video file will be deleted from this project." + : "Replace the existing video? The current local video file will be deleted from this project."; + } else { + // URL source: nothing is deleted from disk, but confirm for consistency. + detail = removing + ? "Remove the current streamed video URL?" + : "Replace the current streamed video URL?"; + } + + const choice = await vscode.window.showWarningMessage(detail, { modal: true }, confirmLabel); + return choice === confirmLabel; +} + +/** + * Tracks stream-only "session cache" videos that were activated (downloaded) + * during the current extension-host session, keyed by `${projectPath}::${rel}`. + * Module-level so it is naturally empty after a reload, which is what makes a + * previously cached video re-stream on the next session. + */ +/** + * Resolve the best playable source for the chapter video and post it to the + * webview. Remote URLs play as-is; local files with real bytes are served via a + * webview URI; in stream-only, a video previously "Loaded" this session is + * served from the external cache (outside the project). LFS pointers are NOT + * streamed directly — instead we tell the webview the video needs downloading + * and what the active media strategy implies, so it can present the right + * action(s). + */ +/** + * Resolves the LFS pointer OID for a document's local (non-remote) chapter + * video, or `undefined` if there is no LFS-backed reference. Used to match a + * session-cache change against the right open editor. + */ +export async function getVideoPointerOidForDocument( + document: CodexCellDocument +): Promise { + const videoUrl = document.getNotebookMetadata()?.videoUrl; + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return undefined; + } + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!workspaceUri) { + return undefined; + } + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return undefined; + } + const filesAbs = vscode.Uri.joinPath(workspaceUri, rel).fsPath; + const pointersRel = rel.includes("attachments/files/") + ? rel.replace("attachments/files/", "attachments/pointers/") + : rel; + const pointersAbs = vscode.Uri.joinPath(workspaceUri, pointersRel).fsPath; + const pointer = + (await parsePointerFile(filesAbs)) ?? (await parsePointerFile(pointersAbs)); + return pointer?.oid; +} + +export async function resolveAndPostVideoStreamUrl( + document: CodexCellDocument, + webviewPanel: vscode.WebviewPanel, + provider: CodexCellEditorProvider +): Promise { + const videoUrl = document.getNotebookMetadata()?.videoUrl; + if (!videoUrl) { + return; + } + + // Remote URLs already stream directly via the browser. + if (isHttpVideoUrl(videoUrl)) { + provider.postMessageToWebview(webviewPanel, { + type: "updateVideoUrlInWebview", + content: videoUrl, + }); + return; + } + + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!workspaceUri) { + return; + } + + // If a fetch for this video is already running (e.g. "Load video"/"Save to + // project" started from a navigation card), show the loading state instead + // of the "needs download" placeholder. The operation's completion path + // re-resolves this editor with the playable URL. + const { isVideoOperationInFlight } = await import("./utils/videoDownloadUtils"); + if (isVideoOperationInFlight(workspaceUri, videoUrl)) { + provider.postMessageToWebview(webviewPanel, { type: "videoStreamResolving" }); + return; + } + + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + // Path outside the workspace — let processVideoUrl decide (likely null). + const direct = processVideoUrl(videoUrl, webviewPanel.webview); + if (direct) { + provider.postMessageToWebview(webviewPanel, { + type: "updateVideoUrlInWebview", + content: direct, + }); + } + return; + } + + const filesAbs = vscode.Uri.joinPath(workspaceUri, rel).fsPath; + const pointersRel = rel.includes("attachments/files/") + ? rel.replace("attachments/files/", "attachments/pointers/") + : rel; + const pointersAbs = vscode.Uri.joinPath(workspaceUri, pointersRel).fsPath; + + let filesExists = false; + let filesIsPointer = true; + let filesSize = 0; + try { + const stat = await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); + filesExists = true; + filesSize = stat.size; + filesIsPointer = await isPointerFile(filesAbs); + } catch { + filesExists = false; + } + + // Real bytes saved locally → serve from disk via a webview URI. Append a + // content-based cache-buster (file size) so that when a file changes from a + // pointer to real bytes (e.g. after "Save to project"), the player fetches + // the new content instead of a stale cached response for the same URL. + if (filesExists && !filesIsPointer) { + const localUri = processVideoUrl(videoUrl, webviewPanel.webview); + if (localUri) { + const busted = `${localUri}${localUri.includes("?") ? "&" : "?"}v=${filesSize}`; + provider.postMessageToWebview(webviewPanel, { + type: "updateVideoUrlInWebview", + content: busted, + }); + return; + } + } + + // Recovery: files/ is a pointer or missing, but the pointers/ sibling still + // holds REAL bytes (e.g. an unsynced local video, or an interrupted strategy + // switch). Serve those directly from disk instead of re-downloading from LFS. + // The whole workspace is in the webview's localResourceRoots, so pointers/ is + // loadable. (Skip when pointersAbs === filesAbs — already handled above.) + if (pointersAbs !== filesAbs) { + try { + const pStat = await vscode.workspace.fs.stat(vscode.Uri.file(pointersAbs)); + if (pStat.size > 0 && !(await isPointerFile(pointersAbs))) { + const pUri = webviewPanel.webview + .asWebviewUri(vscode.Uri.file(pointersAbs)) + .toString(); + const bustedPointer = `${pUri}${pUri.includes("?") ? "&" : "?"}v=${pStat.size}`; + provider.postMessageToWebview(webviewPanel, { + type: "updateVideoUrlInWebview", + content: bustedPointer, + }); + return; + } + } catch { + // pointers/ missing — fall through to normal LFS resolution. + } + } + + const { getMediaFilesStrategy } = await import("../../utils/localProjectSettings"); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + + // Otherwise this is an LFS pointer (or missing). Confirm a pointer exists so + // the video is actually downloadable. + let pointer = filesExists && filesIsPointer ? await parsePointerFile(filesAbs) : null; + if (!pointer) { + pointer = await parsePointerFile(pointersAbs); + } + + if (!pointer) { + provider.postMessageToWebview(webviewPanel, { + type: "videoStreamUnavailable", + reason: "not-found", + message: "The video is not available locally and no LFS reference was found.", + }); + return; + } + + // In stream-only, a video "Loaded" earlier this session lives in the external + // cache (outside the project). Serve it from there so it doesn't re-download. + const { hasCachedVideo, getCachedVideoUri } = await import("../../utils/videoStreamCache"); + const ext = path.extname(rel); + if (await hasCachedVideo(provider.extensionContext, pointer.oid, ext)) { + const cacheUri = getCachedVideoUri(provider.extensionContext, pointer.oid, ext); + if (cacheUri) { + provider.postMessageToWebview(webviewPanel, { + type: "updateVideoUrlInWebview", + content: webviewPanel.webview.asWebviewUri(cacheUri).toString(), + }); + return; + } + } + + provider.postMessageToWebview(webviewPanel, { + type: "videoNeedsDownload", + strategy, + }); +} + +/** + * Map the fine-grained {@link resolveVideoAvailability} result onto the coarse + * status the webview consumes ("saved"/"streamable" both collapse to + * "local-usable" — i.e. the "Show Video" toggle is offered for either). + */ +function toReferenceStatus( + availability: VideoAvailability +): "none" | "url" | "local-usable" | "missing" { + if (availability === "saved" || availability === "streamable") { + return "local-usable"; + } + return availability; +} + +/** + * Whether the chapter video has an on-disk copy in the project (`files/`) that + * can be safely reverted to an LFS pointer to free disk space (and re-streamed + * on demand). Offered in stream-and-save (downloaded copy) and stream-only + * (a "Save to project" copy) — never auto-download (it would just re-download), + * and never the stream-only session cache (that lives in global storage and is + * already ephemeral). Requires a real local file AND a real LFS pointer backing + * it (so it isn't a local-unsynced file we'd lose). + */ +async function computeCanFreeVideoDiskSpace(document: CodexCellDocument): Promise { + const videoUrl = document.getNotebookMetadata()?.videoUrl; + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return false; + } + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!workspaceUri) { + return false; + } + const { getMediaFilesStrategy } = await import("../../utils/localProjectSettings"); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + if (strategy !== "stream-and-save" && strategy !== "stream-only") { + return false; + } + + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return false; + } + const filesAbs = vscode.Uri.joinPath(workspaceUri, rel).fsPath; + const pointersRel = rel.includes("attachments/files/") + ? rel.replace("attachments/files/", "attachments/pointers/") + : rel; + const pointersAbs = vscode.Uri.joinPath(workspaceUri, pointersRel).fsPath; + + // Real bytes present in files/ (taking space)? + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); + } catch { + return false; + } + const filesIsPointer = await isPointerFile(filesAbs).catch(() => false); + if (filesIsPointer) { + return false; + } + // A real LFS pointer must back it so it can be re-streamed without data loss. + const pointer = await parsePointerFile(pointersAbs).catch(() => null); + return !!pointer; +} + +/** Compute and push the chapter video reference status to the webview. */ +export async function postVideoReferenceStatus( + document: CodexCellDocument, + webviewPanel: vscode.WebviewPanel, + provider: CodexCellEditorProvider +): Promise { + const videoUrl = document.getNotebookMetadata()?.videoUrl; + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + const { availability, sizeBytes } = await resolveVideoAvailability(videoUrl, workspaceUri); + const status = toReferenceStatus(availability); + const canFreeDiskSpace = + status === "local-usable" ? await computeCanFreeVideoDiskSpace(document) : false; + provider.postMessageToWebview(webviewPanel, { + type: "videoReferenceStatus", + status, + canFreeDiskSpace, + videoSizeBytes: status === "local-usable" ? sizeBytes : undefined, + }); +} + // Get a reference to the provider function getProvider(): CodexCellEditorProvider | undefined { // Find the provider through the window object @@ -1453,6 +1772,36 @@ const messageHandlers: Record Promise; debug("updateNotebookMetadata message received", { event }); const newMetadata = typedEvent.content; + + // Guard the video field: if the user is replacing an existing local + // video (file -> URL, file -> file, or removal), confirm first and + // delete the old local file from files/ and pointers/. + const oldVideoUrl = document.getNotebookMetadata()?.videoUrl; + const newVideoUrl = newMetadata.videoUrl; + const videoChanged = (oldVideoUrl ?? "") !== (newVideoUrl ?? ""); + if (videoChanged) { + const oldKind = classifyVideo(oldVideoUrl); + const newKind = classifyVideo(newVideoUrl); + if (oldKind === "local") { + const proceed = await confirmVideoReplacement(oldKind, newKind); + if (!proceed) { + // Revert the webview's optimistic edit by re-sending current + // metadata, and restore the player's URL to the unchanged video. + provider.postMessageToWebview(webviewPanel, { + type: "providerUpdatesNotebookMetadataForWebview", + content: document.getNotebookMetadata(), + }); + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + return; + } + + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (workspaceUri) { + await deleteLocalVideoFiles(oldVideoUrl, workspaceUri); + } + } + } + await document.updateNotebookMetadata(newMetadata); await document.save(new vscode.CancellationTokenSource().token); @@ -1461,10 +1810,255 @@ const messageHandlers: Record Promise { + debug("deleteVideoFile message received"); + const currentVideoUrl = document.getNotebookMetadata()?.videoUrl; + const kind = classifyVideo(currentVideoUrl); + if (kind === "none") { + return; + } + + const proceed = await confirmVideoReplacement(kind, "none"); + if (!proceed) { + return; + } + + if (kind === "local") { + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (workspaceUri) { + await deleteLocalVideoFiles(currentVideoUrl, workspaceUri); + } + } + + await document.updateNotebookMetadata({ videoUrl: "" }); + await document.save(new vscode.CancellationTokenSource().token); + + // Lightweight update instead of a full refresh: push the cleared metadata + // and a "none" reference status so the webview hides the toggle and closes + // the player if it's open. + provider.postMessageToWebview(webviewPanel, { + type: "providerUpdatesNotebookMetadataForWebview", + content: document.getNotebookMetadata(), + }); + await postVideoReferenceStatus(document, webviewPanel, provider); + }, + + freeVideoDiskSpace: async ({ document, webviewPanel, provider }) => { + debug("freeVideoDiskSpace message received"); + // Revert a downloaded stream-and-save video back to an LFS pointer to free + // disk space. The reference (videoUrl) is kept, so it re-streams on demand. + const videoUrl = document.getNotebookMetadata()?.videoUrl; + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!videoUrl || !workspaceUri || !(await computeCanFreeVideoDiskSpace(document))) { + return; + } + + const proceed = await vscode.window.showInformationMessage( + "Free up space for this video?", + { + modal: true, + detail: "The downloaded file is removed and the video streams again on demand.", + }, + "Free up space" + ); + if (proceed !== "Free up space") { + return; + } + + const { freeVideoFileToPointer } = await import("./utils/videoDownloadUtils"); + await freeVideoFileToPointer(workspaceUri, videoUrl); + + // Re-resolve playback (local bytes are gone → the player shows the + // download/stream action) and refresh the modal's reference status. + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + await postVideoReferenceStatus(document, webviewPanel, provider); + }, + + requestVideoReferenceStatus: async ({ document, webviewPanel, provider }) => { + await postVideoReferenceStatus(document, webviewPanel, provider); + }, + + requestVideoStreamUrl: async ({ document, webviewPanel, provider }) => { + debug("requestVideoStreamUrl message received"); + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + }, + + downloadVideoFile: async ({ event, document, webviewPanel, provider }) => { + debug("downloadVideoFile message received"); + // `persist` defaults to true so any non-stream-only mode keeps the file. + const persist = event.command === "downloadVideoFile" ? event.persist !== false : true; + const videoUrl = document.getNotebookMetadata()?.videoUrl; + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return; + } + + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!workspaceUri) { + return; + } + + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return; + } + + const filesUri = vscode.Uri.joinPath(workspaceUri, rel); + const pointersRel = rel.includes("attachments/files/") + ? rel.replace("attachments/files/", "attachments/pointers/") + : rel; + const pointersUri = vscode.Uri.joinPath(workspaceUri, pointersRel); + const ext = path.extname(rel); + + const { getMediaFilesStrategy } = await import("../../utils/localProjectSettings"); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + // In stream-only, a plain "Load" is a temporary session cache stored + // outside the project; an explicit "Save to project" (persist) writes to + // files/. Every other strategy always keeps the file in files/. + const keepFile = persist || strategy !== "stream-only"; + + const { writeCachedVideo, hasCachedVideo } = await import("../../utils/videoStreamCache"); + + // If a saved copy already exists in files/, just play it. + if (!(await isPointerFile(filesUri.fsPath))) { + try { + await vscode.workspace.fs.stat(filesUri); + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + return; + } catch { + // Not present; fall through to download. + } + } + + const pointer = + (await parsePointerFile(filesUri.fsPath)) ?? (await parsePointerFile(pointersUri.fsPath)); + if (!pointer) { + provider.postMessageToWebview(webviewPanel, { + type: "videoStreamUnavailable", + reason: "not-found", + message: "No LFS reference found for this video.", + }); + return; + } + + // A session cache from this session already exists → play it, no re-download. + if (!keepFile && (await hasCachedVideo(provider.extensionContext, pointer.oid, ext))) { + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + return; + } + + const authApi = getAuthApi(); + if (!authApi?.downloadLFSFile) { + provider.postMessageToWebview(webviewPanel, { + type: "videoStreamUnavailable", + reason: "error", + message: "Cannot download: the Frontier Authentication extension is unavailable.", + }); + return; + } + + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: keepFile ? "Downloading and saving video…" : "Loading video…", + cancellable: false, + }, + async () => { + const buffer = await authApi.downloadLFSFile( + workspaceUri.fsPath, + pointer.oid, + pointer.size + ); + // Never write/play an incomplete file: require non-empty bytes + // that match the pointer's expected size. A mismatch means the + // download didn't fully succeed. + const byteLength = buffer?.byteLength ?? 0; + if (byteLength === 0) { + throw new Error("Download returned no data."); + } + if (pointer.size > 0 && byteLength !== pointer.size) { + throw new Error( + `Downloaded ${byteLength} of ${pointer.size} bytes; the file is incomplete.` + ); + } + const bytes = new Uint8Array(buffer); + if (keepFile) { + // Permanent: write into the project (files/ is gitignored, + // so this is local-only and survives reloads). + await vscode.workspace.fs.createDirectory( + vscode.Uri.joinPath(filesUri, "..") + ); + await vscode.workspace.fs.writeFile(filesUri, bytes); + } else { + // Temporary session cache: write outside the project so + // files/ stays a pointer. Cleared on reload. + await writeCachedVideo(provider.extensionContext, pointer.oid, ext, bytes); + } + } + ); + + if (keepFile) { + // Confirm the bytes actually landed on disk (not still a pointer) + // before we ever ask the webview to open it. + const writtenIsPointer = await isPointerFile(filesUri.fsPath).catch(() => true); + if (writtenIsPointer) { + throw new Error("The video file is not available after download."); + } + + // Only an explicit "Save to project" in stream-only needs the + // allowlist: other strategies keep files/ by design, and adding + // them would wrongly block a later switch to stream-only from + // freeing space. Record the rel-path so post-sync / strategy-switch + // cleanup never reverts this saved video to a pointer. + if (persist && strategy === "stream-only") { + const FILES_SEG = "attachments/files/"; + const savedRel = rel.includes(FILES_SEG) + ? rel.slice(rel.indexOf(FILES_SEG) + FILES_SEG.length) + : null; + if (savedRel) { + const { addPersistedMediaFile } = await import("../../utils/localProjectSettings"); + await addPersistedMediaFile(savedRel, workspaceUri); + } + } + } + + // Verified bytes present (in files/ or the external cache) → resolves + // to a playable webview URI. + await resolveAndPostVideoStreamUrl(document, webviewPanel, provider); + // A "Save to project"/"download & save" now has a real local copy, so + // refresh the reference status to surface the "Free up space" action. + await postVideoReferenceStatus(document, webviewPanel, provider); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const reason: "not-authenticated" | "error" = /not authenticated|log in/i.test(msg) + ? "not-authenticated" + : "error"; + provider.postMessageToWebview(webviewPanel, { + type: "videoStreamUnavailable", + reason, + message: `Download failed: ${msg}`, + }); + } }, pickVideoFile: async ({ document, webviewPanel, provider }) => { debug("pickVideoFile message received"); + + // If a local video already exists, confirm replacement up front so the + // user can back out before choosing a new file (deletion happens after + // the new file is written successfully). + const existingVideoUrl = document.getNotebookMetadata()?.videoUrl; + const existingKind = classifyVideo(existingVideoUrl); + if (existingKind === "local") { + const proceed = await confirmVideoReplacement("local", "local"); + if (!proceed) { + return; + } + } + const result = await vscode.window.showOpenDialog({ canSelectMany: false, openLabel: "Select Video File", @@ -1483,10 +2077,10 @@ const messageHandlers: Record Promise MAX_BYTES) { - throw new Error("Video file exceeds maximum allowed size (500 MB)"); + throw new Error("Video file exceeds the maximum allowed size (1.5 GB)."); } // Determine document segment @@ -1552,11 +2146,63 @@ const messageHandlers: Record Promise { + void this.refreshVideoStreamForCachedOid(oid); + }) + ); + } private async initializeStateStore() { @@ -684,6 +699,12 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { + const { postVideoReferenceStatus } = await import("./codexCellEditorMessagehandling"); + for (const [documentUri, webviewPanel] of this.webviewPanels.entries()) { + try { + const document = this.documents.get(documentUri); + if (!document || !webviewPanel) continue; + await postVideoReferenceStatus(document, webviewPanel, this); + } catch (error) { + console.warn( + `[refreshVideoReferenceStatusAfterSync] Failed for ${documentUri}:`, + error + ); + } + } + } + + /** + * Put open editors showing this chapter video into the "resolving" (loading) + * state. Called when a navigation card starts a "Load video"/"Save to + * project" action so the player area reflects progress immediately, before + * any bytes have been fetched. + */ + public notifyVideoResolvingForUrl(videoUrl: string): void { + for (const [documentUri, webviewPanel] of this.webviewPanels.entries()) { + const document = this.documents.get(documentUri); + if (!document || !webviewPanel) continue; + if (document.getNotebookMetadata()?.videoUrl === videoUrl) { + this.postMessageToWebview(webviewPanel, { type: "videoStreamResolving" }); + } + } + } + + /** + * Re-resolve the playable source for open editors showing this chapter + * video. Used after a card-driven download/save/free finishes — including + * the fresh-download path that writes straight to files/ and therefore + * fires no session-cache event. + */ + public async refreshVideoStreamForUrl(videoUrl: string): Promise { + const { resolveAndPostVideoStreamUrl } = await import( + "./codexCellEditorMessagehandling" + ); + for (const [documentUri, webviewPanel] of this.webviewPanels.entries()) { + try { + const document = this.documents.get(documentUri); + if (!document || !webviewPanel) continue; + if (document.getNotebookMetadata()?.videoUrl !== videoUrl) continue; + await resolveAndPostVideoStreamUrl(document, webviewPanel, this); + } catch (error) { + console.warn(`[refreshVideoStreamForUrl] Failed for ${documentUri}:`, error); + } + } + } + + /** + * Re-resolve the playable video source for open editors whose chapter video + * matches a session-cache change. Lets a video "loaded" from a navigation + * card (or freed/saved) immediately update the editor's player without a + * manual re-request. When `oid` is undefined (e.g. the cache was cleared), + * every open editor is refreshed. + */ + public async refreshVideoStreamForCachedOid(oid: string | undefined): Promise { + const { resolveAndPostVideoStreamUrl, getVideoPointerOidForDocument } = await import( + "./codexCellEditorMessagehandling" + ); + for (const [documentUri, webviewPanel] of this.webviewPanels.entries()) { + try { + const document = this.documents.get(documentUri); + if (!document || !webviewPanel) continue; + // Only touch the editor whose video matches the changed cache + // entry so unrelated players aren't disturbed. + if (oid) { + const docOid = await getVideoPointerOidForDocument(document); + if (docOid !== oid) continue; + } + await resolveAndPostVideoStreamUrl(document, webviewPanel, this); + } catch (error) { + console.warn( + `[refreshVideoStreamForCachedOid] Failed for ${documentUri}:`, + error + ); + } + } + } + /** * Refresh webviews for specific files by sending refreshCurrentPage messages. * This is used after sync to ensure webviews show newly added cells. diff --git a/src/providers/codexCellEditorProvider/utils/videoDownloadUtils.ts b/src/providers/codexCellEditorProvider/utils/videoDownloadUtils.ts new file mode 100644 index 000000000..9636393a8 --- /dev/null +++ b/src/providers/codexCellEditorProvider/utils/videoDownloadUtils.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { getAuthApi } from "../../../extension"; +import { + parsePointerFile, + isPointerFile, + replaceFileWithPointer, + type LFSPointer, +} from "../../../utils/lfsHelpers"; +import { getVideoWorkspaceRelativePath, isHttpVideoUrl } from "./videoUtils"; + +const FILES_SEG = "attachments/files/"; + +/** + * Tracks chapter-video fetches that are currently in flight (e.g. a "Load + * video"/"Save to project" started from a navigation card). Lets an editor that + * opens its player mid-operation reflect the loading state instead of briefly + * showing the "needs download" placeholder. Keyed per workspace + video ref. + */ +const inFlightVideoOps = new Set(); + +/** Stable key for an in-flight video operation, shared by card + editor. */ +export function videoOperationKey(workspaceUri: vscode.Uri, videoUrl: string): string { + return `${workspaceUri.fsPath}::${videoUrl}`; +} + +export function beginVideoOperation(workspaceUri: vscode.Uri, videoUrl: string): void { + inFlightVideoOps.add(videoOperationKey(workspaceUri, videoUrl)); +} + +export function endVideoOperation(workspaceUri: vscode.Uri, videoUrl: string): void { + inFlightVideoOps.delete(videoOperationKey(workspaceUri, videoUrl)); +} + +export function isVideoOperationInFlight(workspaceUri: vscode.Uri, videoUrl: string): boolean { + return inFlightVideoOps.has(videoOperationKey(workspaceUri, videoUrl)); +} + +/** Tail of a local video reference relative to `attachments/files/`. */ +function relativeToFilesSegment(rel: string | null): string | null { + if (!rel || !rel.includes(FILES_SEG)) { + return null; + } + return rel.slice(rel.indexOf(FILES_SEG) + FILES_SEG.length); +} + +interface VideoPaths { + rel: string; + filesUri: vscode.Uri; + pointersUri: vscode.Uri; + ext: string; +} + +/** Resolve the files/ + pointers/ locations for a local video reference. */ +function getVideoPaths(workspaceUri: vscode.Uri, rel: string): VideoPaths { + const filesUri = vscode.Uri.joinPath(workspaceUri, rel); + const pointersRel = rel.includes(FILES_SEG) + ? rel.replace(FILES_SEG, "attachments/pointers/") + : rel; + const pointersUri = vscode.Uri.joinPath(workspaceUri, pointersRel); + return { rel, filesUri, pointersUri, ext: path.extname(rel) }; +} + +/** + * Revert a downloaded video in `files/` back to its LFS pointer to free disk + * space. The reference (videoUrl) is preserved so it can be re-downloaded / + * streamed later. In stream-only mode the saved copy is also dropped from the + * persisted-media allowlist (it is no longer "saved to project"). + * + * @returns true if a local file was reverted to a pointer. + */ +export async function freeVideoFileToPointer( + workspaceUri: vscode.Uri, + videoUrl: string | undefined | null +): Promise { + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return false; + } + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + const relFromFiles = relativeToFilesSegment(rel); + if (!relFromFiles) { + return false; + } + + const freed = await replaceFileWithPointer(workspaceUri.fsPath, relFromFiles); + if (!freed) { + return false; + } + + const { getMediaFilesStrategy, removePersistedMediaFile } = await import( + "../../../utils/localProjectSettings" + ); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + if (strategy === "stream-only") { + await removePersistedMediaFile(relFromFiles, workspaceUri); + } + return true; +} + +/** + * Whether this video already has a temporary copy in the session cache (i.e. it + * was streamed this session). Used to tailor the "save to project" confirmation + * so the user knows the bytes will be moved from temporary storage rather than + * downloaded fresh. + */ +export async function isVideoSessionCached( + workspaceUri: vscode.Uri, + videoUrl: string | undefined | null, + extensionContext: vscode.ExtensionContext +): Promise { + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return false; + } + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return false; + } + const paths = getVideoPaths(workspaceUri, rel); + const pointer = await resolvePointer(paths); + if (!pointer) { + return false; + } + try { + const { hasCachedVideo } = await import("../../../utils/videoStreamCache"); + return await hasCachedVideo(extensionContext, pointer.oid, paths.ext); + } catch { + return false; + } +} + +export interface DownloadVideoResult { + ok: boolean; + /** True when the bytes were already available locally (no download needed). */ + alreadyPresent?: boolean; + error?: string; +} + +/** Resolve the LFS pointer backing a local video (from files/ or pointers/). */ +async function resolvePointer(paths: VideoPaths): Promise { + return ( + (await parsePointerFile(paths.filesUri.fsPath).catch(() => null)) ?? + (await parsePointerFile(paths.pointersUri.fsPath).catch(() => null)) + ); +} + +/** Download + verify the LFS object bytes against the pointer's expected size. */ +async function fetchVerifiedBytes( + workspaceUri: vscode.Uri, + pointer: LFSPointer +): Promise<{ bytes: Uint8Array } | { error: string }> { + const authApi = getAuthApi(); + if (!authApi?.downloadLFSFile) { + return { error: "Cannot download: the Frontier Authentication extension is unavailable." }; + } + try { + const buffer = await authApi.downloadLFSFile(workspaceUri.fsPath, pointer.oid, pointer.size); + const byteLength = buffer?.byteLength ?? 0; + if (byteLength === 0) { + return { error: "Download returned no data." }; + } + if (pointer.size > 0 && byteLength !== pointer.size) { + return { + error: `Downloaded ${byteLength} of ${pointer.size} bytes; the file is incomplete.`, + }; + } + return { bytes: new Uint8Array(buffer) }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { error: `Download failed: ${msg}` }; + } +} + +/** + * Download an LFS-backed video into the project (`files/`), with "save to + * project" semantics: the bytes persist locally (files/ is gitignored), and in + * stream-only mode the rel-path is added to the persisted-media allowlist so + * later cleanup/sync won't revert it. Does not touch any webview. + */ +export async function downloadVideoToProject( + workspaceUri: vscode.Uri, + videoUrl: string | undefined | null, + extensionContext?: vscode.ExtensionContext +): Promise { + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return { ok: false, error: "This is not a downloadable local video reference." }; + } + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return { ok: false, error: "Could not resolve the video file path." }; + } + const paths = getVideoPaths(workspaceUri, rel); + + // Already a real local file (not a pointer stub)? Nothing to download. + if (!(await isPointerFile(paths.filesUri.fsPath).catch(() => false))) { + try { + await vscode.workspace.fs.stat(paths.filesUri); + return { ok: true, alreadyPresent: true }; + } catch { + // Not present; continue to download. + } + } + + const pointer = await resolvePointer(paths); + if (!pointer) { + return { ok: false, error: "No LFS reference found for this video." }; + } + + // Prefer MOVING an already-streamed copy out of the session cache instead of + // re-downloading. The cached bytes are dropped after a successful write so + // the video ends up in exactly one place (the project files/). + let bytes: Uint8Array | undefined; + let movedFromCache = false; + if (extensionContext) { + try { + const { readCachedVideo } = await import("../../../utils/videoStreamCache"); + const cached = await readCachedVideo(extensionContext, pointer.oid, paths.ext); + if ( + cached && + cached.byteLength > 0 && + (pointer.size <= 0 || cached.byteLength === pointer.size) + ) { + bytes = cached; + movedFromCache = true; + } + } catch { + // Cache unavailable — fall back to a fresh download. + } + } + + if (!bytes) { + const fetched = await fetchVerifiedBytes(workspaceUri, pointer); + if ("error" in fetched) { + return { ok: false, error: fetched.error }; + } + bytes = fetched.bytes; + } + + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(paths.filesUri, "..")); + await vscode.workspace.fs.writeFile(paths.filesUri, bytes); + + // Confirm real bytes landed (not still a pointer) before reporting success. + const writtenIsPointer = await isPointerFile(paths.filesUri.fsPath).catch(() => true); + if (writtenIsPointer) { + return { ok: false, error: "The video file is not available after download." }; + } + + // Move semantics: now that the bytes live in the project, remove the + // temporary session copy so the video isn't duplicated. + if (movedFromCache && extensionContext) { + try { + const { deleteCachedVideo } = await import("../../../utils/videoStreamCache"); + await deleteCachedVideo(extensionContext, pointer.oid, paths.ext); + } catch { + // Best-effort cleanup; the project copy is already in place. + } + } + + // In stream-only, an explicit save must be protected from automatic + // pointer-replacement cleanup (other strategies keep files/ by design). + const { getMediaFilesStrategy, addPersistedMediaFile } = await import( + "../../../utils/localProjectSettings" + ); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + if (strategy === "stream-only") { + const savedRel = relativeToFilesSegment(rel); + if (savedRel) { + await addPersistedMediaFile(savedRel, workspaceUri); + } + } + + return { ok: true }; +} + +/** + * Download an LFS-backed video into the ephemeral session cache (global + * storage, outside the project) without persisting it. files/ stays a pointer, + * so the card still shows "not downloaded", but the editor can play it instantly + * this session. Cleared on reload. Used by the stream-only "Stream this session" + * choice. + */ +export async function downloadVideoToSessionCache( + workspaceUri: vscode.Uri, + videoUrl: string | undefined | null, + extensionContext: vscode.ExtensionContext +): Promise { + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return { ok: false, error: "This is not a downloadable local video reference." }; + } + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return { ok: false, error: "Could not resolve the video file path." }; + } + const paths = getVideoPaths(workspaceUri, rel); + + const pointer = await resolvePointer(paths); + if (!pointer) { + return { ok: false, error: "No LFS reference found for this video." }; + } + + const { writeCachedVideo, hasCachedVideo } = await import("../../../utils/videoStreamCache"); + if (await hasCachedVideo(extensionContext, pointer.oid, paths.ext)) { + return { ok: true, alreadyPresent: true }; + } + + const fetched = await fetchVerifiedBytes(workspaceUri, pointer); + if ("error" in fetched) { + return { ok: false, error: fetched.error }; + } + await writeCachedVideo(extensionContext, pointer.oid, paths.ext, fetched.bytes); + return { ok: true }; +} diff --git a/src/providers/codexCellEditorProvider/utils/videoUtils.ts b/src/providers/codexCellEditorProvider/utils/videoUtils.ts index 521f9e89d..15d82e485 100644 --- a/src/providers/codexCellEditorProvider/utils/videoUtils.ts +++ b/src/providers/codexCellEditorProvider/utils/videoUtils.ts @@ -1,4 +1,189 @@ import * as vscode from "vscode"; +import { parsePointerFile, isPointerFile } from "../../../utils/lfsHelpers"; + +/** + * Returns true when the stored video reference is a remote URL (streamed), + * false when it is a local file reference (relative path or file:// URI). + */ +export function isHttpVideoUrl(videoUrl: string | undefined | null): boolean { + return !!videoUrl && /^https?:\/\//i.test(videoUrl); +} + +/** + * How a chapter's stored video reference is currently available: + * - "none" → no reference at all + * - "url" → remote streamed URL + * - "saved" → real bytes on disk in `files/` (downloaded / saved to project) + * - "streamable" → only an LFS pointer exists (download/stream on demand) + * - "missing" → a local reference that resolves to neither bytes nor a pointer + */ +export type VideoAvailability = "none" | "url" | "saved" | "streamable" | "missing"; + +export interface VideoAvailabilityInfo { + availability: VideoAvailability; + /** Bytes of the referenced video when known (real local bytes or LFS pointer size). */ + sizeBytes?: number; +} + +/** + * Classify a stored video reference and, for local references, report its size. + * A single filesystem resolver shared by every surface that needs to reason + * about a chapter video (editor modal status, navigation cards) so they stay + * consistent. Remote-URL and "no reference" cases never touch the filesystem. + */ +export async function resolveVideoAvailability( + videoUrl: string | undefined | null, + workspaceUri: vscode.Uri | undefined +): Promise { + if (!videoUrl) { + return { availability: "none" }; + } + if (isHttpVideoUrl(videoUrl)) { + return { availability: "url" }; + } + if (!workspaceUri) { + return { availability: "missing" }; + } + + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return { availability: "missing" }; + } + + const filesAbs = vscode.Uri.joinPath(workspaceUri, rel).fsPath; + const pointersRel = rel.includes("attachments/files/") + ? rel.replace("attachments/files/", "attachments/pointers/") + : rel; + const pointersAbs = vscode.Uri.joinPath(workspaceUri, pointersRel).fsPath; + + // Real bytes already on disk (not a tiny pointer stub) → saved. + try { + const stat = await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); + const isPtr = await isPointerFile(filesAbs).catch(() => false); + if (!isPtr) { + return { + availability: "saved", + sizeBytes: + typeof stat.size === "number" && stat.size > 0 ? stat.size : undefined, + }; + } + } catch { + // files/ entry doesn't exist; fall through to the pointer check. + } + + // An LFS pointer (in files/ or pointers/) means it can be downloaded/streamed, + // and records the real object size even before download. + const pointer = + (await parsePointerFile(filesAbs).catch(() => null)) ?? + (await parsePointerFile(pointersAbs).catch(() => null)); + if (pointer) { + return { availability: "streamable", sizeBytes: pointer.size }; + } + + return { availability: "missing" }; +} + +/** + * Resolve the workspace-relative path for a stored local video reference. + * Accepts either a workspace-relative path (as written by pickVideoFile) or a + * `file://` URI inside the workspace. Returns null for remote URLs or paths + * outside the workspace. + */ +export function getVideoWorkspaceRelativePath( + videoUrl: string | undefined | null, + workspaceUri: vscode.Uri +): string | null { + if (!videoUrl || isHttpVideoUrl(videoUrl)) { + return null; + } + + try { + if (videoUrl.startsWith("file://")) { + const fileUri = vscode.Uri.parse(videoUrl); + const wsPath = workspaceUri.fsPath.replace(/\\/g, "/"); + const filePath = fileUri.fsPath.replace(/\\/g, "/"); + if (filePath.toLowerCase().startsWith(wsPath.toLowerCase() + "/")) { + return filePath.substring(wsPath.length).replace(/^\/+/, ""); + } + return null; + } + // Already a workspace-relative path + return videoUrl.replace(/\\/g, "/").replace(/^\/+/, ""); + } catch { + return null; + } +} + +/** + * Delete a managed local video from BOTH `\.project/attachments/files/` + * and `\.project/attachments/pointers/`. Works whether the project is + * unsynced (pointers holds raw bytes) or synced (pointers holds the pointer) — + * the relative paths are identical in either case. + * + * Only files under `attachments/files|pointers/` are touched; arbitrary local + * references are left alone. Paths whose absolute fsPath is in `excludeFsPaths` + * are skipped (used when the replacement reuses the same filename). + * + * @returns the list of deleted absolute fsPaths (best-effort). + */ +export async function deleteLocalVideoFiles( + videoUrl: string | undefined | null, + workspaceUri: vscode.Uri, + excludeFsPaths: Set = new Set() +): Promise { + const relPath = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!relPath) { + return []; + } + + const FILES_SEG = "attachments/files/"; + const POINTERS_SEG = "attachments/pointers/"; + + let tail: string | null = null; + let prefix: string | null = null; + if (relPath.includes(FILES_SEG)) { + prefix = relPath.substring(0, relPath.indexOf(FILES_SEG)); + tail = relPath.substring(relPath.indexOf(FILES_SEG) + FILES_SEG.length); + } else if (relPath.includes(POINTERS_SEG)) { + prefix = relPath.substring(0, relPath.indexOf(POINTERS_SEG)); + tail = relPath.substring(relPath.indexOf(POINTERS_SEG) + POINTERS_SEG.length); + } + + if (!tail || prefix === null) { + // Not a managed attachment; do not delete arbitrary local files. + return []; + } + + const targets = [ + vscode.Uri.joinPath(workspaceUri, `${prefix}${FILES_SEG}${tail}`), + vscode.Uri.joinPath(workspaceUri, `${prefix}${POINTERS_SEG}${tail}`), + ]; + + const deleted: string[] = []; + for (const uri of targets) { + if (excludeFsPaths.has(uri.fsPath)) { + continue; + } + try { + await vscode.workspace.fs.delete(uri, { useTrash: false }); + deleted.push(uri.fsPath); + } catch { + // File may not exist (e.g. pointer never written); ignore. + } + } + + // Keep the persisted-media allowlist in sync: a deleted/replaced video must + // no longer be protected from stream-only pointer-replacement cleanup, + // otherwise the list would guard a stale/empty slot. + try { + const { removePersistedMediaFile } = await import("../../../utils/localProjectSettings"); + await removePersistedMediaFile(tail, workspaceUri); + } catch { + // Non-fatal: allowlist hygiene only. + } + + return deleted; +} /** * Processes a video path and converts it to a webview-compatible URL. diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 17afcf099..5f8d82ca3 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -15,6 +15,8 @@ import { getAuthApi } from "../../extension"; import { CustomNotebookMetadata, ProjectMetadata } from "../../../types"; import { getCorrespondingSourceUri, findCodexFilesByBookAbbr } from "../../utils/codexNotebookUtils"; import { CodexCellEditorProvider } from "../codexCellEditorProvider/codexCellEditorProvider"; +import { resolveVideoAvailability } from "../codexCellEditorProvider/utils/videoUtils"; +import { onDidChangeVideoStreamCache } from "../../utils/videoStreamCache"; import { openCodexDocumentWithSourcePair } from "../../utils/openCodexDocumentWithSourcePair"; interface CodexMetadata { @@ -30,6 +32,7 @@ interface CodexMetadata { progress?: number; fileDisplayName?: string; enforceHtmlStructure?: boolean; + videoUrl?: string; } interface BibleBookInfo { @@ -49,6 +52,10 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private pendingRebuild = false; private serializer = new CodexContentSerializer(); private bibleBookMap: Map = new Map(); + private videoStatusRefreshTimer: ReturnType | undefined; + // Current media-download strategy, sent to the webview so it can disable + // manual video download/free actions in "auto-download" (auto-managed). + private mediaStrategy: string = "auto-download"; constructor(context: vscode.ExtensionContext) { super(context); @@ -134,6 +141,9 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { this.loadBibleBookMap(); await this.buildInitialData(); break; + case "videoCardAction": + await this.handleVideoCardAction(message.uri, message.action); + break; case "deleteFile": try { // Confirmation is handled by the webview's delete modal @@ -441,6 +451,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const groupedItems = this.groupByCorpus(codexItemsWithMetadata); this.codexItems = groupedItems; + await this.refreshMediaStrategy(); this.sendItemsToWebview(); } catch (error) { console.error("Error building data:", error); @@ -556,6 +567,26 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const bookInfo = this.bibleBookMap.get(fileNameAbbr); const label = fileNameAbbr; const sortOrder = bookInfo?.ord; + + // Resolve the chapter video's source/availability (and size for local + // copies) so the card can show a source-specific icon + hover details. + const videoUrl = metadata?.videoUrl; + const hasVideo = typeof videoUrl === "string" && videoUrl.trim().length > 0; + let videoAvailability: CodexItem["videoAvailability"] | undefined; + let videoSizeBytes: number | undefined; + let videoCached: boolean | undefined; + if (hasVideo) { + const workspaceUri = vscode.workspace.getWorkspaceFolder(uri)?.uri; + const { availability, sizeBytes } = await resolveVideoAvailability( + videoUrl, + workspaceUri + ); + videoAvailability = availability === "none" ? undefined : availability; + videoSizeBytes = sizeBytes; + if (availability === "streamable" && workspaceUri) { + videoCached = await this.isVideoCached(videoUrl, workspaceUri); + } + } const corpusMarker = metadata?.corpusMarker ? metadata.corpusMarker.trim() : bookInfo?.testament; @@ -579,6 +610,11 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { sortOrder, fileDisplayName: metadata?.fileDisplayName, enforceHtmlStructure: metadata?.enforceHtmlStructure ?? false, + hasVideo, + videoAvailability, + videoCached, + videoSizeBytes, + videoUrl: hasVideo ? videoUrl : undefined, }; } catch (error: any) { // Don't log warnings for files that don't exist (FileNotFound/ENOENT errors) @@ -719,11 +755,37 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { ); const codexWatcher = vscode.workspace.createFileSystemWatcher(codexWatcherPattern); + // Watch the LFS-backed media folders so cards reflect video downloads/ + // frees/sync without a manual refresh. These changes don't touch the + // .codex file, so the codex watcher above wouldn't catch them. + const attachmentsWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(rootUri.fsPath, "**/attachments/{files,pointers}/**") + ); + + // Watch local project settings so a media-strategy switch (which may not + // touch any attachment file) re-evaluates whether cards can offer + // download/free actions. + const settingsWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(rootUri.fsPath, "**/localProjectSettings.json") + ); + this.disposables.push( codexWatcher, codexWatcher.onDidCreate(() => this.buildInitialData()), codexWatcher.onDidChange(() => this.buildInitialData()), codexWatcher.onDidDelete(() => this.buildInitialData()), + attachmentsWatcher, + attachmentsWatcher.onDidCreate(() => this.scheduleVideoStatusRefresh()), + attachmentsWatcher.onDidChange(() => this.scheduleVideoStatusRefresh()), + attachmentsWatcher.onDidDelete(() => this.scheduleVideoStatusRefresh()), + settingsWatcher, + settingsWatcher.onDidCreate(() => this.scheduleVideoStatusRefresh()), + settingsWatcher.onDidChange(() => this.scheduleVideoStatusRefresh()), + // The session video cache lives in extension global storage, so the + // workspace watchers above can't see it. Listen for cache changes + // (loads from editor playback, saves, frees) so the "loaded + // (temporary)" cloud state updates immediately. + onDidChangeVideoStreamCache(() => this.scheduleVideoStatusRefresh()), vscode.workspace.onDidChangeConfiguration((e) => { if ( e.affectsConfiguration("codex-project-manager.validationCount") || @@ -742,6 +804,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { safePostMessageToView(this._view, { command: "updateItems", codexItems: serializedCodexItems, + mediaStrategy: this.mediaStrategy, }); if (this.bibleBookMap) { @@ -755,8 +818,10 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { } private serializeItem(item: CodexItem): any { + // `videoUrl` is host-only state for recomputing availability; never sent. + const { videoUrl: _videoUrl, ...rest } = item; return { - ...item, + ...rest, uri: (item.uri as vscode.Uri).fsPath, children: item.children ? item.children.map((child) => this.serializeItem(child)) @@ -764,6 +829,289 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { }; } + /** + * Recompute only the video availability/size for each item (no notebook + * re-reads) and push the refreshed list to the webview if anything changed. + * Triggered when files under `attachments/files|pointers/` change (video + * downloaded, freed, or synced) so cards update without a manual refresh. + */ + /** + * Re-read the media-download strategy from local project settings into the + * cached value. Returns true if it changed (so callers can resend items). + */ + private async refreshMediaStrategy(): Promise { + const workspaceUri = vscode.workspace.workspaceFolders?.[0]?.uri; + if (!workspaceUri) { + return false; + } + try { + const { getMediaFilesStrategy } = await import("../../utils/localProjectSettings"); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + if (strategy !== this.mediaStrategy) { + this.mediaStrategy = strategy; + return true; + } + } catch { + // Keep the last known strategy on read failure. + } + return false; + } + + /** + * Whether a streamable (not-downloaded) video has a temporary copy in this + * session's video cache. Reused by initial build and live refresh so the + * card can show the "loaded (temporary)" cloud state. + */ + private async isVideoCached( + videoUrl: string, + workspaceUri: vscode.Uri + ): Promise { + try { + const { isVideoSessionCached } = await import( + "../codexCellEditorProvider/utils/videoDownloadUtils" + ); + return await isVideoSessionCached(workspaceUri, videoUrl, this._context); + } catch { + return false; + } + } + + private async refreshVideoStatuses(): Promise { + let changed = await this.refreshMediaStrategy(); + const visit = async (items: CodexItem[]): Promise => { + for (const item of items) { + if (item.children?.length) { + await visit(item.children); + } + if (!item.videoUrl) { + continue; + } + const workspaceUri = vscode.workspace.getWorkspaceFolder( + item.uri as vscode.Uri + )?.uri; + const { availability, sizeBytes } = await resolveVideoAvailability( + item.videoUrl, + workspaceUri + ); + const nextAvailability = availability === "none" ? undefined : availability; + // Only streamable videos can be "loaded temporarily"; others + // (saved/url/missing) are never session-cached. + const nextCached = + availability === "streamable" && workspaceUri + ? await this.isVideoCached(item.videoUrl, workspaceUri) + : undefined; + if ( + item.videoAvailability !== nextAvailability || + item.videoSizeBytes !== sizeBytes || + item.videoCached !== nextCached + ) { + item.videoAvailability = nextAvailability; + item.videoSizeBytes = sizeBytes; + item.videoCached = nextCached; + changed = true; + } + } + }; + await visit(this.codexItems); + if (changed) { + this.sendItemsToWebview(); + } + } + + /** Find a built item (searching corpus children) by its .codex fsPath. */ + private findItemByFsPath(fsPath: string): CodexItem | undefined { + const search = (items: CodexItem[]): CodexItem | undefined => { + for (const item of items) { + // Corpus groups reuse their first child's uri, so only match the + // actual document items (and recurse into corpus children). + if ( + item.type !== "corpus" && + (item.uri as vscode.Uri | undefined)?.fsPath === fsPath + ) { + return item; + } + if (item.children?.length) { + const found = search(item.children); + if (found) { + return found; + } + } + } + return undefined; + }; + return search(this.codexItems); + } + + /** + * Download (save to project) or free up disk space for a card's chapter + * video, triggered by clicking the card's video icon. The attachments + * watcher refreshes the card automatically, but we also refresh inline for + * snappiness and re-post status to any open editor showing this document. + */ + private async handleVideoCardAction( + uriPath: unknown, + action: unknown + ): Promise { + if (typeof uriPath !== "string" || (action !== "download" && action !== "free")) { + return; + } + + try { + const codexUri = vscode.Uri.file(uriPath.replace(/\\/g, "/")); + const item = this.findItemByFsPath(codexUri.fsPath); + const videoUrl = item?.videoUrl; + const workspaceUri = vscode.workspace.getWorkspaceFolder(codexUri)?.uri; + if (!videoUrl || !workspaceUri) { + return; + } + + const { getMediaFilesStrategy } = await import("../../utils/localProjectSettings"); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + // In auto-download every video is kept downloaded automatically, so + // manual download/free from a card is not allowed (ignore defensively; + // the webview also hides the action in this mode). + if (strategy === "auto-download") { + return; + } + + const { + freeVideoFileToPointer, + downloadVideoToProject, + downloadVideoToSessionCache, + isVideoSessionCached, + beginVideoOperation, + endVideoOperation, + } = await import("../codexCellEditorProvider/utils/videoDownloadUtils"); + + if (action === "free") { + const proceed = await vscode.window.showInformationMessage( + "Free up space for this video?", + { + modal: true, + detail: "The downloaded file is removed. You can download it again later.", + }, + "Free up space" + ); + if (proceed !== "Free up space") { + return; + } + await freeVideoFileToPointer(workspaceUri, videoUrl); + } else { + // Decide what to offer based on whether the video is already + // loaded this session. If it is, "Load video" is pointless — only + // offer to make it permanent. Otherwise offer both a temporary + // session load and a permanent save. + const cached = await isVideoSessionCached(workspaceUri, videoUrl, this._context); + const LOAD = "Load video"; + const SAVE = "Save to project"; + let saveToProject: boolean; + if (cached) { + const proceed = await vscode.window.showInformationMessage( + "Save this video to the project?", + { + modal: true, + detail: "Moves the already-loaded copy into the project so it stays available without re-downloading.", + }, + SAVE + ); + if (proceed !== SAVE) { + return; + } + saveToProject = true; + } else { + const choice = await vscode.window.showInformationMessage( + "Get this video", + { + modal: true, + detail: + "Load video keeps it temporarily for this session.\n\n" + + "Save to project downloads and keeps it in the project so it stays available.", + }, + LOAD, + SAVE + ); + if (choice !== LOAD && choice !== SAVE) { + return; + } + saveToProject = choice === SAVE; + } + + // If this chapter is open in an editor, reflect the in-progress + // fetch in its player area immediately (before bytes arrive). + try { + CodexCellEditorProvider.getInstance()?.notifyVideoResolvingForUrl(videoUrl); + } catch { + // Non-fatal: the editor will still update on completion. + } + + // Mark this video as in-flight so an editor that opens its player + // mid-fetch shows the loading state rather than the placeholder. + beginVideoOperation(workspaceUri, videoUrl); + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: saveToProject + ? "Saving video to project…" + : "Loading video for this session…", + cancellable: false, + }, + async () => { + const result = saveToProject + ? await downloadVideoToProject(workspaceUri, videoUrl, this._context) + : await downloadVideoToSessionCache( + workspaceUri, + videoUrl, + this._context + ); + if (!result.ok) { + vscode.window.showErrorMessage( + result.error ?? "Failed to get video." + ); + } + } + ); + } finally { + endVideoOperation(workspaceUri, videoUrl); + } + } + + await this.refreshVideoStatuses(); + + // Keep any open editor's player + "Show Video"/"Free up space" state + // in sync. refreshVideoStreamForUrl re-resolves the player source + // (covers the fresh-download save path, which fires no cache event); + // refreshVideoReferenceStatusAfterSync updates the toggle/free badge. + try { + const editorProvider = CodexCellEditorProvider.getInstance(); + await editorProvider?.refreshVideoStreamForUrl(videoUrl); + await editorProvider?.refreshVideoReferenceStatusAfterSync(); + } catch { + // Non-fatal: the card itself is already refreshed. + } + } finally { + // Always clear the card's "downloading" spinner, even on early return + // (e.g. the user cancelled the stream-only prompt) or on error. + if (this._view) { + safePostMessageToView(this._view, { + command: "videoCardActionDone", + uri: uriPath, + }); + } + } + } + + /** Debounce bursts of attachment changes (e.g. a sync downloading many files). */ + private scheduleVideoStatusRefresh(): void { + if (this.videoStatusRefreshTimer) { + clearTimeout(this.videoStatusRefreshTimer); + } + this.videoStatusRefreshTimer = setTimeout(() => { + this.videoStatusRefreshTimer = undefined; + void this.refreshVideoStatuses(); + }, 400); + } + private async updateCorpusMarker(oldCorpusLabel: string, newCorpusName: string): Promise { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders?.length) { @@ -1386,6 +1734,10 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { } public dispose(): void { + if (this.videoStatusRefreshTimer) { + clearTimeout(this.videoStatusRefreshTimer); + this.videoStatusRefreshTimer = undefined; + } this.disposables.forEach((d) => d.dispose()); } } diff --git a/src/test/suite/startupFlowProvider_updateSync.test.ts b/src/test/suite/startupFlowProvider_updateSync.test.ts index eaa3f739b..d55ca6f6c 100644 --- a/src/test/suite/startupFlowProvider_updateSync.test.ts +++ b/src/test/suite/startupFlowProvider_updateSync.test.ts @@ -11,6 +11,7 @@ import { createMockExtensionContext, swallowDuplicateCommandRegistrations } from import * as directoryConflicts from "../../projectManager/utils/merge/directoryConflicts"; import * as mergeResolvers from "../../projectManager/utils/merge/resolvers"; import * as projectLocationUtils from "../../utils/projectLocationUtils"; +import * as connectivityChecker from "../../utils/connectivityChecker"; suite("StartupFlowProvider Update - triggers LFS-aware sync", () => { suiteSetup(() => { @@ -28,6 +29,13 @@ suite("StartupFlowProvider Update - triggers LFS-aware sync", () => { // Prevent background preflight/auth initialization from running during this test const initStub = sinon.stub(StartupFlowProvider.prototype as any, "initializeComponentsAsync").resolves(); + // performProjectUpdate calls ensureConnectivity(), which otherwise makes a + // live network request and, when offline, polls forever (causing this test + // to hang until the mocha timeout). Stub it so the test is deterministic. + const ensureConnectivityStub = sinon + .stub(connectivityChecker, "ensureConnectivity") + .resolves(); + // Frontier auth extension activation stub (also used for update version gate) const activateStub = sinon.stub().resolves(); const getExtensionStub = sinon.stub(vscode.extensions, "getExtension").returns({ @@ -154,6 +162,7 @@ suite("StartupFlowProvider Update - triggers LFS-aware sync", () => { // Cleanup stubs infoStub.restore(); initStub.restore(); + ensureConnectivityStub.restore(); getExtensionStub.restore(); getCodexProjectsDirectoryStub.restore(); buildConflictsStub.restore(); diff --git a/src/test/suite/unit/mediaStrategyVideo.test.ts b/src/test/suite/unit/mediaStrategyVideo.test.ts new file mode 100644 index 000000000..be8a787e7 --- /dev/null +++ b/src/test/suite/unit/mediaStrategyVideo.test.ts @@ -0,0 +1,371 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; + +/** + * Locks in the video media-strategy switch rules: + * - Switching to a more restrictive strategy (stream-only) erases SYNCED video + * (reverts files/ to a pointer) to free disk space. + * - Unsynced video (pointers/ still holds real bytes) is NEVER erased. + * - Switching to a less restrictive strategy (removeFilesPointerStubs) preserves + * real video bytes and only drops tiny pointer stubs. + */ +suite("Media strategy: video preserve/erase rules", () => { + const BOOK = "JUD"; + const OID = "a".repeat(64); + + const makePointer = (size: number): string => + `version https://git-lfs.github.com/spec/v1\noid sha256:${OID}\nsize ${size}\n`; + + const setup = (): { tempDir: string; filesPath: (name: string) => string; pointersPath: (name: string) => string; } => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-video-strategy-")); + const filesDir = path.join(tempDir, ".project", "attachments", "files", BOOK); + const pointersDir = path.join(tempDir, ".project", "attachments", "pointers", BOOK); + fs.mkdirSync(filesDir, { recursive: true }); + fs.mkdirSync(pointersDir, { recursive: true }); + return { + tempDir, + filesPath: (name: string) => path.join(filesDir, name), + pointersPath: (name: string) => path.join(pointersDir, name), + }; + }; + + test("stream-only switch reverts a SYNCED video to a pointer", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { replaceFilesWithPointers } = await import("../../../utils/mediaStrategyManager"); + const { isPointerFile } = await import("../../../utils/lfsHelpers"); + + try { + // Synced: pointers/ holds the LFS pointer, files/ holds real bytes. + const realBytes = Buffer.alloc(2048, 7); + fs.writeFileSync(pointersPath("video.mp4"), makePointer(realBytes.length), "utf8"); + fs.writeFileSync(filesPath("video.mp4"), realBytes); + + const replaced = await replaceFilesWithPointers(tempDir); + + assert.ok(replaced >= 1, "expected at least one file replaced with a pointer"); + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + true, + "synced video in files/ should be reverted to a pointer" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("stream-only switch preserves an UNSYNCED video", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { replaceFilesWithPointers } = await import("../../../utils/mediaStrategyManager"); + const { isPointerFile } = await import("../../../utils/lfsHelpers"); + + try { + // Unsynced: BOTH files/ and pointers/ hold real bytes (not uploaded yet). + const realBytes = Buffer.alloc(2048, 7); + fs.writeFileSync(pointersPath("video.mp4"), realBytes); + fs.writeFileSync(filesPath("video.mp4"), realBytes); + + await replaceFilesWithPointers(tempDir); + + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + false, + "unsynced video must NOT be reverted to a pointer" + ); + assert.strictEqual( + fs.statSync(filesPath("video.mp4")).size, + realBytes.length, + "unsynced video bytes must be untouched" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("restrictToVideos pointerizes only videos, leaving audio untouched", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { replaceFilesWithPointers } = await import("../../../utils/mediaStrategyManager"); + const { isPointerFile } = await import("../../../utils/lfsHelpers"); + + try { + const realVideo = Buffer.alloc(2048, 7); + fs.writeFileSync(pointersPath("video.mp4"), makePointer(realVideo.length), "utf8"); + fs.writeFileSync(filesPath("video.mp4"), realVideo); + + const realAudio = Buffer.alloc(1024, 3); + fs.writeFileSync(pointersPath("audio.wav"), makePointer(realAudio.length), "utf8"); + fs.writeFileSync(filesPath("audio.wav"), realAudio); + + await replaceFilesWithPointers(tempDir, { restrictToVideos: true }); + + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + true, + "video should be pointerized when restrictToVideos is set" + ); + assert.strictEqual( + await isPointerFile(filesPath("audio.wav")), + false, + "audio must be left untouched when restrictToVideos is set" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("restrictToAudio pointerizes only audio, leaving video untouched", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { replaceFilesWithPointers } = await import("../../../utils/mediaStrategyManager"); + const { isPointerFile } = await import("../../../utils/lfsHelpers"); + + try { + const realVideo = Buffer.alloc(2048, 7); + fs.writeFileSync(pointersPath("video.mp4"), makePointer(realVideo.length), "utf8"); + fs.writeFileSync(filesPath("video.mp4"), realVideo); + + const realAudio = Buffer.alloc(1024, 3); + fs.writeFileSync(pointersPath("audio.wav"), makePointer(realAudio.length), "utf8"); + fs.writeFileSync(filesPath("audio.wav"), realAudio); + + await replaceFilesWithPointers(tempDir, { ignorePersisted: true, restrictToAudio: true }); + + assert.strictEqual( + await isPointerFile(filesPath("audio.wav")), + true, + "audio should be pointerized when restrictToAudio is set" + ); + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + false, + "video must be left untouched when restrictToAudio is set" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("ignorePersisted frees a saved (allowlisted) video", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { replaceFilesWithPointers } = await import("../../../utils/mediaStrategyManager"); + const { isPointerFile } = await import("../../../utils/lfsHelpers"); + const { addPersistedMediaFile } = await import("../../../utils/localProjectSettings"); + + try { + const realVideo = Buffer.alloc(2048, 7); + fs.writeFileSync(pointersPath("video.mp4"), makePointer(realVideo.length), "utf8"); + fs.writeFileSync(filesPath("video.mp4"), realVideo); + + await addPersistedMediaFile(`${BOOK}/video.mp4`, vscode.Uri.file(tempDir)); + + // Honoring the allowlist (default) keeps the saved video. + await replaceFilesWithPointers(tempDir); + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + false, + "saved video must be protected during automatic cleanup" + ); + + // An explicit Free Space (ignorePersisted) frees it anyway. + await replaceFilesWithPointers(tempDir, { ignorePersisted: true }); + assert.strictEqual( + await isPointerFile(filesPath("video.mp4")), + true, + "ignorePersisted must free even saved videos" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("countLocalVideoFiles counts real videos, ignoring pointers and audio", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { countLocalVideoFiles, collectLocalVideoRelPaths } = await import( + "../../../utils/mediaStrategyManager" + ); + + try { + // Real video -> counts. + fs.writeFileSync(filesPath("real.mp4"), Buffer.alloc(2048, 7)); + // Pointer stub video -> does not count. + fs.writeFileSync(filesPath("stub.mp4"), makePointer(2048), "utf8"); + // Real audio -> not a video, does not count. + fs.writeFileSync(filesPath("audio.wav"), Buffer.alloc(1024, 3)); + // keep pointers/ consistent (not required for counting) + fs.writeFileSync(pointersPath("real.mp4"), makePointer(2048), "utf8"); + + const count = await countLocalVideoFiles(tempDir); + assert.strictEqual(count, 1, "only the real video should be counted"); + + const rels = await collectLocalVideoRelPaths(tempDir); + assert.deepStrictEqual(rels, [`${BOOK}/real.mp4`], "should list the real video rel-path"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("collectLocalVideoRelPaths treats audio .webm as audio, not video", async function () { + // Regression: browser audio recordings are saved as `.webm`, which used + // to be in VIDEO_EXTENSIONS, so 1000+ audio takes were reported as + // "videos". Only `.webm` files actually referenced by a notebook's + // videoUrl are videos. + this.timeout(15000); + const { tempDir, filesPath } = setup(); + const { countLocalVideoFiles, collectLocalVideoRelPaths } = await import( + "../../../utils/mediaStrategyManager" + ); + + try { + // Audio recordings saved as .webm (NOT referenced as videos) -> must NOT count. + fs.writeFileSync(filesPath("JUD_001_001-take1.webm"), Buffer.alloc(1024, 3)); + fs.writeFileSync(filesPath("JUD_001_002-take1.webm"), Buffer.alloc(1024, 4)); + // A real video saved as .webm, referenced by a notebook's videoUrl -> must count. + fs.writeFileSync(filesPath("clip.webm"), Buffer.alloc(4096, 9)); + + // Notebook (.codex under files/target) referencing the video. + const targetDir = path.join(tempDir, "files", "target"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, "JUD.codex"), + JSON.stringify({ + cells: [], + metadata: { videoUrl: `.project/attachments/files/${BOOK}/clip.webm` }, + }), + "utf8" + ); + + const count = await countLocalVideoFiles(tempDir); + assert.strictEqual(count, 1, "audio .webm recordings must not be counted as videos"); + + const rels = await collectLocalVideoRelPaths(tempDir); + assert.deepStrictEqual( + rels, + [`${BOOK}/clip.webm`], + "only the .webm referenced via videoUrl should be listed" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("less-restrictive switch keeps real video and drops only pointer stubs", async function () { + this.timeout(15000); + const { tempDir, filesPath, pointersPath } = setup(); + const { removeFilesPointerStubs } = await import("../../../utils/mediaStrategyManager"); + + try { + // Stubbed entry: files/ is a tiny pointer stub -> should be removed. + fs.writeFileSync(pointersPath("stub.mp4"), makePointer(2048), "utf8"); + fs.writeFileSync(filesPath("stub.mp4"), makePointer(2048), "utf8"); + + // Real downloaded video: files/ holds real bytes -> must be preserved. + const realBytes = Buffer.alloc(4096, 9); + fs.writeFileSync(pointersPath("real.mp4"), makePointer(realBytes.length), "utf8"); + fs.writeFileSync(filesPath("real.mp4"), realBytes); + + await removeFilesPointerStubs(tempDir); + + assert.strictEqual( + fs.existsSync(filesPath("stub.mp4")), + false, + "pointer stub in files/ should be removed so it can re-download" + ); + assert.strictEqual( + fs.existsSync(filesPath("real.mp4")), + true, + "real downloaded video must be preserved on a less-restrictive switch" + ); + assert.strictEqual( + fs.statSync(filesPath("real.mp4")).size, + realBytes.length, + "preserved video bytes must be untouched" + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +/** + * Locks in the fix for the intermittent "saved video not protected" bug: the + * persisted-media allowlist must survive concurrent settings writes. Previously + * a general settings write that read a stale snapshot could clobber a freshly + * added entry (and the file's force-controlled persistedMediaFiles key dropped + * it), so some saved videos lost protection at random. + */ +suite("Persisted media allowlist: concurrency safety", () => { + const newProject = (): string => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-allowlist-")); + fs.mkdirSync(path.join(tempDir, ".project"), { recursive: true }); + return tempDir; + }; + + test("concurrent adds + unrelated settings write keep every entry", async function () { + this.timeout(15000); + const tempDir = newProject(); + const uri = vscode.Uri.file(tempDir); + const { + addPersistedMediaFile, + addPersistedMediaFiles, + getPersistedMediaFiles, + setApplyState, + setMediaFilesStrategy, + } = await import("../../../utils/localProjectSettings"); + + try { + // Fire adds interleaved with unrelated settings writes that previously + // would have clobbered the allowlist. + await Promise.all([ + addPersistedMediaFile("JUD/a.mp4", uri), + setApplyState("applying", uri), + addPersistedMediaFile("JUD/b.mp4", uri), + setMediaFilesStrategy("stream-only", uri), + addPersistedMediaFiles(["JUD/c.mp4", "JUD/d.mp4"], uri), + setApplyState("applied", uri), + ]); + + const entries = (await getPersistedMediaFiles(uri)).sort(); + assert.deepStrictEqual( + entries, + ["JUD/a.mp4", "JUD/b.mp4", "JUD/c.mp4", "JUD/d.mp4"], + "no allowlist entry should be lost to a concurrent settings write" + ); + + // The unrelated write must still have taken effect. + const raw = JSON.parse( + fs.readFileSync(path.join(tempDir, ".project", "localProjectSettings.json"), "utf8") + ); + assert.strictEqual(raw.currentMediaFilesStrategy, "stream-only"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("removeByExtension drops video entries but keeps others", async function () { + this.timeout(15000); + const tempDir = newProject(); + const uri = vscode.Uri.file(tempDir); + const { + addPersistedMediaFiles, + getPersistedMediaFiles, + removePersistedMediaFilesByExtension, + } = await import("../../../utils/localProjectSettings"); + + try { + await addPersistedMediaFiles(["JUD/clip.mp4", "JUD/song.wav", "JUD/movie.mkv"], uri); + await removePersistedMediaFilesByExtension(new Set([".mp4", ".mkv"]), uri); + + const entries = await getPersistedMediaFiles(uri); + assert.deepStrictEqual(entries, ["JUD/song.wav"], "only non-video entries should remain"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/utils/localProjectSettings.ts b/src/utils/localProjectSettings.ts index 4faea823b..2a8be2f8a 100644 --- a/src/utils/localProjectSettings.ts +++ b/src/utils/localProjectSettings.ts @@ -57,6 +57,28 @@ export interface LocalProjectSettings { * Cleared after the switch is applied on project open. */ keepFilesOnStreamAndSave?: boolean; + /** + * When switching from auto-download to stream-and-save AND local videos exist, + * the user decides about video and audio separately. These track each choice: + * true = keep that media type local, false = pointerize it to free space. + * Cleared after the switch is applied on project open. Used in place of + * keepFilesOnStreamAndSave when the granular (video-present) prompt is shown. + */ + keepVideoOnStreamAndSave?: boolean; + keepAudioOnStreamAndSave?: boolean; + /** + * When switching to stream-only AND local videos exist, tracks the user's + * choice: "keep-video" keeps videos local (added to the allowlist) while + * freeing the rest; "free-all" frees everything including saved videos + * (ignores the allowlist). Cleared after the switch is applied on open. + */ + streamOnlyVideoChoice?: "keep-video" | "free-all"; + /** + * When switching stream-only -> stream-and-save AND local videos exist, + * tracks whether to preserve those local videos (true) or pointerize them + * (false). Cleared after the switch is applied on open. + */ + streamAndSavePreserveVideos?: boolean; /** When true, AI Metrics view shows detailed technical metrics instead of simple mode */ detailedAIMetrics?: boolean; /** Track in-progress update for restart-safe cleanup */ @@ -99,6 +121,15 @@ export interface LocalProjectSettings { * (not deleted, not missing) and no explicit selection. */ audioSchemaVersion?: number; + /** + * Rel-paths (within `.project/attachments/files`, e.g. "JUD/JUD_001_025.mp4") + * that the user explicitly chose to keep on this machine via "Save to project". + * These are protected from the stream-only pointer-replacement cleanup + * (`postSyncCleanup` / strategy switches) so they survive reloads. Stored + * locally (this file is gitignored) because the saved copy is a per-machine + * download — `attachments/files/**` is gitignored too. Video-only by design. + */ + persistedMediaFiles?: string[]; // Legacy keys (read and mirrored for backward compatibility) mediaFilesStrategy?: MediaFilesStrategy; lastModeRun?: MediaFilesStrategy; @@ -112,6 +143,40 @@ export const CURRENT_AUDIO_SCHEMA_VERSION = 1; const SETTINGS_FILE_NAME = "localProjectSettings.json"; +/** + * Serializes every write to localProjectSettings.json. Because writers do a + * read-modify-write (read existing JSON, merge, write), concurrent writers + * could otherwise clobber each other's changes — this is what intermittently + * dropped freshly-added `persistedMediaFiles` entries when a save raced an + * unrelated settings write. Each enqueued task reads the latest on-disk state + * inside the lock, so there's no stale-snapshot overwrite. + */ +// One serialization chain PER workspace (keyed by settings-file fsPath), not a +// single global chain. Clobbering can only happen between writes to the SAME +// settings file, so serializing per workspace is both sufficient and correct. +// A global chain is also dangerous: a single task that never settles (e.g. an +// fs operation stubbed to hang in a test, or a stalled disk) would deadlock +// every future settings write across unrelated projects. Per-workspace chains +// keep one project's stall from poisoning another's. +const settingsWriteChains = new Map>(); +function enqueueSettingsWrite(key: string, task: () => Promise): Promise { + const previous = settingsWriteChains.get(key) ?? Promise.resolve(); + const run = previous.then(task, task); + // Keep the chain alive even if a task rejects. + const tail = run.then( + () => undefined, + () => undefined + ); + settingsWriteChains.set(key, tail); + // Drop the entry once it has drained so the map can't grow unbounded. + tail.finally(() => { + if (settingsWriteChains.get(key) === tail) { + settingsWriteChains.delete(key); + } + }); + return run; +} + /** * Gets the path to the local project settings file */ @@ -190,8 +255,12 @@ export async function writeLocalProjectSettings( return; } - // Create a promise for the write operation and register it - const writePromise = writeLocalProjectSettingsInternal(settings, workspaceFolderUri, settingsPath); + // Create a promise for the write operation and register it. Serialize through + // the settings write chain so concurrent read-modify-write cycles can't clobber + // each other (notably the persistedMediaFiles allowlist). + const writePromise = enqueueSettingsWrite(workspaceUri.fsPath, () => + writeLocalProjectSettingsInternal(settings, workspaceFolderUri, settingsPath) + ); // Register this write operation with MetadataManager's tracking system // This ensures waitForPendingWrites will wait for this operation @@ -200,6 +269,34 @@ export async function writeLocalProjectSettings( return writePromise; } +/** + * Atomically read-modify-write the settings file. The mutator runs inside the + * shared write lock against the LATEST on-disk state, so concurrent updates + * (e.g. setApplyState racing setMediaFilesStrategy) can't clobber each other's + * fields. Prefer this over readLocalProjectSettings + writeLocalProjectSettings + * for single-field setters. + */ +export function updateLocalProjectSettings( + mutator: (settings: LocalProjectSettings) => void, + workspaceFolderUri?: vscode.Uri +): Promise { + const workspaceUri = workspaceFolderUri || vscode.workspace.workspaceFolders?.[0]?.uri; + const settingsPath = getSettingsFilePath(workspaceFolderUri); + if (!settingsPath || !workspaceUri) { + console.error("Cannot update local project settings: No workspace folder found"); + return Promise.resolve(); + } + + const writePromise = enqueueSettingsWrite(workspaceUri.fsPath, async () => { + const latest = await readLocalProjectSettings(workspaceFolderUri); + mutator(latest); + await writeLocalProjectSettingsInternal(latest, workspaceFolderUri, settingsPath); + }); + + MetadataManager.registerPendingWrite(workspaceUri.fsPath, writePromise); + return writePromise; +} + /** * Internal implementation of writeLocalProjectSettings */ @@ -229,6 +326,10 @@ async function writeLocalProjectSettingsInternal( mediaFileStrategyApplyState: settings.mediaFileStrategyApplyState ?? (settings as any).applyState ?? "applied", mediaFileStrategySwitchStarted: settings.mediaFileStrategySwitchStarted ?? false, keepFilesOnStreamAndSave: settings.keepFilesOnStreamAndSave, + keepVideoOnStreamAndSave: settings.keepVideoOnStreamAndSave, + keepAudioOnStreamAndSave: settings.keepAudioOnStreamAndSave, + streamOnlyVideoChoice: settings.streamOnlyVideoChoice, + streamAndSavePreserveVideos: settings.streamAndSavePreserveVideos, forceCloseAfterSuccessfulSwap: settings.forceCloseAfterSuccessfulSwap, detailedAIMetrics: settings.detailedAIMetrics, lfsSourceRemoteUrl: settings.lfsSourceRemoteUrl, @@ -239,6 +340,11 @@ async function writeLocalProjectSettingsInternal( autoSyncEnabled: settings.autoSyncEnabled ?? true, syncDelayMinutes: settings.syncDelayMinutes ?? 5, displayedProjectName: settings.displayedProjectName, + // NOTE: persistedMediaFiles is intentionally NOT written here. It is + // owned exclusively by addPersistedMediaFile/removePersistedMediaFile + // (which mutate it atomically under the same write lock). General + // writers preserve the on-disk value via the existingRaw spread below, + // so an unrelated settings write can never clobber the allowlist. }; let existingRaw: Record = {}; try { @@ -248,6 +354,8 @@ async function writeLocalProjectSettingsInternal( existingRaw = {}; } const merged = { ...existingRaw, ...toWrite }; + // Drop dead keys from superseded approaches so the file cleans itself up. + delete (merged as Record).ephemeralStreamMedia; const content = JSON.stringify(merged, null, 2); await vscode.workspace.fs.writeFile(settingsPath, Buffer.from(content, "utf-8")); debug("Wrote local project settings:", merged); @@ -272,20 +380,20 @@ export async function setMediaFilesStrategy( strategy: MediaFilesStrategy, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.mediaFilesStrategy = strategy; // legacy mirror - settings.currentMediaFilesStrategy = strategy; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.mediaFilesStrategy = strategy; // legacy mirror + settings.currentMediaFilesStrategy = strategy; + }, workspaceFolderUri); } export async function setLastModeRun( mode: MediaFilesStrategy, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.lastModeRun = mode; // legacy mirror - settings.lastMediaFileStrategyRun = mode; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.lastModeRun = mode; // legacy mirror + settings.lastMediaFileStrategyRun = mode; + }, workspaceFolderUri); } // Legacy wrapper (kept for compatibility). Prefer setApplyState. @@ -306,23 +414,23 @@ export async function markPendingUpdateRequired( workspaceFolderUri?: vscode.Uri, reason?: string ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.pendingUpdate = { - required: true, - reason, - detectedAt: Date.now(), - }; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.pendingUpdate = { + required: true, + reason, + detectedAt: Date.now(), + }; + }, workspaceFolderUri); } export async function clearPendingUpdate( workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - if (settings.pendingUpdate) { - settings.pendingUpdate = undefined; - await writeLocalProjectSettings(settings, workspaceFolderUri); - } + await updateLocalProjectSettings((settings) => { + if (settings.pendingUpdate) { + settings.pendingUpdate = undefined; + } + }, workspaceFolderUri); } // New explicit helpers (preferred over legacy boolean) @@ -336,11 +444,11 @@ export async function setApplyState( workspaceFolderUri?: vscode.Uri, meta?: { error?: string; } ): Promise { - const s = await readLocalProjectSettings(workspaceFolderUri); - s.mediaFileStrategyApplyState = state; - // Keep the state minimal; no timestamps or error strings persisted - s.changesApplied = state === "applied"; // keep mirror until full removal - await writeLocalProjectSettings(s, workspaceFolderUri); + await updateLocalProjectSettings((s) => { + s.mediaFileStrategyApplyState = state; + // Keep the state minimal; no timestamps or error strings persisted + s.changesApplied = state === "applied"; // keep mirror until full removal + }, workspaceFolderUri); } /** @@ -351,6 +459,137 @@ export async function getMediaFilesStrategyForPath(projectPath: string): Promise return getMediaFilesStrategy(projectUri); } +/** + * Canonical key for the persisted-media allowlist: the path *within* + * `attachments/files` (equivalently `attachments/pointers`), e.g. + * "JUD/JUD_001_025.mp4". Normalized to forward slashes with no leading slash so + * comparisons are stable across OSes and against the OS-native `relPath`s used + * by the cleanup functions. + */ +export function normalizePersistedMediaRelPath(relPath: string): string { + return relPath.replace(/\\/g, "/").replace(/^\/+/, ""); +} + +/** + * Returns the list of media rel-paths the user explicitly saved (allowlist). + * Always returns an array (empty when none/invalid). + */ +export async function getPersistedMediaFiles(workspaceFolderUri?: vscode.Uri): Promise { + const settings = await readLocalProjectSettings(workspaceFolderUri); + return Array.isArray(settings.persistedMediaFiles) ? settings.persistedMediaFiles : []; +} + +/** + * Atomically mutate the persisted-media allowlist. Reads the LATEST on-disk + * value inside the shared settings write lock, applies `mutator`, and writes it + * back — so concurrent saves and unrelated settings writes can never drop an + * entry. This is the sole writer of `persistedMediaFiles` (general writes leave + * the key untouched). Other settings fields in the file are preserved. + */ +function updatePersistedMediaFiles( + workspaceFolderUri: vscode.Uri | undefined, + mutator: (current: string[]) => string[] +): Promise { + const settingsPath = getSettingsFilePath(workspaceFolderUri); + const workspaceUri = workspaceFolderUri || vscode.workspace.workspaceFolders?.[0]?.uri; + if (!settingsPath || !workspaceUri) { + return Promise.resolve(); + } + + const writePromise = enqueueSettingsWrite(workspaceUri.fsPath, async () => { + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(workspaceUri, ".project")); + } catch { + // Directory likely exists already. + } + + let raw: Record = {}; + try { + const content = await vscode.workspace.fs.readFile(settingsPath); + raw = JSON.parse(Buffer.from(content).toString("utf-8")) ?? {}; + } catch { + raw = {}; + } + + const current = Array.isArray(raw.persistedMediaFiles) + ? raw.persistedMediaFiles.map(normalizePersistedMediaRelPath) + : []; + const next = Array.from( + new Set(mutator(current).map(normalizePersistedMediaRelPath).filter(Boolean)) + ); + + if (next.length > 0) { + raw.persistedMediaFiles = next; + } else { + delete raw.persistedMediaFiles; + } + + await vscode.workspace.fs.writeFile( + settingsPath, + Buffer.from(JSON.stringify(raw, null, 2), "utf-8") + ); + }); + + MetadataManager.registerPendingWrite(workspaceUri.fsPath, writePromise); + return writePromise; +} + +/** + * Adds a rel-path to the persisted-media allowlist (no-op if already present). + */ +export async function addPersistedMediaFile( + relPath: string, + workspaceFolderUri?: vscode.Uri +): Promise { + const normalized = normalizePersistedMediaRelPath(relPath); + if (!normalized) return; + await updatePersistedMediaFiles(workspaceFolderUri, (current) => + current.includes(normalized) ? current : [...current, normalized] + ); +} + +/** + * Adds many rel-paths to the persisted-media allowlist in a single atomic write. + * Used when the user chooses to keep videos while switching to a stream mode. + */ +export async function addPersistedMediaFiles( + relPaths: string[], + workspaceFolderUri?: vscode.Uri +): Promise { + if (!relPaths || relPaths.length === 0) return; + await updatePersistedMediaFiles(workspaceFolderUri, (current) => [...current, ...relPaths]); +} + +/** + * Removes a rel-path from the persisted-media allowlist (no-op if absent). + * Call this when a saved video is replaced or deleted so the list doesn't + * protect a stale/empty slot. + */ +export async function removePersistedMediaFile( + relPath: string, + workspaceFolderUri?: vscode.Uri +): Promise { + const normalized = normalizePersistedMediaRelPath(relPath); + if (!normalized) return; + await updatePersistedMediaFiles(workspaceFolderUri, (current) => + current.filter((p) => p !== normalized) + ); +} + +/** + * Removes every allowlist entry whose file extension is in `extensions` + * (lower-case, dot-prefixed, e.g. ".mp4"). Used when an explicit "Free Space" + * choice deliberately frees previously-saved videos. + */ +export async function removePersistedMediaFilesByExtension( + extensions: Set, + workspaceFolderUri?: vscode.Uri +): Promise { + await updatePersistedMediaFiles(workspaceFolderUri, (current) => + current.filter((p) => !extensions.has(path.extname(p).toLowerCase())) + ); +} + /** * Ensure the localProjectSettings.json file exists. If missing, create it * with sensible defaults that avoid unnecessary work. @@ -407,9 +646,9 @@ export async function setAudioSchemaVersion( version: number, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.audioSchemaVersion = version; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.audioSchemaVersion = version; + }, workspaceFolderUri); } /** @@ -427,9 +666,9 @@ export async function setSwitchStarted( value: boolean, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.mediaFileStrategySwitchStarted = !!value; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.mediaFileStrategySwitchStarted = !!value; + }, workspaceFolderUri); } /** @@ -440,12 +679,12 @@ export async function markUpdateCompletedLocally( username: string, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.updateCompletedLocally = { - username, - completedAt: Date.now(), - }; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.updateCompletedLocally = { + username, + completedAt: Date.now(), + }; + }, workspaceFolderUri); debug("Marked update as completed locally for user:", username); } @@ -455,12 +694,12 @@ export async function markUpdateCompletedLocally( export async function clearUpdateCompletedLocally( workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - if (settings.updateCompletedLocally) { - settings.updateCompletedLocally = undefined; - await writeLocalProjectSettings(settings, workspaceFolderUri); - debug("Cleared local update completion flag"); - } + await updateLocalProjectSettings((settings) => { + if (settings.updateCompletedLocally) { + settings.updateCompletedLocally = undefined; + debug("Cleared local update completion flag"); + } + }, workspaceFolderUri); } // ============================================================================= @@ -509,9 +748,10 @@ async function migrateSyncSettingsFromVSCodeConfig( syncDelayMinutes = SYNC_DELAY_MINIMUM; } - settings.autoSyncEnabled = autoSyncEnabled; - settings.syncDelayMinutes = syncDelayMinutes; - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((s) => { + s.autoSyncEnabled = autoSyncEnabled; + s.syncDelayMinutes = syncDelayMinutes; + }, workspaceFolderUri); debug("Migrated sync settings from VS Code config:", { autoSyncEnabled, syncDelayMinutes }); if (!autoSyncEnabled) { @@ -549,10 +789,10 @@ export async function setSyncSettings( syncDelayMinutes: number, workspaceFolderUri?: vscode.Uri ): Promise { - const settings = await readLocalProjectSettings(workspaceFolderUri); - settings.autoSyncEnabled = autoSyncEnabled; - settings.syncDelayMinutes = Math.max(syncDelayMinutes, SYNC_DELAY_MINIMUM); - await writeLocalProjectSettings(settings, workspaceFolderUri); + await updateLocalProjectSettings((settings) => { + settings.autoSyncEnabled = autoSyncEnabled; + settings.syncDelayMinutes = Math.max(syncDelayMinutes, SYNC_DELAY_MINIMUM); + }, workspaceFolderUri); } // ============================================================================= diff --git a/src/utils/mediaStrategyManager.ts b/src/utils/mediaStrategyManager.ts index e7fb48237..6d419594f 100644 --- a/src/utils/mediaStrategyManager.ts +++ b/src/utils/mediaStrategyManager.ts @@ -8,6 +8,10 @@ import { setLastModeRun, setChangesApplied, getFlags, + getPersistedMediaFiles, + normalizePersistedMediaRelPath, + addPersistedMediaFiles, + removePersistedMediaFilesByExtension, } from "./localProjectSettings"; import { findAllPointerFiles, @@ -20,6 +24,124 @@ import { setCachedLfsBytes } from "./mediaCache"; const DEBUG = false; const debug = DEBUG ? (...args: any[]) => console.log("[MediaStrategyManager]", ...args) : () => { }; +// Video extensions get a disk-backed session cache (outside the project) instead +// of the in-memory LFS cache, since video files are large. +const VIDEO_EXTENSIONS = new Set([".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v"]); + +// `.webm` is ambiguous: browser audio recordings (MediaRecorder) are saved as +// `.webm` too, and they live in the SAME attachments/files// tree as +// videos. So a plain extension check would miscount every audio take as a +// "video" (e.g. 1000+ audio cells reported as 1000+ videos). These extensions +// are therefore resolved against the actual notebook `videoUrl` references +// rather than the extension alone. +const AMBIGUOUS_VIDEO_EXTENSIONS = new Set([".webm"]); +// Extensions that are unambiguously video — never produced by the audio recorder. +const UNAMBIGUOUS_VIDEO_EXTENSIONS = new Set( + [...VIDEO_EXTENSIONS].filter((ext) => !AMBIGUOUS_VIDEO_EXTENSIONS.has(ext)) +); + +const FILES_SEGMENT = "attachments/files/"; + +/** + * Reads every notebook's `videoUrl` metadata and returns the set of rel-paths + * (within `attachments/files`, forward-slashed, e.g. "JUD/clip.webm") that are + * referenced as videos. This is the source of truth for distinguishing a real + * video from an audio recording that happens to share the `.webm` extension. + * + * `.codex` files live in `files/target`, `.source` files in + * `.project/sourceTexts`. We scan the raw text for the `videoUrl` field instead + * of fully parsing each (potentially large) notebook JSON. + */ +export async function collectVideoReferenceRelPaths(projectPath: string): Promise> { + const refs = new Set(); + const notebookDirs = [ + { dir: path.join(projectPath, "files", "target"), ext: ".codex" }, + { dir: path.join(projectPath, ".project", "sourceTexts"), ext: ".source" }, + ]; + + const videoUrlPattern = /"videoUrl"\s*:\s*"((?:\\.|[^"\\])*)"/g; + + const scanNotebookDir = async (dirPath: string, fileExt: string): Promise => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + await scanNotebookDir(fullPath, fileExt); + continue; + } + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(fileExt)) { + continue; + } + let text: string; + try { + text = await fs.promises.readFile(fullPath, "utf8"); + } catch { + continue; + } + videoUrlPattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = videoUrlPattern.exec(text)) !== null) { + let raw = match[1]; + try { + raw = JSON.parse(`"${match[1]}"`); + } catch { + // Fall back to the raw captured value if it can't be unescaped. + } + const rel = videoUrlToFilesRelPath(raw); + if (rel) { + refs.add(rel); + } + } + } + }; + + for (const { dir, ext } of notebookDirs) { + await scanNotebookDir(dir, ext); + } + return refs; +} + +/** + * Converts a stored `videoUrl` (workspace-relative path, absolute path, or + * `file://` URI) into its rel-path within `attachments/files`, or null for + * remote URLs and references outside the managed attachments tree. + */ +function videoUrlToFilesRelPath(videoUrl: string): string | null { + if (!videoUrl || /^https?:\/\//i.test(videoUrl)) { + return null; + } + const normalized = videoUrl.replace(/\\/g, "/"); + const idx = normalized.indexOf(FILES_SEGMENT); + if (idx === -1) { + return null; + } + return normalized + .substring(idx + FILES_SEGMENT.length) + .replace(/^\/+/, ""); +} + +/** + * Decides whether a file (by its rel-path within attachments/files) should be + * treated as a video. Unambiguous video extensions always qualify; ambiguous + * ones (`.webm`) only qualify when referenced by a notebook's `videoUrl`, so + * audio recordings sharing the extension are not mistaken for videos. + */ +function isVideoRelPath(relPath: string, videoRefs: Set): boolean { + const ext = path.extname(relPath).toLowerCase(); + if (UNAMBIGUOUS_VIDEO_EXTENSIONS.has(ext)) { + return true; + } + if (AMBIGUOUS_VIDEO_EXTENSIONS.has(ext)) { + return videoRefs.has(relPath.replace(/\\/g, "/").replace(/^\/+/, "")); + } + return false; +} + /** * Replace specific files in attachments/files with their pointer versions * This is optimized for post-sync cleanup where we know exactly which files were uploaded @@ -44,6 +166,12 @@ export async function replaceSpecificFilesWithPointers(projectPath: string, uplo debug(`Processing ${pointerFiles.length} uploaded pointer file(s) for replacement`); + // Allowlist of files the user explicitly saved via "Save to project" — never + // revert these to pointers, even in stream-only mode. + const persisted = new Set( + (await getPersistedMediaFiles(vscode.Uri.file(projectPath))).map(normalizePersistedMediaRelPath) + ); + // Process each file without showing progress UI (it's fast for a few files) for (const filepath of pointerFiles) { // Extract the relative path within the pointers directory @@ -56,11 +184,17 @@ export async function replaceSpecificFilesWithPointers(projectPath: string, uplo if (!relPath) continue; + if (persisted.has(normalizePersistedMediaRelPath(relPath))) { + debug(`PROTECTED: Skipping user-saved media file: ${relPath}`); + continue; + } + try { const pointerPath = path.join(projectPath, ".project", "attachments", "pointers", relPath); const filesPath = path.join(projectPath, ".project", "attachments", "files", relPath); - // If files/ has real bytes, cache them in-memory before replacement + // If files/ has real bytes, cache them for this session before + // replacing the file with a pointer. const filesIsPointer = await isPointerFile(filesPath).catch(() => false); if (!filesIsPointer) { const pointerContent = await vscode.workspace.fs.readFile(vscode.Uri.file(pointerPath)); @@ -68,7 +202,21 @@ export async function replaceSpecificFilesWithPointers(projectPath: string, uplo const pointerInfo = parsePointerContent(pointerText); if (pointerInfo?.oid) { const fileBytes = await vscode.workspace.fs.readFile(vscode.Uri.file(filesPath)); - setCachedLfsBytes(pointerInfo.oid, fileBytes); + const ext = path.extname(relPath).toLowerCase(); + if (VIDEO_EXTENSIONS.has(ext)) { + // Videos are large: keep a session copy on disk (outside + // the project) instead of in RAM, so a stream-only video + // just uploaded replays this session without re-downloading. + try { + const { writeCachedVideo } = await import("./videoStreamCache"); + await writeCachedVideo(undefined, pointerInfo.oid, ext, fileBytes); + } catch { + // Session cache unavailable — it will re-download on next play. + } + } else { + // Audio (small): in-memory cache is fine. + setCachedLfsBytes(pointerInfo.oid, fileBytes); + } } } } catch { @@ -128,13 +276,152 @@ export async function countDownloadedMediaFiles(projectPath: string): Promise { + const filesDir = path.join(projectPath, ".project", "attachments", "files"); + const relPaths: string[] = []; + + if (!fs.existsSync(filesDir)) { + return relPaths; + } + + // Source of truth for resolving ambiguous `.webm` files (audio vs video). + const videoRefs = await collectVideoReferenceRelPaths(projectPath); + + const scanDir = async (dirPath: string, relPrefix: string): Promise => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await scanDir(fullPath, rel); + } else if ( + entry.isFile() && + !entry.name.startsWith(".") && + isVideoRelPath(rel, videoRefs) + ) { + const isPtr = await isPointerFile(fullPath).catch(() => false); + if (!isPtr) { + relPaths.push(rel); + } + } + } + }; + + try { + await scanDir(filesDir, ""); + } catch (error) { + debug("Error collecting local video rel-paths:", error); + } + return relPaths; +} + +/** + * Number of locally-present (real-bytes) video files. Drives the decision to + * show the granular "keep video / free space" prompt when switching strategies. + */ +export async function countLocalVideoFiles(projectPath: string): Promise { + return (await collectLocalVideoRelPaths(projectPath)).length; +} + +/** + * Count locally-present AUDIO files that would be removed (reverted to pointers) + * when switching to stream-only. This mirrors the deletion eligibility used by + * {@link replaceFilesWithPointers}: only real-byte audio files that are synced + * (a pointer exists in `pointers/`) and not in the persisted "save to project" + * allowlist. Locally-recorded, not-yet-synced takes are excluded because they + * would be lost if pointerized, so they are never removed. Used to tell the user + * how much synced audio a stream-only switch will free. + */ +export async function countSyncedDeletableAudioFiles(projectPath: string): Promise { + const filesDir = path.join(projectPath, ".project", "attachments", "files"); + const pointersDir = path.join(projectPath, ".project", "attachments", "pointers"); + if (!fs.existsSync(filesDir)) { + return 0; + } + + // Source of truth for resolving ambiguous `.webm` files (audio vs video). + const videoRefs = await collectVideoReferenceRelPaths(projectPath); + const persisted = new Set( + (await getPersistedMediaFiles(vscode.Uri.file(projectPath))).map(normalizePersistedMediaRelPath) + ); + + let count = 0; + const scanDir = async (dirPath: string, relPrefix: string): Promise => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await scanDir(fullPath, rel); + continue; + } + if (!entry.isFile() || entry.name.startsWith(".")) { + continue; + } + // Audio only — leave videos to the video-specific prompts. + if (isVideoRelPath(rel, videoRefs)) { + continue; + } + // Must currently hold real bytes (not already a pointer stub). + if (await isPointerFile(fullPath).catch(() => false)) { + continue; + } + // User-saved files are never removed by automatic cleanup. + if (persisted.has(normalizePersistedMediaRelPath(rel))) { + continue; + } + // Only synced files are removed: a synced file has a pointer in + // pointers/, whereas a local recording not yet uploaded has full + // bytes there (or no pointer at all) and must be preserved. + const pointersPath = path.join(pointersDir, rel.split("/").join(path.sep)); + let synced = false; + try { + await fs.promises.stat(pointersPath); + synced = await isPointerFile(pointersPath).catch(() => false); + } catch { + synced = false; + } + if (!synced) { + continue; + } + count++; + } + }; + + try { + await scanDir(filesDir, ""); + } catch (error) { + debug("Error counting synced deletable audio files:", error); + } + return count; +} + /** * Replace all downloaded files in attachments/files with their pointer versions * This is used when switching to stream-only or stream-and-save modes * @param projectPath - Root path of the project * @returns Number of files replaced */ -export async function replaceFilesWithPointers(projectPath: string): Promise { +export async function replaceFilesWithPointers( + projectPath: string, + options?: { ignorePersisted?: boolean; restrictToVideos?: boolean; restrictToAudio?: boolean; } +): Promise { let replacedCount = 0; try { @@ -145,6 +432,22 @@ export async function replaceFilesWithPointers(projectPath: string): Promise() + : new Set( + (await getPersistedMediaFiles(vscode.Uri.file(projectPath))).map(normalizePersistedMediaRelPath) + ); + + // When restricting by media kind, resolve the ambiguous `.webm` extension + // against real notebook video references so audio takes aren't treated + // as videos (and vice-versa). + const videoRefs = options?.restrictToVideos || options?.restrictToAudio + ? await collectVideoReferenceRelPaths(projectPath) + : new Set(); + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -169,6 +472,27 @@ export async function replaceFilesWithPointers(projectPath: string): Promise + // stream-and-save "don't preserve"), leave non-video + // media exactly as-is. + const relIsVideo = isVideoRelPath(relPath.replace(/\\/g, "/"), videoRefs); + if (options?.restrictToVideos && !relIsVideo) { + return false; + } + // When restricted to audio (e.g. auto-download -> + // stream-and-save "keep video, free audio"), leave + // videos exactly as-is. + if (options?.restrictToAudio && relIsVideo) { + return false; + } + + // PROTECTED: user explicitly saved this file via + // "Save to project" — never revert it to a pointer. + if (persisted.has(normalizePersistedMediaRelPath(relPath))) { + debug(`PROTECTED: Skipping user-saved media file: ${relPath}`); + return false; + } + // CRITICAL: Check if this is a locally recorded, unsynced file // These files exist in files/ but haven't been uploaded yet // We MUST NOT replace them with pointers to avoid data loss @@ -553,9 +877,13 @@ export async function applyMediaStrategyAndRecord( const settingsMod = await import("./localProjectSettings"); const switchStarted = await settingsMod.getSwitchStarted(projectUri); - // Check if there's a pending keepFilesOnStreamAndSave choice that needs to be applied + // Check if there's a pending keep/free choice that needs to be applied + // (any of these require file operations, so we must not take the fast path). const settings = await settingsMod.readLocalProjectSettings(projectUri); - const hasKeepFilesChoice = settings.keepFilesOnStreamAndSave !== undefined; + const hasKeepFilesChoice = + settings.keepFilesOnStreamAndSave !== undefined || + settings.keepVideoOnStreamAndSave !== undefined || + settings.keepAudioOnStreamAndSave !== undefined; // Only skip if returning to last run strategy AND no interrupted switch // AND no pending keepFilesOnStreamAndSave choice (which requires file operations) @@ -696,41 +1024,130 @@ export async function applyMediaStrategy( break; } case "stream-only": { - // Always replace files with pointers to free disk space - const replacedCount = await replaceFilesWithPointers(projectPath); + const { readLocalProjectSettings, writeLocalProjectSettings } = await import("./localProjectSettings"); + const settings = await readLocalProjectSettings(projectUri); + const videoChoice = settings.streamOnlyVideoChoice; + + let replacedCount = 0; + if (videoChoice === "keep-video") { + // Keep locally-present videos: add them to the allowlist so they + // survive this and future *automatic* cleanups, then free the rest. + const localVideos = await collectLocalVideoRelPaths(projectPath); + if (localVideos.length > 0) { + await addPersistedMediaFiles(localVideos, projectUri); + } + replacedCount = await replaceFilesWithPointers(projectPath); + } else if (videoChoice === "free-all") { + // Explicit "Free Space": free everything including previously + // saved videos, and drop them from the allowlist so a later sync + // won't re-protect them. + replacedCount = await replaceFilesWithPointers(projectPath, { ignorePersisted: true }); + await removePersistedMediaFilesByExtension(VIDEO_EXTENSIONS, projectUri); + } else { + // No prompt (no local videos): default cleanup, honoring allowlist. + replacedCount = await replaceFilesWithPointers(projectPath); + } if (replacedCount > 0) { vscode.window.showInformationMessage(`Removed ${replacedCount} file(s). Media will stream.`); } else { vscode.window.showInformationMessage("Media will stream when needed."); } + + // Clear the choice after applying. + if (settings.streamOnlyVideoChoice !== undefined) { + settings.streamOnlyVideoChoice = undefined; + await writeLocalProjectSettings(settings, projectUri); + } break; } case "stream-and-save": { - // Check if user chose to keep or free files during strategy switch - // (only relevant when switching from auto-download) const { readLocalProjectSettings, writeLocalProjectSettings } = await import("./localProjectSettings"); const settings = await readLocalProjectSettings(projectUri); - if (settings.keepFilesOnStreamAndSave === false) { - // User chose to free space - replace files with pointers - const replacedCount = await replaceFilesWithPointers(projectPath); + // Granular auto-download -> stream-and-save choice (video present): + // the user decided about video and audio independently. + const hasGranularChoice = + settings.keepVideoOnStreamAndSave !== undefined || + settings.keepAudioOnStreamAndSave !== undefined; + + if (hasGranularChoice) { + // Default missing flag to "keep" (only free what was explicitly chosen). + const freeVideo = settings.keepVideoOnStreamAndSave === false; + const freeAudio = settings.keepAudioOnStreamAndSave === false; + let replacedCount = 0; + if (freeVideo) { + replacedCount += await replaceFilesWithPointers(projectPath, { + ignorePersisted: true, + restrictToVideos: true, + }); + // Freed videos must not stay protected by the allowlist. + await removePersistedMediaFilesByExtension(VIDEO_EXTENSIONS, projectUri); + } + if (freeAudio) { + replacedCount += await replaceFilesWithPointers(projectPath, { + ignorePersisted: true, + restrictToAudio: true, + }); + } + // Kept media simply stays local; stream-and-save never auto-pointerizes, + // so no allowlisting is needed to preserve it. + if (replacedCount > 0) { + vscode.window.showInformationMessage(`Removed ${replacedCount} file(s). Media will stream and save.`); + } else { + vscode.window.showInformationMessage("Files kept. Media will stream and save when accessed."); + } + } else if (settings.keepFilesOnStreamAndSave === false) { + // Switching from auto-download, user chose to free space. This is + // an explicit cleanup, so it also frees previously saved videos. + const replacedCount = await replaceFilesWithPointers(projectPath, { ignorePersisted: true }); + await removePersistedMediaFilesByExtension(VIDEO_EXTENSIONS, projectUri); if (replacedCount > 0) { vscode.window.showInformationMessage(`Removed ${replacedCount} file(s). Media will stream and save.`); } else { vscode.window.showInformationMessage("Media will stream and save when accessed."); } } else if (settings.keepFilesOnStreamAndSave === true) { - // User chose to keep files + // Switching from auto-download, user chose to keep files. vscode.window.showInformationMessage("Files kept. Media will stream and save when accessed."); + } else if (settings.streamAndSavePreserveVideos === true) { + // Switching from stream-only, user chose to preserve local videos: + // allowlist them so automatic cleanup keeps them. + const localVideos = await collectLocalVideoRelPaths(projectPath); + if (localVideos.length > 0) { + await addPersistedMediaFiles(localVideos, projectUri); + } + vscode.window.showInformationMessage("Videos kept. Media will stream and save when accessed."); + } else if (settings.streamAndSavePreserveVideos === false) { + // Switching from stream-only, user chose NOT to preserve videos: + // pointerize only the videos (audio is already pointers here). + const replacedCount = await replaceFilesWithPointers(projectPath, { + ignorePersisted: true, + restrictToVideos: true, + }); + await removePersistedMediaFilesByExtension(VIDEO_EXTENSIONS, projectUri); + if (replacedCount > 0) { + vscode.window.showInformationMessage(`Removed ${replacedCount} video(s). Media will stream and save.`); + } else { + vscode.window.showInformationMessage("Media will stream and save when accessed."); + } } else { - // No choice stored (e.g., switching from stream-only) + // No choice stored (e.g., switching from auto-download back to last + // run, or stream-only with no local videos): preserve everything. vscode.window.showInformationMessage("Media will stream and save when accessed."); } - // Clear the flag after applying - if (settings.keepFilesOnStreamAndSave !== undefined) { + // Clear the flags after applying. + if ( + settings.keepFilesOnStreamAndSave !== undefined || + settings.keepVideoOnStreamAndSave !== undefined || + settings.keepAudioOnStreamAndSave !== undefined || + settings.streamAndSavePreserveVideos !== undefined + ) { settings.keepFilesOnStreamAndSave = undefined; + settings.keepVideoOnStreamAndSave = undefined; + settings.keepAudioOnStreamAndSave = undefined; + settings.streamAndSavePreserveVideos = undefined; await writeLocalProjectSettings(settings, projectUri); } break; diff --git a/src/utils/videoStreamCache.ts b/src/utils/videoStreamCache.ts new file mode 100644 index 000000000..104f99b99 --- /dev/null +++ b/src/utils/videoStreamCache.ts @@ -0,0 +1,182 @@ +import * as vscode from "vscode"; + +/** + * On-disk cache for stream-only "Load video" playback. + * + * Large videos can't live in the in-memory media cache (they blow the cap and + * are expensive to ship over the webview boundary), and they must NOT pollute + * the project tree — in stream-only mode `.project/attachments/files/` is meant + * to stay as LFS pointers. So temporary videos are written here, completely + * outside the project, keyed by their LFS oid. The folder is cleared on every + * activation so a "loaded" video re-streams after a reload, mirroring how the + * in-memory audio cache behaves. + * + * All helpers degrade gracefully when the extension has no global storage + * (e.g. some test harnesses), treating the cache as simply unavailable. + */ + +const CACHE_DIR_NAME = "videoStreamCache"; + +/** + * Fires whenever the session video cache gains or loses a copy (load / save / + * free). The cache lives in extension global storage, which no workspace + * FileSystemWatcher can observe, so this event lets interested views (e.g. the + * navigation cards) refresh their "loaded (temporary)" state immediately. + */ +const cacheChangeEmitter = new vscode.EventEmitter(); +/** + * Fires with the affected LFS oid on a single write/delete, or `undefined` when + * the whole cache is cleared (so listeners should refresh everything). + */ +export const onDidChangeVideoStreamCache: vscode.Event = + cacheChangeEmitter.event; + +/** + * Remembered global-storage location. Captured whenever a helper is called with + * a real ExtensionContext (e.g. on activation), so later operations that don't + * have a context on hand — such as post-sync cleanup — can still find the cache. + */ +let cachedStorageBase: vscode.Uri | undefined; + +/** + * Returns the root folder for the video stream cache (under the extension's + * global storage, i.e. outside any project), or `undefined` if global storage + * isn't available. A context is optional: when omitted, the last-seen global + * storage location is used. + */ +export function getVideoStreamCacheRoot( + context?: vscode.ExtensionContext +): vscode.Uri | undefined { + if (context?.globalStorageUri) { + cachedStorageBase = context.globalStorageUri; + } + const base = context?.globalStorageUri ?? cachedStorageBase; + if (!base) { + return undefined; + } + return vscode.Uri.joinPath(base, CACHE_DIR_NAME); +} + +/** + * Builds the cache URI for a given LFS object, or `undefined` if the cache is + * unavailable. The original extension is kept so the player can infer the media + * type from the path. + */ +export function getCachedVideoUri( + context: vscode.ExtensionContext | undefined, + oid: string, + ext?: string +): vscode.Uri | undefined { + const root = getVideoStreamCacheRoot(context); + if (!root) { + return undefined; + } + const safeExt = ext ? (ext.startsWith(".") ? ext : `.${ext}`) : ""; + return vscode.Uri.joinPath(root, `${oid}${safeExt}`); +} + +/** + * Whether a cached copy already exists for this session. + */ +export async function hasCachedVideo( + context: vscode.ExtensionContext | undefined, + oid: string, + ext?: string +): Promise { + const uri = getCachedVideoUri(context, oid, ext); + if (!uri) { + return false; + } + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} + +/** + * Writes bytes to the cache and returns the cache file URI. Throws if the cache + * is unavailable so callers can fall back to an error state. + */ +export async function writeCachedVideo( + context: vscode.ExtensionContext | undefined, + oid: string, + ext: string | undefined, + bytes: Uint8Array +): Promise { + const root = getVideoStreamCacheRoot(context); + const uri = getCachedVideoUri(context, oid, ext); + if (!root || !uri) { + throw new Error("Video cache is unavailable (no extension global storage)."); + } + await vscode.workspace.fs.createDirectory(root); + await vscode.workspace.fs.writeFile(uri, bytes); + cacheChangeEmitter.fire(oid); + return uri; +} + +/** + * Reads the cached bytes for an LFS object, or `undefined` if there is no + * session copy (or the cache is unavailable). Used to "save to project" an + * already-streamed video by moving it out of the cache without re-downloading. + */ +export async function readCachedVideo( + context: vscode.ExtensionContext | undefined, + oid: string, + ext?: string +): Promise { + const uri = getCachedVideoUri(context, oid, ext); + if (!uri) { + return undefined; + } + try { + return await vscode.workspace.fs.readFile(uri); + } catch { + return undefined; + } +} + +/** + * Removes a single cached video. Used after moving a streamed copy into the + * project so the bytes live in exactly one place. + */ +export async function deleteCachedVideo( + context: vscode.ExtensionContext | undefined, + oid: string, + ext?: string +): Promise { + const uri = getCachedVideoUri(context, oid, ext); + if (!uri) { + return; + } + try { + await vscode.workspace.fs.delete(uri, { useTrash: false }); + } catch { + // Nothing cached — nothing to remove. + } + cacheChangeEmitter.fire(oid); +} + +/** + * Deletes the entire cache folder. Called on activation so temporary videos do + * not survive a reload (matches the in-memory audio cache lifetime). + */ +export async function clearVideoStreamCache( + context?: vscode.ExtensionContext +): Promise { + const root = getVideoStreamCacheRoot(context); + if (!root) { + return; + } + try { + await vscode.workspace.fs.delete(root, { + recursive: true, + useTrash: false, + }); + } catch { + // Folder doesn't exist yet — nothing to clear. + } + // undefined oid → "everything changed", listeners refresh all open videos. + cacheChangeEmitter.fire(undefined); +} diff --git a/types/index.d.ts b/types/index.d.ts index 4a62f3622..7ad6ffc0c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -416,6 +416,11 @@ export type EditorPostMessages = | { command: "resolveHtmlStructure"; content: { cellId: string; }; } | { command: "updateNotebookMetadata"; content: CustomNotebookMetadata; } | { command: "pickVideoFile"; } + | { command: "deleteVideoFile"; } + | { command: "freeVideoDiskSpace"; } + | { command: "requestVideoStreamUrl"; } + | { command: "requestVideoReferenceStatus"; } + | { command: "downloadVideoFile"; persist?: boolean; } | { command: "getSourceText"; content: { cellId: string; }; } | { command: "searchSimilarCellIds"; content: { cellId: string; }; } | { command: "updateCellTimestamps"; content: { cellId: string; timestamps: Timestamps; }; } @@ -1898,6 +1903,30 @@ interface CodexItem { sortOrder?: string; fileDisplayName?: string; enforceHtmlStructure?: boolean; + /** True when this document references a chapter video (remote URL or local file). */ + hasVideo?: boolean; + /** + * How the referenced chapter video is currently available: + * - "url" → remote streamed URL + * - "saved" → downloaded real bytes in the project + * - "streamable" → LFS pointer only (download/stream on demand) + * - "missing" → local reference resolving to neither bytes nor a pointer + */ + videoAvailability?: "url" | "saved" | "streamable" | "missing"; + /** + * True when a streamable (not-downloaded) video currently has a temporary + * copy in this session's video cache (i.e. it was "loaded" this session). + * Lets the card distinguish "loaded (temporary)" from "available to download". + */ + videoCached?: boolean; + /** Size of a local video in bytes when known (real bytes or LFS pointer size). */ + videoSizeBytes?: number; + /** + * Internal: the raw stored video reference, kept on the host so video + * availability can be recomputed on demand (download/free) without + * re-reading the notebook. Stripped before sending to the webview. + */ + videoUrl?: string; } type EditorReceiveMessages = | { @@ -2068,6 +2097,19 @@ type EditorReceiveMessages = | { type: "jumpToSection"; content: string; } | { type: "providerUpdatesNotebookMetadataForWebview"; content: CustomNotebookMetadata; } | { type: "updateVideoUrlInWebview"; content: string; } + | { type: "videoStreamResolving"; } + | { type: "videoStreamUnavailable"; reason: "offline" | "not-authenticated" | "not-found" | "error"; message?: string; } + | { type: "videoNeedsDownload"; strategy: "auto-download" | "stream-and-save" | "stream-only"; } + | { + type: "videoReferenceStatus"; + status: "none" | "url" | "local-usable" | "missing"; + // True only in stream-and-save when a downloaded local copy exists and is + // LFS-backed, so it can be reverted to a pointer to free space and re-streamed. + canFreeDiskSpace?: boolean; + // Size of the referenced video in bytes when known (real local bytes or the + // size recorded in the LFS pointer). Omitted for remote URLs / unknown. + videoSizeBytes?: number; + } | { type: "milestoneProgressUpdate"; milestoneProgress: Record void; - onSaveMetadata: () => void; + onSaveMetadata: (updated: CustomNotebookMetadata) => void; onPickFile: () => void; - onUpdateVideoUrl: (url: string) => void; - tempVideoUrl: string; + videoCanFreeDiskSpace: boolean; + onFreeVideoDiskSpace: () => void; + videoReferenceStatus: "none" | "url" | "local-usable" | "missing" | null; + videoSizeBytes?: number | null; toggleScrollSync: () => void; scrollSyncEnabled: boolean; translationUnitsForSection: QuillCellContent[]; @@ -115,8 +117,10 @@ export function ChapterNavigationHeader({ onMetadataChange, onSaveMetadata, onPickFile, - onUpdateVideoUrl, - tempVideoUrl, + videoCanFreeDiskSpace, + onFreeVideoDiskSpace, + videoReferenceStatus, + videoSizeBytes, toggleScrollSync, scrollSyncEnabled, translationUnitsForSection, @@ -405,12 +409,9 @@ ChapterNavigationHeaderProps) { setIsMetadataModalOpen(false); }; - const handleSaveMetadata = () => { - onSaveMetadata(); + const handleSaveMetadata = (updated: CustomNotebookMetadata) => { + onSaveMetadata(updated); setIsMetadataModalOpen(false); - if (metadata?.videoUrl) { - onUpdateVideoUrl(metadata.videoUrl); - } }; const handleFontSizeChange = (value: number[]) => { @@ -1181,10 +1182,12 @@ ChapterNavigationHeaderProps) { isOpen={isMetadataModalOpen} onClose={handleCloseMetadataModal} metadata={metadata} - onMetadataChange={onMetadataChange} onSave={handleSaveMetadata} onPickFile={onPickFile} - tempVideoUrl={tempVideoUrl} + canFreeDiskSpace={videoCanFreeDiskSpace} + onFreeDiskSpace={onFreeVideoDiskSpace} + videoReferenceStatus={videoReferenceStatus} + videoSizeBytes={videoSizeBytes} /> )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index 3f5b452f7..aa1df905e 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -174,6 +174,28 @@ const CodexCellEditor: React.FC = () => { videoUrl: "", // FIXME: use attachments instead of videoUrl } as CustomNotebookMetadata); const [videoUrl, setVideoUrl] = useState(""); + // Set when the host reports a streamed video can't be played (offline, not + // logged in, no LFS reference, etc.); cleared once a playable URL arrives. + const [videoUnavailableMessage, setVideoUnavailableMessage] = useState(null); + // True while the host resolves a playable URL (stream resolve or download). + const [videoResolving, setVideoResolving] = useState(false); + // Set when the chapter video is an LFS pointer and must be downloaded before + // it can play. The active media strategy determines the wording/actions. + const [videoNeedsDownloadStrategy, setVideoNeedsDownloadStrategy] = useState< + "auto-download" | "stream-and-save" | "stream-only" | null + >(null); + // Host-reported status of the stored video reference. Drives whether the + // "Show Video" toggle appears (a broken/missing reference is treated as no + // video) and lets the modal flag a missing file. `null` until first report. + const [videoReferenceStatus, setVideoReferenceStatus] = useState< + "none" | "url" | "local-usable" | "missing" | null + >(null); + // Stream-and-save only: a downloaded local copy exists that can be reverted to + // a pointer to free disk space (re-streamed on demand). + const [videoCanFreeDiskSpace, setVideoCanFreeDiskSpace] = useState(false); + // Size (bytes) of the referenced video when known (local bytes or LFS pointer + // size). null until reported / unknown (e.g. remote URLs). + const [videoSizeBytes, setVideoSizeBytes] = useState(null); const playerRef = useRef(null); const [shouldShowVideoPlayer, setShouldShowVideoPlayer] = useState(false); const [muteVideoAudioDuringPlayback, setMuteVideoAudioDuringPlayback] = useState(true); @@ -1287,11 +1309,6 @@ const CodexCellEditor: React.FC = () => { } }, [chapterNumber, isSourceText, highlightedCellId, lastHighlightedChapter]); - // A "temp" video URL that is used to update the video URL in the metadata modal. - // We need to use the client-side file picker, so we need to then pass the picked - // video URL back to the extension so the user can save or cancel the change. - const [tempVideoUrl, setTempVideoUrl] = useState(""); - // Debug timestamp to track when a cell started processing const processingStartTimeRef = useRef(null); @@ -1734,7 +1751,58 @@ const CodexCellEditor: React.FC = () => { } }, updateVideoUrl: (url: string) => { - setTempVideoUrl(url); + // The host resolves the best playable URL (local webview URI or remote + // URL) and pushes it here. + setVideoUrl(url); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(false); + }, + videoReferenceStatus: (status, canFreeDiskSpace, sizeBytes) => { + setVideoReferenceStatus(status); + setVideoCanFreeDiskSpace(!!canFreeDiskSpace); + setVideoSizeBytes(typeof sizeBytes === "number" ? sizeBytes : null); + // When the reference is gone (removed/cleared), close the player and + // clear any transient video state so nothing lingers on screen. + if (status === "none") { + setShouldShowVideoPlayer(false); + setVideoUrl(""); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(false); + } + }, + videoStreamResolving: () => { + // An action started elsewhere (e.g. "Load video" / "Save to project" + // from a navigation card) is fetching this chapter's video. Reflect + // that in the player area: drop the placeholder and show the loading + // state until the host pushes the resolved URL (or an error). + setVideoUrl(""); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(true); + }, + videoStreamUnavailable: (_reason: string, message?: string) => { + // Drop the (likely pointer/stale) URL so the player is replaced by + // the unavailable state with a retry action. + setVideoUrl(""); + setVideoResolving(false); + setVideoNeedsDownloadStrategy(null); + setVideoUnavailableMessage( + message || "This video isn't available locally." + ); + }, + videoNeedsDownload: (strategy) => { + // The video is an LFS pointer; show strategy-appropriate actions. + setVideoUrl(""); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(strategy); + // In auto-download the file is meant to live on disk, so fetch it + // automatically (like audio) instead of asking the user to click. + // Show the resolving state so the manual overlay doesn't flash before + // the auto-download effect runs; a real failure resets this via + // `videoStreamUnavailable`. + setVideoResolving(strategy === "auto-download"); }, // Use cellError handler instead of showErrorMessage cellError: (data) => { @@ -2996,10 +3064,20 @@ const CodexCellEditor: React.FC = () => { vscode.postMessage({ command: "pickVideoFile" } as EditorPostMessages); }; - const handleSaveMetadata = () => { - const updatedMetadata = { ...metadata }; + // Stream-and-save: revert the downloaded local copy back to an LFS pointer to + // free disk space. The host confirms; the video reference is kept so it + // re-streams on demand. + const handleFreeVideoDiskSpace = () => { + vscode.postMessage({ command: "freeVideoDiskSpace" } as EditorPostMessages); + }; + + // Commit metadata edits from the modal. The modal edits a local draft and only + // calls this on "Save Changes", so removals/edits are deferred until here. + // When the saved videoUrl changes, the host confirms and (for a local file) + // deletes the old file from disk as part of updateNotebookMetadata. + const handleSaveMetadata = (updatedMetadata: CustomNotebookMetadata) => { + setMetadata(updatedMetadata); setVideoUrl(updatedMetadata.videoUrl || ""); - setTempVideoUrl(""); debug("metadata", "Saving metadata:", updatedMetadata); vscode.postMessage({ command: "updateNotebookMetadata", @@ -3008,9 +3086,58 @@ const CodexCellEditor: React.FC = () => { setIsMetadataModalOpen(false); }; - const handleUpdateVideoUrl = (url: string) => { - setVideoUrl(url); - }; + // Keep the reference status fresh: ask the host whenever the stored video + // reference changes (and on mount). This drives the toggle + modal badge + // independently of whether the player is open. + useEffect(() => { + vscode.postMessage({ command: "requestVideoReferenceStatus" } as EditorPostMessages); + }, [metadata.videoUrl]); + + // Ask the host to resolve a playable source for the chapter video. Remote + // URLs play directly; local files resolve to a webview URI; LFS pointers come + // back as a "needs download" state (we no longer stream LFS directly). We + // clear the current URL so a stale pointer URI isn't shown while resolving. + const requestVideoStreamUrl = useCallback(() => { + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(true); + setVideoUrl(""); + vscode.postMessage({ command: "requestVideoStreamUrl" } as EditorPostMessages); + }, []); + + // Download the LFS-backed video so it can play locally. `persist` controls + // whether stream-only keeps the file ("Save to project") or treats it as a + // session cache that re-streams next session (the default "Load"). + const downloadVideoFile = useCallback((persist: boolean = true) => { + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(true); + setVideoUrl(""); + vscode.postMessage({ command: "downloadVideoFile", persist } as EditorPostMessages); + }, []); + + // When the player opens (or the video reference changes while open), and the + // stored reference isn't already a direct remote URL, request a stream URL. + useEffect(() => { + if (!shouldShowVideoPlayer) { + return; + } + const stored = metadata.videoUrl; + if (!stored || /^https?:\/\//i.test(stored)) { + return; + } + requestVideoStreamUrl(); + }, [shouldShowVideoPlayer, metadata.videoUrl, requestVideoStreamUrl]); + + // In auto-download mode a pointer-backed video should download on its own + // (mirroring audio's auto-fetch) rather than waiting for a manual click. + // `downloadVideoFile` clears the strategy, so this can't loop; a failure + // surfaces via `videoStreamUnavailable` and won't re-trigger. + useEffect(() => { + if (videoNeedsDownloadStrategy === "auto-download") { + downloadVideoFile(true); + } + }, [videoNeedsDownloadStrategy, downloadVideoFile]); // Handler for temporary font size changes (for preview) const handleTempFontSizeChange = (fontSize: number) => { @@ -3092,7 +3219,14 @@ const CodexCellEditor: React.FC = () => { (window as any).getCurrentEditingCellId = getCurrentEditingCellId; - const documentHasVideoAvailable = !!metadata.videoUrl; + // The "Show Video" toggle should appear only when there's a usable reference: + // a remote URL, or a local file with bytes/pointer. A "missing" reference (or + // an empty one) hides it. Until the host reports status, fall back to the raw + // presence of a videoUrl so the toggle isn't briefly hidden on load. + const documentHasVideoAvailable = + videoReferenceStatus !== null + ? videoReferenceStatus === "url" || videoReferenceStatus === "local-usable" + : !!metadata.videoUrl; // Debug helper: Log info about translation units and their validation status useEffect(() => { @@ -3492,11 +3626,13 @@ const CodexCellEditor: React.FC = () => { openSourceText={openSourceText} documentHasVideoAvailable={documentHasVideoAvailable} metadata={metadata} - tempVideoUrl={tempVideoUrl} + videoReferenceStatus={videoReferenceStatus} onMetadataChange={handleMetadataChange} onSaveMetadata={handleSaveMetadata} onPickFile={handlePickFile} - onUpdateVideoUrl={handleUpdateVideoUrl} + videoCanFreeDiskSpace={videoCanFreeDiskSpace} + onFreeVideoDiskSpace={handleFreeVideoDiskSpace} + videoSizeBytes={videoSizeBytes} toggleScrollSync={() => setScrollSyncEnabled(!scrollSyncEnabled)} scrollSyncEnabled={scrollSyncEnabled} translationUnitsForSection={translationUnitsWithCurrentEditorContent} @@ -3530,6 +3666,138 @@ const CodexCellEditor: React.FC = () => { />
+ {shouldShowVideoPlayer && !videoUrl && videoResolving && ( +
+ + Loading video… +
+ )} + {shouldShowVideoPlayer && !videoUrl && !videoResolving && videoNeedsDownloadStrategy && ( +
+ + + {videoNeedsDownloadStrategy === "stream-only" + ? "Streaming mode" + : videoNeedsDownloadStrategy === "stream-and-save" + ? "Stream & save mode" + : "Video not downloaded yet"} + + + {videoNeedsDownloadStrategy === "stream-only" + ? "This video isn't saved to your project. Load it for this session, or save it so it's kept for next time." + : "This video will be downloaded and saved to your project."} + +
+ {videoNeedsDownloadStrategy === "stream-only" ? ( + <> + + + + ) : ( + + )} +
+
+ )} + {shouldShowVideoPlayer && !videoUrl && !videoResolving && videoUnavailableMessage && ( +
+ + {videoUnavailableMessage} +
+ +
+
+ )} {shouldShowVideoPlayer && videoUrl && (
{ playerRef={playerRef} audioAttachments={audioAttachments} muteVideoWhenPlayingAudio={muteVideoAudioDuringPlayback} + onRequestStreamUrl={requestVideoStreamUrl} />
)} diff --git a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx index 9cd7fe542..79c313e75 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { CustomNotebookMetadata } from "../../../../types"; import { Dialog, @@ -15,15 +15,21 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../com import { Separator } from "../components/ui/separator"; import { Badge } from "../components/ui/badge"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { formatBytes } from "../lib/utils"; interface NotebookMetadataModalProps { isOpen: boolean; onClose: () => void; metadata: CustomNotebookMetadata; - onMetadataChange: (key: string, value: string) => void; - onSave: () => void; + /** Commit the edited metadata. Only called when the user clicks "Save Changes". */ + onSave: (updated: CustomNotebookMetadata) => void; onPickFile: () => void; - tempVideoUrl: string; + /** Stream-and-save: a downloaded local copy can be reverted to a pointer to free space. */ + canFreeDiskSpace: boolean; + onFreeDiskSpace: () => void; + videoReferenceStatus: "none" | "url" | "local-usable" | "missing" | null; + /** Size of the referenced video in bytes when known (local bytes or LFS pointer size). */ + videoSizeBytes?: number | null; } // Define user-editable fields with proper labels and descriptions @@ -61,30 +67,66 @@ const NotebookMetadataModal: React.FC = ({ isOpen, onClose, metadata, - onMetadataChange, onSave, onPickFile, - tempVideoUrl, + canFreeDiskSpace, + onFreeDiskSpace, + videoReferenceStatus, + videoSizeBytes, }) => { + // All edits happen on a local draft and are only committed on "Save Changes". + // Closing via X/Cancel discards the draft, so nothing — including video + // removal — is persisted unless the user explicitly saves. + const [draft, setDraft] = useState(metadata); const [hasChanges, setHasChanges] = useState(false); + // Start the draft from the latest saved metadata each time the modal opens. + useEffect(() => { + if (isOpen) { + setDraft(metadata); + setHasChanges(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // Picking a file is an immediate host action that updates the saved videoUrl; + // mirror it into the draft so the field reflects the newly picked file. + useEffect(() => { + if (!isOpen) { + return; + } + setDraft((d) => + d.videoUrl === metadata.videoUrl ? d : { ...d, videoUrl: metadata.videoUrl } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metadata.videoUrl]); + const handleFieldChange = (key: string, value: string) => { setHasChanges(true); - onMetadataChange(key, value); + setDraft((d) => ({ ...d, [key]: value }) as CustomNotebookMetadata); + }; + + // Clear/remove is deferred: it only empties the draft field. The actual file + // deletion + JSON change happens on Save (the host removes the old local file + // when the saved videoUrl changes). + const handleClearVideo = () => { + setHasChanges(true); + setDraft((d) => ({ ...d, videoUrl: "" }) as CustomNotebookMetadata); }; const handleSave = () => { - onSave(); + onSave(draft); setHasChanges(false); }; const handleClose = () => { - onClose(); + setDraft(metadata); setHasChanges(false); + onClose(); }; const renderField = (key: string, config: typeof USER_EDITABLE_FIELDS[keyof typeof USER_EDITABLE_FIELDS]) => { - const currentValue = metadata[key as keyof CustomNotebookMetadata] || ""; + const currentValue = draft[key as keyof CustomNotebookMetadata] || ""; return (
@@ -123,25 +165,112 @@ const NotebookMetadataModal: React.FC = ({ ) : config.type === "url" && config.hasFilePicker ? ( -
- handleFieldChange(key, e.target.value)} - placeholder="Enter video URL or use file picker" - className="flex-1" - /> - -
+ (() => { + const videoValue = String(currentValue); + const hasVideo = !!videoValue; + const isLocalFile = hasVideo && !/^https?:\/\//i.test(videoValue); + // Only flag "missing" when the draft still reflects the saved + // reference (not a pending edit the host hasn't evaluated yet). + const isMissing = + videoReferenceStatus === "missing" && draft.videoUrl === metadata.videoUrl; + // Offer "free up space" only while the field reflects the saved + // reference (not a pending edit the host hasn't evaluated yet). + const showFreeSpace = + canFreeDiskSpace && draft.videoUrl === metadata.videoUrl; + const fileName = videoValue.split(/[\\/]/).pop() || videoValue; + const displayLabel = isLocalFile ? fileName : videoValue; + // Only show the size for a local file that still reflects the + // saved reference (so the size matches what's actually on disk). + const sizeLabel = + isLocalFile && draft.videoUrl === metadata.videoUrl + ? formatBytes(videoSizeBytes) + : ""; + + // When a video is set, lock the field. Changing it requires an + // explicit Clear first (deferred — the file is only deleted and + // the JSON updated on Save). Once cleared, the field becomes + // editable for the next URL or picked file. + if (hasVideo) { + return ( +
+
+ + {displayLabel} + {sizeLabel && !isMissing && ( + + {sizeLabel} + + )} + {isMissing && ( + + File missing + + )} +
+ {showFreeSpace && ( + + )} + +
+ ); + } + + return ( +
+ handleFieldChange(key, e.target.value)} + placeholder="Enter video URL or use file picker" + className="flex-1 min-w-0" + /> + +
+ ); + })() ) : config.type === "number" ? ( = ({ return ( !open && handleClose()}> - + @@ -201,10 +330,10 @@ const NotebookMetadataModal: React.FC = ({

System Information

-
-
+
+
ID: - {metadata.id} + {metadata.id}
Original Name: diff --git a/webviews/codex-webviews/src/CodexCellEditor/VideoPlayer.tsx b/webviews/codex-webviews/src/CodexCellEditor/VideoPlayer.tsx index 201bceeac..fb44fc21e 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/VideoPlayer.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/VideoPlayer.tsx @@ -14,6 +14,12 @@ interface VideoPlayerProps { onPause?: () => void; autoPlay: boolean; playerHeight: number; + /** + * Ask the host to (re)resolve a playable URL. Used to recover from an + * expired presigned stream URL: on a media error we request a fresh one + * before surfacing an error to the user. + */ + onRequestStreamUrl?: () => void; } const VideoPlayer: React.FC = ({ @@ -26,6 +32,7 @@ const VideoPlayer: React.FC = ({ onPause, autoPlay, playerHeight, + onRequestStreamUrl, }) => { const { subtitleUrl } = useSubtitleData(translationUnitsForSection); const [error, setError] = useState(null); @@ -36,8 +43,31 @@ const VideoPlayer: React.FC = ({ // Check if the URL is a YouTube URL const isYouTubeUrl = videoUrl?.includes("youtube.com") || videoUrl?.includes("youtu.be"); + // Guard so a persistently-failing URL doesn't trigger an infinite refresh loop. + const lastStreamRefreshRef = useRef<{ url: string; at: number; }>({ url: "", at: 0 }); + const handleError = (error: any) => { console.error("Video player error:", error); + + // A streamed (presigned) URL may have expired mid-watch. Ask the host for + // a fresh URL once before showing an error. Only do this for genuine + // remote stream URLs — local webview-resource URLs won't benefit and + // would otherwise loop. A new URL changes the player key and remounts; + // the same URL is guarded by time + identity. + const isRemoteStreamUrl = + /^https?:\/\//i.test(videoUrl) && + !/vscode-resource|vscode-cdn|vscode-webview/i.test(videoUrl) && + !isYouTubeUrl; + if (onRequestStreamUrl && isRemoteStreamUrl) { + const now = Date.now(); + const guard = lastStreamRefreshRef.current; + if (guard.url !== videoUrl || now - guard.at > 10000) { + lastStreamRefreshRef.current = { url: videoUrl, at: now }; + onRequestStreamUrl(); + return; + } + } + // ReactPlayer onError receives an error object or event if (error?.target?.error) { const videoError = error.target.error; diff --git a/webviews/codex-webviews/src/CodexCellEditor/VideoTimelineEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/VideoTimelineEditor.tsx index 1428987ef..f851db9f9 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/VideoTimelineEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/VideoTimelineEditor.tsx @@ -16,6 +16,7 @@ interface VideoTimelineEditorProps { [cellId: string]: AudioAvailability; }; muteVideoWhenPlayingAudio?: boolean; + onRequestStreamUrl?: () => void; } const VideoTimelineEditor: React.FC = ({ @@ -25,6 +26,7 @@ const VideoTimelineEditor: React.FC = ({ playerRef, audioAttachments, muteVideoWhenPlayingAudio = true, + onRequestStreamUrl, }) => { const [playerHeight, setPlayerHeight] = useState(300); const [isDragging, setIsDragging] = useState(false); @@ -108,6 +110,7 @@ const VideoTimelineEditor: React.FC = ({ onPlay={handlePlay} onPause={handlePause} playerHeight={playerHeight} + onRequestStreamUrl={onRequestStreamUrl} />
void; updateNotebookMetadata: (metadata: CustomNotebookMetadata) => void; updateVideoUrl: (url: string) => void; + videoStreamResolving?: () => void; + videoStreamUnavailable?: (reason: string, message?: string) => void; + videoNeedsDownload?: (strategy: "auto-download" | "stream-and-save" | "stream-only") => void; + videoReferenceStatus?: ( + status: "none" | "url" | "local-usable" | "missing", + canFreeDiskSpace?: boolean, + videoSizeBytes?: number + ) => void; // New handlers for provider-centric state management updateAutocompletionState?: (state: { @@ -157,6 +165,10 @@ export const useVSCodeMessageHandler = ({ updateTextDirection, updateNotebookMetadata, updateVideoUrl, + videoStreamResolving, + videoStreamUnavailable, + videoNeedsDownload, + videoReferenceStatus, // New handlers updateAutocompletionState, @@ -250,6 +262,18 @@ export const useVSCodeMessageHandler = ({ case "updateVideoUrlInWebview": updateVideoUrl(message.content); break; + case "videoStreamResolving": + videoStreamResolving?.(); + break; + case "videoStreamUnavailable": + videoStreamUnavailable?.(message.reason, message.message); + break; + case "videoNeedsDownload": + videoNeedsDownload?.(message.strategy); + break; + case "videoReferenceStatus": + videoReferenceStatus?.(message.status, message.canFreeDiskSpace, message.videoSizeBytes); + break; case "providerAutocompletionState": if (updateAutocompletionState) { updateAutocompletionState(message.state); @@ -432,6 +456,9 @@ export const useVSCodeMessageHandler = ({ updateTextDirection, updateNotebookMetadata, updateVideoUrl, + videoStreamResolving, + videoStreamUnavailable, + videoNeedsDownload, updateAutocompletionState, updateSingleCellTranslationState, updateSingleCellQueueState, diff --git a/webviews/codex-webviews/src/NavigationView/index.tsx b/webviews/codex-webviews/src/NavigationView/index.tsx index d5b28112a..a7212baa5 100644 --- a/webviews/codex-webviews/src/NavigationView/index.tsx +++ b/webviews/codex-webviews/src/NavigationView/index.tsx @@ -12,7 +12,21 @@ import { } from "../components/ui/dropdown-menu"; import "../tailwind.css"; import { CodexItem } from "types"; -import { Languages, Mic, ShieldCheck } from "lucide-react"; +import { + Languages, + Mic, + ShieldCheck, + Video, + VideoOff, + Globe, + ArrowDownToLine, + Check, + Cloud, + Loader2, + type LucideIcon, +} from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/ui/tooltip"; +import { formatBytes } from "../lib/utils"; import { Switch } from "../components/ui/switch"; import { Label } from "../components/ui/label"; import { RenameModal } from "../components/RenameModal"; @@ -130,6 +144,113 @@ const styles = { }, }; +// Maps a document's video source/availability to a card indicator: a base +// "video" icon plus a small corner badge denoting the source/state, a colour, +// and hover-tooltip text (with size for local copies). The video icon anchors +// the meaning; the badge differentiates web vs downloaded vs not-yet-downloaded. +// Returns null when the document has no video. +interface VideoIndicator { + Icon: LucideIcon; + /** Small corner modifier (download arrow, check, globe). Omitted for "missing". */ + Badge?: LucideIcon; + className: string; + badgeClassName?: string; + /** Tailwind position classes for the badge; defaults to the bottom-right corner. */ + badgePosition?: string; + label: string; + detail?: string; + /** Clicking the icon performs this action on the chapter video, if set. */ + action?: "download" | "free"; + /** Extra tooltip line describing the click action. */ + actionHint?: string; +} + +const getVideoIndicator = ( + item: CodexItem, + mediaStrategy: string +): VideoIndicator | null => { + if (!item.hasVideo) { + return null; + } + // In auto-download the project keeps every video downloaded automatically, so + // manual download/free actions are not offered (the icon is status-only). + const autoManaged = mediaStrategy === "auto-download"; + const size = formatBytes(item.videoSizeBytes); + switch (item.videoAvailability) { + case "url": + return { + Icon: Video, + Badge: Globe, + className: "text-muted-foreground", + badgeClassName: "text-muted-foreground", + label: "Web video", + detail: "Streams from the internet", + }; + case "saved": + return { + Icon: Video, + Badge: Check, + className: "text-foreground", + badgeClassName: "text-charts-green", + label: "Video downloaded", + detail: size || undefined, + action: autoManaged ? undefined : "free", + actionHint: autoManaged ? undefined : "Click to free up space", + }; + case "streamable": + if (autoManaged) { + // In auto-download a pointer is transient — the project downloads + // it automatically — so it's shown as a status-only "not yet + // downloaded" state with no manual action. + return { + Icon: Video, + Badge: ArrowDownToLine, + className: "text-muted-foreground", + badgeClassName: "text-muted-foreground", + badgePosition: "-bottom-1 -right-1.5", + label: "Video not downloaded", + detail: size || undefined, + }; + } + // Loaded for this session (temporary copy in the session cache) → + // cloud. Clicking again offers to make it a permanent project copy. + if (item.videoCached) { + return { + Icon: Video, + Badge: Cloud, + className: "text-foreground", + badgeClassName: "text-muted-foreground", + label: "Loaded (temporary)", + detail: size ? `${size} · cleared on reload` : "Cleared on reload", + action: "download", + actionHint: "Click to save to project", + }; + } + // Available to download / stream on demand → down arrow. + return { + Icon: Video, + Badge: ArrowDownToLine, + className: "text-foreground", + badgeClassName: "text-foreground", + badgePosition: "-bottom-1 -right-1.5", + label: "Available to download", + detail: size || undefined, + action: "download", + actionHint: "Click to load or save to project", + }; + case "missing": + return { + Icon: VideoOff, + className: "text-destructive", + label: "Video missing", + detail: "File not found", + }; + default: + // Has a video but availability wasn't resolved (e.g. older data). + return { Icon: Video, className: "text-muted-foreground", label: "Has video" }; + } +}; + // Helper function to sort items based on Bible book order or alphanumerically const sortItems = (a: CodexItem, b: CodexItem) => { // If both items have sortOrder (Bible books), sort by that @@ -229,6 +350,14 @@ function NavigationView() { const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + // Current media-download strategy (from the host). In "auto-download" videos + // are managed automatically, so manual download/free actions are disabled. + const [mediaStrategy, setMediaStrategy] = useState("auto-download"); + + // .codex fsPaths whose video is currently downloading (clicked from a card). + // While present, the card icon shows a spinner and is not clickable. + const [downloadingUris, setDownloadingUris] = useState>(() => new Set()); + // Initialize Bible book map on component mount useEffect(() => { const bookMap = new Map(); @@ -253,6 +382,9 @@ function NavigationView() { const message = event.data; switch (message.command) { case "updateItems": + if (typeof message.mediaStrategy === "string") { + setMediaStrategy(message.mediaStrategy); + } setState((prevState) => { // Process items to add sortOrder for Bible books const processedCodexItems = (message.codexItems || []).map( @@ -322,6 +454,20 @@ function NavigationView() { } } break; + case "videoCardActionDone": + // A card-triggered download finished (or failed) — clear its + // spinner so the icon reflects the refreshed status. + if (typeof message.uri === "string") { + setDownloadingUris((prev) => { + if (!prev.has(message.uri)) { + return prev; + } + const next = new Set(prev); + next.delete(message.uri); + return next; + }); + } + break; } }; @@ -834,6 +980,127 @@ function NavigationView() { {displayLabel} + {/* Video indicator - source-specific icon + hover details */} + {(() => { + const indicator = isGroup + ? null + : getVideoIndicator(item, mediaStrategy); + if (!indicator) { + return null; + } + const { + Icon, + Badge, + className, + badgeClassName, + badgePosition, + label, + detail, + action, + actionHint, + } = indicator; + const isDownloading = downloadingUris.has(item.uri); + // While fetching, the icon is status-only (spinner) and not clickable. + const effectiveAction = isDownloading ? undefined : action; + const effectiveLabel = isDownloading ? "Working…" : label; + const handleVideoAction = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!effectiveAction) { + return; + } + if (effectiveAction === "download") { + setDownloadingUris((prev) => { + const next = new Set(prev); + next.add(item.uri); + return next; + }); + } + vscode.postMessage({ + command: "videoCardAction", + uri: item.uri, + action: effectiveAction, + }); + }; + return ( + + + + { + if ( + effectiveAction && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + handleVideoAction( + e as unknown as React.MouseEvent + ); + } + }} + tabIndex={effectiveAction ? 0 : undefined} + > + + {isDownloading ? ( + + + + ) : ( + Badge && ( + + + + ) + )} + + + +
+ + {effectiveLabel} + + {!isDownloading && detail && ( + {detail} + )} + {!isDownloading && actionHint && ( + + {actionHint} + + )} +
+
+
+
+ ); + })()} + {/* More options menu - visible on hover */}
diff --git a/webviews/codex-webviews/src/StartupFlow/types.ts b/webviews/codex-webviews/src/StartupFlow/types.ts index c21b9bdbe..746ffa9e7 100644 --- a/webviews/codex-webviews/src/StartupFlow/types.ts +++ b/webviews/codex-webviews/src/StartupFlow/types.ts @@ -189,6 +189,17 @@ export interface FrontierAPI { size: number ) => Promise; + /** + * Resolve a (typically presigned) download URL for a single LFS object + * without downloading the bytes — used to stream media directly from the + * object store. Optional: older auth-extension builds may not provide it. + */ + getLFSDownloadUrl?: ( + projectPath: string, + oid: string, + size: number + ) => Promise<{ href: string; header: Record; expiresInMs?: number; }>; + getGitBinaryPath?: () => { localGitDir: string; execPath: string; } | undefined; isGitBinaryAvailable?: () => boolean; /** Returns true when git operations can succeed (native binary OR isomorphic-git fallback). */ diff --git a/webviews/codex-webviews/src/lib/utils.ts b/webviews/codex-webviews/src/lib/utils.ts index 4ddef49d2..b4fe016d3 100644 --- a/webviews/codex-webviews/src/lib/utils.ts +++ b/webviews/codex-webviews/src/lib/utils.ts @@ -4,3 +4,20 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Human-readable byte size, e.g. 245 MB / 1.4 GB. Returns "" for unknown/zero. + * Uses decimal units (base-1000) to match what file managers (macOS Finder, + * most "Get Info" dialogs) report, so an 840 MB file reads as ~840 MB rather + * than ~802 MiB. + */ +export function formatBytes(bytes?: number | null): string { + if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) { + return "" + } + const units = ["B", "KB", "MB", "GB", "TB"] + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1000))) + const value = bytes / Math.pow(1000, i) + const rounded = value >= 100 || i === 0 ? Math.round(value) : Math.round(value * 10) / 10 + return `${rounded} ${units[i]}` +}