diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 0eb7bfb812e..e77178f1ab0 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -249,6 +249,9 @@ --component-node-widget-promoted: var(--color-purple-700); --component-node-widget-advanced: var(--color-azure-400); + --video-trim-selection-background: var(--color-datatype-CLIP, #ffd500); + --video-trim-playhead-background: #f0513b; + /* Default UI element color palette variables */ --palette-contrast-mix-color: #fff; --palette-interface-panel-surface: var(--comfy-menu-bg); @@ -532,6 +535,10 @@ ); --color-component-node-widget-promoted: var(--component-node-widget-promoted); --color-component-node-widget-advanced: var(--component-node-widget-advanced); + --color-video-trim-selection-background: var( + --video-trim-selection-background + ); + --color-video-trim-playhead-background: var(--video-trim-playhead-background); /* Semantic tokens */ --color-base-foreground: var(--base-foreground); diff --git a/src/components/videoEdit/VideoFilmstripTrim.test.ts b/src/components/videoEdit/VideoFilmstripTrim.test.ts new file mode 100644 index 00000000000..29d12bf20ed --- /dev/null +++ b/src/components/videoEdit/VideoFilmstripTrim.test.ts @@ -0,0 +1,506 @@ +/* eslint-disable testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import type { Ref } from 'vue' + +const { activeHandle } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ref: createRef } = require('vue') + return { + activeHandle: createRef(null) as Ref<'min' | 'max' | 'midpoint' | null> + } +}) + +vi.mock('@/composables/useRangeEditor', () => ({ + useRangeEditor: () => ({ + startDrag: vi.fn(), + activeHandle + }) +})) + +import type { ComponentProps } from 'vue-component-type-helpers' +import { fireEvent, render, screen } from '@testing-library/vue' +import { createI18n } from 'vue-i18n' + +import VideoFilmstripTrim from './VideoFilmstripTrim.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + videoEdit: { + play: 'Play', + pause: 'Pause', + loadingFilmstrip: 'Loading filmstrip…', + seekVideo: 'Seek video', + adjustStartFrame: 'Adjust start frame', + adjustEndFrame: 'Adjust end frame' + } + } + } +}) + +type FilmstripProps = ComponentProps + +function expectedFrameAt(clientX: number, width = 200, frameMax = 100) { + const contentWidth = Math.max(width - 32, 1) + const norm = Math.min(Math.max((clientX - 16) / contentWidth, 0), 1) + return Math.round(norm * frameMax) +} + +function renderFilmstrip(props: FilmstripProps) { + return render(VideoFilmstripTrim, { + props, + global: { + plugins: [i18n] + } + }) +} + +function mockTrackRect() { + const track = screen.getByTestId('trim-track') + vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 200, + height: 64, + right: 200, + bottom: 64, + x: 0, + y: 0, + toJSON: () => ({}) + }) + return track +} + +describe('VideoFilmstripTrim', () => { + beforeEach(() => { + activeHandle.value = null + }) + + it('insets the filmstrip track by handle width on each side', () => { + renderFilmstrip({ + totalFrames: 100, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 99, + playheadFrame: 0, + disabled: false + }) + + const filmstrip = screen.getByTestId('filmstrip-track') + expect(filmstrip.style.left).toBe('16px') + expect(filmstrip.style.right).toBe('16px') + }) + + it('prevents filmstrip thumbnails from being dragged', () => { + renderFilmstrip({ + totalFrames: 100, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 99, + playheadFrame: 0, + disabled: false + }) + + expect( + screen.getByTestId('filmstrip-thumbnail').getAttribute('draggable') + ).toBe('false') + }) + + it('shows whole frame number in tooltip while dragging end handle', () => { + activeHandle.value = 'max' + renderFilmstrip({ + totalFrames: 401, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 400, + playheadFrame: 0, + disabled: false + }) + + expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('400') + }) + + it('shows whole frame number in tooltip while dragging start handle', () => { + activeHandle.value = 'min' + renderFilmstrip({ + totalFrames: 401, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 120, + endFrame: 400, + playheadFrame: 120, + disabled: false + }) + + expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('120') + }) + + it('maps the content-inset edges and midpoint to fixed frames', async () => { + const playheadFrame = ref(0) + render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 100, + playheadFrame: 0, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + + await fireEvent.pointerDown(track, { clientX: 16, button: 0, pointerId: 1 }) + await fireEvent.pointerUp(track, { pointerId: 1 }) + expect(playheadFrame.value).toBe(0) + + await fireEvent.pointerDown(track, { + clientX: 100, + button: 0, + pointerId: 1 + }) + await fireEvent.pointerUp(track, { pointerId: 1 }) + expect(playheadFrame.value).toBe(50) + + await fireEvent.pointerDown(track, { + clientX: 184, + button: 0, + pointerId: 1 + }) + await fireEvent.pointerUp(track, { pointerId: 1 }) + expect(playheadFrame.value).toBe(100) + + await fireEvent.pointerDown(track, { + clientX: 300, + button: 0, + pointerId: 1 + }) + await fireEvent.pointerUp(track, { pointerId: 1 }) + expect(playheadFrame.value).toBe(100) + }) + + it('scrubs to the clicked frame on the filmstrip', async () => { + const playheadFrame = ref(0) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 100, + playheadFrame: 0, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + + await fireEvent.pointerDown(track, { clientX: 100, button: 0 }) + + expect(playheadFrame.value).toBe(expectedFrameAt(100)) + expect(emitted().scrub).toEqual([[expectedFrameAt(100)]]) + }) + + it('clamps scrubbing to the trim selection when trim is enabled', async () => { + const playheadFrame = ref(50) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 50, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + + await fireEvent.pointerDown(track, { clientX: 20, button: 0 }) + + expect(playheadFrame.value).toBe(10) + expect(emitted().scrub).toEqual([[10]]) + + await fireEvent.pointerDown(track, { clientX: 180, button: 0 }) + + expect(playheadFrame.value).toBe(80) + expect(emitted().scrub).toEqual([[10], [80]]) + }) + + it('emits a single scrub while dragging past the trim bound', async () => { + const playheadFrame = ref(50) + const scrubs: number[] = [] + const Host = defineComponent({ + setup() { + return () => + h(VideoFilmstripTrim, { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: playheadFrame.value, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + }, + onScrub: (frame: number) => { + scrubs.push(frame) + } + }) + } + }) + render(Host, { + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + track.setPointerCapture = vi.fn() + + await fireEvent.pointerDown(track, { + clientX: 180, + button: 0, + pointerId: 1 + }) + await fireEvent.pointerMove(track, { clientX: 190, pointerId: 1 }) + await fireEvent.pointerMove(track, { clientX: 195, pointerId: 1 }) + + expect(playheadFrame.value).toBe(80) + expect(scrubs).toEqual([80]) + }) + + it('emits the initial scrub even when the pointer lands on the current frame', async () => { + const playheadFrame = ref(50) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 100, + playheadFrame: 50, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + + await fireEvent.pointerDown(track, { clientX: 100, button: 0 }) + + expect(emitted().scrub).toEqual([[50]]) + }) + + it('seeks with arrow keys, Home and End from the slider track', async () => { + const playheadFrame = ref(50) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 50, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = screen.getByRole('slider', { name: 'Seek video' }) + expect(track.getAttribute('aria-valuemin')).toBe('10') + expect(track.getAttribute('aria-valuemax')).toBe('80') + expect(track.getAttribute('aria-valuenow')).toBe('50') + expect(track.getAttribute('tabindex')).toBe('0') + + await fireEvent.keyDown(track, { key: 'ArrowRight' }) + expect(playheadFrame.value).toBe(51) + + await fireEvent.keyDown(track, { key: 'Home' }) + expect(playheadFrame.value).toBe(10) + + await fireEvent.keyDown(track, { key: 'End' }) + expect(playheadFrame.value).toBe(80) + + expect(emitted().scrub).toEqual([[51], [10], [80]]) + }) + + it('updates playhead while dragging across the filmstrip', async () => { + const playheadFrame = ref(0) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 100, + playheadFrame: 0, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + track.setPointerCapture = vi.fn() + + await fireEvent.pointerDown(track, { clientX: 40, button: 0, pointerId: 1 }) + await fireEvent.pointerMove(track, { + clientX: 120, + button: 0, + pointerId: 1 + }) + + expect(playheadFrame.value).toBe(expectedFrameAt(120)) + expect(emitted().scrub).toEqual([ + [expectedFrameAt(40)], + [expectedFrameAt(120)] + ]) + }) + + it('shows the frame number in a tooltip while scrubbing', async () => { + const playheadFrame = ref(0) + const Host = defineComponent({ + setup() { + return () => + h(VideoFilmstripTrim, { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 0, + endFrame: 100, + playheadFrame: playheadFrame.value, + disabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }) + } + }) + render(Host, { + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + track.setPointerCapture = vi.fn() + + expect(screen.queryByTestId('scrub-tooltip')).toBeNull() + + await fireEvent.pointerDown(track, { + clientX: 120, + button: 0, + pointerId: 1 + }) + + expect(screen.getByTestId('scrub-tooltip')).toHaveTextContent( + String(expectedFrameAt(120)) + ) + + await fireEvent.pointerUp(track, { pointerId: 1 }) + + expect(screen.queryByTestId('scrub-tooltip')).toBeNull() + }) + + it('renders trim handles when enabled', () => { + renderFilmstrip({ + totalFrames: 100, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 10, + disabled: false + }) + + expect(screen.getByTestId('handle-start')).toBeTruthy() + expect(screen.getByTestId('handle-end')).toBeTruthy() + }) + + it('hides trim handles when disabled', () => { + renderFilmstrip({ + totalFrames: 100, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 10, + disabled: true + }) + + expect(screen.queryByTestId('handle-start')).toBeNull() + expect(screen.queryByTestId('handle-end')).toBeNull() + }) + + it('hides trim selection UI when trim is toggled off', () => { + renderFilmstrip({ + totalFrames: 100, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 10, + trimEnabled: false + }) + + expect(screen.getByTestId('playhead')).toBeTruthy() + expect(screen.getByTestId('filmstrip-track').style.left).toBe('16px') + expect(screen.getByTestId('filmstrip-track').style.right).toBe('16px') + expect(screen.queryByTestId('handle-start')).toBeNull() + expect(screen.queryByTestId('handle-end')).toBeNull() + }) + + it('scrubs across the full timeline when trim is toggled off', async () => { + const playheadFrame = ref(0) + const { emitted } = render(VideoFilmstripTrim, { + props: { + totalFrames: 101, + thumbnails: ['data:image/jpeg;base64,one'], + startFrame: 10, + endFrame: 80, + playheadFrame: 0, + trimEnabled: false, + 'onUpdate:playheadFrame': (value: number) => { + playheadFrame.value = value + } + }, + global: { + plugins: [i18n] + } + }) + + const track = mockTrackRect() + + await fireEvent.pointerDown(track, { clientX: 100, button: 0 }) + + expect(playheadFrame.value).toBe(expectedFrameAt(100)) + expect(emitted().scrub).toEqual([[expectedFrameAt(100)]]) + }) +}) diff --git a/src/components/videoEdit/VideoFilmstripTrim.vue b/src/components/videoEdit/VideoFilmstripTrim.vue new file mode 100644 index 00000000000..66ea5913649 --- /dev/null +++ b/src/components/videoEdit/VideoFilmstripTrim.vue @@ -0,0 +1,349 @@ + + + diff --git a/src/composables/useRangeEditor.test.ts b/src/composables/useRangeEditor.test.ts index 9f31c9014af..c8bfd2688c4 100644 --- a/src/composables/useRangeEditor.test.ts +++ b/src/composables/useRangeEditor.test.ts @@ -51,6 +51,7 @@ interface HarnessOptions { showMidpoint?: boolean track?: HTMLElement | null contentInsetX?: number + handleCenterOffsetX?: number } interface Harness { @@ -74,6 +75,7 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => { const valueMax = ref(opts.valueMax ?? 100) const showMidpoint = ref(opts.showMidpoint ?? true) const contentInsetX = ref(opts.contentInsetX ?? 0) + const handleCenterOffsetX = ref(opts.handleCenterOffsetX ?? 0) let api: ReturnType | undefined const TestComponent = defineComponent({ @@ -84,7 +86,8 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => { valueMin, valueMax, showMidpoint, - contentInsetX + contentInsetX, + handleCenterOffsetX }) return () => null } @@ -157,6 +160,64 @@ describe('useRangeEditor', () => { expect(harness.modelValue.value.max).toBe(80) }) + it('shifts min handle drags by the handle-center offset', () => { + harness = mountRangeEditor({ + initial: { min: 20, max: 80, midpoint: 0.5 }, + valueMin: 0, + valueMax: 100, + handleCenterOffsetX: 8 + }) + + harness.api.startDrag( + 'min', + createPointerEvent('pointerdown', { clientX: 32 }) + ) + harness.trackRef.value!.dispatchEvent( + createPointerEvent('pointermove', { clientX: 92 }) + ) + + expect(harness.modelValue.value.min).toBe(50) + }) + + it('shifts max handle drags by the handle-center offset', () => { + harness = mountRangeEditor({ + initial: { min: 20, max: 80, midpoint: 0.5 }, + valueMin: 0, + valueMax: 100, + handleCenterOffsetX: 8 + }) + + harness.api.startDrag( + 'max', + createPointerEvent('pointerdown', { clientX: 168 }) + ) + harness.trackRef.value!.dispatchEvent( + createPointerEvent('pointermove', { clientX: 108 }) + ) + + expect(harness.modelValue.value.max).toBe(50) + }) + + it('combines the handle-center offset with the content inset', () => { + harness = mountRangeEditor({ + initial: { min: 20, max: 80, midpoint: 0.5 }, + valueMin: 0, + valueMax: 100, + contentInsetX: 16, + handleCenterOffsetX: 8 + }) + + harness.api.startDrag( + 'min', + createPointerEvent('pointerdown', { clientX: 40 }) + ) + harness.trackRef.value!.dispatchEvent( + createPointerEvent('pointermove', { clientX: 92 }) + ) + + expect(harness.modelValue.value.min).toBe(50) + }) + it('drags the max handle and clamps to the configured ceiling', () => { harness = mountRangeEditor({ initial: { min: 20, max: 80, midpoint: 0.5 }, diff --git a/src/composables/useRangeEditor.ts b/src/composables/useRangeEditor.ts index 13cd4a62e85..6bf4d063006 100644 --- a/src/composables/useRangeEditor.ts +++ b/src/composables/useRangeEditor.ts @@ -15,6 +15,7 @@ interface UseRangeEditorOptions { valueMax: Ref showMidpoint: Ref contentInsetX?: Ref + handleCenterOffsetX?: Ref } export function useRangeEditor({ @@ -23,12 +24,24 @@ export function useRangeEditor({ valueMin, valueMax, showMidpoint, - contentInsetX + contentInsetX, + handleCenterOffsetX }: UseRangeEditorOptions) { const activeHandle = ref(null) let cleanupDrag: (() => void) | null = null - function pointerToValue(e: PointerEvent): number { + function grabShiftFor(handle: HandleType | null): number { + const offset = handleCenterOffsetX?.value ?? 0 + if (!Number.isFinite(offset)) return 0 + if (handle === 'min') return offset + if (handle === 'max') return -offset + return 0 + } + + function pointerToValue( + e: PointerEvent, + handle: HandleType | null = null + ): number { const el = trackRef.value if (!el) return valueMin.value const rect = el.getBoundingClientRect() @@ -38,7 +51,7 @@ export function useRangeEditor({ : 0 const contentWidth = Math.max(rect.width - 2 * inset, 1) const normalized = clamp( - (e.clientX - rect.left - inset) / contentWidth, + (e.clientX + grabShiftFor(handle) - rect.left - inset) / contentWidth, 0, 1 ) @@ -94,7 +107,7 @@ export function useRangeEditor({ const onMove = (ev: PointerEvent) => { if (!activeHandle.value) return - updateValue(activeHandle.value, pointerToValue(ev)) + updateValue(activeHandle.value, pointerToValue(ev, activeHandle.value)) } const endDrag = () => { diff --git a/src/composables/video/useTimelineScrub.ts b/src/composables/video/useTimelineScrub.ts new file mode 100644 index 00000000000..244e800fe26 --- /dev/null +++ b/src/composables/video/useTimelineScrub.ts @@ -0,0 +1,100 @@ +import { onScopeDispose, ref } from 'vue' +import type { Ref } from 'vue' + +import { clamp } from 'es-toolkit' + +import { denormalize } from '@/utils/mathUtil' + +interface UseTimelineScrubOptions { + trackRef: Ref + frameMax: Ref + scrubMin: Ref + scrubMax: Ref + contentInsetX: number + isDisabled: () => boolean + onScrub: (frame: number) => void +} + +export function useTimelineScrub( + playheadFrame: Ref, + options: UseTimelineScrubOptions +) { + const { + trackRef, + frameMax, + scrubMin, + scrubMax, + contentInsetX, + isDisabled, + onScrub + } = options + + const isScrubDragging = ref(false) + let cleanupScrubDrag: (() => void) | null = null + + function pointerToFrame(event: PointerEvent) { + const el = trackRef.value + if (!el) return playheadFrame.value + const rect = el.getBoundingClientRect() + const contentWidth = Math.max(rect.width - 2 * contentInsetX, 1) + const normalized = clamp( + (event.clientX - rect.left - contentInsetX) / contentWidth, + 0, + 1 + ) + return Math.round(denormalize(normalized, 0, frameMax.value)) + } + + function scrubToFrame(frame: number, force = false) { + const clamped = clamp(frame, scrubMin.value, scrubMax.value) + if (!force && clamped === playheadFrame.value) return + playheadFrame.value = clamped + onScrub(clamped) + } + + function updateScrubFromPointer(event: PointerEvent) { + scrubToFrame(pointerToFrame(event)) + } + + function startScrubDrag(event: PointerEvent) { + if (isDisabled() || event.button !== 0) return + + const el = trackRef.value + if (!el) return + + cleanupScrubDrag?.() + + isScrubDragging.value = true + scrubToFrame(pointerToFrame(event), true) + try { + el.setPointerCapture(event.pointerId) + } catch { + // non-active pointer id; scrubbing continues with bubbling events + } + + const onMove = (moveEvent: PointerEvent) => { + updateScrubFromPointer(moveEvent) + } + + const endDrag = () => { + isScrubDragging.value = false + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerup', endDrag) + el.removeEventListener('lostpointercapture', endDrag) + cleanupScrubDrag = null + } + + cleanupScrubDrag = endDrag + + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerup', endDrag) + el.addEventListener('lostpointercapture', endDrag) + } + + onScopeDispose(() => { + isScrubDragging.value = false + cleanupScrubDrag?.() + }) + + return { isScrubDragging, startScrubDrag, scrubToFrame } +} diff --git a/src/composables/video/useTrimPlayback.test.ts b/src/composables/video/useTrimPlayback.test.ts new file mode 100644 index 00000000000..9f693b16331 --- /dev/null +++ b/src/composables/video/useTrimPlayback.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import type { EffectScope, Ref } from 'vue' + +import { useTrimPlayback } from './useTrimPlayback' + +type Listener = (event: Event) => void + +class MockVideoElement { + duration = 10 + paused = true + emitSeeked = true + private _currentTime = 0 + play = vi.fn(async () => { + this.paused = false + }) + pause = vi.fn(() => { + this.paused = true + }) + private listeners = new Map>() + + get currentTime() { + return this._currentTime + } + + set currentTime(value: number) { + this._currentTime = value + if (this.emitSeeked) { + queueMicrotask(() => this.emit('seeked')) + } + } + + addEventListener(type: string, listener: Listener) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()) + this.listeners.get(type)!.add(listener) + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener) + } + + emit(type: string) { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener(new Event(type)) + } + } +} + +async function flushSeek() { + await new Promise((resolve) => setTimeout(resolve)) +} + +describe('useTrimPlayback', () => { + let scope: EffectScope | undefined + + afterEach(() => { + scope?.stop() + scope = undefined + }) + + function createPlayback({ hasTrimTimeline = true } = {}) { + const video = new MockVideoElement() + const startFrame = ref(0) + const endFrame = ref(100) + const playheadFrame = ref(0) + + scope = effectScope() + const playback = scope.run(() => + useTrimPlayback({ + videoRef: ref(video) as unknown as Ref, + frameMax: ref(100), + startFrame, + endFrame, + playheadFrame, + hasTrimTimeline: ref(hasTrimTimeline), + frameToTime: (frame) => frame / 10, + timeToFrame: (time) => Math.round(time * 10) + }) + )! + + return { video, startFrame, endFrame, playheadFrame, ...playback } + } + + it('seeks the video and stops playback on scrub', async () => { + const { video, playheadFrame, isPlaying, handleScrub } = createPlayback() + isPlaying.value = true + + handleScrub(50) + await flushSeek() + + expect(isPlaying.value).toBe(false) + expect(playheadFrame.value).toBe(50) + expect(video.currentTime).toBe(5) + }) + + it('plays from the current playhead within the trim window', async () => { + const { video, playheadFrame, isPlaying } = createPlayback() + playheadFrame.value = 30 + + isPlaying.value = true + await flushSeek() + + expect(video.currentTime).toBe(3) + expect(video.play).toHaveBeenCalled() + }) + + it('restarts from the start frame when the playhead reached the end', async () => { + const { video, startFrame, endFrame, playheadFrame, isPlaying } = + createPlayback() + startFrame.value = 20 + endFrame.value = 80 + await nextTick() + playheadFrame.value = 80 + + isPlaying.value = true + await flushSeek() + + expect(playheadFrame.value).toBe(20) + expect(video.currentTime).toBe(2) + }) + + it('stops playing when the video refuses to play', async () => { + const { video, isPlaying } = createPlayback() + video.play.mockRejectedValueOnce(new Error('autoplay blocked')) + + isPlaying.value = true + await flushSeek() + + expect(isPlaying.value).toBe(false) + }) + + it('re-seeks the video when a trim handle passes the playhead during playback', async () => { + const { video, startFrame, playheadFrame, isPlaying } = createPlayback() + playheadFrame.value = 10 + + isPlaying.value = true + await flushSeek() + expect(video.currentTime).toBe(1) + + startFrame.value = 30 + await nextTick() + await flushSeek() + + expect(isPlaying.value).toBe(true) + expect(playheadFrame.value).toBe(30) + expect(video.currentTime).toBe(3) + }) + + it('pauses the video when playback stops', async () => { + const { video, isPlaying } = createPlayback() + isPlaying.value = true + await flushSeek() + + isPlaying.value = false + await nextTick() + + expect(video.pause).toHaveBeenCalled() + }) + + it('syncs the playhead on timeupdate and stops at the trim end', async () => { + const { video, endFrame, playheadFrame, isPlaying, handleTimeUpdate } = + createPlayback() + endFrame.value = 60 + await nextTick() + isPlaying.value = true + await flushSeek() + + video.currentTime = 4 + handleTimeUpdate() + expect(playheadFrame.value).toBe(40) + expect(isPlaying.value).toBe(true) + + video.currentTime = 6.2 + handleTimeUpdate() + expect(playheadFrame.value).toBe(60) + expect(isPlaying.value).toBe(false) + }) + + it('ignores timeupdate when trim is not the active feature', async () => { + const { video, playheadFrame, isPlaying, handleTimeUpdate } = + createPlayback({ hasTrimTimeline: false }) + isPlaying.value = true + await flushSeek() + + video.currentTime = 4 + handleTimeUpdate() + + expect(playheadFrame.value).toBe(0) + }) + + it('releases the seek lock when no seeked event ever arrives', async () => { + vi.useFakeTimers() + try { + const { video, playheadFrame, isPlaying, handleTimeUpdate } = + createPlayback() + video.emitSeeked = false + playheadFrame.value = 30 + + isPlaying.value = true + await nextTick() + + video.currentTime = 4 + handleTimeUpdate() + expect(playheadFrame.value).toBe(30) + + await vi.advanceTimersByTimeAsync(5000) + + video.currentTime = 4.5 + handleTimeUpdate() + expect(playheadFrame.value).toBe(45) + } finally { + vi.useRealTimers() + } + }) + + it('clamps the playhead when the trim handles move past it', async () => { + const { startFrame, playheadFrame } = createPlayback() + playheadFrame.value = 10 + + startFrame.value = 30 + await nextTick() + await flushSeek() + + expect(playheadFrame.value).toBe(30) + }) +}) diff --git a/src/composables/video/useTrimPlayback.ts b/src/composables/video/useTrimPlayback.ts new file mode 100644 index 00000000000..6c17f59b964 --- /dev/null +++ b/src/composables/video/useTrimPlayback.ts @@ -0,0 +1,136 @@ +import { ref, watch } from 'vue' +import type { Ref } from 'vue' + +import { clamp } from 'es-toolkit' + +const SEEK_EVENT_TIMEOUT_MS = 5000 + +interface UseTrimPlaybackOptions { + videoRef: Ref + frameMax: Ref + startFrame: Ref + endFrame: Ref + playheadFrame: Ref + hasTrimTimeline: Ref + frameToTime: (frame: number) => number + timeToFrame: (time: number) => number +} + +export function useTrimPlayback(options: UseTrimPlaybackOptions) { + const { + videoRef, + frameMax, + startFrame, + endFrame, + playheadFrame, + hasTrimTimeline, + frameToTime, + timeToFrame + } = options + + const isPlaying = ref(false) + const isSeeking = ref(false) + let activeSeekId = 0 + + function clampSeekTime(video: HTMLVideoElement, time: number) { + if (!Number.isFinite(video.duration) || video.duration <= 0) { + return Math.max(time, 0) + } + return clamp(time, 0, Math.max(video.duration - 0.001, 0)) + } + + function waitForVideoSeek(video: HTMLVideoElement): Promise { + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer) + video.removeEventListener('seeked', finish) + video.removeEventListener('error', finish) + resolve() + } + const timer = setTimeout(finish, SEEK_EVENT_TIMEOUT_MS) + video.addEventListener('seeked', finish, { once: true }) + video.addEventListener('error', finish, { once: true }) + }) + } + + async function seekPreviewToFrame(frame: number) { + const video = videoRef.value + if (!video) return + + const clamped = clamp(frame, 0, frameMax.value) + playheadFrame.value = clamped + + const targetTime = clampSeekTime(video, frameToTime(clamped)) + if (Math.abs(video.currentTime - targetTime) <= 0.0001) return + + const seekId = ++activeSeekId + isSeeking.value = true + video.currentTime = targetTime + await waitForVideoSeek(video) + + if (seekId === activeSeekId) { + isSeeking.value = false + } + } + + async function handlePlaybackChange(playing: boolean) { + const video = videoRef.value + if (!video) return + if (playing) { + const startAt = + playheadFrame.value >= endFrame.value + ? startFrame.value + : clamp(playheadFrame.value, startFrame.value, endFrame.value) + await seekPreviewToFrame(startAt) + if (!isPlaying.value) return + try { + await video.play() + } catch { + isPlaying.value = false + } + } else { + video.pause() + } + } + + function resolvePlayheadTrimCollision() { + const start = startFrame.value + const end = endFrame.value + const previous = playheadFrame.value + if (previous < start) { + playheadFrame.value = start + } else if (previous > end) { + playheadFrame.value = end + } + if (playheadFrame.value !== previous) { + void seekPreviewToFrame(playheadFrame.value) + } + } + + function handleScrub(frame: number) { + isPlaying.value = false + void seekPreviewToFrame(frame) + } + + function handleTimeUpdate() { + const video = videoRef.value + if (!video || !hasTrimTimeline.value || !isPlaying.value || isSeeking.value) + return + + const frame = timeToFrame(video.currentTime) + playheadFrame.value = clamp(frame, startFrame.value, endFrame.value) + + if (frame >= endFrame.value) { + isPlaying.value = false + void seekPreviewToFrame(endFrame.value) + } + } + + watch(isPlaying, (playing) => { + void handlePlaybackChange(playing) + }) + + watch([startFrame, endFrame], resolvePlayheadTrimCollision) + + return { isPlaying, seekPreviewToFrame, handleScrub, handleTimeUpdate } +} diff --git a/src/composables/video/useVideoEditFormats.test.ts b/src/composables/video/useVideoEditFormats.test.ts new file mode 100644 index 00000000000..ed8ffd5ff50 --- /dev/null +++ b/src/composables/video/useVideoEditFormats.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' + +import { useVideoEditFormats } from './useVideoEditFormats' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}|${Object.values(params).join(',')}` : key + }) +})) + +describe('useVideoEditFormats', () => { + describe('formatDuration', () => { + it('uses the zero label for empty durations', () => { + const { formatDuration } = useVideoEditFormats() + + expect(formatDuration(0)).toBe('videoEdit.durationZero') + }) + + it('rounds to a tenth of a second', () => { + const { formatDuration } = useVideoEditFormats() + + expect(formatDuration(2.44)).toBe('videoEdit.durationSeconds|2.4') + expect(formatDuration(10)).toBe('videoEdit.durationSeconds|10') + }) + }) + + describe('formatFileSize', () => { + it('shows a placeholder for unknown sizes', () => { + const { formatFileSize } = useVideoEditFormats() + + expect(formatFileSize(undefined)).toBe('videoEdit.fileSizeUnknown') + }) + + it('picks the unit by magnitude', () => { + const { formatFileSize } = useVideoEditFormats() + + expect(formatFileSize(500)).toBe('videoEdit.fileSizeBytes|500') + expect(formatFileSize(1024)).toBe('videoEdit.fileSizeKilobytes|1') + expect(formatFileSize(2048)).toBe('videoEdit.fileSizeKilobytes|2') + expect(formatFileSize(1024 * 1024)).toBe('videoEdit.fileSizeMegabytes|1') + expect(formatFileSize(1.3 * 1024 * 1024)).toBe( + 'videoEdit.fileSizeMegabytes|1.3' + ) + }) + }) +}) diff --git a/src/composables/video/useVideoEditFormats.ts b/src/composables/video/useVideoEditFormats.ts new file mode 100644 index 00000000000..288c4eb19e7 --- /dev/null +++ b/src/composables/video/useVideoEditFormats.ts @@ -0,0 +1,29 @@ +import { useI18n } from 'vue-i18n' + +export function useVideoEditFormats() { + const { t } = useI18n() + + function formatDuration(seconds: number) { + if (!seconds) return t('videoEdit.durationZero') + return t('videoEdit.durationSeconds', { + count: Math.round(seconds * 10) / 10 + }) + } + + function formatFileSize(bytes?: number) { + if (bytes == null) return t('videoEdit.fileSizeUnknown') + if (bytes < 1024) { + return t('videoEdit.fileSizeBytes', { count: bytes }) + } + if (bytes < 1024 * 1024) { + return t('videoEdit.fileSizeKilobytes', { + count: Math.round(bytes / 1024) + }) + } + return t('videoEdit.fileSizeMegabytes', { + count: Number((bytes / (1024 * 1024)).toFixed(1)) + }) + } + + return { formatDuration, formatFileSize } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 7edc215326c..4bd07574c38 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2212,7 +2212,19 @@ "swatchTitle": "Click edit · drag reorder · right-click remove" }, "videoEdit": { - "adjustCrop": "Adjust crop region" + "play": "Play", + "pause": "Pause", + "loadingFilmstrip": "Loading filmstrip…", + "seekVideo": "Seek video", + "adjustStartFrame": "Adjust start frame", + "adjustEndFrame": "Adjust end frame", + "adjustCrop": "Adjust crop region", + "durationZero": "0s", + "durationSeconds": "{count}s", + "fileSizeUnknown": "—", + "fileSizeBytes": "{count} B", + "fileSizeKilobytes": "{count} KB", + "fileSizeMegabytes": "{count} MB" }, "toastMessages": { "nothingToQueue": "Nothing to queue",