Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 19 additions & 4 deletions src/components/MediaEditor/MediaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {
PlaybackSegment,
PlaybackSpeed,
} from '../TranscriptView/transcript';
import { PLAYBACK_SPEEDS } from '../TranscriptView/transcript';
import { PLAYBACK_SPEEDS, isSilenceType } from '../TranscriptView/transcript';
import { useTranscriptEdits } from '../../hooks/useTranscriptEdits';
import {
MediaPlayer,
Expand Down Expand Up @@ -315,10 +315,25 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(

// -- Refs --
const playerRef = React.useRef<MediaPlayerRef>(null);
// Mirror the internal player handle to the host-provided ref, if any
// Mirror the internal player handle to the host-provided ref via a STABLE
// delegating proxy: every call forwards to playerRef.current at call time.
// (Snapshotting playerRef.current with [] deps captured the first-commit
// handle — ref assignment doesn't re-render, so hosts could hold a stale
// handle whose mediaElement stayed null forever.)
React.useImperativeHandle(
externalPlayerRef,
() => playerRef.current as MediaPlayerRef,
(): MediaPlayerRef => ({
seekToMs: (timeMs) => playerRef.current?.seekToMs(timeMs),
play: () => playerRef.current?.play(),
pause: () => playerRef.current?.pause(),
getCurrentTimeMs: () => playerRef.current?.getCurrentTimeMs() ?? 0,
getDurationMs: () => playerRef.current?.getDurationMs() ?? 0,
isPaused: () => playerRef.current?.isPaused() ?? true,
setPlaybackRate: (rate) => playerRef.current?.setPlaybackRate(rate),
get mediaElement() {
return playerRef.current?.mediaElement ?? null;
},
}),
[]
);
const contentRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -580,7 +595,7 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
.filter((ew): ew is EditableWord => ew !== undefined && !ew.deleted);
if (selectedWords.length === 0) return;
const selectedText = selectedWords
.filter((ew) => ew.word.wordType !== 'silence')
.filter((ew) => !isSilenceType(ew.word.wordType))
.map((ew) => ew.word.text)
.join(' ');
e.preventDefault();
Expand Down
10 changes: 10 additions & 0 deletions src/components/MediaEditor/ScriptPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ async function parseScript(
`Entry ${i + 1}: each entry needs word.text, word.startMs and word.endMs.`
);
}
if (
w.wordType !== undefined &&
w.wordType !== 'word' &&
w.wordType !== 'silence' &&
w.wordType !== 'silence-newline'
) {
throw new Error(
`Entry ${i + 1}: wordType must be 'word', 'silence' or 'silence-newline'.`
);
}
const word: TranscriptWord = {
text: w.text,
startMs: w.startMs,
Expand Down
3 changes: 2 additions & 1 deletion src/components/MediaEditor/WordEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../Modal';
import { Button } from '../Button';
import { Input } from '../Input';
import { isSilenceType } from '../TranscriptView/transcript';
import type { EditableWord } from '../TranscriptView/transcript';

export interface WordEditorModalProps {
Expand Down Expand Up @@ -53,7 +54,7 @@ export const WordEditorModal: React.FC<WordEditorModalProps> = ({
const [error, setError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);

const isSilence = editableWord?.word.wordType === 'silence';
const isSilence = isSilenceType(editableWord?.word.wordType);
const durationMs = editableWord
? editableWord.word.endMs - editableWord.word.startMs
: 0;
Expand Down
7 changes: 7 additions & 0 deletions src/components/MediaPlayer/MediaPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ export const MediaPlayer = React.forwardRef<MediaPlayerRef, MediaPlayerProps>(
ref
) => {
const [error, setError] = React.useState<string | null>(null);

// A failed load must not outlive its src: clearing on change lets a host
// swap in a corrected URL without being trapped on the error surface
// (which unmounts the media element, so the new src would never load).
React.useEffect(() => {
setError(null);
}, [src]);
const resolvedKind = kind ?? inferMediaKind(src);

const transport = useMediaTransport({
Expand Down
2 changes: 1 addition & 1 deletion src/components/TranscriptView/TranscriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ export const TranscriptView = React.forwardRef<
className={`hover:bg-muted focus-visible:ring-ring flex w-full cursor-pointer gap-3 rounded px-2 py-1 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none ${
isActive ? 'bg-primary-500/20' : ''
}`}
{...activationProps(() => seekTo(row.startMs))}
onClick={() => seekTo(row.startMs)}
>
{showTimestamps && (
<span className="text-muted-foreground w-12 shrink-0 font-mono">
Expand Down
13 changes: 12 additions & 1 deletion src/components/TranscriptView/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
/** Type of transcript word - 'word' for spoken content, 'silence' for detected gaps, 'silence-newline' for longer pauses */
export type WordType = 'word' | 'silence' | 'silence-newline';

/** True for BOTH silence pseudo-word types ('silence' and 'silence-newline').
* Use this instead of comparing against 'silence' directly — checking only one
* of the two types is the root cause of a whole family of review findings
* (stats, splitting, filler analysis, clipboard copy). */
export function isSilenceType(wordType: WordType | undefined): boolean {
return wordType === 'silence' || wordType === 'silence-newline';
}

export interface TranscriptWord {
text: string;
startMs: number;
Expand Down Expand Up @@ -50,7 +58,10 @@ export interface Transcript {
* Used in the edited timeline to track deletions and reordering.
*/
export interface EditableWord {
/** Reference to original word in transcript.words */
/** Index into the SILENCE-INSERTED baseline timeline (transcript.words
* with detected silence pseudo-words interleaved) — NOT into
* transcript.words. -1 for split/inserted entries with no baseline slot.
* Consumers persisting edits should key by this timeline. */
originalIndex: number;
/** The word data (from original transcript) */
word: TranscriptWord;
Expand Down
149 changes: 149 additions & 0 deletions src/hooks/useTranscriptEdits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ describe('getSpeedAtIndex', () => {
expect(getSpeedAtIndex(2, markers, 1)).toBe(1.5);
expect(getSpeedAtIndex(3, markers, 1)).toBe(2);
});

it('is order-independent for external callers (#327 review)', () => {
// Public helper: callers may pass markers in arbitrary order.
const unsorted: SpeedMarker[] = [
{ wordIndex: 5, speed: 2 },
{ wordIndex: 1, speed: 1.5 },
{ wordIndex: 3, speed: 0.5 },
];
expect(getSpeedAtIndex(0, unsorted, 1)).toBe(1);
expect(getSpeedAtIndex(1, unsorted, 1)).toBe(1.5);
expect(getSpeedAtIndex(4, unsorted, 1)).toBe(0.5);
expect(getSpeedAtIndex(9, unsorted, 1)).toBe(2);
});
});

// ============================================================================
Expand Down Expand Up @@ -233,3 +246,139 @@ describe('useTranscriptEdits', () => {
expect((words as EditableWord[])[0].deleted).toBe(true);
});
});

// ============================================================================
// Regression tests for the ui#323 review findings
// ============================================================================

// A transcript with a leading pause and an inter-word gap, so the derived
// timeline interleaves silence pseudo-words: [sil, A, sil-nl, B]
const gappy: Transcript = {
durationMs: 4600,
words: [
{ text: 'Alpha', startMs: 600, endMs: 1000 },
{ text: 'Beta', startMs: 3000, endMs: 3500 },
],
};

describe('ui#323 / #327-review regressions', () => {
it('counts silence-newline entries as silences, not words (finding 3)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: gappy })
);
// Timeline: [silence 0-600][Alpha][silence-newline 1000-3000][Beta]
expect(result.current.stats.activeWordCount).toBe(2);
expect(result.current.stats.activeSilenceCount).toBe(2);
});

it('counts default filler words exactly once (findings 4/5)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: packed })
);
expect(result.current.fillerAnalysis.matchCounts.get('um')).toBe(1);
expect(result.current.fillerAnalysis.durations.get('um')).toBe(300);
});

it('can split a silence-newline entry (finding 6)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: gappy })
);
const nlIndex = result.current.editedWords.findIndex(
(ew) => ew.word.wordType === 'silence-newline'
);
expect(nlIndex).toBeGreaterThan(-1);
act(() => result.current.splitSilence(nlIndex, [1, 1]));
// The 2s newline silence is replaced by two 1s segments
expect(result.current.editedWords.filter((ew) => ew.inserted)).toHaveLength(
2
);
});

it('keeps deletions on the SAME words across a threshold rebuild (finding 2)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: gappy })
);
// Delete 'Beta' (the second non-silence word)
const betaIdx = result.current.editedWords.findIndex(
(ew) => ew.word.text === 'Beta'
);
act(() => result.current.toggleWordDeleted(betaIdx));
// Change thresholds so the silence layout changes entirely
act(() => result.current.setSilenceThresholds(100, 5000));
const beta = result.current.editedWords.find(
(ew) => ew.word.text === 'Beta'
);
const alpha = result.current.editedWords.find(
(ew) => ew.word.text === 'Alpha'
);
expect(beta?.deleted).toBe(true);
expect(alpha?.deleted).toBe(false);
});

it('clears hasEdits after undoing back to baseline with silences present (finding 1)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: gappy })
);
act(() => result.current.toggleWordDeleted(1));
expect(result.current.hasEdits).toBe(true);
act(() => result.current.undo());
expect(result.current.hasEdits).toBe(false);
});

it('drops stale undo snapshots on a threshold rebuild (#327 review)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: gappy })
);
const betaIdx = result.current.editedWords.findIndex(
(ew) => ew.word.text === 'Beta'
);
act(() => result.current.toggleWordDeleted(betaIdx));
expect(result.current.undoStack).toHaveLength(1);
// The snapshot in the stack was captured against the old silence layout
act(() => result.current.setSilenceThresholds(100, 5000));
expect(result.current.undoStack).toHaveLength(0);
// undo() is a no-op instead of restoring a stale-layout timeline
const before = result.current.editedWords;
act(() => result.current.undo());
expect(result.current.editedWords).toBe(before);
});

it('keeps deletions on the right words across a threshold rebuild with pasted words (#327 review)', () => {
const { result } = renderHook(() =>
useTranscriptEdits({ transcript: packed })
);
// Copy 'Hello' and paste the copy before 'world'. The pasted word keeps
// its source originalIndex, but the rebuild re-inits from the original
// transcript (dropping inserts) — so it must not occupy a slot in the
// deletion keyspace, or every word after it shifts.
act(() => result.current.copy([0]));
act(() => result.current.paste(2, true));
const worldIdx = result.current.editedWords.findIndex(
(ew) => ew.word.text === 'world'
);
act(() => result.current.toggleWordDeleted(worldIdx));

act(() => result.current.setSilenceThresholds(100, 5000));

// The rebuild drops the pasted copy; 'world' must still be the deleted one
expect(result.current.editedWords).toHaveLength(3);
const [hello, um, world] = result.current.editedWords;
expect(world.deleted).toBe(true);
expect(hello.deleted).toBe(false);
expect(um.deleted).toBe(false);
});

it('initializes hasEdits=true for saved text-only edits (finding 13)', () => {
const baseline = initEditableWords(packed);
const withTextEdit: EditableWord[] = baseline.map((ew, i) =>
i === 0 ? { ...ew, word: { ...ew.word, text: 'Howdy' } } : ew
);
const { result } = renderHook(() =>
useTranscriptEdits({
transcript: packed,
initialEditedWords: withTextEdit,
})
);
expect(result.current.hasEdits).toBe(true);
});
});
Loading
Loading