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 PlayerControls from '$lib/components/PlayerControls.svelte';
|
||||||
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
|
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
|
||||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.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 {
|
import {
|
||||||
adjustShuttle,
|
adjustShuttle,
|
||||||
resetShuttleRate,
|
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() {
|
function handleToggleCaptions() {
|
||||||
captionsEnabled = !captionsEnabled;
|
captionsEnabled = !captionsEnabled;
|
||||||
}
|
}
|
||||||
@@ -230,6 +238,20 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleMode();
|
toggleMode();
|
||||||
break;
|
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':
|
||||||
case 'F':
|
case 'F':
|
||||||
if (isPlayerMode) {
|
if (isPlayerMode) {
|
||||||
@@ -405,6 +427,7 @@
|
|||||||
hasCaptions={session.hasCaptions || false}
|
hasCaptions={session.hasCaptions || false}
|
||||||
onToggleCaptions={handleToggleCaptions}
|
onToggleCaptions={handleToggleCaptions}
|
||||||
onOpenCaptionSettings={() => {}}
|
onOpenCaptionSettings={() => {}}
|
||||||
|
onCutClip={cutClip}
|
||||||
/>
|
/>
|
||||||
<PlayerTimeline visible={showPlayerControls} />
|
<PlayerTimeline visible={showPlayerControls} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -38,12 +38,14 @@
|
|||||||
hasCaptions,
|
hasCaptions,
|
||||||
onToggleCaptions,
|
onToggleCaptions,
|
||||||
onOpenCaptionSettings,
|
onOpenCaptionSettings,
|
||||||
|
onCutClip,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
captionsEnabled: boolean;
|
captionsEnabled: boolean;
|
||||||
hasCaptions: boolean;
|
hasCaptions: boolean;
|
||||||
onToggleCaptions: () => void;
|
onToggleCaptions: () => void;
|
||||||
onOpenCaptionSettings: () => void;
|
onOpenCaptionSettings: () => void;
|
||||||
|
onCutClip: (position: 'at' | 'before') => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let showSpeedSelector = $state(false);
|
let showSpeedSelector = $state(false);
|
||||||
@@ -225,6 +227,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if hasCaptions}
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn"
|
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