feat: cut clip from Player mode (X/Z keys, scissors button)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 02:27:21 -04:00
parent d964c97fe6
commit ac7cb70cb7
4 changed files with 105 additions and 1 deletions

View File

@@ -13,7 +13,8 @@
import PlayerControls from '$lib/components/PlayerControls.svelte';
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
import { getSelectedClipId, removeClip, markInPoint, markOutPoint } from '$lib/stores/clips.svelte';
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
import { computeQuickClipRange } from '$lib/utils/clipRange';
import {
adjustShuttle,
resetShuttleRate,
@@ -186,6 +187,13 @@
}
}
function cutClip(position: 'at' | 'before') {
const range = computeQuickClipRange(session.currentTime, session.duration, position);
if (!range) return;
addClip(range.start, range.end);
setAppMode('clipper');
}
function handleToggleCaptions() {
captionsEnabled = !captionsEnabled;
}
@@ -230,6 +238,20 @@
e.preventDefault();
toggleMode();
break;
case 'x':
case 'X':
if (isPlayerMode) {
e.preventDefault();
cutClip('at');
}
break;
case 'z':
case 'Z':
if (isPlayerMode) {
e.preventDefault();
cutClip('before');
}
break;
case 'f':
case 'F':
if (isPlayerMode) {
@@ -405,6 +427,7 @@
hasCaptions={session.hasCaptions || false}
onToggleCaptions={handleToggleCaptions}
onOpenCaptionSettings={() => {}}
onCutClip={cutClip}
/>
<PlayerTimeline visible={showPlayerControls} />
{:else}

View File

@@ -38,12 +38,14 @@
hasCaptions,
onToggleCaptions,
onOpenCaptionSettings,
onCutClip,
}: {
visible: boolean;
captionsEnabled: boolean;
hasCaptions: boolean;
onToggleCaptions: () => void;
onOpenCaptionSettings: () => void;
onCutClip: (position: 'at' | 'before') => void;
} = $props();
let showSpeedSelector = $state(false);
@@ -225,6 +227,14 @@
{/if}
</div>
<button
class="ctrl-btn"
onclick={() => onCutClip('at')}
oncontextmenu={(e) => { e.preventDefault(); onCutClip('before'); }}
title="Cut clip at playhead (X) · Right-click: 10s before (Z)"
type="button"
><Scissors size={16} /></button>
{#if hasCaptions}
<button
class="ctrl-btn"

View File

@@ -0,0 +1,28 @@
export type QuickClipPosition = 'at' | 'before';
export function computeQuickClipRange(
currentTime: number,
duration: number,
position: QuickClipPosition,
clipDuration = 10,
): { start: number; end: number } | null {
if (duration === 0) return null;
switch (position) {
case 'at':
return {
start: currentTime,
end: Math.min(currentTime + clipDuration, duration),
};
case 'before':
return {
start: Math.max(currentTime - clipDuration, 0),
end: currentTime,
};
default: {
const _exhaustive: never = position;
void _exhaustive;
return null;
}
}
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { computeQuickClipRange } from '$lib/utils/clipRange';
describe('computeQuickClipRange', () => {
it('returns null when duration is 0', () => {
expect(computeQuickClipRange(5, 0, 'at')).toBeNull();
});
it('"at" creates clip from currentTime to currentTime+10', () => {
const result = computeQuickClipRange(30, 120, 'at');
expect(result).toEqual({ start: 30, end: 40 });
});
it('"before" creates clip from currentTime-10 to currentTime', () => {
const result = computeQuickClipRange(30, 120, 'before');
expect(result).toEqual({ start: 20, end: 30 });
});
it('"at" clamps end to duration', () => {
const result = computeQuickClipRange(115, 120, 'at');
expect(result).toEqual({ start: 115, end: 120 });
});
it('"before" clamps start to 0', () => {
const result = computeQuickClipRange(3, 120, 'before');
expect(result).toEqual({ start: 0, end: 3 });
});
it('accepts custom clip duration', () => {
const result = computeQuickClipRange(50, 120, 'at', 20);
expect(result).toEqual({ start: 50, end: 70 });
});
it('"at" at time 0 works', () => {
const result = computeQuickClipRange(0, 120, 'at');
expect(result).toEqual({ start: 0, end: 10 });
});
it('"before" at exact duration works', () => {
const result = computeQuickClipRange(120, 120, 'before');
expect(result).toEqual({ start: 110, end: 120 });
});
});