From cade7d1a88cad9303e2762b3d3a8747a5a9de313 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Wed, 27 May 2026 16:17:36 -0400 Subject: [PATCH 1/9] - Make option to exclude speaker tags from .vtt export options. --- src/exportHandler/exportHandler.ts | 9 ++++++++- src/exportHandler/vttUtils.ts | 5 +++-- src/projectManager/projectExportView.ts | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/exportHandler/exportHandler.ts b/src/exportHandler/exportHandler.ts index b0ed3438d..fa27d4ec1 100644 --- a/src/exportHandler/exportHandler.ts +++ b/src/exportHandler/exportHandler.ts @@ -250,6 +250,7 @@ export interface ExportOptions { removeIds?: boolean; includeAudio?: boolean; includeTimestamps?: boolean; + excludeLabels?: boolean; } // IDML Round-trip export: Uses idmlExporter or biblicaExporter based on filename @@ -2066,7 +2067,13 @@ export const exportCodexContentAsSubtitlesVtt = async ( debug(`File has ${cells.length} active cells`); // Generate VTT content - const vttContent = generateVttData(cells, includeStyles, cueSplitting, file.fsPath); // Include styles for VTT + const vttContent = generateVttData( + cells, + includeStyles, + cueSplitting, + file.fsPath, + options?.excludeLabels === true + ); debug({ vttContent, cells, includeStyles }); // Write file diff --git a/src/exportHandler/vttUtils.ts b/src/exportHandler/vttUtils.ts index b5c9892c6..a839e442f 100644 --- a/src/exportHandler/vttUtils.ts +++ b/src/exportHandler/vttUtils.ts @@ -72,7 +72,8 @@ export const generateVttData = ( cells: CodexNotebookAsJSONData["cells"], includeStyles: boolean, cueSplitting: boolean, - filePath: string + filePath: string, + excludeLabels: boolean = false ): string => { if (!cells.length) return ""; @@ -96,7 +97,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/projectManager/projectExportView.ts b/src/projectManager/projectExportView.ts index 910158eca..32674a6cf 100644 --- a/src/projectManager/projectExportView.ts +++ b/src/projectManager/projectExportView.ts @@ -532,6 +532,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; @@ -723,6 +738,12 @@ function getWebviewContent( +
+ +
@@ -1519,6 +1540,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; + } vscode.postMessage({ command: 'export', format: formatToSend, From 0c7f606019351194fb2db991ffa1731244626672 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 28 May 2026 14:28:14 -0400 Subject: [PATCH 2/9] - Increase upload limit for videos to 900 MB. --- .../codexCellEditorMessagehandling.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index cff261a5a..98e7882c5 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -1483,10 +1483,10 @@ const messageHandlers: Record Promise MAX_BYTES) { - throw new Error("Video file exceeds maximum allowed size (500 MB)"); + throw new Error("Video file exceeds maximum allowed size 900 MB)"); } // Determine document segment From c8b1646af85ad582a2105241b409cde73ffb4ee2 Mon Sep 17 00:00:00 2001 From: TimRl Date: Mon, 1 Jun 2026 12:33:24 -0600 Subject: [PATCH 3/9] Fixed upload to prevent timeout --- src/projectManager/syncManager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/projectManager/syncManager.ts b/src/projectManager/syncManager.ts index 8e5b6f527..d32f00107 100644 --- a/src/projectManager/syncManager.ts +++ b/src/projectManager/syncManager.ts @@ -1682,6 +1682,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]}%`; From 4534f026c082f0346f20720b67a890a551634073 Mon Sep 17 00:00:00 2001 From: TimRl Date: Tue, 2 Jun 2026 10:58:39 -0600 Subject: [PATCH 4/9] Enhanced video handling and caching mechanisms - Implemented a video stream cache to manage large videos outside the project directory, ensuring they do not pollute the project structure. - Added functionality to clear the video stream cache on extension activation for seamless re-streaming of previously loaded videos. - Updated video file handling to allow for user-initiated clean-up of media files, protecting user-saved videos from being reverted to pointers. - Introduced confirmation prompts for replacing or deleting local video files to prevent accidental data loss. - Enhanced the video player and editor components to handle video availability states and requests for playable URLs more effectively. --- src/extension.ts | 9 + .../StartupFlow/StartupFlowProvider.ts | 4 +- .../codexCellEditorMessagehandling.ts | 404 ++++++++++++++++++ .../codexCellEditorProvider.ts | 27 +- .../utils/videoUtils.ts | 110 +++++ src/utils/localProjectSettings.ts | 74 ++++ src/utils/mediaStrategyManager.ts | 34 +- src/utils/videoStreamCache.ts | 110 +++++ types/index.d.ts | 5 + .../src/CodexCellEditor/CodexCellEditor.tsx | 202 +++++++++ .../CodexCellEditor/NotebookMetadataModal.tsx | 84 +++- .../src/CodexCellEditor/VideoPlayer.tsx | 30 ++ .../CodexCellEditor/VideoTimelineEditor.tsx | 3 + .../hooks/useVSCodeMessageHandler.ts | 12 + .../codex-webviews/src/StartupFlow/types.ts | 11 + 15 files changed, 1095 insertions(+), 24 deletions(-) create mode 100644 src/utils/videoStreamCache.ts 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/providers/StartupFlow/StartupFlowProvider.ts b/src/providers/StartupFlow/StartupFlowProvider.ts index b68a352ef..bc6139fe5 100644 --- a/src/providers/StartupFlow/StartupFlowProvider.ts +++ b/src/providers/StartupFlow/StartupFlowProvider.ts @@ -3581,7 +3581,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 b0312d062..04b73362d 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 } from "./utils/videoUtils"; +import { parsePointerFile, isPointerFile } from "../../utils/lfsHelpers"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -140,6 +142,173 @@ 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 { + if (oldKind !== "local") { + return true; + } + + let detail: string; + let confirmLabel: string; + if (newKind === "url") { + detail = + "Replace the locally stored video with a streamed URL? The local video file will be deleted from this project."; + confirmLabel = "Replace"; + } else if (newKind === "none") { + detail = "Remove the current video? The local video file will be deleted from this project."; + confirmLabel = "Remove"; + } else { + detail = "Replace the existing video file? This deletes the current video file from this project."; + confirmLabel = "Replace"; + } + + 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). + */ +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; + } + + 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; + } + } + + 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, + }); +} + // Get a reference to the provider function getProvider(): CodexCellEditorProvider | undefined { // Find the provider through the window object @@ -1453,6 +1622,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); @@ -1463,8 +1662,206 @@ 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); + provider.refreshWebview(webviewPanel, document); + }, + + 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); + } 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", @@ -1552,6 +1949,13 @@ const messageHandlers: Record Promise` + * 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. * Handles HTTP/HTTPS URLs, file:// URIs, and relative paths. diff --git a/src/utils/localProjectSettings.ts b/src/utils/localProjectSettings.ts index 4faea823b..1cd78fac5 100644 --- a/src/utils/localProjectSettings.ts +++ b/src/utils/localProjectSettings.ts @@ -99,6 +99,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; @@ -239,6 +248,10 @@ async function writeLocalProjectSettingsInternal( autoSyncEnabled: settings.autoSyncEnabled ?? true, syncDelayMinutes: settings.syncDelayMinutes ?? 5, displayedProjectName: settings.displayedProjectName, + // Only persisted when present, so we don't pollute every project with an empty array + persistedMediaFiles: Array.isArray(settings.persistedMediaFiles) && settings.persistedMediaFiles.length > 0 + ? settings.persistedMediaFiles + : undefined, }; let existingRaw: Record = {}; try { @@ -248,6 +261,13 @@ 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; + // If the persisted list emptied out, remove the key entirely instead of + // leaving a stale array carried over from existingRaw. + if (!Array.isArray(toWrite.persistedMediaFiles) || toWrite.persistedMediaFiles.length === 0) { + delete (merged as Record).persistedMediaFiles; + } const content = JSON.stringify(merged, null, 2); await vscode.workspace.fs.writeFile(settingsPath, Buffer.from(content, "utf-8")); debug("Wrote local project settings:", merged); @@ -351,6 +371,60 @@ 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 : []; +} + +/** + * 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; + const settings = await readLocalProjectSettings(workspaceFolderUri); + const current = Array.isArray(settings.persistedMediaFiles) ? settings.persistedMediaFiles : []; + if (current.includes(normalized)) return; + settings.persistedMediaFiles = [...current, normalized]; + await writeLocalProjectSettings(settings, workspaceFolderUri); +} + +/** + * 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; + const settings = await readLocalProjectSettings(workspaceFolderUri); + const current = Array.isArray(settings.persistedMediaFiles) ? settings.persistedMediaFiles : []; + if (!current.includes(normalized)) return; + settings.persistedMediaFiles = current.filter((p) => p !== normalized); + await writeLocalProjectSettings(settings, workspaceFolderUri); +} + /** * Ensure the localProjectSettings.json file exists. If missing, create it * with sensible defaults that avoid unnecessary work. diff --git a/src/utils/mediaStrategyManager.ts b/src/utils/mediaStrategyManager.ts index e7fb48237..5bce7dbd6 100644 --- a/src/utils/mediaStrategyManager.ts +++ b/src/utils/mediaStrategyManager.ts @@ -8,6 +8,8 @@ import { setLastModeRun, setChangesApplied, getFlags, + getPersistedMediaFiles, + normalizePersistedMediaRelPath, } from "./localProjectSettings"; import { findAllPointerFiles, @@ -44,6 +46,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,6 +64,11 @@ 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); @@ -134,7 +147,10 @@ export async function countDownloadedMediaFiles(projectPath: string): Promise { +export async function replaceFilesWithPointers( + projectPath: string, + options?: { ignorePersisted?: boolean; } +): Promise { let replacedCount = 0; try { @@ -145,6 +161,15 @@ export async function replaceFilesWithPointers(projectPath: string): Promise() + : new Set( + (await getPersistedMediaFiles(vscode.Uri.file(projectPath))).map(normalizePersistedMediaRelPath) + ); + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -169,6 +194,13 @@ export async function replaceFilesWithPointers(projectPath: 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, + 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); + return uri; +} + +/** + * 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. + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 4a62f3622..dc6d348de 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -416,6 +416,9 @@ export type EditorPostMessages = | { command: "resolveHtmlStructure"; content: { cellId: string; }; } | { command: "updateNotebookMetadata"; content: CustomNotebookMetadata; } | { command: "pickVideoFile"; } + | { command: "deleteVideoFile"; } + | { command: "requestVideoStreamUrl"; } + | { command: "downloadVideoFile"; persist?: boolean; } | { command: "getSourceText"; content: { cellId: string; }; } | { command: "searchSimilarCellIds"; content: { cellId: string; }; } | { command: "updateCellTimestamps"; content: { cellId: string; timestamps: Timestamps; }; } @@ -2068,6 +2071,8 @@ type EditorReceiveMessages = | { type: "jumpToSection"; content: string; } | { type: "providerUpdatesNotebookMetadataForWebview"; content: CustomNotebookMetadata; } | { type: "updateVideoUrlInWebview"; content: string; } + | { type: "videoStreamUnavailable"; reason: "offline" | "not-authenticated" | "not-found" | "error"; message?: string; } + | { type: "videoNeedsDownload"; strategy: "auto-download" | "stream-and-save" | "stream-only"; } | { type: "milestoneProgressUpdate"; milestoneProgress: Record { 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); const playerRef = useRef(null); const [shouldShowVideoPlayer, setShouldShowVideoPlayer] = useState(false); const [muteVideoAudioDuringPlayback, setMuteVideoAudioDuringPlayback] = useState(true); @@ -1734,7 +1744,30 @@ const CodexCellEditor: React.FC = () => { } }, updateVideoUrl: (url: string) => { + // The host resolves the best playable URL (local webview URI, remote + // URL, or a presigned R2 stream URL) and pushes it here. setTempVideoUrl(url); + setVideoUrl(url); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(null); + setVideoResolving(false); + }, + 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(""); + setVideoResolving(false); + setVideoUnavailableMessage(null); + setVideoNeedsDownloadStrategy(strategy); }, // Use cellError handler instead of showErrorMessage cellError: (data) => { @@ -3012,6 +3045,42 @@ const CodexCellEditor: React.FC = () => { setVideoUrl(url); }; + // 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]); + // Handler for temporary font size changes (for preview) const handleTempFontSizeChange = (fontSize: number) => { setTempFontSize(fontSize); @@ -3530,6 +3599,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..13506ac99 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx @@ -67,6 +67,9 @@ const NotebookMetadataModal: React.FC = ({ tempVideoUrl, }) => { const [hasChanges, setHasChanges] = useState(false); + // When the video field holds a local file we lock the input until the user + // explicitly chooses to replace it (deletion is confirmed host-side). + const [isReplacingVideo, setIsReplacingVideo] = useState(false); const handleFieldChange = (key: string, value: string) => { setHasChanges(true); @@ -76,11 +79,13 @@ const NotebookMetadataModal: React.FC = ({ const handleSave = () => { onSave(); setHasChanges(false); + setIsReplacingVideo(false); }; const handleClose = () => { onClose(); setHasChanges(false); + setIsReplacingVideo(false); }; const renderField = (key: string, config: typeof USER_EDITABLE_FIELDS[keyof typeof USER_EDITABLE_FIELDS]) => { @@ -123,25 +128,66 @@ 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 isLocalFile = !!videoValue && !/^https?:\/\//i.test(videoValue); + const locked = isLocalFile && !isReplacingVideo; + const fileName = videoValue.split(/[\\/]/).pop() || videoValue; + + if (locked) { + return ( +
+
+ + {fileName} +
+ +
+ ); + } + + return ( +
+ handleFieldChange(key, e.target.value)} + placeholder="Enter video URL or use file picker" + className="flex-1" + /> + + {isReplacingVideo && ( + + )} +
+ ); + })() ) : config.type === "number" ? ( 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; + videoStreamUnavailable?: (reason: string, message?: string) => void; + videoNeedsDownload?: (strategy: "auto-download" | "stream-and-save" | "stream-only") => void; // New handlers for provider-centric state management updateAutocompletionState?: (state: { @@ -157,6 +159,8 @@ export const useVSCodeMessageHandler = ({ updateTextDirection, updateNotebookMetadata, updateVideoUrl, + videoStreamUnavailable, + videoNeedsDownload, // New handlers updateAutocompletionState, @@ -250,6 +254,12 @@ export const useVSCodeMessageHandler = ({ case "updateVideoUrlInWebview": updateVideoUrl(message.content); break; + case "videoStreamUnavailable": + videoStreamUnavailable?.(message.reason, message.message); + break; + case "videoNeedsDownload": + videoNeedsDownload?.(message.strategy); + break; case "providerAutocompletionState": if (updateAutocompletionState) { updateAutocompletionState(message.state); @@ -432,6 +442,8 @@ export const useVSCodeMessageHandler = ({ updateTextDirection, updateNotebookMetadata, updateVideoUrl, + videoStreamUnavailable, + videoNeedsDownload, updateAutocompletionState, updateSingleCellTranslationState, updateSingleCellQueueState, 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). */ From 73be8d00ab66f17fdd9986012880bf871666dab7 Mon Sep 17 00:00:00 2001 From: TimRl Date: Tue, 2 Jun 2026 14:45:48 -0600 Subject: [PATCH 5/9] Better video handling with local temp storage in stream only --- .../codexCellEditorMessagehandling.ts | 152 ++++++++++++++++-- src/utils/mediaStrategyManager.ts | 23 ++- src/utils/videoStreamCache.ts | 27 +++- types/index.d.ts | 2 + .../ChapterNavigationHeader.tsx | 18 +-- .../src/CodexCellEditor/CodexCellEditor.tsx | 58 +++++-- .../CodexCellEditor/NotebookMetadataModal.tsx | 127 ++++++++++----- .../hooks/useVSCodeMessageHandler.ts | 5 + 8 files changed, 322 insertions(+), 90 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 04b73362d..89433622d 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -161,22 +161,25 @@ async function confirmVideoReplacement( oldKind: VideoKind, newKind: VideoKind ): Promise { - if (oldKind !== "local") { + // 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; - let confirmLabel: string; - if (newKind === "url") { - detail = - "Replace the locally stored video with a streamed URL? The local video file will be deleted from this project."; - confirmLabel = "Replace"; - } else if (newKind === "none") { - detail = "Remove the current video? The local video file will be deleted from this project."; - confirmLabel = "Remove"; + 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 { - detail = "Replace the existing video file? This deletes the current video file from this project."; - confirmLabel = "Replace"; + // 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); @@ -309,6 +312,73 @@ async function resolveAndPostVideoStreamUrl( }); } +/** + * Classify the chapter's stored video reference so the webview can decide + * whether to offer the "Show Video" toggle (and the modal can flag a broken + * reference). This is independent of opening the player: + * - "none" → no video reference at all + * - "url" → remote streamed URL + * - "local-usable" → local file with real bytes, or an LFS pointer (downloadable/streamable) + * - "missing" → local reference that resolves to neither bytes nor a pointer + */ +async function computeVideoReferenceStatus( + document: CodexCellDocument +): Promise<"none" | "url" | "local-usable" | "missing"> { + const videoUrl = document.getNotebookMetadata()?.videoUrl; + if (!videoUrl) { + return "none"; + } + if (isHttpVideoUrl(videoUrl)) { + return "url"; + } + + const workspaceUri = vscode.workspace.getWorkspaceFolder(document.uri)?.uri; + if (!workspaceUri) { + return "missing"; + } + + const rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + if (!rel) { + return "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 → usable. + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); + const isPtr = await isPointerFile(filesAbs).catch(() => false); + if (!isPtr) { + return "local-usable"; + } + } catch { + // files/ entry doesn't exist; fall through to pointer check. + } + + // An LFS pointer (in files/ or pointers/) means it can be downloaded/streamed. + const pointer = + (await parsePointerFile(filesAbs).catch(() => null)) ?? + (await parsePointerFile(pointersAbs).catch(() => null)); + return pointer ? "local-usable" : "missing"; +} + +/** Compute and push the chapter video reference status to the webview. */ +async function postVideoReferenceStatus( + document: CodexCellDocument, + webviewPanel: vscode.WebviewPanel, + provider: CodexCellEditorProvider +): Promise { + const status = await computeVideoReferenceStatus(document); + provider.postMessageToWebview(webviewPanel, { + type: "videoReferenceStatus", + status, + }); +} + // Get a reference to the provider function getProvider(): CodexCellEditorProvider | undefined { // Find the provider through the window object @@ -1660,6 +1730,7 @@ const messageHandlers: Record Promise { @@ -1684,7 +1755,19 @@ const messageHandlers: Record Promise { + await postVideoReferenceStatus(document, webviewPanel, provider); }, requestVideoStreamUrl: async ({ document, webviewPanel, provider }) => { @@ -1960,7 +2043,52 @@ const messageHandlers: Record Promise 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"]); + /** * 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 @@ -73,7 +77,8 @@ export async function replaceSpecificFilesWithPointers(projectPath: string, uplo 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)); @@ -81,7 +86,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 { diff --git a/src/utils/videoStreamCache.ts b/src/utils/videoStreamCache.ts index 54e9b7c21..058b28743 100644 --- a/src/utils/videoStreamCache.ts +++ b/src/utils/videoStreamCache.ts @@ -17,15 +17,26 @@ import * as vscode from "vscode"; const CACHE_DIR_NAME = "videoStreamCache"; +/** + * 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. + * isn't available. A context is optional: when omitted, the last-seen global + * storage location is used. */ export function getVideoStreamCacheRoot( - context: vscode.ExtensionContext + context?: vscode.ExtensionContext ): vscode.Uri | undefined { - const base = context?.globalStorageUri; + if (context?.globalStorageUri) { + cachedStorageBase = context.globalStorageUri; + } + const base = context?.globalStorageUri ?? cachedStorageBase; if (!base) { return undefined; } @@ -38,7 +49,7 @@ export function getVideoStreamCacheRoot( * type from the path. */ export function getCachedVideoUri( - context: vscode.ExtensionContext, + context: vscode.ExtensionContext | undefined, oid: string, ext?: string ): vscode.Uri | undefined { @@ -54,7 +65,7 @@ export function getCachedVideoUri( * Whether a cached copy already exists for this session. */ export async function hasCachedVideo( - context: vscode.ExtensionContext, + context: vscode.ExtensionContext | undefined, oid: string, ext?: string ): Promise { @@ -75,7 +86,7 @@ export async function hasCachedVideo( * is unavailable so callers can fall back to an error state. */ export async function writeCachedVideo( - context: vscode.ExtensionContext, + context: vscode.ExtensionContext | undefined, oid: string, ext: string | undefined, bytes: Uint8Array @@ -94,7 +105,9 @@ export async function writeCachedVideo( * 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 { +export async function clearVideoStreamCache( + context?: vscode.ExtensionContext +): Promise { const root = getVideoStreamCacheRoot(context); if (!root) { return; diff --git a/types/index.d.ts b/types/index.d.ts index dc6d348de..34c9046ff 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -418,6 +418,7 @@ export type EditorPostMessages = | { command: "pickVideoFile"; } | { command: "deleteVideoFile"; } | { command: "requestVideoStreamUrl"; } + | { command: "requestVideoReferenceStatus"; } | { command: "downloadVideoFile"; persist?: boolean; } | { command: "getSourceText"; content: { cellId: string; }; } | { command: "searchSimilarCellIds"; content: { cellId: string; }; } @@ -2073,6 +2074,7 @@ type EditorReceiveMessages = | { type: "updateVideoUrlInWebview"; content: string; } | { 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"; } | { type: "milestoneProgressUpdate"; milestoneProgress: Record void; - onSaveMetadata: () => void; + onSaveMetadata: (updated: CustomNotebookMetadata) => void; onPickFile: () => void; - onUpdateVideoUrl: (url: string) => void; - tempVideoUrl: string; + videoReferenceStatus: "none" | "url" | "local-usable" | "missing" | null; toggleScrollSync: () => void; scrollSyncEnabled: boolean; translationUnitsForSection: QuillCellContent[]; @@ -115,8 +114,7 @@ export function ChapterNavigationHeader({ onMetadataChange, onSaveMetadata, onPickFile, - onUpdateVideoUrl, - tempVideoUrl, + videoReferenceStatus, toggleScrollSync, scrollSyncEnabled, translationUnitsForSection, @@ -405,12 +403,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 +1176,9 @@ ChapterNavigationHeaderProps) { isOpen={isMetadataModalOpen} onClose={handleCloseMetadataModal} metadata={metadata} - onMetadataChange={onMetadataChange} onSave={handleSaveMetadata} onPickFile={onPickFile} - tempVideoUrl={tempVideoUrl} + videoReferenceStatus={videoReferenceStatus} /> )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index a281249ae..a98ab835d 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -184,6 +184,12 @@ const CodexCellEditor: React.FC = () => { 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); const playerRef = useRef(null); const [shouldShowVideoPlayer, setShouldShowVideoPlayer] = useState(false); const [muteVideoAudioDuringPlayback, setMuteVideoAudioDuringPlayback] = useState(true); @@ -1297,11 +1303,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); @@ -1744,14 +1745,25 @@ const CodexCellEditor: React.FC = () => { } }, updateVideoUrl: (url: string) => { - // The host resolves the best playable URL (local webview URI, remote - // URL, or a presigned R2 stream URL) and pushes it here. - 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) => { + setVideoReferenceStatus(status); + // 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); + } + }, videoStreamUnavailable: (_reason: string, message?: string) => { // Drop the (likely pointer/stale) URL so the player is replaced by // the unavailable state with a retry action. @@ -3029,10 +3041,13 @@ const CodexCellEditor: React.FC = () => { vscode.postMessage({ command: "pickVideoFile" } as EditorPostMessages); }; - const handleSaveMetadata = () => { - const updatedMetadata = { ...metadata }; + // 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", @@ -3041,9 +3056,12 @@ 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 @@ -3161,7 +3179,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(() => { @@ -3561,11 +3586,10 @@ const CodexCellEditor: React.FC = () => { openSourceText={openSourceText} documentHasVideoAvailable={documentHasVideoAvailable} metadata={metadata} - tempVideoUrl={tempVideoUrl} + videoReferenceStatus={videoReferenceStatus} onMetadataChange={handleMetadataChange} onSaveMetadata={handleSaveMetadata} onPickFile={handlePickFile} - onUpdateVideoUrl={handleUpdateVideoUrl} toggleScrollSync={() => setScrollSyncEnabled(!scrollSyncEnabled)} scrollSyncEnabled={scrollSyncEnabled} translationUnitsForSection={translationUnitsWithCurrentEditorContent} diff --git a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx index 13506ac99..fded7fa52 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, @@ -20,10 +20,10 @@ 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; + videoReferenceStatus: "none" | "url" | "local-usable" | "missing" | null; } // Define user-editable fields with proper labels and descriptions @@ -61,35 +61,63 @@ const NotebookMetadataModal: React.FC = ({ isOpen, onClose, metadata, - onMetadataChange, onSave, onPickFile, - tempVideoUrl, + videoReferenceStatus, }) => { + // 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); - // When the video field holds a local file we lock the input until the user - // explicitly chooses to replace it (deletion is confirmed host-side). - const [isReplacingVideo, setIsReplacingVideo] = 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); - setIsReplacingVideo(false); }; const handleClose = () => { - onClose(); + setDraft(metadata); setHasChanges(false); - setIsReplacingVideo(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 (
@@ -130,26 +158,55 @@ const NotebookMetadataModal: React.FC = ({ ) : config.type === "url" && config.hasFilePicker ? ( (() => { const videoValue = String(currentValue); - const isLocalFile = !!videoValue && !/^https?:\/\//i.test(videoValue); - const locked = isLocalFile && !isReplacingVideo; + 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; const fileName = videoValue.split(/[\\/]/).pop() || videoValue; + const displayLabel = isLocalFile ? fileName : videoValue; - if (locked) { + // 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 ( -
+
- - {fileName} + + {displayLabel} + {isMissing && ( + + File missing + + )}
); @@ -163,28 +220,18 @@ const NotebookMetadataModal: React.FC = ({ value={videoValue} onChange={(e) => handleFieldChange(key, e.target.value)} placeholder="Enter video URL or use file picker" - className="flex-1" + className="flex-1 min-w-0" /> - {isReplacingVideo && ( - - )}
); })() @@ -225,7 +272,7 @@ const NotebookMetadataModal: React.FC = ({ return ( !open && handleClose()}> - + @@ -247,10 +294,10 @@ const NotebookMetadataModal: React.FC = ({

System Information

-
-
+
+
ID: - {metadata.id} + {metadata.id}
Original Name: diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index c79b53e42..a04bfe8dc 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -88,6 +88,7 @@ interface UseVSCodeMessageHandlerProps { updateVideoUrl: (url: string) => void; videoStreamUnavailable?: (reason: string, message?: string) => void; videoNeedsDownload?: (strategy: "auto-download" | "stream-and-save" | "stream-only") => void; + videoReferenceStatus?: (status: "none" | "url" | "local-usable" | "missing") => void; // New handlers for provider-centric state management updateAutocompletionState?: (state: { @@ -161,6 +162,7 @@ export const useVSCodeMessageHandler = ({ updateVideoUrl, videoStreamUnavailable, videoNeedsDownload, + videoReferenceStatus, // New handlers updateAutocompletionState, @@ -260,6 +262,9 @@ export const useVSCodeMessageHandler = ({ case "videoNeedsDownload": videoNeedsDownload?.(message.strategy); break; + case "videoReferenceStatus": + videoReferenceStatus?.(message.status); + break; case "providerAutocompletionState": if (updateAutocompletionState) { updateAutocompletionState(message.state); From b6685756c6566e828083987b416946e9577f95a8 Mon Sep 17 00:00:00 2001 From: TimRl Date: Wed, 3 Jun 2026 10:03:58 -0600 Subject: [PATCH 6/9] Added free space and clear and proper loading as well as immediate updating of edit metadata modal --- src/projectManager/syncManager.ts | 13 +++ .../codexCellEditorMessagehandling.ts | 109 +++++++++++++++++- .../codexCellEditorProvider.ts | 22 ++++ types/index.d.ts | 9 +- .../ChapterNavigationHeader.tsx | 6 + .../src/CodexCellEditor/CodexCellEditor.tsx | 15 ++- .../CodexCellEditor/NotebookMetadataModal.tsx | 21 ++++ .../hooks/useVSCodeMessageHandler.ts | 7 +- 8 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src/projectManager/syncManager.ts b/src/projectManager/syncManager.ts index d32f00107..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(); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 89433622d..ac069ccf8 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -366,16 +366,68 @@ async function computeVideoReferenceStatus( return pointer ? "local-usable" : "missing"; } +/** + * 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. */ -async function postVideoReferenceStatus( +export async function postVideoReferenceStatus( document: CodexCellDocument, webviewPanel: vscode.WebviewPanel, provider: CodexCellEditorProvider ): Promise { const status = await computeVideoReferenceStatus(document); + const canFreeDiskSpace = + status === "local-usable" ? await computeCanFreeVideoDiskSpace(document) : false; provider.postMessageToWebview(webviewPanel, { type: "videoReferenceStatus", status, + canFreeDiskSpace, }); } @@ -1766,6 +1818,58 @@ const messageHandlers: Record Promise { + 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 rel = getVideoWorkspaceRelativePath(videoUrl, workspaceUri); + const FILES_SEG = "attachments/files/"; + const relFromFiles = + rel && rel.includes(FILES_SEG) + ? rel.slice(rel.indexOf(FILES_SEG) + FILES_SEG.length) + : null; + if (!relFromFiles) { + return; + } + + const { replaceFileWithPointer } = await import("../../utils/lfsHelpers"); + await replaceFileWithPointer(workspaceUri.fsPath, relFromFiles); + + // In stream-only, a saved copy is protected by the persisted allowlist. + // Freeing space means it's no longer "saved to project", so drop it so + // future syncs/cleanup don't treat it as protected. + const { getMediaFilesStrategy, removePersistedMediaFile } = await import( + "../../utils/localProjectSettings" + ); + const strategy = (await getMediaFilesStrategy(workspaceUri)) ?? "auto-download"; + if (strategy === "stream-only") { + await removePersistedMediaFile(relFromFiles, workspaceUri); + } + + // 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); }, @@ -1917,6 +2021,9 @@ const messageHandlers: Record Promise { + 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 + ); + } + } + } + /** * Refresh webviews for specific files by sending refreshCurrentPage messages. * This is used after sync to ensure webviews show newly added cells. diff --git a/types/index.d.ts b/types/index.d.ts index 34c9046ff..2eb1a690e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -417,6 +417,7 @@ export type EditorPostMessages = | { command: "updateNotebookMetadata"; content: CustomNotebookMetadata; } | { command: "pickVideoFile"; } | { command: "deleteVideoFile"; } + | { command: "freeVideoDiskSpace"; } | { command: "requestVideoStreamUrl"; } | { command: "requestVideoReferenceStatus"; } | { command: "downloadVideoFile"; persist?: boolean; } @@ -2074,7 +2075,13 @@ type EditorReceiveMessages = | { type: "updateVideoUrlInWebview"; content: string; } | { 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"; } + | { + 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; + } | { type: "milestoneProgressUpdate"; milestoneProgress: Record void; onSaveMetadata: (updated: CustomNotebookMetadata) => void; onPickFile: () => void; + videoCanFreeDiskSpace: boolean; + onFreeVideoDiskSpace: () => void; videoReferenceStatus: "none" | "url" | "local-usable" | "missing" | null; toggleScrollSync: () => void; scrollSyncEnabled: boolean; @@ -114,6 +116,8 @@ export function ChapterNavigationHeader({ onMetadataChange, onSaveMetadata, onPickFile, + videoCanFreeDiskSpace, + onFreeVideoDiskSpace, videoReferenceStatus, toggleScrollSync, scrollSyncEnabled, @@ -1178,6 +1182,8 @@ ChapterNavigationHeaderProps) { metadata={metadata} onSave={handleSaveMetadata} onPickFile={onPickFile} + canFreeDiskSpace={videoCanFreeDiskSpace} + onFreeDiskSpace={onFreeVideoDiskSpace} videoReferenceStatus={videoReferenceStatus} /> )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index a98ab835d..af5f7aa2d 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -190,6 +190,9 @@ const CodexCellEditor: React.FC = () => { 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); const playerRef = useRef(null); const [shouldShowVideoPlayer, setShouldShowVideoPlayer] = useState(false); const [muteVideoAudioDuringPlayback, setMuteVideoAudioDuringPlayback] = useState(true); @@ -1752,8 +1755,9 @@ const CodexCellEditor: React.FC = () => { setVideoNeedsDownloadStrategy(null); setVideoResolving(false); }, - videoReferenceStatus: (status) => { + videoReferenceStatus: (status, canFreeDiskSpace) => { setVideoReferenceStatus(status); + setVideoCanFreeDiskSpace(!!canFreeDiskSpace); // When the reference is gone (removed/cleared), close the player and // clear any transient video state so nothing lingers on screen. if (status === "none") { @@ -3041,6 +3045,13 @@ const CodexCellEditor: React.FC = () => { vscode.postMessage({ command: "pickVideoFile" } as EditorPostMessages); }; + // 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) @@ -3590,6 +3601,8 @@ const CodexCellEditor: React.FC = () => { onMetadataChange={handleMetadataChange} onSaveMetadata={handleSaveMetadata} onPickFile={handlePickFile} + videoCanFreeDiskSpace={videoCanFreeDiskSpace} + onFreeVideoDiskSpace={handleFreeVideoDiskSpace} toggleScrollSync={() => setScrollSyncEnabled(!scrollSyncEnabled)} scrollSyncEnabled={scrollSyncEnabled} translationUnitsForSection={translationUnitsWithCurrentEditorContent} diff --git a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx index fded7fa52..4068f4d78 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/NotebookMetadataModal.tsx @@ -23,6 +23,9 @@ interface NotebookMetadataModalProps { /** Commit the edited metadata. Only called when the user clicks "Save Changes". */ onSave: (updated: CustomNotebookMetadata) => void; onPickFile: () => void; + /** 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; } @@ -63,6 +66,8 @@ const NotebookMetadataModal: React.FC = ({ metadata, onSave, onPickFile, + canFreeDiskSpace, + onFreeDiskSpace, videoReferenceStatus, }) => { // All edits happen on a local draft and are only committed on "Save Changes". @@ -164,6 +169,10 @@ const NotebookMetadataModal: React.FC = ({ // 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; @@ -194,6 +203,18 @@ const NotebookMetadataModal: React.FC = ({ )}
+ {showFreeSpace && ( + + )}