From ab48f7f646248f8fda4fda509c534f30d5adb84a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:15:26 +0000 Subject: [PATCH 1/6] Implement VideoPreview component with audio mixing and waveform timeline Co-authored-by: saebyn <185030+saebyn@users.noreply.github.com> --- VideoPreview.md | 120 ++++++++++++ src/components/VideoPreview.test.tsx | 95 ++++++++++ .../AnimatedHamburgerIconButton.stories.tsx | 18 -- src/components/atoms/AudioChannelControl.tsx | 56 ++++++ src/components/atoms/AudioLevelSlider.tsx | 60 ++++++ src/components/atoms/Button.stories.tsx | 15 -- src/components/atoms/Heading.stories.tsx | 14 -- src/components/atoms/HeadingLink.stories.tsx | 14 -- src/components/atoms/IconButton.stories.tsx | 15 -- src/components/atoms/Search.stories.tsx | 13 -- src/components/atoms/Tab.stories.tsx | 36 ---- .../atoms/TimeDotMarker.stories.tsx | 22 --- src/components/atoms/TimeLink.stories.tsx | 16 -- .../atoms/TimelineLegend.stories.tsx | 9 - .../atoms/VideoPlayerProgressBar.stories.tsx | 24 --- src/components/atoms/WaveformDisplay.tsx | 140 ++++++++++++++ src/components/molecules/AudioMixerPanel.tsx | 124 ++++++++++++ .../molecules/ClipSelectionDialog.stories.tsx | 68 ------- src/components/molecules/PreviewTimeline.tsx | 143 ++++++++++++++ .../molecules/VideoPlayer.stories.tsx | 21 -- src/components/organisms/VideoPreview.tsx | 179 ++++++++++++++++++ src/index.ts | 3 +- src/types.ts | 56 ++++++ 23 files changed, 975 insertions(+), 286 deletions(-) create mode 100644 VideoPreview.md create mode 100644 src/components/VideoPreview.test.tsx delete mode 100644 src/components/atoms/AnimatedHamburgerIconButton.stories.tsx create mode 100644 src/components/atoms/AudioChannelControl.tsx create mode 100644 src/components/atoms/AudioLevelSlider.tsx delete mode 100644 src/components/atoms/Button.stories.tsx delete mode 100644 src/components/atoms/Heading.stories.tsx delete mode 100644 src/components/atoms/HeadingLink.stories.tsx delete mode 100644 src/components/atoms/IconButton.stories.tsx delete mode 100644 src/components/atoms/Search.stories.tsx delete mode 100644 src/components/atoms/Tab.stories.tsx delete mode 100644 src/components/atoms/TimeDotMarker.stories.tsx delete mode 100644 src/components/atoms/TimeLink.stories.tsx delete mode 100644 src/components/atoms/TimelineLegend.stories.tsx delete mode 100644 src/components/atoms/VideoPlayerProgressBar.stories.tsx create mode 100644 src/components/atoms/WaveformDisplay.tsx create mode 100644 src/components/molecules/AudioMixerPanel.tsx delete mode 100644 src/components/molecules/ClipSelectionDialog.stories.tsx create mode 100644 src/components/molecules/PreviewTimeline.tsx delete mode 100644 src/components/molecules/VideoPlayer.stories.tsx create mode 100644 src/components/organisms/VideoPreview.tsx diff --git a/VideoPreview.md b/VideoPreview.md new file mode 100644 index 0000000..c5a003d --- /dev/null +++ b/VideoPreview.md @@ -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 ( + + ); +} +``` + +## 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 \ No newline at end of file diff --git a/src/components/VideoPreview.test.tsx b/src/components/VideoPreview.test.tsx new file mode 100644 index 0000000..f3d3370 --- /dev/null +++ b/src/components/VideoPreview.test.tsx @@ -0,0 +1,95 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import AudioLevelSlider from "@/components/atoms/AudioLevelSlider"; +import AudioChannelControl from "@/components/atoms/AudioChannelControl"; +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, WaveformData, VideoClip, PreviewSettings } 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( + + ); + expect(container.querySelector("input[type='range']")).toBeTruthy(); + }); + + it("renders AudioChannelControl", () => { + const onChange = vi.fn(); + const { getByText } = render( + + ); + expect(getByText("Test Channel")).toBeTruthy(); + }); + + it("renders WaveformDisplay", () => { + const { container } = render( + + ); + expect(container.querySelector("canvas")).toBeTruthy(); + }); + + it("renders AudioMixerPanel", () => { + const onChange = vi.fn(); + const { getByText } = render( + + ); + expect(getByText("Audio Mixer")).toBeTruthy(); + }); + + it("renders PreviewTimeline", () => { + const { getByText } = render( + + ); + expect(getByText("Preview Timeline")).toBeTruthy(); + }); + + it("renders VideoPreview", () => { + const { getByText } = render( + + ); + expect(getByText("Video Preview")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx b/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx deleted file mode 100644 index cfc5955..0000000 --- a/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useState } from "react"; -import AnimatedHamburgerIconButton from "./AnimatedHamburgerIconButton"; - -export default { - title: "Atoms/AnimatedHamburgerIconButton", - component: AnimatedHamburgerIconButton, - tags: ["atoms"], -}; - -export const Default = () => { - const [isExpanded, setIsExpanded] = useState(false); - return ( - setIsExpanded(!isExpanded)} - /> - ); -}; diff --git a/src/components/atoms/AudioChannelControl.tsx b/src/components/atoms/AudioChannelControl.tsx new file mode 100644 index 0000000..8d7b76a --- /dev/null +++ b/src/components/atoms/AudioChannelControl.tsx @@ -0,0 +1,56 @@ +import IconButton from "./IconButton"; +import AudioLevelSlider from "./AudioLevelSlider"; +import type { AudioChannel } from "@/types"; + +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 ( +
+
+
+ {channel.name} +
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/atoms/AudioLevelSlider.tsx b/src/components/atoms/AudioLevelSlider.tsx new file mode 100644 index 0000000..c1dd204 --- /dev/null +++ b/src/components/atoms/AudioLevelSlider.tsx @@ -0,0 +1,60 @@ +interface AudioLevelSliderProps { + /** + * Current audio level from 0.0 to 1.0 + */ + level: number; + /** + * Callback when level changes + */ + onChange: (level: number) => void; + /** + * Label for the slider + */ + label?: string; + /** + * Whether the slider is disabled + */ + disabled?: boolean; +} + +export default function AudioLevelSlider({ + level, + onChange, + label, + disabled = false, +}: AudioLevelSliderProps) { + const sliderId = `audio-slider-${Math.random().toString(36).substr(2, 9)}`; + + const handleChange = (event: React.ChangeEvent) => { + const newLevel = Number.parseFloat(event.target.value); + onChange(newLevel); + }; + + return ( +
+ {label && ( + + )} +
+ 0 + + 1 +
+
+ {Math.round(level * 100)}% +
+
+ ); +} \ No newline at end of file diff --git a/src/components/atoms/Button.stories.tsx b/src/components/atoms/Button.stories.tsx deleted file mode 100644 index 6838a9b..0000000 --- a/src/components/atoms/Button.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import Button from "./Button"; - -export default { - title: "Atoms/Button", - component: Button, - tags: ["atoms"], -}; - -export const Default = { - args: { - children: "Button", - onClick: action("onClick"), - }, -}; diff --git a/src/components/atoms/Heading.stories.tsx b/src/components/atoms/Heading.stories.tsx deleted file mode 100644 index 42c5bd6..0000000 --- a/src/components/atoms/Heading.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Heading from "./Heading"; - -export default { - title: "Atoms/Heading", - component: Heading, - tags: ["atoms"], -}; - -export const Default = { - args: { - level: 1, - children: "Heading", - }, -}; diff --git a/src/components/atoms/HeadingLink.stories.tsx b/src/components/atoms/HeadingLink.stories.tsx deleted file mode 100644 index 101133c..0000000 --- a/src/components/atoms/HeadingLink.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import HeadingLink from "./HeadingLink"; - -export default { - title: "Atoms/HeadingLink", - component: HeadingLink, - tags: ["atoms"], -}; - -export const Default = { - args: { - href: "#", - children: "Heading Link", - }, -}; diff --git a/src/components/atoms/IconButton.stories.tsx b/src/components/atoms/IconButton.stories.tsx deleted file mode 100644 index ff2f516..0000000 --- a/src/components/atoms/IconButton.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import IconButton from "./IconButton"; - -export default { - title: "Atoms/IconButton", - component: IconButton, - tags: ["atoms"], -}; - -export const Default = { - args: { - children: "IconButton", - onClick: action("onClick"), - }, -}; diff --git a/src/components/atoms/Search.stories.tsx b/src/components/atoms/Search.stories.tsx deleted file mode 100644 index 766d421..0000000 --- a/src/components/atoms/Search.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useState } from "react"; -import Search from "./Search"; - -export default { - title: "Atoms/Search", - component: Search, - tags: ["atoms"], -}; - -export const Default = () => { - const [text, setText] = useState(""); - return ; -}; diff --git a/src/components/atoms/Tab.stories.tsx b/src/components/atoms/Tab.stories.tsx deleted file mode 100644 index c944ea2..0000000 --- a/src/components/atoms/Tab.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useState } from "react"; -import Tab, { TabContainer } from "./Tab"; - -export default { - title: "Atoms/Tab", - component: Tab, - tags: ["atoms"], -}; - -export const Default = () => { - const [activeTab, setActiveTab] = useState(0); - return ( - setActiveTab(0)} - />, - setActiveTab(1)} - />, - ]} - > - {activeTab === 0 ? ( -
Content for Tab 1
- ) : ( -
Content for Tab 2
- )} -
- ); -}; diff --git a/src/components/atoms/TimeDotMarker.stories.tsx b/src/components/atoms/TimeDotMarker.stories.tsx deleted file mode 100644 index 132d40f..0000000 --- a/src/components/atoms/TimeDotMarker.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { TimelineProvider } from "@/context/TimelineContext"; -import { TimeDotMarker } from "./TimeDotMarker"; - -export default { - title: "Atoms/TimeDotMarker", - component: TimeDotMarker, - tags: ["atoms"], - decorators: [ - (story: () => React.ReactNode) => ( - - {story()} - - ), - ], -}; - -export const Default = { - args: { - timestampMilliseconds: 60000, - className: "bg-red-500", - }, -}; diff --git a/src/components/atoms/TimeLink.stories.tsx b/src/components/atoms/TimeLink.stories.tsx deleted file mode 100644 index 24a024d..0000000 --- a/src/components/atoms/TimeLink.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import TimeLink from "./TimeLink"; - -export default { - title: "Atoms/TimeLink", - component: TimeLink, - tags: ["atoms"], -}; - -export const Default = { - args: { - milliseconds: 60000, - onClick: action("onClick"), - children: "Click me", - }, -}; diff --git a/src/components/atoms/TimelineLegend.stories.tsx b/src/components/atoms/TimelineLegend.stories.tsx deleted file mode 100644 index bc8cbe6..0000000 --- a/src/components/atoms/TimelineLegend.stories.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import TimelineLegend from "./TimelineLegend"; - -export default { - title: "Atoms/TimelineLegend", - component: TimelineLegend, - tags: ["atoms"], -}; - -export const Default = {}; diff --git a/src/components/atoms/VideoPlayerProgressBar.stories.tsx b/src/components/atoms/VideoPlayerProgressBar.stories.tsx deleted file mode 100644 index 1f220d8..0000000 --- a/src/components/atoms/VideoPlayerProgressBar.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import VideoPlayerProgressBar from "./VideoPlayerProgressBar"; - -export default { - title: "Atoms/VideoPlayerProgressBar", - component: VideoPlayerProgressBar, - tags: ["atoms"], -}; - -export const Default = { - args: { - progress: 0, - seekToPercent: action("Seek to Percent"), - duration: 100, - }, -}; - -export const Progress = { - args: { - progress: 70, - seekToPercent: action("Seek to Percent"), - duration: 100, - }, -}; diff --git a/src/components/atoms/WaveformDisplay.tsx b/src/components/atoms/WaveformDisplay.tsx new file mode 100644 index 0000000..40f5509 --- /dev/null +++ b/src/components/atoms/WaveformDisplay.tsx @@ -0,0 +1,140 @@ +import type { WaveformData } from "@/types"; +import { useEffect, useRef } from "react"; + +interface WaveformDisplayProps { + /** + * Waveform data to display + */ + waveformData: WaveformData; + /** + * Width of the waveform display + */ + width?: number; + /** + * Height of the waveform display + */ + height?: number; + /** + * Current playhead position in milliseconds + */ + playheadPosition?: number; + /** + * Color of the waveform + */ + color?: string; + /** + * Color of the playhead + */ + playheadColor?: string; + /** + * Callback when user clicks on the waveform + */ + onSeek?: (milliseconds: number) => void; +} + +export default function WaveformDisplay({ + waveformData, + width = 400, + height = 80, + playheadPosition = 0, + color = "#3b82f6", + playheadColor = "#ef4444", + onSeek, +}: WaveformDisplayProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Draw waveform + const { amplitudes } = waveformData; + if (amplitudes.length === 0) return; + + const barWidth = width / amplitudes.length; + const halfHeight = height / 2; + + ctx.fillStyle = color; + + for (let i = 0; i < amplitudes.length; i++) { + const amplitude = Math.abs(amplitudes[i]); + const barHeight = amplitude * halfHeight; + const x = i * barWidth; + + // Draw positive amplitude + ctx.fillRect(x, halfHeight - barHeight, barWidth - 1, barHeight); + // Draw negative amplitude (mirrored) + ctx.fillRect(x, halfHeight, barWidth - 1, barHeight); + } + + // Draw playhead + if (playheadPosition >= 0 && waveformData.duration > 0) { + const playheadX = (playheadPosition / waveformData.duration) * width; + ctx.strokeStyle = playheadColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(playheadX, 0); + ctx.lineTo(playheadX, height); + ctx.stroke(); + } + }, [waveformData, width, height, playheadPosition, color, playheadColor]); + + const handleClick = (event: React.MouseEvent) => { + if (!onSeek || waveformData.duration === 0) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const relativeX = x / width; + const seekTime = relativeX * waveformData.duration; + + onSeek(Math.max(0, Math.min(seekTime, waveformData.duration))); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!onSeek || waveformData.duration === 0) return; + + // Allow seeking with arrow keys + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const step = waveformData.duration * 0.05; // 5% step + const currentTime = playheadPosition || 0; + const newTime = event.key === "ArrowLeft" + ? Math.max(0, currentTime - step) + : Math.min(waveformData.duration, currentTime + step); + onSeek(newTime); + } + }; + + return ( +
+ + {waveformData.amplitudes.length === 0 && ( +
+ No waveform data +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/molecules/AudioMixerPanel.tsx b/src/components/molecules/AudioMixerPanel.tsx new file mode 100644 index 0000000..061cd72 --- /dev/null +++ b/src/components/molecules/AudioMixerPanel.tsx @@ -0,0 +1,124 @@ +import AudioChannelControl from "@/components/atoms/AudioChannelControl"; +import Button from "@/components/atoms/Button"; +import type { AudioChannel } from "@/types"; + +interface AudioMixerPanelProps { + /** + * Array of audio channels to control + */ + channels: AudioChannel[]; + /** + * Callback when any channel changes + */ + onChange: (channels: AudioChannel[]) => void; + /** + * Callback when save is clicked + */ + onSave?: () => void; + /** + * Whether the mixer is disabled + */ + disabled?: boolean; + /** + * Whether save is in progress + */ + saving?: boolean; +} + +export default function AudioMixerPanel({ + channels, + onChange, + onSave, + disabled = false, + saving = false, +}: AudioMixerPanelProps) { + const handleChannelChange = (updatedChannel: AudioChannel) => { + const updatedChannels = channels.map((channel) => + channel.id === updatedChannel.id ? updatedChannel : channel + ); + onChange(updatedChannels); + }; + + const handleMasterMute = () => { + const allMuted = channels.every((channel) => channel.muted); + const updatedChannels = channels.map((channel) => ({ + ...channel, + muted: !allMuted, + })); + onChange(updatedChannels); + }; + + const handleResetLevels = () => { + const resetChannels = channels.map((channel) => ({ + ...channel, + level: 1.0, + muted: false, + })); + onChange(resetChannels); + }; + + const allMuted = channels.every((channel) => channel.muted); + const hasChanges = channels.some((channel) => + channel.level !== 1.0 || channel.muted + ); + + return ( +
+
+

+ Audio Mixer +

+
+ + + {onSave && ( + + )} +
+
+ +
+ {channels.length === 0 ? ( +
+ No audio channels available +
+ ) : ( + channels.map((channel) => ( + + )) + )} +
+ + {channels.length > 0 && ( +
+
+ {channels.filter(c => !c.muted).length} of {channels.length} channels active +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/molecules/ClipSelectionDialog.stories.tsx b/src/components/molecules/ClipSelectionDialog.stories.tsx deleted file mode 100644 index 25c5d92..0000000 --- a/src/components/molecules/ClipSelectionDialog.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { action } from "@storybook/addon-actions"; - -import ClipSelectionDialog from "./ClipSelectionDialog"; - -export default { - title: "Molecules/SelectedCutsDialog", - component: ClipSelectionDialog, - tags: ["molecules"], - decorators: [ - (story: () => React.ReactNode) => ( -
{story()}
- ), - ], -}; - -export const Default = () => { - return ( - - ); -}; - -export const MultipleCuts = () => { - return ( - - ); -}; diff --git a/src/components/molecules/PreviewTimeline.tsx b/src/components/molecules/PreviewTimeline.tsx new file mode 100644 index 0000000..fec4f6d --- /dev/null +++ b/src/components/molecules/PreviewTimeline.tsx @@ -0,0 +1,143 @@ +import WaveformDisplay from "@/components/atoms/WaveformDisplay"; +import type { WaveformData, VideoClip } from "@/types"; +import { formatMs } from "@/utils/duration"; + +interface PreviewTimelineProps { + /** + * Waveform data for all channels + */ + waveformData: WaveformData[]; + /** + * Current playhead position in milliseconds + */ + playheadPosition: number; + /** + * Selected cutlist clips + */ + cutlist: VideoClip[]; + /** + * Total duration in milliseconds + */ + duration: number; + /** + * Width of the timeline + */ + width?: number; + /** + * Height per waveform channel + */ + waveformHeight?: number; + /** + * Callback when user seeks to a time + */ + onSeek?: (milliseconds: number) => void; + /** + * Callback when cutlist is updated + */ + onCutlistChange?: (cutlist: VideoClip[]) => void; +} + +export default function PreviewTimeline({ + waveformData, + playheadPosition, + cutlist, + duration, + width = 800, + waveformHeight = 60, + onSeek, + onCutlistChange, +}: PreviewTimelineProps) { + const colors = [ + "#3b82f6", // blue + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#06b6d4", // cyan + ]; + + const handleSeek = (milliseconds: number) => { + onSeek?.(Math.max(0, Math.min(milliseconds, duration))); + }; + + return ( +
+
+

+ Preview Timeline +

+
+ {formatMs(playheadPosition)} / {formatMs(duration)} +
+
+ + {/* Cutlist visualization */} +
+
+ Selected Clips ({cutlist.length}) +
+
+ {cutlist.map((clip, index) => { + const left = (clip.start / duration) * 100; + const width = ((clip.end - clip.start) / duration) * 100; + return ( +
+ + {index + 1} + +
+ ); + })} + + {/* Playhead indicator */} +
+
+
+ + {/* Waveform displays */} +
+ {waveformData.length === 0 ? ( +
+ No waveform data available +
+ ) : ( + waveformData.map((waveform, index) => ( +
+
+ Channel {waveform.channelId} +
+ +
+ )) + )} +
+ + {/* Timeline ruler */} +
+
+ {formatMs(0)} + {formatMs(duration / 4)} + {formatMs(duration / 2)} + {formatMs((duration * 3) / 4)} + {formatMs(duration)} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/molecules/VideoPlayer.stories.tsx b/src/components/molecules/VideoPlayer.stories.tsx deleted file mode 100644 index 42de3ed..0000000 --- a/src/components/molecules/VideoPlayer.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { action } from "@storybook/addon-actions"; - -import VideoPlayer from "./VideoPlayer"; - -export default { - title: "Molecules/VideoPlayer", - component: VideoPlayer, - tags: ["molecules"], -}; - -export const Default = () => { - return ( -
- -
- ); -}; diff --git a/src/components/organisms/VideoPreview.tsx b/src/components/organisms/VideoPreview.tsx new file mode 100644 index 0000000..9b5db61 --- /dev/null +++ b/src/components/organisms/VideoPreview.tsx @@ -0,0 +1,179 @@ +import VideoPlayer, { type VideoPlayerRef } from "@/components/molecules/VideoPlayer"; +import AudioMixerPanel from "@/components/molecules/AudioMixerPanel"; +import PreviewTimeline from "@/components/molecules/PreviewTimeline"; +import Button from "@/components/atoms/Button"; +import type { PreviewSettings, AudioChannel, WaveformData, VideoClip } from "@/types"; +import { useRef, useState } from "react"; + +interface VideoPreviewProps { + /** + * Preview settings including cutlist and audio configuration + */ + settings: PreviewSettings; + /** + * URL to the preview video (HLS stream) + */ + previewVideoUrl: string; + /** + * Current playhead position in milliseconds + */ + playheadPosition?: number; + /** + * Total duration of the preview in milliseconds + */ + duration: number; + /** + * Callback when preview settings change + */ + onSettingsChange?: (settings: PreviewSettings) => void; + /** + * Callback when user requests to re-render preview + */ + onRegenerate?: (settings: PreviewSettings) => void; + /** + * Callback when user saves settings + */ + onSave?: (settings: PreviewSettings) => void; + /** + * Whether the preview is currently being generated + */ + regenerating?: boolean; + /** + * Whether save is in progress + */ + saving?: boolean; +} + +export default function VideoPreview({ + settings, + previewVideoUrl, + playheadPosition = 0, + duration, + onSettingsChange, + onRegenerate, + onSave, + regenerating = false, + saving = false, +}: VideoPreviewProps) { + const videoPlayerRef = useRef(null); + const [currentTime, setCurrentTime] = useState(playheadPosition); + + const handleAudioChannelsChange = (channels: AudioChannel[]) => { + const updatedSettings = { + ...settings, + audioChannels: channels, + }; + onSettingsChange?.(updatedSettings); + }; + + const handleCutlistChange = (cutlist: VideoClip[]) => { + const updatedSettings = { + ...settings, + cutlist, + }; + onSettingsChange?.(updatedSettings); + }; + + const handleSeekToTime = (milliseconds: number) => { + videoPlayerRef.current?.seekTo(milliseconds); + setCurrentTime(milliseconds); + }; + + const handleTimeUpdate = (time: number) => { + setCurrentTime(time); + }; + + const handleRegenerate = () => { + onRegenerate?.(settings); + }; + + const handleSave = () => { + onSave?.(settings); + }; + + const hasAudioChanges = settings.audioChannels.some( + (channel) => channel.level !== 1.0 || channel.muted + ); + + return ( +
+ {/* Header */} +
+
+

+ Video Preview +

+
+ + +
+
+
+ +
+ {/* Main content area */} +
+ {/* Video player */} +
+ +
+ + {/* Timeline */} +
+ +
+
+ + {/* Audio mixer sidebar */} +
+
+ +
+
+
+ + {/* Status bar */} +
+
+
+ {settings.cutlist.length} clips selected • {settings.audioChannels.filter(c => !c.muted).length} of {settings.audioChannels.length} audio channels active +
+
+ {regenerating && "Generating preview..."} + {saving && "Saving changes..."} + {!regenerating && !saving && hasAudioChanges && "Changes pending"} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5d4b2c6..a4cbeb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,8 @@ import "material-symbols/outlined.css"; import "./index.css"; import VideoSelectionPage from "@/components/pages/VideoSelectionPage"; +import VideoPreview from "@/components/organisms/VideoPreview"; -export { VideoSelectionPage }; +export { VideoSelectionPage, VideoPreview }; export type * from "./types"; diff --git a/src/types.ts b/src/types.ts index ee4e176..c8fc776 100644 --- a/src/types.ts +++ b/src/types.ts @@ -121,3 +121,59 @@ export type VideoClip = { */ keyframeSrc?: string; }; + +/** + * Audio channel information for preview + */ +export interface AudioChannel { + id: string; + name: string; + /** + * Audio level from 0.0 to 1.0 + */ + level: number; + /** + * Whether this channel is muted + */ + muted: boolean; +} + +/** + * Waveform data for visualization + */ +export interface WaveformData { + /** + * Audio channel ID this waveform belongs to + */ + channelId: string; + /** + * Array of amplitude values for visualization + */ + amplitudes: number[]; + /** + * Duration this waveform data represents in milliseconds + */ + duration: number; + /** + * Sample rate of the waveform data + */ + sampleRate: number; +} + +/** + * Preview settings for video rendering + */ +export interface PreviewSettings { + /** + * Selected cutlist for preview + */ + cutlist: VideoClip[]; + /** + * Audio channel configurations + */ + audioChannels: AudioChannel[]; + /** + * Waveform data for each channel + */ + waveformData: WaveformData[]; +} From b27ba6e4a2af30e96eb7e3e2ca8a3cc367a98002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:42:01 +0000 Subject: [PATCH 2/6] Restore deleted Storybook stories and add stories for new components Co-authored-by: saebyn <185030+saebyn@users.noreply.github.com> --- .../AnimatedHamburgerIconButton.stories.tsx | 18 ++ .../atoms/AudioChannelControl.stories.tsx | 69 +++++++ .../atoms/AudioLevelSlider.stories.tsx | 58 ++++++ src/components/atoms/Button.stories.tsx | 15 ++ src/components/atoms/Heading.stories.tsx | 14 ++ src/components/atoms/HeadingLink.stories.tsx | 14 ++ src/components/atoms/IconButton.stories.tsx | 15 ++ src/components/atoms/Search.stories.tsx | 13 ++ src/components/atoms/Tab.stories.tsx | 36 ++++ .../atoms/TimeDotMarker.stories.tsx | 22 +++ src/components/atoms/TimeLink.stories.tsx | 16 ++ .../atoms/TimelineLegend.stories.tsx | 9 + .../atoms/VideoPlayerProgressBar.stories.tsx | 24 +++ .../atoms/WaveformDisplay.stories.tsx | 91 +++++++++ .../molecules/AudioMixerPanel.stories.tsx | 125 ++++++++++++ .../molecules/ClipSelectionDialog.stories.tsx | 68 +++++++ .../molecules/PreviewTimeline.stories.tsx | 153 +++++++++++++++ .../molecules/VideoPlayer.stories.tsx | 21 ++ .../organisms/VideoPreview.stories.tsx | 184 ++++++++++++++++++ 19 files changed, 965 insertions(+) create mode 100644 src/components/atoms/AnimatedHamburgerIconButton.stories.tsx create mode 100644 src/components/atoms/AudioChannelControl.stories.tsx create mode 100644 src/components/atoms/AudioLevelSlider.stories.tsx create mode 100644 src/components/atoms/Button.stories.tsx create mode 100644 src/components/atoms/Heading.stories.tsx create mode 100644 src/components/atoms/HeadingLink.stories.tsx create mode 100644 src/components/atoms/IconButton.stories.tsx create mode 100644 src/components/atoms/Search.stories.tsx create mode 100644 src/components/atoms/Tab.stories.tsx create mode 100644 src/components/atoms/TimeDotMarker.stories.tsx create mode 100644 src/components/atoms/TimeLink.stories.tsx create mode 100644 src/components/atoms/TimelineLegend.stories.tsx create mode 100644 src/components/atoms/VideoPlayerProgressBar.stories.tsx create mode 100644 src/components/atoms/WaveformDisplay.stories.tsx create mode 100644 src/components/molecules/AudioMixerPanel.stories.tsx create mode 100644 src/components/molecules/ClipSelectionDialog.stories.tsx create mode 100644 src/components/molecules/PreviewTimeline.stories.tsx create mode 100644 src/components/molecules/VideoPlayer.stories.tsx create mode 100644 src/components/organisms/VideoPreview.stories.tsx diff --git a/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx b/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx new file mode 100644 index 0000000..cfc5955 --- /dev/null +++ b/src/components/atoms/AnimatedHamburgerIconButton.stories.tsx @@ -0,0 +1,18 @@ +import { useState } from "react"; +import AnimatedHamburgerIconButton from "./AnimatedHamburgerIconButton"; + +export default { + title: "Atoms/AnimatedHamburgerIconButton", + component: AnimatedHamburgerIconButton, + tags: ["atoms"], +}; + +export const Default = () => { + const [isExpanded, setIsExpanded] = useState(false); + return ( + setIsExpanded(!isExpanded)} + /> + ); +}; diff --git a/src/components/atoms/AudioChannelControl.stories.tsx b/src/components/atoms/AudioChannelControl.stories.tsx new file mode 100644 index 0000000..e8221d7 --- /dev/null +++ b/src/components/atoms/AudioChannelControl.stories.tsx @@ -0,0 +1,69 @@ +import { action } from "@storybook/addon-actions"; +import AudioChannelControl from "./AudioChannelControl"; +import type { AudioChannel } from "@/types"; + +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, + }, +}; \ No newline at end of file diff --git a/src/components/atoms/AudioLevelSlider.stories.tsx b/src/components/atoms/AudioLevelSlider.stories.tsx new file mode 100644 index 0000000..eb7dcdb --- /dev/null +++ b/src/components/atoms/AudioLevelSlider.stories.tsx @@ -0,0 +1,58 @@ +import { action } from "@storybook/addon-actions"; +import AudioLevelSlider from "./AudioLevelSlider"; + +export default { + title: "Atoms/AudioLevelSlider", + component: AudioLevelSlider, + tags: ["atoms"], +}; + +export const Default = { + args: { + level: 0.75, + onChange: action("onChange"), + disabled: false, + }, +}; + +export const WithLabel = { + args: { + level: 0.5, + onChange: action("onChange"), + label: "Master Volume", + disabled: false, + }, +}; + +export const Muted = { + args: { + level: 0, + onChange: action("onChange"), + disabled: false, + }, +}; + +export const FullVolume = { + args: { + level: 1.0, + onChange: action("onChange"), + disabled: false, + }, +}; + +export const Disabled = { + args: { + level: 0.6, + onChange: action("onChange"), + disabled: true, + }, +}; + +export const DisabledWithLabel = { + args: { + level: 0.3, + onChange: action("onChange"), + label: "Disabled Track", + disabled: true, + }, +}; \ No newline at end of file diff --git a/src/components/atoms/Button.stories.tsx b/src/components/atoms/Button.stories.tsx new file mode 100644 index 0000000..6838a9b --- /dev/null +++ b/src/components/atoms/Button.stories.tsx @@ -0,0 +1,15 @@ +import { action } from "@storybook/addon-actions"; +import Button from "./Button"; + +export default { + title: "Atoms/Button", + component: Button, + tags: ["atoms"], +}; + +export const Default = { + args: { + children: "Button", + onClick: action("onClick"), + }, +}; diff --git a/src/components/atoms/Heading.stories.tsx b/src/components/atoms/Heading.stories.tsx new file mode 100644 index 0000000..42c5bd6 --- /dev/null +++ b/src/components/atoms/Heading.stories.tsx @@ -0,0 +1,14 @@ +import Heading from "./Heading"; + +export default { + title: "Atoms/Heading", + component: Heading, + tags: ["atoms"], +}; + +export const Default = { + args: { + level: 1, + children: "Heading", + }, +}; diff --git a/src/components/atoms/HeadingLink.stories.tsx b/src/components/atoms/HeadingLink.stories.tsx new file mode 100644 index 0000000..101133c --- /dev/null +++ b/src/components/atoms/HeadingLink.stories.tsx @@ -0,0 +1,14 @@ +import HeadingLink from "./HeadingLink"; + +export default { + title: "Atoms/HeadingLink", + component: HeadingLink, + tags: ["atoms"], +}; + +export const Default = { + args: { + href: "#", + children: "Heading Link", + }, +}; diff --git a/src/components/atoms/IconButton.stories.tsx b/src/components/atoms/IconButton.stories.tsx new file mode 100644 index 0000000..ff2f516 --- /dev/null +++ b/src/components/atoms/IconButton.stories.tsx @@ -0,0 +1,15 @@ +import { action } from "@storybook/addon-actions"; +import IconButton from "./IconButton"; + +export default { + title: "Atoms/IconButton", + component: IconButton, + tags: ["atoms"], +}; + +export const Default = { + args: { + children: "IconButton", + onClick: action("onClick"), + }, +}; diff --git a/src/components/atoms/Search.stories.tsx b/src/components/atoms/Search.stories.tsx new file mode 100644 index 0000000..766d421 --- /dev/null +++ b/src/components/atoms/Search.stories.tsx @@ -0,0 +1,13 @@ +import { useState } from "react"; +import Search from "./Search"; + +export default { + title: "Atoms/Search", + component: Search, + tags: ["atoms"], +}; + +export const Default = () => { + const [text, setText] = useState(""); + return ; +}; diff --git a/src/components/atoms/Tab.stories.tsx b/src/components/atoms/Tab.stories.tsx new file mode 100644 index 0000000..c944ea2 --- /dev/null +++ b/src/components/atoms/Tab.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import Tab, { TabContainer } from "./Tab"; + +export default { + title: "Atoms/Tab", + component: Tab, + tags: ["atoms"], +}; + +export const Default = () => { + const [activeTab, setActiveTab] = useState(0); + return ( + setActiveTab(0)} + />, + setActiveTab(1)} + />, + ]} + > + {activeTab === 0 ? ( +
Content for Tab 1
+ ) : ( +
Content for Tab 2
+ )} +
+ ); +}; diff --git a/src/components/atoms/TimeDotMarker.stories.tsx b/src/components/atoms/TimeDotMarker.stories.tsx new file mode 100644 index 0000000..132d40f --- /dev/null +++ b/src/components/atoms/TimeDotMarker.stories.tsx @@ -0,0 +1,22 @@ +import { TimelineProvider } from "@/context/TimelineContext"; +import { TimeDotMarker } from "./TimeDotMarker"; + +export default { + title: "Atoms/TimeDotMarker", + component: TimeDotMarker, + tags: ["atoms"], + decorators: [ + (story: () => React.ReactNode) => ( + + {story()} + + ), + ], +}; + +export const Default = { + args: { + timestampMilliseconds: 60000, + className: "bg-red-500", + }, +}; diff --git a/src/components/atoms/TimeLink.stories.tsx b/src/components/atoms/TimeLink.stories.tsx new file mode 100644 index 0000000..24a024d --- /dev/null +++ b/src/components/atoms/TimeLink.stories.tsx @@ -0,0 +1,16 @@ +import { action } from "@storybook/addon-actions"; +import TimeLink from "./TimeLink"; + +export default { + title: "Atoms/TimeLink", + component: TimeLink, + tags: ["atoms"], +}; + +export const Default = { + args: { + milliseconds: 60000, + onClick: action("onClick"), + children: "Click me", + }, +}; diff --git a/src/components/atoms/TimelineLegend.stories.tsx b/src/components/atoms/TimelineLegend.stories.tsx new file mode 100644 index 0000000..bc8cbe6 --- /dev/null +++ b/src/components/atoms/TimelineLegend.stories.tsx @@ -0,0 +1,9 @@ +import TimelineLegend from "./TimelineLegend"; + +export default { + title: "Atoms/TimelineLegend", + component: TimelineLegend, + tags: ["atoms"], +}; + +export const Default = {}; diff --git a/src/components/atoms/VideoPlayerProgressBar.stories.tsx b/src/components/atoms/VideoPlayerProgressBar.stories.tsx new file mode 100644 index 0000000..1f220d8 --- /dev/null +++ b/src/components/atoms/VideoPlayerProgressBar.stories.tsx @@ -0,0 +1,24 @@ +import { action } from "@storybook/addon-actions"; +import VideoPlayerProgressBar from "./VideoPlayerProgressBar"; + +export default { + title: "Atoms/VideoPlayerProgressBar", + component: VideoPlayerProgressBar, + tags: ["atoms"], +}; + +export const Default = { + args: { + progress: 0, + seekToPercent: action("Seek to Percent"), + duration: 100, + }, +}; + +export const Progress = { + args: { + progress: 70, + seekToPercent: action("Seek to Percent"), + duration: 100, + }, +}; diff --git a/src/components/atoms/WaveformDisplay.stories.tsx b/src/components/atoms/WaveformDisplay.stories.tsx new file mode 100644 index 0000000..adddc92 --- /dev/null +++ b/src/components/atoms/WaveformDisplay.stories.tsx @@ -0,0 +1,91 @@ +import { action } from "@storybook/addon-actions"; +import WaveformDisplay from "./WaveformDisplay"; +import type { WaveformData } from "@/types"; + +export default { + title: "Atoms/WaveformDisplay", + component: WaveformDisplay, + tags: ["atoms"], +}; + +// Generate sample waveform data +const generateWaveformData = (samples: number = 200): WaveformData => { + const amplitudes: number[] = []; + for (let i = 0; i < samples; i++) { + // Generate a mix of sine waves for realistic waveform + const freq1 = Math.sin((i / samples) * Math.PI * 4) * 0.5; + const freq2 = Math.sin((i / samples) * Math.PI * 8) * 0.3; + const noise = (Math.random() - 0.5) * 0.2; + amplitudes.push(Math.abs(freq1 + freq2 + noise)); + } + return { + channelId: "channel-1", + amplitudes, + duration: 60000, // 1 minute + sampleRate: 44100, + }; +}; + +const mockWaveformData = generateWaveformData(); + +export const Default = { + args: { + waveformData: mockWaveformData, + width: 400, + height: 80, + playheadPosition: 15000, // 15 seconds + onSeek: action("onSeek"), + }, +}; + +export const Large = { + args: { + waveformData: mockWaveformData, + width: 800, + height: 120, + playheadPosition: 30000, // 30 seconds + onSeek: action("onSeek"), + }, +}; + +export const CustomColors = { + args: { + waveformData: mockWaveformData, + width: 600, + height: 100, + playheadPosition: 45000, // 45 seconds + color: "#10b981", // emerald + playheadColor: "#f59e0b", // amber + onSeek: action("onSeek"), + }, +}; + +export const Interactive = { + args: { + waveformData: mockWaveformData, + width: 500, + height: 80, + playheadPosition: 0, + onSeek: action("onSeek"), + }, +}; + +export const NoPlayhead = { + args: { + waveformData: mockWaveformData, + width: 400, + height: 80, + // No playheadPosition provided - should default to 0 + onSeek: action("onSeek"), + }, +}; + +export const ReadOnly = { + args: { + waveformData: mockWaveformData, + width: 400, + height: 80, + playheadPosition: 20000, + // No onSeek callback - should be read-only + }, +}; \ No newline at end of file diff --git a/src/components/molecules/AudioMixerPanel.stories.tsx b/src/components/molecules/AudioMixerPanel.stories.tsx new file mode 100644 index 0000000..08a2bd0 --- /dev/null +++ b/src/components/molecules/AudioMixerPanel.stories.tsx @@ -0,0 +1,125 @@ +import { action } from "@storybook/addon-actions"; +import AudioMixerPanel from "./AudioMixerPanel"; +import type { AudioChannel } from "@/types"; + +export default { + title: "Molecules/AudioMixerPanel", + component: AudioMixerPanel, + tags: ["molecules"], + decorators: [ + (story: () => React.ReactNode) => ( +
{story()}
+ ), + ], +}; + +const mockChannels: AudioChannel[] = [ + { + id: "channel-1", + name: "Game Audio", + level: 0.8, + muted: false, + }, + { + id: "channel-2", + name: "Microphone", + level: 0.6, + muted: false, + }, + { + id: "channel-3", + name: "Desktop Audio", + level: 0.4, + muted: true, + }, +]; + +export const Default = { + args: { + channels: mockChannels, + onChange: action("onChange"), + onSave: action("onSave"), + disabled: false, + saving: false, + }, +}; + +export const SingleChannel = { + args: { + channels: [mockChannels[0]], + onChange: action("onChange"), + onSave: action("onSave"), + disabled: false, + saving: false, + }, +}; + +export const ManyChannels = { + args: { + channels: [ + ...mockChannels, + { + id: "channel-4", + name: "Music", + level: 0.3, + muted: false, + }, + { + id: "channel-5", + name: "Sound Effects", + level: 0.7, + muted: false, + }, + { + id: "channel-6", + name: "Voice Chat", + level: 0.5, + muted: true, + }, + ], + onChange: action("onChange"), + onSave: action("onSave"), + disabled: false, + saving: false, + }, +}; + +export const Disabled = { + args: { + channels: mockChannels, + onChange: action("onChange"), + onSave: action("onSave"), + disabled: true, + saving: false, + }, +}; + +export const Saving = { + args: { + channels: mockChannels, + onChange: action("onChange"), + onSave: action("onSave"), + disabled: false, + saving: true, + }, +}; + +export const AllMuted = { + args: { + channels: mockChannels.map(channel => ({ ...channel, muted: true })), + onChange: action("onChange"), + onSave: action("onSave"), + disabled: false, + saving: false, + }, +}; + +export const NoSaveCallback = { + args: { + channels: mockChannels, + onChange: action("onChange"), + // No onSave callback + disabled: false, + saving: false, + }, +}; \ No newline at end of file diff --git a/src/components/molecules/ClipSelectionDialog.stories.tsx b/src/components/molecules/ClipSelectionDialog.stories.tsx new file mode 100644 index 0000000..25c5d92 --- /dev/null +++ b/src/components/molecules/ClipSelectionDialog.stories.tsx @@ -0,0 +1,68 @@ +import { action } from "@storybook/addon-actions"; + +import ClipSelectionDialog from "./ClipSelectionDialog"; + +export default { + title: "Molecules/SelectedCutsDialog", + component: ClipSelectionDialog, + tags: ["molecules"], + decorators: [ + (story: () => React.ReactNode) => ( +
{story()}
+ ), + ], +}; + +export const Default = () => { + return ( + + ); +}; + +export const MultipleCuts = () => { + return ( + + ); +}; diff --git a/src/components/molecules/PreviewTimeline.stories.tsx b/src/components/molecules/PreviewTimeline.stories.tsx new file mode 100644 index 0000000..071f008 --- /dev/null +++ b/src/components/molecules/PreviewTimeline.stories.tsx @@ -0,0 +1,153 @@ +import { action } from "@storybook/addon-actions"; +import PreviewTimeline from "./PreviewTimeline"; +import type { WaveformData, VideoClip } from "@/types"; + +export default { + title: "Molecules/PreviewTimeline", + component: PreviewTimeline, + tags: ["molecules"], + decorators: [ + (story: () => React.ReactNode) => ( +
{story()}
+ ), + ], +}; + +// Generate sample waveform data +const generateWaveformData = (channelId: string, samples: number = 400): WaveformData => { + const amplitudes: number[] = []; + for (let i = 0; i < samples; i++) { + // Generate different patterns for different channels + let amplitude = 0; + if (channelId === "channel-1") { + // Game audio - more complex waveform + amplitude = Math.sin((i / samples) * Math.PI * 6) * 0.6 + Math.sin((i / samples) * Math.PI * 12) * 0.3; + } else if (channelId === "channel-2") { + // Microphone - speech-like pattern + amplitude = Math.sin((i / samples) * Math.PI * 3) * 0.4 + (Math.random() - 0.5) * 0.2; + } else { + // Desktop audio - more uniform + amplitude = Math.sin((i / samples) * Math.PI * 4) * 0.5; + } + amplitudes.push(Math.abs(amplitude)); + } + return { + channelId, + amplitudes, + duration: 120000, // 2 minutes + sampleRate: 44100, + }; +}; + +const mockWaveformData: WaveformData[] = [ + generateWaveformData("channel-1"), + generateWaveformData("channel-2"), + generateWaveformData("channel-3"), +]; + +const mockCutlist: VideoClip[] = [ + { + id: "clip-1", + start: 5000, // 5 seconds + end: 25000, // 25 seconds + }, + { + id: "clip-2", + start: 40000, // 40 seconds + end: 70000, // 70 seconds + }, + { + id: "clip-3", + start: 90000, // 90 seconds + end: 110000, // 110 seconds + }, +]; + +export const Default = { + args: { + waveformData: mockWaveformData, + playheadPosition: 30000, // 30 seconds + cutlist: mockCutlist, + duration: 120000, // 2 minutes + width: 800, + waveformHeight: 60, + onSeek: action("onSeek"), + onCutlistChange: action("onCutlistChange"), + }, +}; + +export const SingleChannel = { + args: { + waveformData: [mockWaveformData[0]], + playheadPosition: 15000, + cutlist: mockCutlist, + duration: 120000, + width: 800, + waveformHeight: 80, + onSeek: action("onSeek"), + onCutlistChange: action("onCutlistChange"), + }, +}; + +export const LongTimeline = { + args: { + waveformData: mockWaveformData, + playheadPosition: 180000, // 3 minutes + cutlist: [ + ...mockCutlist, + { + id: "clip-4", + start: 150000, // 2.5 minutes + end: 210000, // 3.5 minutes + }, + { + id: "clip-5", + start: 240000, // 4 minutes + end: 280000, // 4:40 + }, + ], + duration: 300000, // 5 minutes + width: 1200, + waveformHeight: 50, + onSeek: action("onSeek"), + onCutlistChange: action("onCutlistChange"), + }, +}; + +export const EmptyCutlist = { + args: { + waveformData: mockWaveformData, + playheadPosition: 45000, + cutlist: [], + duration: 120000, + width: 800, + waveformHeight: 60, + onSeek: action("onSeek"), + onCutlistChange: action("onCutlistChange"), + }, +}; + +export const ReadOnly = { + args: { + waveformData: mockWaveformData, + playheadPosition: 60000, + cutlist: mockCutlist, + duration: 120000, + width: 800, + waveformHeight: 60, + // No callbacks provided - should be read-only + }, +}; + +export const CompactView = { + args: { + waveformData: mockWaveformData, + playheadPosition: 30000, + cutlist: mockCutlist, + duration: 120000, + width: 600, + waveformHeight: 40, + onSeek: action("onSeek"), + onCutlistChange: action("onCutlistChange"), + }, +}; \ No newline at end of file diff --git a/src/components/molecules/VideoPlayer.stories.tsx b/src/components/molecules/VideoPlayer.stories.tsx new file mode 100644 index 0000000..42de3ed --- /dev/null +++ b/src/components/molecules/VideoPlayer.stories.tsx @@ -0,0 +1,21 @@ +import { action } from "@storybook/addon-actions"; + +import VideoPlayer from "./VideoPlayer"; + +export default { + title: "Molecules/VideoPlayer", + component: VideoPlayer, + tags: ["molecules"], +}; + +export const Default = () => { + return ( +
+ +
+ ); +}; diff --git a/src/components/organisms/VideoPreview.stories.tsx b/src/components/organisms/VideoPreview.stories.tsx new file mode 100644 index 0000000..5d7153b --- /dev/null +++ b/src/components/organisms/VideoPreview.stories.tsx @@ -0,0 +1,184 @@ +import { action } from "@storybook/addon-actions"; +import VideoPreview from "./VideoPreview"; +import type { PreviewSettings, AudioChannel, WaveformData, VideoClip } from "@/types"; + +export default { + title: "Organisms/VideoPreview", + component: VideoPreview, + tags: ["organisms"], + decorators: [ + (story: () => React.ReactNode) => ( +
{story()}
+ ), + ], + parameters: { + layout: "fullscreen", + }, +}; + +// Generate sample waveform data +const generateWaveformData = (channelId: string, samples: number = 400): WaveformData => { + const amplitudes: number[] = []; + for (let i = 0; i < samples; i++) { + let amplitude = 0; + if (channelId === "channel-1") { + amplitude = Math.sin((i / samples) * Math.PI * 6) * 0.6 + Math.sin((i / samples) * Math.PI * 12) * 0.3; + } else if (channelId === "channel-2") { + amplitude = Math.sin((i / samples) * Math.PI * 3) * 0.4 + (Math.random() - 0.5) * 0.2; + } else { + amplitude = Math.sin((i / samples) * Math.PI * 4) * 0.5; + } + amplitudes.push(Math.abs(amplitude)); + } + return { + channelId, + amplitudes, + duration: 180000, // 3 minutes + sampleRate: 44100, + }; +}; + +const mockAudioChannels: AudioChannel[] = [ + { + id: "channel-1", + name: "Game Audio", + level: 0.8, + muted: false, + }, + { + id: "channel-2", + name: "Microphone", + level: 0.6, + muted: false, + }, + { + id: "channel-3", + name: "Desktop Audio", + level: 0.4, + muted: true, + }, +]; + +const mockWaveformData: WaveformData[] = [ + generateWaveformData("channel-1"), + generateWaveformData("channel-2"), + generateWaveformData("channel-3"), +]; + +const mockCutlist: VideoClip[] = [ + { + id: "clip-1", + start: 10000, // 10 seconds + end: 45000, // 45 seconds + }, + { + id: "clip-2", + start: 60000, // 1 minute + end: 120000, // 2 minutes + }, + { + id: "clip-3", + start: 140000, // 2:20 + end: 170000, // 2:50 + }, +]; + +const mockPreviewSettings: PreviewSettings = { + cutlist: mockCutlist, + audioChannels: mockAudioChannels, + waveformData: mockWaveformData, +}; + +export const Default = { + args: { + settings: mockPreviewSettings, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 30000, // 30 seconds + duration: 180000, // 3 minutes + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: false, + saving: false, + }, +}; + +export const Regenerating = { + args: { + settings: mockPreviewSettings, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 60000, + duration: 180000, + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: true, + saving: false, + }, +}; + +export const Saving = { + args: { + settings: mockPreviewSettings, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 90000, + duration: 180000, + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: false, + saving: true, + }, +}; + +export const SingleChannel = { + args: { + settings: { + ...mockPreviewSettings, + audioChannels: [mockAudioChannels[0]], + waveformData: [mockWaveformData[0]], + }, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 45000, + duration: 180000, + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: false, + saving: false, + }, +}; + +export const EmptyCutlist = { + args: { + settings: { + ...mockPreviewSettings, + cutlist: [], + }, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 0, + duration: 180000, + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: false, + saving: false, + }, +}; + +export const AllMuted = { + args: { + settings: { + ...mockPreviewSettings, + audioChannels: mockAudioChannels.map(channel => ({ ...channel, muted: true })), + }, + previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + playheadPosition: 75000, + duration: 180000, + onSettingsChange: action("onSettingsChange"), + onRegenerate: action("onRegenerate"), + onSave: action("onSave"), + regenerating: false, + saving: false, + }, +}; \ No newline at end of file From 4d88b43e03bb20e1cf3b945a122435a6f8e2af38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:57:16 +0000 Subject: [PATCH 3/6] Fix biome formatting and linting issues Co-authored-by: saebyn <185030+saebyn@users.noreply.github.com> --- src/components/VideoPreview.test.tsx | 23 +++++--- .../atoms/AudioChannelControl.stories.tsx | 6 +- src/components/atoms/AudioChannelControl.tsx | 6 +- .../atoms/AudioLevelSlider.stories.tsx | 2 +- src/components/atoms/AudioLevelSlider.tsx | 9 ++- .../atoms/WaveformDisplay.stories.tsx | 6 +- src/components/atoms/WaveformDisplay.tsx | 15 ++--- .../molecules/AudioMixerPanel.stories.tsx | 12 ++-- src/components/molecules/AudioMixerPanel.tsx | 11 ++-- .../molecules/PreviewTimeline.stories.tsx | 37 ++++++++----- src/components/molecules/PreviewTimeline.tsx | 8 +-- .../organisms/VideoPreview.stories.tsx | 55 +++++++++++++------ src/components/organisms/VideoPreview.tsx | 21 +++++-- src/index.ts | 2 +- 14 files changed, 132 insertions(+), 81 deletions(-) diff --git a/src/components/VideoPreview.test.tsx b/src/components/VideoPreview.test.tsx index f3d3370..e4390d6 100644 --- a/src/components/VideoPreview.test.tsx +++ b/src/components/VideoPreview.test.tsx @@ -1,14 +1,19 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import AudioLevelSlider from "@/components/atoms/AudioLevelSlider"; 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, WaveformData, VideoClip, PreviewSettings } from "@/types"; +import type { + AudioChannel, + PreviewSettings, + VideoClip, + WaveformData, +} from "@/types"; describe("Video Preview Components", () => { const sampleAudioChannel: AudioChannel = { @@ -42,7 +47,7 @@ describe("Video Preview Components", () => { it("renders AudioLevelSlider", () => { const onChange = vi.fn(); const { container } = render( - + , ); expect(container.querySelector("input[type='range']")).toBeTruthy(); }); @@ -50,14 +55,14 @@ describe("Video Preview Components", () => { it("renders AudioChannelControl", () => { const onChange = vi.fn(); const { getByText } = render( - + , ); expect(getByText("Test Channel")).toBeTruthy(); }); it("renders WaveformDisplay", () => { const { container } = render( - + , ); expect(container.querySelector("canvas")).toBeTruthy(); }); @@ -65,7 +70,7 @@ describe("Video Preview Components", () => { it("renders AudioMixerPanel", () => { const onChange = vi.fn(); const { getByText } = render( - + , ); expect(getByText("Audio Mixer")).toBeTruthy(); }); @@ -77,7 +82,7 @@ describe("Video Preview Components", () => { playheadPosition={1500} cutlist={sampleClips} duration={5000} - /> + />, ); expect(getByText("Preview Timeline")).toBeTruthy(); }); @@ -88,8 +93,8 @@ describe("Video Preview Components", () => { settings={sampleSettings} previewVideoUrl="test-url" duration={5000} - /> + />, ); expect(getByText("Video Preview")).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/src/components/atoms/AudioChannelControl.stories.tsx b/src/components/atoms/AudioChannelControl.stories.tsx index e8221d7..c784da6 100644 --- a/src/components/atoms/AudioChannelControl.stories.tsx +++ b/src/components/atoms/AudioChannelControl.stories.tsx @@ -1,6 +1,6 @@ +import type { AudioChannel } from "@/types"; import { action } from "@storybook/addon-actions"; import AudioChannelControl from "./AudioChannelControl"; -import type { AudioChannel } from "@/types"; export default { title: "Atoms/AudioChannelControl", @@ -16,7 +16,7 @@ const mockChannel: AudioChannel = { }; const mockMutedChannel: AudioChannel = { - id: "channel-2", + id: "channel-2", name: "Audio Track 2", level: 0.5, muted: true, @@ -66,4 +66,4 @@ export const HighLevel = { onChange: action("onChange"), disabled: false, }, -}; \ No newline at end of file +}; diff --git a/src/components/atoms/AudioChannelControl.tsx b/src/components/atoms/AudioChannelControl.tsx index 8d7b76a..2a579e2 100644 --- a/src/components/atoms/AudioChannelControl.tsx +++ b/src/components/atoms/AudioChannelControl.tsx @@ -1,6 +1,6 @@ -import IconButton from "./IconButton"; -import AudioLevelSlider from "./AudioLevelSlider"; import type { AudioChannel } from "@/types"; +import AudioLevelSlider from "./AudioLevelSlider"; +import IconButton from "./IconButton"; interface AudioChannelControlProps { /** @@ -53,4 +53,4 @@ export default function AudioChannelControl({
); -} \ No newline at end of file +} diff --git a/src/components/atoms/AudioLevelSlider.stories.tsx b/src/components/atoms/AudioLevelSlider.stories.tsx index eb7dcdb..178dda7 100644 --- a/src/components/atoms/AudioLevelSlider.stories.tsx +++ b/src/components/atoms/AudioLevelSlider.stories.tsx @@ -55,4 +55,4 @@ export const DisabledWithLabel = { label: "Disabled Track", disabled: true, }, -}; \ No newline at end of file +}; diff --git a/src/components/atoms/AudioLevelSlider.tsx b/src/components/atoms/AudioLevelSlider.tsx index c1dd204..9ae610c 100644 --- a/src/components/atoms/AudioLevelSlider.tsx +++ b/src/components/atoms/AudioLevelSlider.tsx @@ -24,7 +24,7 @@ export default function AudioLevelSlider({ disabled = false, }: AudioLevelSliderProps) { const sliderId = `audio-slider-${Math.random().toString(36).substr(2, 9)}`; - + const handleChange = (event: React.ChangeEvent) => { const newLevel = Number.parseFloat(event.target.value); onChange(newLevel); @@ -33,7 +33,10 @@ export default function AudioLevelSlider({ return (
{label && ( -
); -} \ No newline at end of file +} diff --git a/src/components/atoms/WaveformDisplay.stories.tsx b/src/components/atoms/WaveformDisplay.stories.tsx index adddc92..cb95630 100644 --- a/src/components/atoms/WaveformDisplay.stories.tsx +++ b/src/components/atoms/WaveformDisplay.stories.tsx @@ -1,6 +1,6 @@ +import type { WaveformData } from "@/types"; import { action } from "@storybook/addon-actions"; import WaveformDisplay from "./WaveformDisplay"; -import type { WaveformData } from "@/types"; export default { title: "Atoms/WaveformDisplay", @@ -9,7 +9,7 @@ export default { }; // Generate sample waveform data -const generateWaveformData = (samples: number = 200): WaveformData => { +const generateWaveformData = (samples = 200): WaveformData => { const amplitudes: number[] = []; for (let i = 0; i < samples; i++) { // Generate a mix of sine waves for realistic waveform @@ -88,4 +88,4 @@ export const ReadOnly = { playheadPosition: 20000, // No onSeek callback - should be read-only }, -}; \ No newline at end of file +}; diff --git a/src/components/atoms/WaveformDisplay.tsx b/src/components/atoms/WaveformDisplay.tsx index 40f5509..67398eb 100644 --- a/src/components/atoms/WaveformDisplay.tsx +++ b/src/components/atoms/WaveformDisplay.tsx @@ -61,12 +61,12 @@ export default function WaveformDisplay({ const halfHeight = height / 2; ctx.fillStyle = color; - + for (let i = 0; i < amplitudes.length; i++) { const amplitude = Math.abs(amplitudes[i]); const barHeight = amplitude * halfHeight; const x = i * barWidth; - + // Draw positive amplitude ctx.fillRect(x, halfHeight - barHeight, barWidth - 1, barHeight); // Draw negative amplitude (mirrored) @@ -95,7 +95,7 @@ export default function WaveformDisplay({ const x = event.clientX - rect.left; const relativeX = x / width; const seekTime = relativeX * waveformData.duration; - + onSeek(Math.max(0, Math.min(seekTime, waveformData.duration))); }; @@ -107,9 +107,10 @@ export default function WaveformDisplay({ event.preventDefault(); const step = waveformData.duration * 0.05; // 5% step const currentTime = playheadPosition || 0; - const newTime = event.key === "ArrowLeft" - ? Math.max(0, currentTime - step) - : Math.min(waveformData.duration, currentTime + step); + const newTime = + event.key === "ArrowLeft" + ? Math.max(0, currentTime - step) + : Math.min(waveformData.duration, currentTime + step); onSeek(newTime); } }; @@ -137,4 +138,4 @@ export default function WaveformDisplay({ )} ); -} \ No newline at end of file +} diff --git a/src/components/molecules/AudioMixerPanel.stories.tsx b/src/components/molecules/AudioMixerPanel.stories.tsx index 08a2bd0..c44b6f4 100644 --- a/src/components/molecules/AudioMixerPanel.stories.tsx +++ b/src/components/molecules/AudioMixerPanel.stories.tsx @@ -1,6 +1,6 @@ +import type { AudioChannel } from "@/types"; import { action } from "@storybook/addon-actions"; import AudioMixerPanel from "./AudioMixerPanel"; -import type { AudioChannel } from "@/types"; export default { title: "Molecules/AudioMixerPanel", @@ -8,7 +8,9 @@ export default { tags: ["molecules"], decorators: [ (story: () => React.ReactNode) => ( -
{story()}
+
+ {story()} +
), ], }; @@ -21,7 +23,7 @@ const mockChannels: AudioChannel[] = [ muted: false, }, { - id: "channel-2", + id: "channel-2", name: "Microphone", level: 0.6, muted: false, @@ -106,7 +108,7 @@ export const Saving = { export const AllMuted = { args: { - channels: mockChannels.map(channel => ({ ...channel, muted: true })), + channels: mockChannels.map((channel) => ({ ...channel, muted: true })), onChange: action("onChange"), onSave: action("onSave"), disabled: false, @@ -122,4 +124,4 @@ export const NoSaveCallback = { disabled: false, saving: false, }, -}; \ No newline at end of file +}; diff --git a/src/components/molecules/AudioMixerPanel.tsx b/src/components/molecules/AudioMixerPanel.tsx index 061cd72..83d8b2b 100644 --- a/src/components/molecules/AudioMixerPanel.tsx +++ b/src/components/molecules/AudioMixerPanel.tsx @@ -34,7 +34,7 @@ export default function AudioMixerPanel({ }: AudioMixerPanelProps) { const handleChannelChange = (updatedChannel: AudioChannel) => { const updatedChannels = channels.map((channel) => - channel.id === updatedChannel.id ? updatedChannel : channel + channel.id === updatedChannel.id ? updatedChannel : channel, ); onChange(updatedChannels); }; @@ -58,8 +58,8 @@ export default function AudioMixerPanel({ }; const allMuted = channels.every((channel) => channel.muted); - const hasChanges = channels.some((channel) => - channel.level !== 1.0 || channel.muted + const hasChanges = channels.some( + (channel) => channel.level !== 1.0 || channel.muted, ); return ( @@ -115,10 +115,11 @@ export default function AudioMixerPanel({ {channels.length > 0 && (
- {channels.filter(c => !c.muted).length} of {channels.length} channels active + {channels.filter((c) => !c.muted).length} of {channels.length}{" "} + channels active
)} ); -} \ No newline at end of file +} diff --git a/src/components/molecules/PreviewTimeline.stories.tsx b/src/components/molecules/PreviewTimeline.stories.tsx index 071f008..2b83869 100644 --- a/src/components/molecules/PreviewTimeline.stories.tsx +++ b/src/components/molecules/PreviewTimeline.stories.tsx @@ -1,30 +1,39 @@ +import type { VideoClip, WaveformData } from "@/types"; import { action } from "@storybook/addon-actions"; import PreviewTimeline from "./PreviewTimeline"; -import type { WaveformData, VideoClip } from "@/types"; export default { - title: "Molecules/PreviewTimeline", + title: "Molecules/PreviewTimeline", component: PreviewTimeline, tags: ["molecules"], decorators: [ (story: () => React.ReactNode) => ( -
{story()}
+
+ {story()} +
), ], }; // Generate sample waveform data -const generateWaveformData = (channelId: string, samples: number = 400): WaveformData => { +const generateWaveformData = ( + channelId: string, + samples = 400, +): WaveformData => { const amplitudes: number[] = []; for (let i = 0; i < samples; i++) { // Generate different patterns for different channels let amplitude = 0; if (channelId === "channel-1") { // Game audio - more complex waveform - amplitude = Math.sin((i / samples) * Math.PI * 6) * 0.6 + Math.sin((i / samples) * Math.PI * 12) * 0.3; + amplitude = + Math.sin((i / samples) * Math.PI * 6) * 0.6 + + Math.sin((i / samples) * Math.PI * 12) * 0.3; } else if (channelId === "channel-2") { // Microphone - speech-like pattern - amplitude = Math.sin((i / samples) * Math.PI * 3) * 0.4 + (Math.random() - 0.5) * 0.2; + amplitude = + Math.sin((i / samples) * Math.PI * 3) * 0.4 + + (Math.random() - 0.5) * 0.2; } else { // Desktop audio - more uniform amplitude = Math.sin((i / samples) * Math.PI * 4) * 0.5; @@ -48,18 +57,18 @@ const mockWaveformData: WaveformData[] = [ const mockCutlist: VideoClip[] = [ { id: "clip-1", - start: 5000, // 5 seconds - end: 25000, // 25 seconds + start: 5000, // 5 seconds + end: 25000, // 25 seconds }, { - id: "clip-2", + id: "clip-2", start: 40000, // 40 seconds - end: 70000, // 70 seconds + end: 70000, // 70 seconds }, { id: "clip-3", start: 90000, // 90 seconds - end: 110000, // 110 seconds + end: 110000, // 110 seconds }, ]; @@ -98,12 +107,12 @@ export const LongTimeline = { { id: "clip-4", start: 150000, // 2.5 minutes - end: 210000, // 3.5 minutes + end: 210000, // 3.5 minutes }, { id: "clip-5", start: 240000, // 4 minutes - end: 280000, // 4:40 + end: 280000, // 4:40 }, ], duration: 300000, // 5 minutes @@ -150,4 +159,4 @@ export const CompactView = { onSeek: action("onSeek"), onCutlistChange: action("onCutlistChange"), }, -}; \ No newline at end of file +}; diff --git a/src/components/molecules/PreviewTimeline.tsx b/src/components/molecules/PreviewTimeline.tsx index fec4f6d..01660b0 100644 --- a/src/components/molecules/PreviewTimeline.tsx +++ b/src/components/molecules/PreviewTimeline.tsx @@ -1,5 +1,5 @@ import WaveformDisplay from "@/components/atoms/WaveformDisplay"; -import type { WaveformData, VideoClip } from "@/types"; +import type { VideoClip, WaveformData } from "@/types"; import { formatMs } from "@/utils/duration"; interface PreviewTimelineProps { @@ -49,7 +49,7 @@ export default function PreviewTimeline({ }: PreviewTimelineProps) { const colors = [ "#3b82f6", // blue - "#10b981", // emerald + "#10b981", // emerald "#f59e0b", // amber "#ef4444", // red "#8b5cf6", // violet @@ -93,7 +93,7 @@ export default function PreviewTimeline({ ); })} - + {/* Playhead indicator */}
); -} \ No newline at end of file +} diff --git a/src/components/organisms/VideoPreview.stories.tsx b/src/components/organisms/VideoPreview.stories.tsx index 5d7153b..fb63e02 100644 --- a/src/components/organisms/VideoPreview.stories.tsx +++ b/src/components/organisms/VideoPreview.stories.tsx @@ -1,6 +1,11 @@ +import type { + AudioChannel, + PreviewSettings, + VideoClip, + WaveformData, +} from "@/types"; import { action } from "@storybook/addon-actions"; import VideoPreview from "./VideoPreview"; -import type { PreviewSettings, AudioChannel, WaveformData, VideoClip } from "@/types"; export default { title: "Organisms/VideoPreview", @@ -17,14 +22,21 @@ export default { }; // Generate sample waveform data -const generateWaveformData = (channelId: string, samples: number = 400): WaveformData => { +const generateWaveformData = ( + channelId: string, + samples = 400, +): WaveformData => { const amplitudes: number[] = []; for (let i = 0; i < samples; i++) { let amplitude = 0; if (channelId === "channel-1") { - amplitude = Math.sin((i / samples) * Math.PI * 6) * 0.6 + Math.sin((i / samples) * Math.PI * 12) * 0.3; + amplitude = + Math.sin((i / samples) * Math.PI * 6) * 0.6 + + Math.sin((i / samples) * Math.PI * 12) * 0.3; } else if (channelId === "channel-2") { - amplitude = Math.sin((i / samples) * Math.PI * 3) * 0.4 + (Math.random() - 0.5) * 0.2; + amplitude = + Math.sin((i / samples) * Math.PI * 3) * 0.4 + + (Math.random() - 0.5) * 0.2; } else { amplitude = Math.sin((i / samples) * Math.PI * 4) * 0.5; } @@ -68,18 +80,18 @@ const mockWaveformData: WaveformData[] = [ const mockCutlist: VideoClip[] = [ { id: "clip-1", - start: 10000, // 10 seconds - end: 45000, // 45 seconds + start: 10000, // 10 seconds + end: 45000, // 45 seconds }, { id: "clip-2", - start: 60000, // 1 minute - end: 120000, // 2 minutes + start: 60000, // 1 minute + end: 120000, // 2 minutes }, { id: "clip-3", start: 140000, // 2:20 - end: 170000, // 2:50 + end: 170000, // 2:50 }, ]; @@ -92,7 +104,8 @@ const mockPreviewSettings: PreviewSettings = { export const Default = { args: { settings: mockPreviewSettings, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 30000, // 30 seconds duration: 180000, // 3 minutes onSettingsChange: action("onSettingsChange"), @@ -106,7 +119,8 @@ export const Default = { export const Regenerating = { args: { settings: mockPreviewSettings, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 60000, duration: 180000, onSettingsChange: action("onSettingsChange"), @@ -120,7 +134,8 @@ export const Regenerating = { export const Saving = { args: { settings: mockPreviewSettings, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 90000, duration: 180000, onSettingsChange: action("onSettingsChange"), @@ -138,7 +153,8 @@ export const SingleChannel = { audioChannels: [mockAudioChannels[0]], waveformData: [mockWaveformData[0]], }, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 45000, duration: 180000, onSettingsChange: action("onSettingsChange"), @@ -155,7 +171,8 @@ export const EmptyCutlist = { ...mockPreviewSettings, cutlist: [], }, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 0, duration: 180000, onSettingsChange: action("onSettingsChange"), @@ -170,9 +187,13 @@ export const AllMuted = { args: { settings: { ...mockPreviewSettings, - audioChannels: mockAudioChannels.map(channel => ({ ...channel, muted: true })), + audioChannels: mockAudioChannels.map((channel) => ({ + ...channel, + muted: true, + })), }, - previewVideoUrl: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + previewVideoUrl: + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", playheadPosition: 75000, duration: 180000, onSettingsChange: action("onSettingsChange"), @@ -181,4 +202,4 @@ export const AllMuted = { regenerating: false, saving: false, }, -}; \ No newline at end of file +}; diff --git a/src/components/organisms/VideoPreview.tsx b/src/components/organisms/VideoPreview.tsx index 9b5db61..5410e64 100644 --- a/src/components/organisms/VideoPreview.tsx +++ b/src/components/organisms/VideoPreview.tsx @@ -1,8 +1,15 @@ -import VideoPlayer, { type VideoPlayerRef } from "@/components/molecules/VideoPlayer"; +import Button from "@/components/atoms/Button"; import AudioMixerPanel from "@/components/molecules/AudioMixerPanel"; import PreviewTimeline from "@/components/molecules/PreviewTimeline"; -import Button from "@/components/atoms/Button"; -import type { PreviewSettings, AudioChannel, WaveformData, VideoClip } from "@/types"; +import VideoPlayer, { + type VideoPlayerRef, +} from "@/components/molecules/VideoPlayer"; +import type { + AudioChannel, + PreviewSettings, + VideoClip, + WaveformData, +} from "@/types"; import { useRef, useState } from "react"; interface VideoPreviewProps { @@ -92,7 +99,7 @@ export default function VideoPreview({ }; const hasAudioChanges = settings.audioChannels.some( - (channel) => channel.level !== 1.0 || channel.muted + (channel) => channel.level !== 1.0 || channel.muted, ); return ( @@ -165,7 +172,9 @@ export default function VideoPreview({
- {settings.cutlist.length} clips selected • {settings.audioChannels.filter(c => !c.muted).length} of {settings.audioChannels.length} audio channels active + {settings.cutlist.length} clips selected •{" "} + {settings.audioChannels.filter((c) => !c.muted).length} of{" "} + {settings.audioChannels.length} audio channels active
{regenerating && "Generating preview..."} @@ -176,4 +185,4 @@ export default function VideoPreview({
); -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index a4cbeb4..b4e3ce1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import "material-symbols/outlined.css"; import "./index.css"; -import VideoSelectionPage from "@/components/pages/VideoSelectionPage"; import VideoPreview from "@/components/organisms/VideoPreview"; +import VideoSelectionPage from "@/components/pages/VideoSelectionPage"; export { VideoSelectionPage, VideoPreview }; From 93f8bc37a0b79442fed6488ab1129dc90687a7c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:05:50 +0000 Subject: [PATCH 4/6] Add AudioChannelNameEditor component for editing channel names Co-authored-by: saebyn <185030+saebyn@users.noreply.github.com> --- .../atoms/AudioChannelControl.stories.tsx | 123 +++++++++--- src/components/atoms/AudioChannelControl.tsx | 25 ++- .../atoms/AudioChannelNameEditor.stories.tsx | 136 +++++++++++++ .../atoms/AudioChannelNameEditor.tsx | 115 +++++++++++ .../molecules/AudioMixerPanel.stories.tsx | 181 +++++++++++------- src/components/molecules/AudioMixerPanel.tsx | 6 + src/index.ts | 3 +- 7 files changed, 486 insertions(+), 103 deletions(-) create mode 100644 src/components/atoms/AudioChannelNameEditor.stories.tsx create mode 100644 src/components/atoms/AudioChannelNameEditor.tsx diff --git a/src/components/atoms/AudioChannelControl.stories.tsx b/src/components/atoms/AudioChannelControl.stories.tsx index c784da6..5d1e64f 100644 --- a/src/components/atoms/AudioChannelControl.stories.tsx +++ b/src/components/atoms/AudioChannelControl.stories.tsx @@ -1,5 +1,6 @@ import type { AudioChannel } from "@/types"; import { action } from "@storybook/addon-actions"; +import { useState } from "react"; import AudioChannelControl from "./AudioChannelControl"; export default { @@ -22,48 +23,108 @@ const mockMutedChannel: AudioChannel = { muted: true, }; +// Interactive wrapper for testing name editing +function InteractiveWrapper({ + initialChannel, + ...props +}: { + initialChannel: AudioChannel; +} & Partial>) { + const [channel, setChannel] = useState(initialChannel); + + const handleChange = (updatedChannel: AudioChannel) => { + setChannel(updatedChannel); + action("onChange")(updatedChannel); + }; + + return ( + + ); +} + export const Default = { - args: { - channel: mockChannel, - onChange: action("onChange"), - disabled: false, - }, + render: () => ( + + ), }; export const Muted = { - args: { - channel: mockMutedChannel, - onChange: action("onChange"), - disabled: false, - }, + render: () => ( + + ), }; export const Disabled = { - args: { - channel: mockChannel, - onChange: action("onChange"), - disabled: true, - }, + render: () => ( + + ), }; export const LowLevel = { - args: { - channel: { - ...mockChannel, - level: 0.1, - }, - onChange: action("onChange"), - disabled: false, - }, + render: () => ( + + ), }; export const HighLevel = { - args: { - channel: { - ...mockChannel, - level: 1.0, - }, - onChange: action("onChange"), - disabled: false, - }, + render: () => ( + + ), +}; + +export const WithNameEdit = { + render: () => ( + + ), +}; + +export const WithNameEditMuted = { + render: () => ( + + ), +}; + +export const WithNameEditDisabled = { + render: () => ( + + ), +}; + +export const WithNameEditEmptyName = { + render: () => ( + + ), }; diff --git a/src/components/atoms/AudioChannelControl.tsx b/src/components/atoms/AudioChannelControl.tsx index 2a579e2..2020771 100644 --- a/src/components/atoms/AudioChannelControl.tsx +++ b/src/components/atoms/AudioChannelControl.tsx @@ -1,4 +1,5 @@ import type { AudioChannel } from "@/types"; +import AudioChannelNameEditor from "./AudioChannelNameEditor"; import AudioLevelSlider from "./AudioLevelSlider"; import IconButton from "./IconButton"; @@ -15,12 +16,17 @@ interface AudioChannelControlProps { * Whether the control is disabled */ disabled?: boolean; + /** + * Whether the channel name can be edited + */ + allowNameEdit?: boolean; } export default function AudioChannelControl({ channel, onChange, disabled = false, + allowNameEdit = false, }: AudioChannelControlProps) { const handleLevelChange = (level: number) => { onChange({ ...channel, level }); @@ -30,11 +36,26 @@ export default function AudioChannelControl({ onChange({ ...channel, muted: !channel.muted }); }; + const handleNameChange = (name: string) => { + onChange({ ...channel, name }); + }; + return (
-
- {channel.name} +
+ {allowNameEdit ? ( + + ) : ( +
+ {channel.name} +
+ )}
+>) { + const [name, setName] = useState(initialName); + + const handleNameChange = (newName: string) => { + setName(newName); + action("onNameChange")(newName); + }; + + return ( +
+ +
+ ); +} + +export const Default = { + render: () => ( + + ), +}; + +export const EmptyName = { + render: () => ( + + ), +}; + +export const LongName = { + render: () => ( + + ), +}; + +export const CustomPlaceholder = { + render: () => ( + + ), +}; + +export const Disabled = { + render: () => ( + + ), +}; + +export const DisabledEmpty = { + render: () => ( + + ), +}; + +export const ShortMaxLength = { + render: () => ( + + ), +}; + +export const MultipleChannels = { + render: () => { + const [channels, setChannels] = useState([ + { id: "1", name: "Main Audio" }, + { id: "2", name: "Commentary" }, + { id: "3", name: "" }, + { id: "4", name: "Music Track" }, + ]); + + const handleChannelNameChange = (id: string, newName: string) => { + setChannels(prev => + prev.map(channel => + channel.id === id ? { ...channel, name: newName } : channel + ) + ); + action("onNameChange")(`Channel ${id}: ${newName}`); + }; + + return ( +
+ {channels.map(channel => ( +
+ #{channel.id} + handleChannelNameChange(channel.id, newName)} + placeholder={`Channel ${channel.id}`} + /> +
+ ))} +
+ ); + }, +}; + +export const InDarkMode = { + render: () => ( +
+
+ + + +
+
+ ), +}; \ No newline at end of file diff --git a/src/components/atoms/AudioChannelNameEditor.tsx b/src/components/atoms/AudioChannelNameEditor.tsx new file mode 100644 index 0000000..8f71e9d --- /dev/null +++ b/src/components/atoms/AudioChannelNameEditor.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; + +interface AudioChannelNameEditorProps { + /** + * Current name of the audio channel + */ + name: string; + /** + * Callback when name changes + */ + onNameChange: (name: string) => void; + /** + * Whether the editor is disabled + */ + disabled?: boolean; + /** + * Placeholder text when name is empty + */ + placeholder?: string; + /** + * Maximum length for channel name + */ + maxLength?: number; +} + +export default function AudioChannelNameEditor({ + name, + onNameChange, + disabled = false, + placeholder = "Channel Name", + maxLength = 50, +}: AudioChannelNameEditorProps) { + const [isEditing, setIsEditing] = useState(false); + const [editingName, setEditingName] = useState(name); + const inputRef = useRef(null); + + useEffect(() => { + setEditingName(name); + }, [name]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleStartEdit = () => { + if (!disabled) { + setIsEditing(true); + setEditingName(name); + } + }; + + const handleSave = () => { + const trimmedName = editingName.trim(); + if (trimmedName && trimmedName !== name) { + onNameChange(trimmedName); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setEditingName(name); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + }; + + const handleBlur = () => { + handleSave(); + }; + + if (isEditing) { + return ( + setEditingName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + maxLength={maxLength} + placeholder={placeholder} + className="text-sm font-medium bg-white dark:bg-gray-800 border border-blue-500 dark:border-blue-400 rounded px-2 py-1 text-gray-900 dark:text-white min-w-0 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" + aria-label="Edit channel name" + /> + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/molecules/AudioMixerPanel.stories.tsx b/src/components/molecules/AudioMixerPanel.stories.tsx index c44b6f4..362159e 100644 --- a/src/components/molecules/AudioMixerPanel.stories.tsx +++ b/src/components/molecules/AudioMixerPanel.stories.tsx @@ -1,5 +1,6 @@ import type { AudioChannel } from "@/types"; import { action } from "@storybook/addon-actions"; +import { useState } from "react"; import AudioMixerPanel from "./AudioMixerPanel"; export default { @@ -36,92 +37,134 @@ const mockChannels: AudioChannel[] = [ }, ]; +// Interactive wrapper for testing name editing +function InteractiveWrapper({ + initialChannels, + ...props +}: { + initialChannels: AudioChannel[]; +} & Partial>) { + const [channels, setChannels] = useState(initialChannels); + + const handleChange = (updatedChannels: AudioChannel[]) => { + setChannels(updatedChannels); + action("onChange")(updatedChannels); + }; + + return ( + + ); +} + export const Default = { - args: { - channels: mockChannels, - onChange: action("onChange"), - onSave: action("onSave"), - disabled: false, - saving: false, - }, + render: () => ( + + ), }; export const SingleChannel = { - args: { - channels: [mockChannels[0]], - onChange: action("onChange"), - onSave: action("onSave"), - disabled: false, - saving: false, - }, + render: () => ( + + ), }; export const ManyChannels = { - args: { - channels: [ - ...mockChannels, - { - id: "channel-4", - name: "Music", - level: 0.3, - muted: false, - }, - { - id: "channel-5", - name: "Sound Effects", - level: 0.7, - muted: false, - }, - { - id: "channel-6", - name: "Voice Chat", - level: 0.5, - muted: true, - }, - ], - onChange: action("onChange"), - onSave: action("onSave"), - disabled: false, - saving: false, - }, + render: () => ( + + ), }; export const Disabled = { - args: { - channels: mockChannels, - onChange: action("onChange"), - onSave: action("onSave"), - disabled: true, - saving: false, - }, + render: () => ( + + ), }; export const Saving = { - args: { - channels: mockChannels, - onChange: action("onChange"), - onSave: action("onSave"), - disabled: false, - saving: true, - }, + render: () => ( + + ), }; export const AllMuted = { - args: { - channels: mockChannels.map((channel) => ({ ...channel, muted: true })), - onChange: action("onChange"), - onSave: action("onSave"), - disabled: false, - saving: false, - }, + render: () => ( + ({ ...channel, muted: true }))} + /> + ), }; export const NoSaveCallback = { - args: { - channels: mockChannels, - onChange: action("onChange"), - // No onSave callback - disabled: false, - saving: false, - }, + render: () => ( + + ), +}; + +export const WithNameEdit = { + render: () => ( + + ), +}; + +export const WithNameEditAndEmptyNames = { + render: () => ( + + ), +}; + +export const WithNameEditDisabled = { + render: () => ( + + ), }; diff --git a/src/components/molecules/AudioMixerPanel.tsx b/src/components/molecules/AudioMixerPanel.tsx index 83d8b2b..fc5421d 100644 --- a/src/components/molecules/AudioMixerPanel.tsx +++ b/src/components/molecules/AudioMixerPanel.tsx @@ -23,6 +23,10 @@ interface AudioMixerPanelProps { * Whether save is in progress */ saving?: boolean; + /** + * Whether channel names can be edited + */ + allowNameEdit?: boolean; } export default function AudioMixerPanel({ @@ -31,6 +35,7 @@ export default function AudioMixerPanel({ onSave, disabled = false, saving = false, + allowNameEdit = false, }: AudioMixerPanelProps) { const handleChannelChange = (updatedChannel: AudioChannel) => { const updatedChannels = channels.map((channel) => @@ -107,6 +112,7 @@ export default function AudioMixerPanel({ channel={channel} onChange={handleChannelChange} disabled={disabled} + allowNameEdit={allowNameEdit} /> )) )} diff --git a/src/index.ts b/src/index.ts index b4e3ce1..4684262 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import "material-symbols/outlined.css"; import "./index.css"; +import AudioChannelNameEditor from "@/components/atoms/AudioChannelNameEditor"; import VideoPreview from "@/components/organisms/VideoPreview"; import VideoSelectionPage from "@/components/pages/VideoSelectionPage"; -export { VideoSelectionPage, VideoPreview }; +export { VideoSelectionPage, VideoPreview, AudioChannelNameEditor }; export type * from "./types"; From 27e74030be531193ce7980b648a01bc34b50cbf8 Mon Sep 17 00:00:00 2001 From: saebyn Date: Sun, 10 Aug 2025 12:38:34 -0700 Subject: [PATCH 5/6] Refactor audio handling: replace inline checks with utility functions for better readability and maintainability --- src/components/atoms/AudioLevelSlider.tsx | 4 +++- src/components/molecules/AudioMixerPanel.tsx | 7 +++---- src/components/organisms/VideoPreview.tsx | 12 +++++------- src/utils/audioChannels.ts | 9 +++++++++ 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 src/utils/audioChannels.ts diff --git a/src/components/atoms/AudioLevelSlider.tsx b/src/components/atoms/AudioLevelSlider.tsx index 9ae610c..8ea77df 100644 --- a/src/components/atoms/AudioLevelSlider.tsx +++ b/src/components/atoms/AudioLevelSlider.tsx @@ -1,3 +1,5 @@ +import React, { useId } from 'react'; + interface AudioLevelSliderProps { /** * Current audio level from 0.0 to 1.0 @@ -23,7 +25,7 @@ export default function AudioLevelSlider({ label, disabled = false, }: AudioLevelSliderProps) { - const sliderId = `audio-slider-${Math.random().toString(36).substr(2, 9)}`; + const sliderId = useId(); const handleChange = (event: React.ChangeEvent) => { const newLevel = Number.parseFloat(event.target.value); diff --git a/src/components/molecules/AudioMixerPanel.tsx b/src/components/molecules/AudioMixerPanel.tsx index fc5421d..7aa804f 100644 --- a/src/components/molecules/AudioMixerPanel.tsx +++ b/src/components/molecules/AudioMixerPanel.tsx @@ -1,6 +1,7 @@ import AudioChannelControl from "@/components/atoms/AudioChannelControl"; import Button from "@/components/atoms/Button"; import type { AudioChannel } from "@/types"; +import { isAllMuted, hasAudioChanges } from "@/utils/audioChannels"; interface AudioMixerPanelProps { /** @@ -62,10 +63,8 @@ export default function AudioMixerPanel({ onChange(resetChannels); }; - const allMuted = channels.every((channel) => channel.muted); - const hasChanges = channels.some( - (channel) => channel.level !== 1.0 || channel.muted, - ); + const allMuted = isAllMuted(channels); + const hasChanges = hasAudioChanges(channels); return (
diff --git a/src/components/organisms/VideoPreview.tsx b/src/components/organisms/VideoPreview.tsx index 5410e64..7b1a63a 100644 --- a/src/components/organisms/VideoPreview.tsx +++ b/src/components/organisms/VideoPreview.tsx @@ -8,8 +8,8 @@ import type { AudioChannel, PreviewSettings, VideoClip, - WaveformData, } from "@/types"; +import { hasAudioChanges } from "@/utils/audioChannels"; import { useRef, useState } from "react"; interface VideoPreviewProps { @@ -98,9 +98,7 @@ export default function VideoPreview({ onSave?.(settings); }; - const hasAudioChanges = settings.audioChannels.some( - (channel) => channel.level !== 1.0 || channel.muted, - ); + const hasChanges = hasAudioChanges(settings.audioChannels); return (
@@ -121,7 +119,7 @@ export default function VideoPreview({ @@ -160,7 +158,7 @@ export default function VideoPreview({ @@ -179,7 +177,7 @@ export default function VideoPreview({
{regenerating && "Generating preview..."} {saving && "Saving changes..."} - {!regenerating && !saving && hasAudioChanges && "Changes pending"} + {!regenerating && !saving && hasChanges && "Changes pending"}
diff --git a/src/utils/audioChannels.ts b/src/utils/audioChannels.ts new file mode 100644 index 0000000..bb377d7 --- /dev/null +++ b/src/utils/audioChannels.ts @@ -0,0 +1,9 @@ +import { AudioChannel } from "@/types"; + +export function hasAudioChanges(channels: AudioChannel[]): boolean { + return channels.some((channel) => channel.level !== 1.0 || channel.muted); +} + +export function isAllMuted(channels: AudioChannel[]): boolean { + return channels.every((channel) => channel.muted); +} From 233cf1874ef51b78c16aa8bd443b1bf66a531816 Mon Sep 17 00:00:00 2001 From: saebyn Date: Sun, 10 Aug 2025 12:42:34 -0700 Subject: [PATCH 6/6] biome formattng --- .../atoms/AudioChannelControl.stories.tsx | 44 ++++++---------- .../atoms/AudioChannelNameEditor.stories.tsx | 48 +++++++---------- .../atoms/AudioChannelNameEditor.tsx | 6 ++- src/components/atoms/AudioLevelSlider.tsx | 3 +- .../molecules/AudioMixerPanel.stories.tsx | 51 +++++++------------ src/components/molecules/AudioMixerPanel.tsx | 2 +- src/components/organisms/VideoPreview.tsx | 6 +-- src/utils/audioChannels.ts | 2 +- 8 files changed, 62 insertions(+), 100 deletions(-) diff --git a/src/components/atoms/AudioChannelControl.stories.tsx b/src/components/atoms/AudioChannelControl.stories.tsx index 5d1e64f..5fb6b1c 100644 --- a/src/components/atoms/AudioChannelControl.stories.tsx +++ b/src/components/atoms/AudioChannelControl.stories.tsx @@ -24,52 +24,41 @@ const mockMutedChannel: AudioChannel = { }; // Interactive wrapper for testing name editing -function InteractiveWrapper({ - initialChannel, - ...props -}: { +function InteractiveWrapper({ + initialChannel, + ...props +}: { initialChannel: AudioChannel; } & Partial>) { const [channel, setChannel] = useState(initialChannel); - + const handleChange = (updatedChannel: AudioChannel) => { setChannel(updatedChannel); action("onChange")(updatedChannel); }; return ( - + ); } export const Default = { - render: () => ( - - ), + render: () => , }; export const Muted = { - render: () => ( - - ), + render: () => , }; export const Disabled = { render: () => ( - + ), }; export const LowLevel = { render: () => ( - ( - ( - + ), }; export const WithNameEditMuted = { render: () => ( - @@ -109,7 +95,7 @@ export const WithNameEditMuted = { export const WithNameEditDisabled = { render: () => ( - ( - >) { const [name, setName] = useState(initialName); - + const handleNameChange = (newName: string) => { setName(newName); action("onNameChange")(newName); @@ -34,15 +34,11 @@ function InteractiveWrapper({ } export const Default = { - render: () => ( - - ), + render: () => , }; export const EmptyName = { - render: () => ( - - ), + render: () => , }; export const LongName = { @@ -53,26 +49,23 @@ export const LongName = { export const CustomPlaceholder = { render: () => ( - ), }; export const Disabled = { render: () => ( - + ), }; export const DisabledEmpty = { render: () => ( - @@ -80,12 +73,7 @@ export const DisabledEmpty = { }; export const ShortMaxLength = { - render: () => ( - - ), + render: () => , }; export const MultipleChannels = { @@ -98,22 +86,24 @@ export const MultipleChannels = { ]); const handleChannelNameChange = (id: string, newName: string) => { - setChannels(prev => - prev.map(channel => - channel.id === id ? { ...channel, name: newName } : channel - ) + setChannels((prev) => + prev.map((channel) => + channel.id === id ? { ...channel, name: newName } : channel, + ), ); action("onNameChange")(`Channel ${id}: ${newName}`); }; return (
- {channels.map(channel => ( + {channels.map((channel) => (
#{channel.id} handleChannelNameChange(channel.id, newName)} + onNameChange={(newName) => + handleChannelNameChange(channel.id, newName) + } placeholder={`Channel ${channel.id}`} />
@@ -133,4 +123,4 @@ export const InDarkMode = {
), -}; \ No newline at end of file +}; diff --git a/src/components/atoms/AudioChannelNameEditor.tsx b/src/components/atoms/AudioChannelNameEditor.tsx index 8f71e9d..f19465c 100644 --- a/src/components/atoms/AudioChannelNameEditor.tsx +++ b/src/components/atoms/AudioChannelNameEditor.tsx @@ -106,10 +106,12 @@ export default function AudioChannelNameEditor({ ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-text" }`} - title={disabled ? "Cannot edit channel name" : "Click to edit channel name"} + title={ + disabled ? "Cannot edit channel name" : "Click to edit channel name" + } aria-label="Channel name (click to edit)" > {name || placeholder} ); -} \ No newline at end of file +} diff --git a/src/components/atoms/AudioLevelSlider.tsx b/src/components/atoms/AudioLevelSlider.tsx index 8ea77df..3e420f4 100644 --- a/src/components/atoms/AudioLevelSlider.tsx +++ b/src/components/atoms/AudioLevelSlider.tsx @@ -1,4 +1,5 @@ -import React, { useId } from 'react'; +import type React from "react"; +import { useId } from "react"; interface AudioLevelSliderProps { /** diff --git a/src/components/molecules/AudioMixerPanel.stories.tsx b/src/components/molecules/AudioMixerPanel.stories.tsx index 362159e..26d5619 100644 --- a/src/components/molecules/AudioMixerPanel.stories.tsx +++ b/src/components/molecules/AudioMixerPanel.stories.tsx @@ -38,14 +38,14 @@ const mockChannels: AudioChannel[] = [ ]; // Interactive wrapper for testing name editing -function InteractiveWrapper({ - initialChannels, - ...props -}: { +function InteractiveWrapper({ + initialChannels, + ...props +}: { initialChannels: AudioChannel[]; } & Partial>) { const [channels, setChannels] = useState(initialChannels); - + const handleChange = (updatedChannels: AudioChannel[]) => { setChannels(updatedChannels); action("onChange")(updatedChannels); @@ -62,20 +62,16 @@ function InteractiveWrapper({ } export const Default = { - render: () => ( - - ), + render: () => , }; export const SingleChannel = { - render: () => ( - - ), + render: () => , }; export const ManyChannels = { render: () => ( - ( - + ), }; export const Saving = { render: () => ( - + ), }; export const AllMuted = { render: () => ( - ({ ...channel, muted: true }))} + ({ + ...channel, + muted: true, + }))} /> ), }; export const NoSaveCallback = { render: () => ( - + ), }; export const WithNameEdit = { render: () => ( - + ), }; export const WithNameEditAndEmptyNames = { render: () => ( - ( - channel.level !== 1.0 || channel.muted);