feat: publish av-tools 0.1.0
This commit is contained in:
363
tests/unit/components/editor-integration.test.tsx
Normal file
363
tests/unit/components/editor-integration.test.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ImportedMediaAsset } from '../../../src/app/application-state';
|
||||
import { Inspector } from '../../../src/components/Inspector';
|
||||
import { Timeline } from '../../../src/components/Timeline';
|
||||
import type { AvProjectV1, TimelineClip } from '../../../src/project';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
describe('visual editor integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
createCanvasContext()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows encoded dimensions separately from normalized display metadata', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={createImportedAsset()}
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={vi.fn()}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Encoded pixels 101×99 · coded 102×100 · 25.000 fps')
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('SAR 4:3 · DAR 16:9 · rotation -90°')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses the local video object URL in the rotation-aware crop editor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
const clip = {
|
||||
...project.timeline[0]!,
|
||||
crop: { x: 0, y: 0, width: 100, height: 98 },
|
||||
rotation: 90 as const,
|
||||
};
|
||||
const asset = createImportedAsset();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={asset}
|
||||
project={project}
|
||||
clip={clip}
|
||||
disabled
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
|
||||
const cropEditor = screen.getByRole('region', {
|
||||
name: 'Crop local-video.mp4',
|
||||
});
|
||||
expect(cropEditor).toHaveAttribute('data-orientation', '90');
|
||||
expect(
|
||||
screen.getByLabelText('Local preview of local-video.mp4')
|
||||
).toHaveAttribute('src', 'blob:local-video');
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Move crop rectangle' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'Encoded source pixels' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps numeric crop inputs when a visual video source is unavailable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
expect(
|
||||
screen.getByText(/visual crop preview is unavailable/i)
|
||||
).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'WIDTH' }), {
|
||||
target: { value: '101' },
|
||||
});
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
crop: { x: 0, y: 0, width: 100, height: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('edits allowlisted fade curves and persists chapter time bases', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const onChaptersChange = vi.fn();
|
||||
const baseProject = createProject();
|
||||
const project: AvProjectV1 = {
|
||||
...baseProject,
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Intro',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/1000',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={onChaptersChange}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Video fade curve' }),
|
||||
'exp'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
video: { enabled: true, fadeCurve: 'exp' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Audio' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Audio fade curve' }),
|
||||
'qsin'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
audio: { enabled: true, fadeCurve: 'qsin' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Chapters' }));
|
||||
fireEvent.change(screen.getByLabelText('Chapter 1 time base'), {
|
||||
target: { value: '1/48000' },
|
||||
});
|
||||
expect(onChaptersChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ id: 'chapter-1', timeBase: '1/48000' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates the selected clip and forwards validated waveform selections', () => {
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
const onSelectionChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
waveformPeaks={createPeaks()}
|
||||
currentTimeSeconds={4}
|
||||
splitMarkers={[3]}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith('clip-1', {
|
||||
sourceInSeconds: 1.1,
|
||||
sourceOutSeconds: 9,
|
||||
});
|
||||
expect(onSelectionChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1.1,
|
||||
outSeconds: 9,
|
||||
markers: [3],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original clip-card timeline when waveform data is omitted', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText('Single sequential audio track')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('spinbutton', { name: 'In' })).toHaveValue(1);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Out' })).toHaveValue(9);
|
||||
});
|
||||
|
||||
it('resets the selected clip trim to its full probed source duration', async () => {
|
||||
const user = userEvent.setup();
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Reset trim to full source' })
|
||||
);
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('clip-1', {
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createProject(): AvProjectV1 {
|
||||
const clip: TimelineClip = {
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 1,
|
||||
sourceOutSeconds: 9,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'project-1',
|
||||
name: 'Test project',
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
originalName: 'local-video.mp4',
|
||||
size: 4,
|
||||
lastModified: 0,
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
probe: createProbe(),
|
||||
},
|
||||
],
|
||||
timeline: [clip],
|
||||
output: {
|
||||
presetId: 'video-h264',
|
||||
fileName: 'output.mp4',
|
||||
container: 'mp4',
|
||||
videoEnabled: true,
|
||||
audioEnabled: true,
|
||||
},
|
||||
metadata: { custom: {} },
|
||||
chapters: [],
|
||||
subtitles: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createImportedAsset(): ImportedMediaAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
file: new File(['test'], 'local-video.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:local-video',
|
||||
phase: 'ready',
|
||||
probe: createProbe(),
|
||||
};
|
||||
}
|
||||
|
||||
function createProbe() {
|
||||
return {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video' as const,
|
||||
codecName: 'h264',
|
||||
width: 101,
|
||||
height: 99,
|
||||
codedWidth: 102,
|
||||
codedHeight: 100,
|
||||
sampleAspectRatio: '4:3',
|
||||
displayAspectRatio: '16:9',
|
||||
rotationDegrees: -90,
|
||||
frameRate: 25,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
Math.round(Math.sin(index / 5) * 20_000)
|
||||
)
|
||||
),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
function createCanvasContext(): CanvasRenderingContext2D {
|
||||
return {
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
Reference in New Issue
Block a user