feat: cut clip from Player mode (X/Z keys, scissors button)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
28
src/lib/utils/clipRange.ts
Normal file
28
src/lib/utils/clipRange.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
tests/lib/utils/clipRange.test.ts
Normal file
43
tests/lib/utils/clipRange.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user