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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes.

## [Unreleased]

### Added

- Timeline editing in the clip editor: split at the playhead (S or ⌘K), select a piece and ripple-delete it (Delete), and click a cut to put it back. Automatically removed pauses are shown on the timeline; click one to keep it.
- Cut words straight from the transcript: drag across them and press Delete or "Cut selection". Cut words are struck through.
- Undo and redo for clip edits (⌘Z / ⇧⌘Z), and Premiere-style keys: Space or K play/pause, J/L step a second, arrow keys step a frame, I/O set in and out.
- The trim bar shows how long the clip plays after pauses and cuts.

### Improved

- Show found clips as soon as they are scored; the top clips' layouts finish in the background, each marked "Framing…". A failed background layout no longer fails the run.
Expand Down
30 changes: 30 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise<void> {
await click('[data-testid="clip-thumb"]')
await sleep(800)
await shot('editor')
// Timeline editing with real key events: two razor splits, select the
// middle piece, ripple-delete it, then undo.
const key = async (keyCode: string, modifiers: Array<'meta' | 'control'> = []): Promise<void> => {
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await sleep(250)
}
const hasText = (text: string): Promise<boolean> =>
win.webContents.executeJavaScript(`document.body.innerText.includes(${JSON.stringify(text)})`)
for (let i = 0; i < 4; i++) await key('L')
await key('S')
for (let i = 0; i < 3; i++) await key('L')
await key('S')
await win.webContents.executeJavaScript(`(() => {
const track = document.querySelector('[data-testid="trim-track"]')
const rect = track.getBoundingClientRect()
const splits = [...document.querySelectorAll('[data-testid="trim-track"] .bg-amber-300')]
const x = splits.length >= 2
? (splits[0].getBoundingClientRect().left + splits[1].getBoundingClientRect().left) / 2
: rect.left + rect.width / 2
track.dispatchEvent(new PointerEvent('pointerdown', { clientX: x, bubbles: true }))
})()`)
await sleep(400)
await key('Delete')
await sleep(600)
if (!(await hasText('1 cut'))) throw new Error('Smoke capture: splitting and deleting a piece did not cut it')
await shot('editor-cut')
await key('Z', [process.platform === 'darwin' ? 'meta' : 'control'])
await sleep(600)
if (await hasText('1 cut')) throw new Error('Smoke capture: undo did not restore the cut piece')
// Out to the setup screen, which is where the two modes are chosen.
await click('[data-testid="back-button"]')
await click('[data-testid="regenerate-button"]')
Expand Down
10 changes: 5 additions & 5 deletions src/main/pipeline/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
WatermarkPosition
} from '@shared/types'
import type { EncoderPreference } from '@shared/types'
import { computeKeptSegments, remapTranscript, TimeMap, type KeptSegment } from '@shared/tighten'
import { clipKeptSegments, remapTranscript, TimeMap, type KeptSegment } from '@shared/tighten'
import { focusPanDuration, focusSnaps } from '@shared/focusTrack'
import { automaticLayoutShots, clipAllowsAutoZoom, compositionHidesTitle, detailCaptionRanges, layoutBlocksAutoZoom } from '@shared/contentType'
import { compositionGraph, fitRegionGraph } from './layoutFilters'
Expand Down Expand Up @@ -633,10 +633,10 @@ async function render(job: RenderJob): Promise<RenderResult> {
// Tighten cuts: figure out the kept segments and remap everything that is
// timed against the source (captions, B-roll, face track) into the
// compacted output timeline.
const segments =
clip.edit.tightenCuts && transcript
? computeKeptSegments(transcript, start, clip.edit.end, clip.visualStory?.protectedRanges)
: null
const segments = clipKeptSegments(clip, transcript)
if (segments && segments.length === 0) {
throw new Error('Nothing is left to export: every part of this clip has been cut. Undo a cut or widen the trim.')
}
const map = segments ? new TimeMap(segments) : null
const outputDuration = map ? map.outputDuration : duration

Expand Down
10 changes: 3 additions & 7 deletions src/main/pipeline/visualScore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto'
import { validRectangle } from '@shared/composition'
import type { Clip, ContentRegion, Transcript } from '@shared/types'
import { wordsInRange } from '@shared/captionLayout'
import { computeKeptSegments, editedClipDuration, TimeMap } from '@shared/tighten'
import { clipKeptSegments, editedClipDuration, TimeMap } from '@shared/tighten'
import { chatJSON, type ChatContentPart } from './openai'
import { runAnalysisFfmpeg as runFfmpeg } from './ffmpeg'

Expand Down Expand Up @@ -189,19 +189,15 @@ export function validatedStoryIssue(value: unknown, transcriptText: string): Sto

/** Sample the planned edit, so removed waiting time cannot masquerade as payoff. */
export function plannedClipFrameTimes(clip: Clip, transcript: Transcript, count = 6): number[] {
const ranges = clip.edit.tightenCuts
? computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges)
: null
const ranges = clipKeptSegments(clip, transcript)
if (!ranges) return clipFrameTimes(clip.edit.start, clip.edit.end, count)
const map = new TimeMap(ranges)
return clipFrameTimes(0, map.outputDuration, count).map(t => map.toSource(t))
}

/** Supply the same retained speech as the export, not words inside cut gaps. */
export function plannedClipTranscriptText(clip: Clip, transcript: Transcript): string {
const kept = clip.edit.tightenCuts
? computeKeptSegments(transcript, clip.edit.start, clip.edit.end, clip.visualStory?.protectedRanges)
: null
const kept = clipKeptSegments(clip, transcript)
return wordsInRange(transcript, clip.edit.start, clip.edit.end)
.filter(w => !kept || kept.some(r => (w.start + w.end) / 2 >= r.start && (w.start + w.end) / 2 <= r.end))
.map(w => w.text).join(' ')
Expand Down
50 changes: 29 additions & 21 deletions src/renderer/src/components/EditorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { automaticLayoutShots, validLayoutShots } from '@shared/contentType'
import { useStore } from '../store'
import PreviewPlayer from './PreviewPlayer'
import CompositionControls from './CompositionControls'
import TrimBar from './TrimBar'
import TimelineEditor from './TimelineEditor'
import { cutRange, keepsPlayback } from '@shared/editOps'
import { trackClip } from '@shared/editHistory'
import ScoreBadge from './ScoreBadge'
import TranscriptEditor from './TranscriptEditor'
import { ExportButton } from './ClipsScreen'
Expand Down Expand Up @@ -95,6 +97,12 @@ export default function EditorScreen(): React.JSX.Element {
// opening one is what triggers the analysis (see shared/reframe.ts).
const clipId = clip?.id ?? null
const reframePending = clip ? needsReframe(clip) : false
// Undo history starts from the clip as opened, whichever screen led here
// (whole-video projects open straight into the editor).
useEffect(() => {
const opened = useStore.getState().project?.clips.find((c) => c.id === clipId)
if (opened) trackClip(opened)
}, [clipId])
useEffect(() => {
if (clipId && !sourceMissing) void ensureReframe(clipId)
// Later trim changes trigger analysis after updateClip has saved them.
Expand Down Expand Up @@ -173,27 +181,28 @@ export default function EditorScreen(): React.JSX.Element {
)}

<Section icon={Scissors} title="Trim">
<TrimBar
<TimelineEditor
clip={clip}
windowStart={windowStart}
windowEnd={windowEnd}
start={clip.edit.start}
end={clip.edit.end}
fps={project.video.fps || 30}
timeline={timeline}
onChange={(start, end) => setLocal({ start, end })}
onCommit={() => {
// Read the live clip: the pointerup closure inside TrimBar was
// created at drag start, so `clip` here would be pre-drag.
const current = useStore
.getState()
.project?.clips.find((c) => c.id === clip.id)
if (current) void updateClip(current)
// Timeline edits never touch layout fields, so they save as-is
// (userClipEdit would mark the layout as manually chosen).
onLocal={(edit) => updateClipLocal({ ...clip, edit })}
onSave={(edit) => {
const live = useStore.getState().project?.clips.find((c) => c.id === clip.id) ?? clip
void updateClip({ ...live, edit })
Comment thread
cursor[bot] marked this conversation as resolved.
}}
/>
<div className="mt-3">
<Toggle
label="Tighten cuts — remove pauses and filler words"
checked={clip.edit.tightenCuts}
onChange={(v) => set({ tightenCuts: v })}
onChange={(v) => {
// Pause removal must not remove the last playable span.
if (keepsPlayback({ ...clip, edit: { ...clip.edit, tightenCuts: v } }, project.transcript)) set({ tightenCuts: v })
}}
/>
{clip.edit.end - clip.edit.start > TIGHTEN_WARN_SEC && (
<p className="mt-1 text-[10px] leading-relaxed text-zinc-600">
Expand Down Expand Up @@ -527,15 +536,14 @@ export default function EditorScreen(): React.JSX.Element {
transcript={project.transcript}
clipStart={clip.edit.start}
clipEnd={clip.edit.end}
cuts={clip.edit.cuts}
onCut={(range) => {
const next = { ...clip, edit: cutRange(clip.edit, range) }
if (keepsPlayback(next, project.transcript)) void updateClip(next)
}}
onTrim={(start, end) => {
void updateClip({
...clip,
edit: {
...clip.edit,
start: Math.max(windowStart, start),
end: Math.min(windowEnd, end)
}
})
const next = { ...clip, edit: { ...clip.edit, start: Math.max(windowStart, start), end: Math.min(windowEnd, end) } }
if (keepsPlayback(next, project.transcript)) void updateClip(next)
}}
/>
) : (
Expand Down
21 changes: 16 additions & 5 deletions src/renderer/src/components/PreviewPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
groupWords,
wordsInRange
} from '@shared/captionLayout'
import { computeKeptSegments, TimeMap } from '@shared/tighten'
import { clipKeptSegments, TimeMap } from '@shared/tighten'
import { automaticLayoutShots, clipAllowsAutoZoom, compositionHidesTitle, detailCaptionRanges, layoutBlocksAutoZoom } from '@shared/contentType'
import { captionPositionAt } from '@shared/contentRegion'
import { computeZoomEvents, fitZoomEvents } from '@shared/zoom'
Expand Down Expand Up @@ -182,11 +182,13 @@ export default function PreviewPlayer({
return () => setScrubHandler(null)
}, [setScrubHandler, requestSeek])

// Mirrors the export's tighten-cuts behaviour by skipping removed spans.
// Mirrors the export by skipping removed pauses and the user's cuts.
// An edit with nothing left plays the trim rather than seeking every frame;
// export reports it instead.
const keptSegments = useMemo(() => {
if (!clip.edit.tightenCuts || !project.transcript) return null
return computeKeptSegments(project.transcript, start, end, clip.visualStory?.protectedRanges)
}, [clip.edit.tightenCuts, clip.visualStory, project.transcript, start, end])
const kept = clipKeptSegments(clip, project.transcript)
return kept?.length ? kept : null
}, [clip, project.transcript])
const timeMap = useMemo(() => (keptSegments ? new TimeMap(keptSegments) : null), [keptSegments])
const outputDuration = timeMap?.outputDuration ?? duration
const outputTime = timeMap ? timeMap.toOutput(time) : Math.max(0, time - start)
Expand Down Expand Up @@ -275,6 +277,15 @@ export default function PreviewPlayer({
}
}

// Keyboard shortcuts play/pause through the bus; the ref keeps the handler current.
const togglePlayRef = useRef(togglePlay)
useEffect(() => { togglePlayRef.current = togglePlay })
const setToggleHandler = usePreviewBus((s) => s.setToggleHandler)
useEffect(() => {
setToggleHandler(() => togglePlayRef.current())
return () => setToggleHandler(null)
}, [setToggleHandler])

Comment thread
cursor[bot] marked this conversation as resolved.
const restart = (): void => {
requestSeek(start)
}
Expand Down
Loading
Loading