Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions VideoPreview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Video Preview Component

The VideoPreview component provides a complete interface for previewing rendered video with audio mixing capabilities.

## Basic Usage

```tsx
import { VideoPreview } from '@saebyn/glowing-telegram-video-editor';
import type { PreviewSettings } from '@saebyn/glowing-telegram-video-editor';

// Example preview settings
const settings: PreviewSettings = {
cutlist: [
{ id: '1', start: 10000, end: 30000 },
{ id: '2', start: 45000, end: 75000 },
],
audioChannels: [
{ id: '1', name: 'Main Audio', level: 0.8, muted: false },
{ id: '2', name: 'Background Music', level: 0.3, muted: false },
],
waveformData: [
{
channelId: '1',
amplitudes: [/* amplitude data */],
duration: 120000,
sampleRate: 44100,
},
],
};

function MyVideoPreview() {
const [previewSettings, setPreviewSettings] = useState(settings);

const handleRegenerate = (newSettings: PreviewSettings) => {
// Send settings to backend to regenerate preview
console.log('Regenerating preview with:', newSettings);
};

const handleSave = (newSettings: PreviewSettings) => {
// Save audio settings to backend
console.log('Saving settings:', newSettings);
};

return (
<VideoPreview
settings={previewSettings}
previewVideoUrl="https://example.com/preview.m3u8"
duration={120000}
onSettingsChange={setPreviewSettings}
onRegenerate={handleRegenerate}
onSave={handleSave}
/>
);
}
```

## Features

- **HLS Video Playback**: Uses existing VideoPlayer component with HLS.js support
- **Audio Mixer**: Configurable audio levels and mute controls for each channel
- **Waveform Timeline**: Visual representation of audio with clickable seeking
- **Cutlist Visualization**: Shows selected clips on the timeline
- **Real-time Updates**: Changes to audio settings are reflected immediately
- **Keyboard Accessibility**: Waveform supports keyboard navigation

## Component Architecture

The VideoPreview component follows the atomic design pattern:

### Atoms
- `AudioLevelSlider`: Individual volume control slider
- `AudioChannelControl`: Complete control for a single audio channel
- `WaveformDisplay`: Canvas-based waveform visualization

### Molecules
- `AudioMixerPanel`: Panel containing all audio channel controls
- `PreviewTimeline`: Timeline with waveforms and cutlist visualization

### Organisms
- `VideoPreview`: Complete preview interface composing all subcomponents

## Types

```tsx
interface AudioChannel {
id: string;
name: string;
level: number; // 0.0 to 1.0
muted: boolean;
}

interface WaveformData {
channelId: string;
amplitudes: number[];
duration: number;
sampleRate: number;
}

interface PreviewSettings {
cutlist: VideoClip[];
audioChannels: AudioChannel[];
waveformData: WaveformData[];
}
```

## Backend Integration

The component is designed to work with a backend that can:

1. **Generate Preview Videos**: When `onRegenerate` is called, send the cutlist and audio settings to generate an HLS preview
2. **Provide Waveform Data**: Extract audio waveform data for visualization
3. **Detect Audio Channels**: Analyze source video to determine available audio channels
4. **Save Settings**: Persist audio mixing settings for final render

## Accessibility

- Keyboard navigation support for waveform seeking (arrow keys)
- Proper ARIA labels and roles
- Screen reader compatible controls
- Focus management for interactive elements
100 changes: 100 additions & 0 deletions src/components/VideoPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import AudioChannelControl from "@/components/atoms/AudioChannelControl";
import AudioLevelSlider from "@/components/atoms/AudioLevelSlider";
import WaveformDisplay from "@/components/atoms/WaveformDisplay";
import AudioMixerPanel from "@/components/molecules/AudioMixerPanel";
import PreviewTimeline from "@/components/molecules/PreviewTimeline";
import VideoPreview from "@/components/organisms/VideoPreview";

import type {
AudioChannel,
PreviewSettings,
VideoClip,
WaveformData,
} from "@/types";

describe("Video Preview Components", () => {
const sampleAudioChannel: AudioChannel = {
id: "1",
name: "Test Channel",
level: 0.5,
muted: false,
};

const sampleWaveformData: WaveformData = {
channelId: "1",
amplitudes: [0.1, 0.2, 0.3, 0.2, 0.1],
duration: 5000,
sampleRate: 44100,
};

const sampleClips: VideoClip[] = [
{
id: "1",
start: 1000,
end: 2000,
},
];

const sampleSettings: PreviewSettings = {
cutlist: sampleClips,
audioChannels: [sampleAudioChannel],
waveformData: [sampleWaveformData],
};

it("renders AudioLevelSlider", () => {
const onChange = vi.fn();
const { container } = render(
<AudioLevelSlider level={0.5} onChange={onChange} label="Test" />,
);
expect(container.querySelector("input[type='range']")).toBeTruthy();
});

it("renders AudioChannelControl", () => {
const onChange = vi.fn();
const { getByText } = render(
<AudioChannelControl channel={sampleAudioChannel} onChange={onChange} />,
);
expect(getByText("Test Channel")).toBeTruthy();
});

it("renders WaveformDisplay", () => {
const { container } = render(
<WaveformDisplay waveformData={sampleWaveformData} />,
);
expect(container.querySelector("canvas")).toBeTruthy();
});

it("renders AudioMixerPanel", () => {
const onChange = vi.fn();
const { getByText } = render(
<AudioMixerPanel channels={[sampleAudioChannel]} onChange={onChange} />,
);
expect(getByText("Audio Mixer")).toBeTruthy();
});

it("renders PreviewTimeline", () => {
const { getByText } = render(
<PreviewTimeline
waveformData={[sampleWaveformData]}
playheadPosition={1500}
cutlist={sampleClips}
duration={5000}
/>,
);
expect(getByText("Preview Timeline")).toBeTruthy();
});

it("renders VideoPreview", () => {
const { getByText } = render(
<VideoPreview
settings={sampleSettings}
previewVideoUrl="test-url"
duration={5000}
/>,
);
expect(getByText("Video Preview")).toBeTruthy();
});
});
69 changes: 69 additions & 0 deletions src/components/atoms/AudioChannelControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { AudioChannel } from "@/types";
import { action } from "@storybook/addon-actions";
import AudioChannelControl from "./AudioChannelControl";

export default {
title: "Atoms/AudioChannelControl",
component: AudioChannelControl,
tags: ["atoms"],
};

const mockChannel: AudioChannel = {
id: "channel-1",
name: "Audio Track 1",
level: 0.75,
muted: false,
};

const mockMutedChannel: AudioChannel = {
id: "channel-2",
name: "Audio Track 2",
level: 0.5,
muted: true,
};

export const Default = {
args: {
channel: mockChannel,
onChange: action("onChange"),
disabled: false,
},
};

export const Muted = {
args: {
channel: mockMutedChannel,
onChange: action("onChange"),
disabled: false,
},
};

export const Disabled = {
args: {
channel: mockChannel,
onChange: action("onChange"),
disabled: true,
},
};

export const LowLevel = {
args: {
channel: {
...mockChannel,
level: 0.1,
},
onChange: action("onChange"),
disabled: false,
},
};

export const HighLevel = {
args: {
channel: {
...mockChannel,
level: 1.0,
},
onChange: action("onChange"),
disabled: false,
},
};
56 changes: 56 additions & 0 deletions src/components/atoms/AudioChannelControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { AudioChannel } from "@/types";
import AudioLevelSlider from "./AudioLevelSlider";
import IconButton from "./IconButton";

interface AudioChannelControlProps {
/**
* Audio channel configuration
*/
channel: AudioChannel;
/**
* Callback when channel settings change
*/
onChange: (channel: AudioChannel) => void;
/**
* Whether the control is disabled
*/
disabled?: boolean;
}

export default function AudioChannelControl({
channel,
onChange,
disabled = false,
}: AudioChannelControlProps) {
const handleLevelChange = (level: number) => {
onChange({ ...channel, level });
};

const handleMuteToggle = () => {
onChange({ ...channel, muted: !channel.muted });
};

return (
<div className="flex items-center space-x-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex-shrink-0">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{channel.name}
</div>
<IconButton
icon={channel.muted ? "volume_off" : "volume_up"}
onClick={handleMuteToggle}
disabled={disabled}
variant={channel.muted ? "danger" : "secondary"}
title={channel.muted ? "Unmute" : "Mute"}
/>
</div>
<div className="flex-1 min-w-0">
<AudioLevelSlider
level={channel.muted ? 0 : channel.level}
onChange={handleLevelChange}
disabled={disabled || channel.muted}
/>
</div>
</div>
);
}
Loading
Loading