Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
5060c5e
- Fix video file saving when using edit metadata modal.
LeviXIII Jan 2, 2026
277bb02
- Update React Player to v3 and enhance video handling in CodexCellEd…
LeviXIII Jan 5, 2026
cf03c3e
- Allow for .webm video playback.
LeviXIII Jan 5, 2026
0f27263
- Fix saving cell changes when dealing with timestamps.
LeviXIII Jan 5, 2026
6d62bc0
- Introduce video player props and manage video playback synchronizat…
LeviXIII Jan 5, 2026
4752b2c
- Set up a play button in the Timestamp tab to play current cell audi…
LeviXIII Jan 6, 2026
7401656
- Add a checkbox to mute video when clicking play button in Timestamp…
LeviXIII Jan 6, 2026
e3e9531
- Pause video playback once recorded audio finishes.
LeviXIII Jan 6, 2026
4b1e2b5
- Remove duplicate AudioPlayButton components. Took CellContentDispla…
LeviXIII Jan 6, 2026
8e2ee0c
- Cleanup commented code and unused imports
LeviXIII Jan 6, 2026
5fba2b2
- Create previous and next cell timestamps in current cell timestamp …
LeviXIII Jan 6, 2026
0717a39
- Remove timeline component.
LeviXIII Jan 7, 2026
596fae7
- Overlapping audio now play at correct timestamps.
LeviXIII Jan 7, 2026
9780767
- Add loading spinner to play button in timestamps tab.
LeviXIII Jan 7, 2026
18ee815
- Pressing play button on video now plays all recorded audio at their…
LeviXIII Jan 7, 2026
94c46f8
- Suppress known error due to cleanup of audio.
LeviXIII Jan 7, 2026
b2f2462
- Disable clicking of audio in the source if the target is already pl…
LeviXIII Jan 9, 2026
4ec8dce
- Fix error occuring when switching quickly between audio files.
LeviXIII Jan 9, 2026
b7db295
Add unit and integration tests for audio state synchronization in Cod…
LeviXIII Jan 9, 2026
86d0291
- wip for timestamps display.
LeviXIII Jan 9, 2026
5df2b39
Merge remote-tracking branch 'origin/384-miga-task' into 387-audio-du…
LeviXIII Jan 9, 2026
5a2833c
- Fix isLockedCell omission when merging with MIGA.
LeviXIII Jan 9, 2026
b2349b1
Merge branch '384-miga-task' into 387-audio-dubbing-feature
LeviXIII Jan 9, 2026
f4a0f52
- TextCellEditor takes in metadata for importerType to display prev a…
LeviXIII Jan 12, 2026
2e1ba50
- Hide subtitles timestamp controls.
LeviXIII Jan 13, 2026
2a3a53b
- More timestamp UI changes.
LeviXIII Jan 13, 2026
7e09d44
Merge branch 'main' into 387-audio-dubbing-feature
LeviXIII Jan 15, 2026
840bb15
Merge branch 'main' into 387-audio-dubbing-feature
LeviXIII Jan 20, 2026
eb1c52c
- More tweaks to timestamp display for subtitles.
LeviXIII Jan 20, 2026
9d3e26c
- Fix video player not working when clicking show video.
LeviXIII Jan 21, 2026
003c65f
Add audio timestamp handling to CodexCellEditor
LeviXIII Jan 22, 2026
33954dd
- UI changes for Timestamps tab in TextCellEditor.
LeviXIII Jan 23, 2026
3860977
- Get ReactPlayer 3.4.0 working again.
LeviXIII Jan 23, 2026
248d423
- More adjustments to previous and next cell range audio timestamp sl…
LeviXIII Jan 26, 2026
a9e21ed
- Fix autoplay for videos.
LeviXIII Jan 26, 2026
35133f6
- Fix overlapping of audio and make sure it gets put into a blob inst…
LeviXIII Jan 26, 2026
5ac1707
Enhance audio timestamp management in TextCellEditor
LeviXIII Jan 26, 2026
8eaf399
- Add audio warning when there is no audio available.
LeviXIII Jan 26, 2026
8def8a7
- Get certain videos to play.
LeviXIII Jan 27, 2026
aaf59ef
- Add countdown button.
LeviXIII Jan 29, 2026
81d0dd1
- Add target duration bar under audio waveform.
LeviXIII Jan 29, 2026
b1cfcb3
- Stabilize audio and video playback (no more play/pause loops).
LeviXIII Jan 29, 2026
fc9c61e
- Update audio timestamp in Timestamps tab after re-recording audio.
LeviXIII Jan 30, 2026
06b1bc6
- Clean up error where video wasn't playing after playing audio and v…
LeviXIII Jan 30, 2026
d683339
- Update test to reflect changes in the code.
LeviXIII Jan 30, 2026
ed92ed8
Merge branch 'main' into 387-audio-dubbing-feature
LeviXIII Jan 30, 2026
91f387e
- More tweaking to stop play/pause loops when cycling through AudioPl…
LeviXIII Jan 30, 2026
949bd66
- Fix volume adjuster so it stops going back to max.
LeviXIII Jan 30, 2026
8b5ff9a
- Allow audioExporter to account for milestone cells, since they shou…
LeviXIII Jan 30, 2026
4dcb5af
- Update timeReference (audio start time) for audio file metadata.
LeviXIII Jan 30, 2026
21fbc64
- Ensure audio recording works outside of having timestamps available.
LeviXIII Feb 2, 2026
143dbb8
- Fix mute video checkbox so that it actually unmutes the video.
LeviXIII Feb 2, 2026
2450456
- Add option for cue splitting for overlapping subtitles when exporti…
LeviXIII Feb 3, 2026
f851b36
- Add alert if the user tries to export a vtt with overlapping subtit…
LeviXIII Feb 3, 2026
d312a8f
...
LeviXIII Feb 3, 2026
bd23fd9
Merge remote-tracking branch 'origin/main' into 387-audio-dubbing-fea…
LeviXIII Feb 3, 2026
ef6eec9
Merge branch 'main' into 387-audio-dubbing-feature
LeviXIII Feb 10, 2026
a099fc9
Merge branch 'main' into 387-audio-dubbing-feature
LeviXIII Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/activationHelpers/contextAware/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export async function registerCommands(context: vscode.ExtensionContext) {
filesToExport: string[];
options?: { skipValidation?: boolean; removeIds?: boolean; };
}) => {
await exportCodexContent(format, userSelectedPath, filesToExport, options);
return exportCodexContent(format, userSelectedPath, filesToExport, options);
}
);

Expand Down
20 changes: 16 additions & 4 deletions src/exportHandler/audioExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { exec } from "child_process";
import { promisify } from "util";
import * as os from "os";
import * as fs from "fs";
import { CodexCellTypes } from "../../types/enums";

const execAsync = promisify(exec);

Expand All @@ -20,6 +21,12 @@ type ExportAudioOptions = {
includeTimestamps?: boolean;
};

type AudioCellData = {
startTime?: number;
endTime?: number;
audioStartTime?: number;
audioEndTime?: number;
};

function sanitizeFileComponent(input: string): string {
return input
Expand Down Expand Up @@ -125,7 +132,8 @@ function computeDialogueLineNumbers(
const isMerged = !!(data && data.merged);
const isDeleted = !!(data && data.deleted);
const isParatext = cell?.metadata?.type === "paratext";
if (!isValidKind || isMerged || isDeleted || isParatext) continue;
const isMilestone = cell?.metadata?.type === CodexCellTypes.MILESTONE;
if (!isValidKind || isMerged || isDeleted || isParatext || isMilestone) continue;
const id: string | undefined = cell?.metadata?.id;
if (!id) continue;
line += 1;
Expand Down Expand Up @@ -540,6 +548,10 @@ export async function exportAudioAttachments(
debug(`Skipping cell with kind ${cell.kind}`);
continue;
}
if (cell?.metadata?.type === CodexCellTypes.MILESTONE) {
debug(`Skipping milestone cell: ${cell?.metadata?.id}`);
continue;
}
if (!isActiveCell(cell)) {
debug(`Skipping inactive cell: ${cell?.metadata?.id}`);
continue;
Expand Down Expand Up @@ -588,9 +600,9 @@ export async function exportAudioAttachments(
}

// Build destination filename: <file>_<lang>_<label>_<line>.wav (always export as WAV)
const timeFromCell = (cell?.metadata?.data || {}) as { startTime?: number; endTime?: number; };
const start = timeFromCell.startTime;
const end = timeFromCell.endTime;
const timeFromCell = (cell?.metadata?.data || {}) as AudioCellData;
const start = timeFromCell.audioStartTime || timeFromCell.startTime;
const end = timeFromCell.audioEndTime || timeFromCell.endTime;
const originalExt = extname(absoluteSrc.fsPath) || ".wav";
const labelRaw = cell?.metadata?.cellLabel || "unlabeled";
const label = sanitizeFileComponent(String(labelRaw).toLowerCase());
Expand Down
85 changes: 66 additions & 19 deletions src/exportHandler/exportHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as fs from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { removeHtmlTags, generateSrtData } from "./subtitleUtils";
import { generateVttData } from "./vttUtils";
import { generateVttData, hasOverlappingCues } from "./vttUtils";
// import { exportRtfWithPandoc } from "../../webviews/codex-webviews/src/NewSourceUploader/importers/rtf/pandocNodeBridge";

const execAsync = promisify(exec);
Expand Down Expand Up @@ -61,6 +61,36 @@ function getActiveCells(cells: CodexNotebookAsJSONData["cells"]) {
});
}

const SUBTITLE_OVERLAP_WARNING =
"Some selected files have overlapping subtitle timestamps. To split overlapping cues so they appear at different times, use the WebVTT with Cue Splitting option. Do you want to export anyway?";

/**
* Checks selected files for overlapping VTT/SRT cues. If any file has overlaps, shows a warning
* and asks the user to confirm. Returns true if export should proceed, false to cancel.
*/
async function checkSubtitleOverlapsAndConfirm(filesToExport: string[]): Promise<boolean> {
let hasOverlaps = false;
for (const filePath of filesToExport) {
try {
const codexData = await readCodexNotebookFromUri(vscode.Uri.file(filePath));
const cells = getActiveCells(codexData.cells);
if (hasOverlappingCues(cells)) {
hasOverlaps = true;
break;
}
} catch {
// If we can't read a file, skip overlap check for it; export will fail later if needed
}
}
if (!hasOverlaps) return true;
const choice = await vscode.window.showWarningMessage(
SUBTITLE_OVERLAP_WARNING,
{ modal: true },
"Export anyway"
);
return choice === "Export anyway";
}

/**
* Maps book codes to their full names for USFM export
*/
Expand Down Expand Up @@ -305,6 +335,7 @@ export enum CodexExportFormat {
SUBTITLES_SRT = "subtitles-srt",
SUBTITLES_VTT_WITH_STYLES = "subtitles-vtt-with-styles",
SUBTITLES_VTT_WITHOUT_STYLES = "subtitles-vtt-without-styles",
SUBTITLES_VTT_WITH_CUE_SPLITTING = "subtitles-vtt-with-cue-splitting",
XLIFF = "xliff",
CSV = "csv",
TSV = "tsv",
Expand Down Expand Up @@ -1470,51 +1501,66 @@ async function exportCodexContentAsRebuild(
);
}

/**
* @returns true if export completed, false if user cancelled due to subtitle overlap warning.
*/
export async function exportCodexContent(
format: CodexExportFormat,
userSelectedPath: string,
filesToExport: string[],
options?: ExportOptions
) {
): Promise<boolean> {
switch (format) {
case CodexExportFormat.PLAINTEXT:
await exportCodexContentAsPlaintext(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.USFM:
await exportCodexContentAsUsfm(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.HTML:
await exportCodexContentAsHtml(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.AUDIO: {
const { exportAudioAttachments } = await import("./audioExporter");
await exportAudioAttachments(userSelectedPath, filesToExport, { includeTimestamps: (options as any)?.includeTimestamps });
break;
return true;
}
case CodexExportFormat.SUBTITLES_VTT_WITH_STYLES:
await exportCodexContentAsSubtitlesVtt(userSelectedPath, filesToExport, options, true);
break;
if (await checkSubtitleOverlapsAndConfirm(filesToExport)) {
await exportCodexContentAsSubtitlesVtt(userSelectedPath, filesToExport, options, true);
return true;
}
return false;
case CodexExportFormat.SUBTITLES_VTT_WITHOUT_STYLES:
await exportCodexContentAsSubtitlesVtt(userSelectedPath, filesToExport, options, false);
break;
if (await checkSubtitleOverlapsAndConfirm(filesToExport)) {
await exportCodexContentAsSubtitlesVtt(userSelectedPath, filesToExport, options, false);
return true;
}
return false;
case CodexExportFormat.SUBTITLES_VTT_WITH_CUE_SPLITTING:
await exportCodexContentAsSubtitlesVtt(userSelectedPath, filesToExport, options, false, true);
return true;
case CodexExportFormat.SUBTITLES_SRT:
await exportCodexContentAsSubtitlesSrt(userSelectedPath, filesToExport, options);
break;
if (await checkSubtitleOverlapsAndConfirm(filesToExport)) {
await exportCodexContentAsSubtitlesSrt(userSelectedPath, filesToExport, options);
return true;
}
return false;
case CodexExportFormat.XLIFF:
await exportCodexContentAsXliff(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.CSV:
await exportCodexContentAsCsv(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.TSV:
await exportCodexContentAsTsv(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.REBUILD_EXPORT:
await exportCodexContentAsRebuild(userSelectedPath, filesToExport, options);
break;
return true;
case CodexExportFormat.BACKTRANSLATIONS:
await exportCodexContentAsBacktranslations(userSelectedPath, filesToExport, options);
break;
return true;
}
}

Expand Down Expand Up @@ -1624,7 +1670,8 @@ export const exportCodexContentAsSubtitlesVtt = async (
userSelectedPath: string,
filesToExport: string[],
options?: ExportOptions,
includeStyles: boolean = true
includeStyles: boolean = true,
cueSplitting: boolean = false
) => {
try {
debug("Starting exportCodexContentAsSubtitlesVtt function");
Expand Down Expand Up @@ -1672,7 +1719,7 @@ export const exportCodexContentAsSubtitlesVtt = async (
debug(`File has ${cells.length} active cells`);

// Generate VTT content
const vttContent = generateVttData(cells, includeStyles, file.fsPath); // Include styles for VTT
const vttContent = generateVttData(cells, includeStyles, cueSplitting, file.fsPath); // Include styles for VTT
debug({ vttContent, cells, includeStyles });

// Write file
Expand Down
104 changes: 91 additions & 13 deletions src/exportHandler/vttUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { useMemo } from "react";
import { CodexNotebookAsJSONData, QuillCellContent } from "@types";
import { CodexNotebookAsJSONData } from "@types";
import { removeHtmlTags } from "./subtitleUtils";
import { ExportOptions } from "./exportHandler";
import * as vscode from "vscode";

/**
Expand Down Expand Up @@ -53,9 +51,17 @@ const processVttContent = (content: string): string => {
return ensureDialogueLineBreaks(processed);
};

type ProcessedUnit = {
id: string | undefined;
startTime: number;
endTime: number;
finalText: string;
};

export const generateVttData = (
cells: CodexNotebookAsJSONData["cells"],
includeStyles: boolean,
cueSplitting: boolean,
filePath: string
): string => {
if (!cells.length) return "";
Expand All @@ -65,24 +71,37 @@ export const generateVttData = (
return date.toISOString().substr(11, 12);
};

const cues = cells
// Filter out merged cells before processing
const units: ProcessedUnit[] = cells
.filter((unit) => {
const metadata = unit.metadata;
return !metadata?.data?.merged && !!unit.metadata?.data?.startTime;
})
.map((unit, index) => {
const startTime = unit.metadata?.data?.startTime ?? index;
const endTime = unit.metadata?.data?.endTime ?? index + 1;
const startTime = Number(unit.metadata?.data?.startTime ?? index);
const endTime = Number(unit.metadata?.data?.endTime ?? index + 1);
const text = includeStyles ? processVttContent(unit.value) : removeHtmlTags(unit.value);
const finalText = ensureDialogueLineBreaks(text);
return `${unit.metadata?.id}
${formatTime(Number(startTime))} --> ${formatTime(Number(endTime))}
${finalText}
return {
id: unit.metadata?.cellLabel || unit.metadata?.id,
startTime,
endTime,
finalText,
};
});

`;
})
.join("\n");
const cues =
cueSplitting && units.length > 0
? buildSplitCues(units, formatTime)
: units
.map(
(unit) =>
`${unit.id}
${formatTime(unit.startTime)} --> ${formatTime(unit.endTime)}
${unit.finalText}

`
)
.join("\n");

if (cues.length === 0) {
vscode.window.showInformationMessage("No cues found in the " + filePath);
Expand All @@ -91,3 +110,62 @@ ${finalText}

${cues}`;
};

/**
* Returns true if any two cues in the given cells have overlapping time ranges.
* Uses the same cell filtering as generateVttData (excludes merged, requires startTime).
* Two cues [s1,e1] and [s2,e2] overlap when s1 < e2 && s2 < e1.
*/
export const hasOverlappingCues = (cells: CodexNotebookAsJSONData["cells"]): boolean => {
const units = cells
.filter((unit) => {
const metadata = unit.metadata;
return !metadata?.data?.merged && !!unit.metadata?.data?.startTime;
})
.map((unit, index) => ({
startTime: Number(unit.metadata?.data?.startTime ?? index),
endTime: Number(unit.metadata?.data?.endTime ?? index + 1),
}));

for (let i = 0; i < units.length; i++) {
for (let j = i + 1; j < units.length; j++) {
const a = units[i];
const b = units[j];
if (a.startTime < b.endTime && b.startTime < a.endTime) return true;
}
}
return false;
};

/**
* Build VTT cues by splitting on all unique timestamps. For each adjacent pair of timestamps,
* emits one cue containing the concatenated text of all units active in that time range.
* Cue is active in [tStart, tEnd) when unit.startTime < tEnd && unit.endTime > tStart.
*/
function buildSplitCues(units: ProcessedUnit[], formatTime: (s: number) => string): string {
const timestamps = new Set<number>();
for (const unit of units) {
timestamps.add(unit.startTime);
timestamps.add(unit.endTime);
}
const sorted = Array.from(timestamps).sort((a, b) => a - b);

const parts: string[] = [];
for (let i = 0; i < sorted.length - 1; i++) {
const tStart = sorted[i];
const tEnd = sorted[i + 1];
if (tStart === tEnd) continue;

const active = units.filter((unit) => unit.startTime < tEnd && unit.endTime > tStart);
if (active.length === 0) continue;

const text = active.map((unit) => unit.finalText).join("\n\n");
const cueId = `${active[0].id}-split`;
parts.push(`${cueId}
${formatTime(tStart)} --> ${formatTime(tEnd)}
${text}

`);
}
return parts.join("\n");
}
12 changes: 7 additions & 5 deletions src/globalProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,6 @@ export abstract class BaseWebviewProvider implements vscode.WebviewViewProvider
public postMessage(message: any): void {
if (this._view) {
safePostMessageToView(this._view, message, "Global");
} else {
console.error(`WebviewView ${this.getWebviewId()} is not initialized`);
}
}

Expand Down Expand Up @@ -201,14 +199,18 @@ export class GlobalProvider {

const destination = message.destination;
if (destination === "webview") {
this.postMessageToAllWebviews(message);
// Forward the message to all webviews, preserving the original structure
// Send the full GlobalMessage object so webviews receive command, destination, and content
this.providers.forEach((provider, _key) => {
provider.postMessage(message as GlobalMessage);
});
} else if (destination === "provider") {
this.postMessageToAllProviders(message);
}
}
}
public postMessageToAllProviders(message: any) {
this.providers.forEach((provider, key) => {
this.providers.forEach((provider, _key) => {
provider.receiveMessage(message);
});
}
Expand All @@ -227,7 +229,7 @@ export class GlobalProvider {
content,
};

this.providers.forEach((provider, key) => {
this.providers.forEach((provider, _key) => {
provider.postMessage(message);
});
}
Expand Down
Loading