[UI] Fix dialog accessibility and export functionality

- Add DialogTitle with VisuallyHidden to CommandDialog and onboarding
- Replace YouTube redirect with functional VideoExporter implementation
- Create VisuallyHidden component for screen reader accessibility

Resolves GitHub issue #477 (export redirecting to YouTube)
Fixes console warnings about DialogContent accessibility compliance
This commit is contained in:
naoNao89 2025-07-29 21:35:33 +07:00
parent 14475dc9a6
commit 073cb817d3
5 changed files with 417 additions and 10 deletions

View File

@ -11,19 +11,79 @@ import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useMediaStore } from "@/stores/media-store";
import {
VideoExporter,
downloadBlob,
type ExportOptions,
} from "@/lib/export-utils";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { getTotalDuration, tracks } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const { mediaItems } = useMediaStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const [isExporting, setIsExporting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
const handleExport = async () => {
if (!activeProject) {
toast.error("No active project to export");
return;
}
if (tracks.length === 0) {
toast.error("Timeline is empty - nothing to export");
return;
}
setIsExporting(true);
try {
const exportOptions: ExportOptions = {
format: "mp4",
quality: "medium",
resolution: "1080p",
fps: activeProject.fps || 30,
};
const exporter = new VideoExporter((progress) => {
toast.loading(
`${progress.message} (${Math.round(progress.progress)}%)`,
{
id: "export-progress",
}
);
});
const exportedBlob = await exporter.exportProject(
activeProject,
tracks,
mediaItems,
exportOptions
);
// Download the exported video
const filename = `${activeProject.name}.${exportOptions.format}`;
downloadBlob(exportedBlob, filename);
toast.success("Video exported successfully!", {
id: "export-progress",
});
exporter.cleanup();
} catch (error) {
console.error("Export failed:", error);
toast.error(
`Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{
id: "export-progress",
}
);
} finally {
setIsExporting(false);
}
};
const handleNameClick = () => {
@ -109,9 +169,12 @@ export function EditorHeader() {
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
disabled={isExporting}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
<span className="text-sm">
{isExporting ? "Exporting..." : "Export"}
</span>
</Button>
</nav>
);

View File

@ -1,10 +1,11 @@
"use client";
import { Dialog, DialogContent } from "./ui/dialog";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { VisuallyHidden } from "./ui/visually-hidden";
export function Onboarding() {
const [step, setStep] = useState(0);
@ -26,6 +27,19 @@ export function Onboarding() {
localStorage.setItem("hasSeenOnboarding", "true");
};
const getStepTitle = () => {
switch (step) {
case 0:
return "Welcome to OpenCut Beta! 🎉";
case 1:
return "⚠️ This is a super early beta!";
case 2:
return "🦋 Have fun testing!";
default:
return "OpenCut Onboarding";
}
};
const renderStepContent = () => {
switch (step) {
case 0:
@ -67,6 +81,9 @@ export function Onboarding() {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
<DialogTitle>
<VisuallyHidden>{getStepTitle()}</VisuallyHidden>
</DialogTitle>
{renderStepContent()}
</DialogContent>
</Dialog>

View File

@ -1,12 +1,14 @@
"use client";
import * as React from "react";
import { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
import { Dialog, DialogContent } from "./dialog";
import { Dialog, DialogContent, DialogTitle } from "./dialog";
import { VisuallyHidden } from "./visually-hidden";
type DialogProps = React.ComponentProps<typeof Dialog>;
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@ -27,6 +29,9 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<DialogTitle>
<VisuallyHidden>Command Menu</VisuallyHidden>
</DialogTitle>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
/**
* VisuallyHidden component provides content that is accessible to screen readers
* but visually hidden from sighted users. This is useful for providing context
* or labels that are necessary for accessibility but would be redundant visually.
*
* Based on the Radix UI VisuallyHidden primitive pattern.
*/
const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
className={cn(
// Screen reader only styles - visually hidden but accessible
"absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0",
// Alternative approach using sr-only if preferred
// "sr-only",
className
)}
{...props}
/>
));
VisuallyHidden.displayName = "VisuallyHidden";
export { VisuallyHidden };

View File

@ -0,0 +1,290 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { initFFmpeg } from "./ffmpeg-utils";
import { TimelineTrack, TimelineElement } from "@/types/timeline";
import { MediaItem } from "@/stores/media-store";
import { TProject } from "@/types/project";
export interface ExportOptions {
format: "mp4" | "webm" | "mov";
quality: "low" | "medium" | "high";
resolution: "720p" | "1080p" | "4k";
fps: number;
}
export interface ExportProgress {
phase: "preparing" | "processing" | "finalizing" | "complete";
progress: number; // 0-100
message: string;
}
export class VideoExporter {
private ffmpeg: FFmpeg | null = null;
private onProgress?: (progress: ExportProgress) => void;
constructor(onProgress?: (progress: ExportProgress) => void) {
this.onProgress = onProgress;
}
private updateProgress(
phase: ExportProgress["phase"],
progress: number,
message: string
) {
if (this.onProgress) {
this.onProgress({ phase, progress, message });
}
}
async exportProject(
project: TProject,
tracks: TimelineTrack[],
mediaItems: MediaItem[],
options: ExportOptions
): Promise<Blob> {
this.updateProgress("preparing", 0, "Initializing FFmpeg...");
this.ffmpeg = await initFFmpeg();
// Set up progress callback for FFmpeg
this.ffmpeg.on("progress", ({ progress }) => {
this.updateProgress("processing", progress * 100, "Rendering video...");
});
try {
this.updateProgress("preparing", 10, "Analyzing timeline...");
// Get timeline duration
const duration = this.calculateTimelineDuration(tracks);
if (duration === 0) {
throw new Error("Timeline is empty - nothing to export");
}
this.updateProgress("preparing", 20, "Preparing media files...");
// Process media tracks (video/image/audio)
const mediaTracks = tracks.filter(
(track) => track.type === "media" || track.type === "audio"
);
if (mediaTracks.length === 0) {
throw new Error("No media tracks found to export");
}
this.updateProgress("preparing", 40, "Setting up export parameters...");
// Get resolution settings
const { width, height } = this.getResolutionDimensions(
options.resolution
);
// Create a simple concatenation for now
// In a full implementation, this would handle overlays, transitions, etc.
const outputBlob = await this.renderSimpleTimeline(
mediaTracks,
mediaItems,
duration,
options,
width,
height
);
this.updateProgress("complete", 100, "Export complete!");
return outputBlob;
} catch (error) {
console.error("Export failed:", error);
throw error;
}
}
private calculateTimelineDuration(tracks: TimelineTrack[]): number {
let maxDuration = 0;
for (const track of tracks) {
for (const element of track.elements) {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
maxDuration = Math.max(maxDuration, elementEnd);
}
}
return maxDuration;
}
private getResolutionDimensions(resolution: string): {
width: number;
height: number;
} {
switch (resolution) {
case "720p":
return { width: 1280, height: 720 };
case "1080p":
return { width: 1920, height: 1080 };
case "4k":
return { width: 3840, height: 2160 };
default:
return { width: 1920, height: 1080 };
}
}
private async renderSimpleTimeline(
tracks: TimelineTrack[],
mediaItems: MediaItem[],
duration: number,
options: ExportOptions,
width: number,
height: number
): Promise<Blob> {
if (!this.ffmpeg) throw new Error("FFmpeg not initialized");
// For now, let's implement a simple case: export the first video/image element
// A full implementation would handle complex timeline rendering
const firstMediaTrack = tracks.find((track) => track.type === "media");
if (!firstMediaTrack || firstMediaTrack.elements.length === 0) {
throw new Error("No media elements found to export");
}
const firstElement = firstMediaTrack.elements[0];
// Type guard to ensure we have a media element
if (firstElement.type !== "media") {
throw new Error("First element is not a media element");
}
const mediaItem = mediaItems.find(
(item) => item.id === firstElement.mediaId
);
if (!mediaItem || !mediaItem.file) {
throw new Error("Media file not found for export");
}
this.updateProgress("processing", 10, "Processing media file...");
const inputName = `input.${this.getFileExtension(mediaItem.file.name)}`;
const outputName = `output.${options.format}`;
// Write input file
await this.ffmpeg.writeFile(
inputName,
new Uint8Array(await mediaItem.file.arrayBuffer())
);
// Build FFmpeg command based on media type and options
const ffmpegArgs = this.buildFFmpegCommand(
inputName,
outputName,
firstElement,
options,
width,
height,
duration
);
this.updateProgress("processing", 30, "Rendering video...");
// Execute FFmpeg command
await this.ffmpeg.exec(ffmpegArgs);
this.updateProgress("finalizing", 90, "Finalizing export...");
// Read output file
const data = await this.ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: `video/${options.format}` });
// Cleanup
await this.ffmpeg.deleteFile(inputName);
await this.ffmpeg.deleteFile(outputName);
return blob;
}
private buildFFmpegCommand(
inputName: string,
outputName: string,
element: TimelineElement,
options: ExportOptions,
width: number,
height: number,
totalDuration: number
): string[] {
const args = ["-i", inputName];
// Handle trimming
if (element.trimStart > 0) {
args.push("-ss", element.trimStart.toString());
}
// Set duration
const elementDuration =
element.duration - element.trimStart - element.trimEnd;
args.push("-t", Math.min(elementDuration, totalDuration).toString());
// Set resolution and scaling
args.push(
"-vf",
`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
);
// Set frame rate
args.push("-r", options.fps.toString());
// Set quality/bitrate based on options
const { videoBitrate, audioBitrate } = this.getQualitySettings(
options.quality
);
args.push("-b:v", videoBitrate, "-b:a", audioBitrate);
// Set codec based on format
if (options.format === "mp4") {
args.push("-c:v", "libx264", "-c:a", "aac");
} else if (options.format === "webm") {
args.push("-c:v", "libvpx-vp9", "-c:a", "libopus");
}
args.push(outputName);
return args;
}
private getQualitySettings(quality: string): {
videoBitrate: string;
audioBitrate: string;
} {
switch (quality) {
case "low":
return { videoBitrate: "1M", audioBitrate: "128k" };
case "medium":
return { videoBitrate: "3M", audioBitrate: "192k" };
case "high":
return { videoBitrate: "8M", audioBitrate: "320k" };
default:
return { videoBitrate: "3M", audioBitrate: "192k" };
}
}
private getFileExtension(filename: string): string {
return filename.split(".").pop() || "mp4";
}
cleanup() {
if (this.ffmpeg) {
// Remove all progress listeners
this.ffmpeg.off("progress", () => {});
}
}
}
// Utility function to download blob as file
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}