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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Required for realtime transcript token generation.
# Required for post-recording batch transcription (main process only).
ELEVENLABS_API_KEY=
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,11 @@ Default to:
- Very long timelines with dense camera-overlay keyframes can produce extremely nested ffmpeg filter expressions that exceed parser limits and fail render; the overlay graph needs to stay bounded for long sessions.
- Heavy H.264 screen exports (high resolution, high quality, dense UI detail) can stress QuickTime playback more than NLEs; explicit yuv420p and a compatible profile/level—and avoiding unnecessary output resolution—improves default macOS playback.
- `@electron/packager` dependency pruning can miss transitive packages under `pnpm`’s symlinked `node_modules` layout (fresh CI installs surface this); the packaging smoke harness avoids relying on that prune path.
- The Scribe live-transcription `AudioWorklet` (`audio-processor`) must load as a plain browser worklet script; compiling it with the main-process TypeScript target (e.g. CommonJS/NodeNext) breaks loading (`exports` undefined) and silently disables transcription-driven features.
- Transcription is on-demand, not part of recording: stopping a take puts it on the timeline immediately as one full-length section (no network in the stop flow), and the editor's "Transcribe & Cut" button later batch-transcribes the take (main-process `transcription-service` extracts audio via ffmpeg stream copy, then calls ElevenLabs batch Scribe with word timestamps) and replaces that take's timeline sections with speech-cut ones as a single undo step. Cuts must never mutate media files; failure/timeout/no-speech or a mid-flight timeline edit must leave the timeline unchanged with a visible status. The realtime Scribe WebSocket/AudioWorklet pipeline was removed because long silences and session limits killed the live socket mid-recording.
- `src/renderer/styles/main.css` is Tailwind build output (`build:styles`, minified); full `check` rebuilds styles via nested steps, so tracking a non-matching formatted copy causes perpetual diffs—prefer generating locally and not treating it as hand-edited source.
- Recording durability depends on streaming `MediaRecorder` chunks directly to disk via IPC temp `.part` files (renamed on finalize) instead of buffering in the renderer; screen and camera are independent recorder pipelines, so stop/save/finalize must tolerate one failing without blocking the other, and recovery normalization must require the screen file but tolerate a missing camera file so a partial failure does not discard an otherwise-recoverable take. Partial WebM files report `duration=Infinity`; resolve real duration by seeking a probe `<video>` element to `1e101` so the browser computes duration from the seekable range, and tolerate unreadable probes without wedging the whole recovery flow.
- Premiere Pro FCP7 xmeml `Crop` must be emitted as `<effecttype>filter</effecttype>` + `<effectcategory>Matte</effectcategory>`; mis-classifying it as a motion effect makes Premiere silently drop the effect, leaving the camera uncropped and PiP placement drifting off the intended corner. Crop runs before the fixed Motion effect in Premiere's pipeline, which shapes how authored PiP geometry translates into Motion center/scale + Crop percentages.
- Premiere imports xmeml Basic Motion `<center>` in units of the MEDIA frame size, not the sequence frame: Position = seqCenter + center × mediaDim (verified empirically — a 4K camera PiP emitted with sequence-relative units landed at 960 + horiz×3840, off the program monitor). Emit center as canvasOffsetPx / mediaDim; with 1080p media in a 1080p sequence the conventions differ by 2× but small offsets masked it, and the bug only clearly surfaced once capture quality raised camera recordings to 4K. The xmeml importer also cannot target Premiere's built-in Properties-panel Crop section — a separate `Crop` fx effect is the only vehicle.
- Rounded PiP corners in the Premiere export were attempted via a luma matte track and intentionally REVERTED (the PiP imports square; rounding is left to the editor in Premiere). Hard-won xmeml importer facts from that attempt, kept to avoid re-litigating: (1) FCP7 travel-matte composite modes are not translated — `<compositemode>lumamask</compositemode>` yields "Composite mode <lumamask> not supported" and is dropped; (2) Track Matte Key only offers tracks ABOVE the fill clip and reads output-disabled tracks as empty (keying against a disabled track blanks the fill); (3) xmeml Motion translates with different geometry per media type — media-relative center/scale was verified exact for full-res video clips, but a PNG still and a small (pipSize²) video both imported misplaced/mis-scaled.
- `flushScheduledProjectSave()` runs when switching away from or closing a project and writes the in-memory project state back to `project.json`, overwriting any external edits made to the file while the app was running; edit `project.json` externally only with the app fully quit.
- Editor playback must never track timeline sections by object identity: section arrays get wholesale-replaced (Transcribe & Cut, undo/redo restore copies), and an identity `indexOf` on a stale reference returned -1 so playback silently jumped to `sections[0]` and replayed old content. The draw-loop advance decision lives in `renderer/features/timeline/playback-advance.ts` — id-based lookup, geometry read from the current array, and a `stale` result that forces a re-resolve instead of guessing. Route future playback-advance changes through that module and its tests.
22 changes: 9 additions & 13 deletions docs/production/feature-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,19 @@ Acceptance criteria:

## C. Transcript And Trim

### C1. Realtime transcript
### C1. On-demand Transcribe & Cut

- During recording, app sends PCM chunks to Scribe realtime websocket and displays partial + committed text.
- Stopping a recording puts the take on the timeline immediately as one full-length section — no transcription and no network call in the stop flow.
- The timeline toolbar's "Transcribe & Cut" button batch-transcribes the target take's mic audio (audio-only file, or audio extracted from the camera file via ffmpeg stream copy) with ElevenLabs Scribe in the main process, groups word timestamps into speech segments, and replaces the take's timeline sections with speech-cut sections.
- The target take is the selected section's take, falling back to the most recent take still on the timeline.

Acceptance criteria:

- Committed transcript segments store `start/end/text`.
- Non-speech annotations (e.g. bracketed cues) are stripped from user-visible transcript and segment content.

### C2. Segment editing

- User can select transcript segments and toggle deletion with keyboard shortcuts.

Acceptance criteria:

- Deleted segments are excluded from trim input.
- Badge reflects active vs removed count.
- The recording view shows no transcript panel and no silence-cutting option; recorder failures surface on a compact notice line.
- The stop flow performs no transcription work; the finalized files and the recovery checkpoint are on disk before the take enters the timeline.
- Transcribe & Cut applies as a single undo step; failure, timeout, no-speech, or a timeline edit made while transcription was in flight leaves the timeline unchanged and reports a visible status.
- Word timestamps map into take time using the per-file recorder start offsets; non-speech annotations are stripped; system-audio "keep" regions captured during the same app session are respected.
- Takes without mic audio report a visible message and trigger no network call.

### C3. Section computation

Expand Down
2 changes: 1 addition & 1 deletion docs/production/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Environment

- Copy `.env.example` to `.env`.
- Set `ELEVENLABS_API_KEY` for realtime transcription token generation.
- Set `ELEVENLABS_API_KEY` for post-recording batch transcription (used only in the main process; never exposed to the renderer).

## Local Verification

Expand Down
6 changes: 5 additions & 1 deletion docs/production/target-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ src/
render-service.js
sections-service.js
scribe-service.js
transcription-service.js
media-service.js
infra/
file-system.js
Expand All @@ -33,9 +34,12 @@ src/
features/
transcript/
transcript-utils.js
batch-transcript.js
timeline/
section-utils.js
keyframe-utils.js
transcribe-cut.js
playback-advance.js
services/
electron-api.js
project-session.js
Expand All @@ -59,7 +63,7 @@ flowchart LR
mainIpc --> recoveryService["services/recovery-service.js"]
mainIpc --> renderService["services/render-service.js"]
mainIpc --> sectionsService["services/sections-service.js"]
mainIpc --> scribeService["services/scribe-service.js"]
mainIpc --> transcriptionService["services/transcription-service.js"]
projectService --> sharedDomain["shared/domain/*.js"]
recoveryService --> sharedDomain
renderService --> sharedDomain
Expand Down
15 changes: 0 additions & 15 deletions src/audio-processor.ts

This file was deleted.

33 changes: 11 additions & 22 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,8 @@ <h2 class="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">R
</div>
</div>

<!-- Transcript panel (shown during recording) -->
<div id="transcriptPanel" class="hidden flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between px-1 py-1.5">
<span class="text-xs font-medium text-neutral-500 uppercase tracking-wider"
>Transcript</span
>
<span id="segmentBadge" class="text-xs text-neutral-500 tabular-nums">0 segments</span>
</div>
<div id="transcriptStatus" class="hidden px-1 pb-1 text-[11px] text-neutral-500"></div>
<div
id="transcriptContent"
class="flex-1 overflow-y-auto text-sm leading-relaxed min-h-0"
></div>
</div>
<!-- Recording notices (recorder failures, capture warnings) -->
<div id="recordingNotice" class="hidden px-1 py-1.5 text-[11px] text-neutral-500"></div>

<!-- Settings -->
<div id="settingsPanel" class="space-y-3">
Expand Down Expand Up @@ -270,14 +258,6 @@ <h2 class="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">R
>
</span>
</label>
<label class="flex items-start gap-2 text-xs text-neutral-400 cursor-pointer select-none">
<input
id="keepSilencesToggle"
type="checkbox"
class="mt-0.5 rounded border-neutral-700 bg-neutral-800 text-neutral-50 focus:ring-neutral-600"
/>
<span>Keep silences</span>
</label>
</div>
</div>

Expand Down Expand Up @@ -558,6 +538,15 @@ <h2 class="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">R
>
Apply to Future
</button>
<div class="w-px h-5 bg-neutral-800 mx-1"></div>
<button
id="transcribeCutBtn"
title="Transcribe the take and cut out silences"
class="px-3.5 py-1.5 bg-neutral-100 hover:bg-white text-neutral-950 font-medium rounded-lg text-sm transition-colors disabled:opacity-40 disabled:cursor-default"
>
Transcribe &amp; Cut
</button>
<span id="transcribeCutStatus" class="hidden text-[11px] text-neutral-500"></span>
</div>
<div class="text-[11px] text-neutral-600 text-center">
<span class="text-neutral-500">⌘Z</span> undo ·
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { renderComposite } from './main/services/render-service';
import { exportPremiereProject } from './main/services/premiere-export-service';
import { computeSections } from './main/services/sections-service';
import { generatePreview } from './main/services/preview-render-service';
import { getScribeToken } from './main/services/scribe-service';
import { transcribeRecordingFile } from './main/services/transcription-service';
import * as proxyService from './main/services/proxy-service';
import * as recordingService from './main/services/recording-service';

Expand All @@ -49,7 +49,7 @@ registerIpcHandlers({
exportPremiereProject,
computeSections,
generatePreview,
getScribeToken,
transcribeRecordingFile,
proxyService,
recordingService,
setPendingDisplayMediaSource
Expand Down
23 changes: 18 additions & 5 deletions src/main/ipc/register-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { renderComposite } from '../services/render-service';
import type { exportPremiereProject } from '../services/premiere-export-service';
import type { computeSections } from '../services/sections-service';
import type { generatePreview } from '../services/preview-render-service';
import type { transcribeRecordingFile } from '../services/transcription-service';
import type * as proxyServiceModule from '../services/proxy-service';
import type * as recordingServiceModule from '../services/recording-service';

Expand All @@ -28,6 +29,9 @@ type RenderComposite = typeof renderComposite;
type ExportPremiereProject = typeof exportPremiereProject;
type ComputeSections = typeof computeSections;
type GeneratePreview = typeof generatePreview;
type TranscribeRecordingFile = (
opts: Parameters<typeof transcribeRecordingFile>[0]
) => ReturnType<typeof transcribeRecordingFile>;
type ProxyService = typeof proxyServiceModule;
type RecordingService = typeof recordingServiceModule;

Expand All @@ -51,7 +55,7 @@ export function registerIpcHandlers({
exportPremiereProject,
computeSections,
generatePreview,
getScribeToken,
transcribeRecordingFile,
proxyService,
recordingService,
setPendingDisplayMediaSource
Expand All @@ -69,7 +73,7 @@ export function registerIpcHandlers({
exportPremiereProject: ExportPremiereProject;
computeSections: ComputeSections;
generatePreview: GeneratePreview;
getScribeToken: () => Promise<string>;
transcribeRecordingFile: TranscribeRecordingFile;
proxyService: ProxyService;
recordingService: RecordingService;
setPendingDisplayMediaSource: (sourceId: string | null) => void;
Expand Down Expand Up @@ -331,11 +335,20 @@ export function registerIpcHandlers({
return filePaths[0];
});

ipcMain.handle('get-scribe-token', async () => {
ipcMain.handle('transcription:transcribe', async (_event, opts: unknown) => {
const payload = (opts || {}) as { sourcePath?: unknown; languageCode?: unknown };
if (typeof payload.sourcePath !== 'string' || !payload.sourcePath.trim()) {
throw new Error('transcription:transcribe requires a sourcePath');
}
try {
return await getScribeToken();
return await transcribeRecordingFile({
sourcePath: payload.sourcePath,
...(typeof payload.languageCode === 'string' && payload.languageCode
? { languageCode: payload.languageCode }
: {})
});
} catch (error) {
console.error('Failed to get Scribe token:', error);
console.error('Batch transcription failed:', error);
throw error;
}
});
Expand Down
10 changes: 1 addition & 9 deletions src/main/services/scribe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,4 @@ function getRequiredEnv(name: string): string {
return value;
}

async function getScribeToken(): Promise<string> {
const apiKey = getRequiredEnv('ELEVENLABS_API_KEY');
const { ElevenLabsClient } = await import('@elevenlabs/elevenlabs-js');
const client = new ElevenLabsClient({ apiKey });
const response = await client.tokens.singleUse.create('realtime_scribe');
return response.token;
}

export { getRequiredEnv, getScribeToken };
export { getRequiredEnv };
Loading
Loading