Add GIF export feature to video editor
Implements GIF export alongside MP4, including new export types, a GIF exporter module, UI components for format selection and GIF options, and integration into the export dialog and video editor. Adds property-based and unit tests for GIF export correctness, updates dependencies to include gif.js and related types, and refines Electron save dialog to support GIF files.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"figma": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@anthropic-ai/figma-mcp@latest"],
|
||||
"env": {
|
||||
"FIGMA_ACCESS_TOKEN": "figd_1ru7zU6NCqkHoAeJlqV4CUX7diuVibxuNpAvBBjQ"
|
||||
},
|
||||
"disabled": false,
|
||||
"autoApprove": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
# Design Document: GIF Export Feature
|
||||
|
||||
## Overview
|
||||
|
||||
This design extends the existing video export system to support animated GIF output. The implementation introduces a new export format selection UI, a GIF encoder module using the `gif.js` library for client-side GIF encoding, and modifications to the existing export pipeline to support both MP4 and GIF workflows.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph UI["Export Dialog UI"]
|
||||
FS[Format Selector]
|
||||
MP4Opts[MP4 Options Panel]
|
||||
GIFOpts[GIF Options Panel]
|
||||
Progress[Progress Display]
|
||||
end
|
||||
|
||||
subgraph Exporter["Export Pipeline"]
|
||||
VE[VideoExporter]
|
||||
GE[GifExporter]
|
||||
FR[FrameRenderer]
|
||||
VD[VideoDecoder]
|
||||
end
|
||||
|
||||
subgraph Output["Output"]
|
||||
MP4[MP4 File]
|
||||
GIF[GIF File]
|
||||
end
|
||||
|
||||
FS --> |MP4 Selected| MP4Opts
|
||||
FS --> |GIF Selected| GIFOpts
|
||||
MP4Opts --> VE
|
||||
GIFOpts --> GE
|
||||
VE --> FR
|
||||
GE --> FR
|
||||
FR --> VD
|
||||
VE --> MP4
|
||||
GE --> GIF
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. Export Format Types
|
||||
|
||||
```typescript
|
||||
// src/lib/exporter/types.ts
|
||||
|
||||
export type ExportFormat = 'mp4' | 'gif';
|
||||
|
||||
export type GifFrameRate = 10 | 15 | 20 | 25 | 30;
|
||||
|
||||
export type GifSizePreset = 'small' | 'medium' | 'large' | 'original';
|
||||
|
||||
export interface GifExportConfig {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
// MP4 settings
|
||||
quality?: ExportQuality;
|
||||
// GIF settings
|
||||
gifConfig?: GifExportConfig;
|
||||
}
|
||||
|
||||
export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = {
|
||||
small: { maxHeight: 480, label: 'Small (480p)' },
|
||||
medium: { maxHeight: 720, label: 'Medium (720p)' },
|
||||
large: { maxHeight: 1080, label: 'Large (1080p)' },
|
||||
original: { maxHeight: Infinity, label: 'Original' },
|
||||
};
|
||||
|
||||
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
|
||||
{ value: 10, label: '10 FPS - Smaller file' },
|
||||
{ value: 15, label: '15 FPS - Balanced' },
|
||||
{ value: 20, label: '20 FPS - Smooth' },
|
||||
{ value: 25, label: '25 FPS - Very smooth' },
|
||||
{ value: 30, label: '30 FPS - Maximum' },
|
||||
];
|
||||
```
|
||||
|
||||
### 2. GIF Exporter Module
|
||||
|
||||
```typescript
|
||||
// src/lib/exporter/gifExporter.ts
|
||||
|
||||
interface GifExporterConfig {
|
||||
videoUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
// ... other effect settings
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
export class GifExporter {
|
||||
private config: GifExporterConfig;
|
||||
private decoder: VideoFileDecoder | null = null;
|
||||
private renderer: FrameRenderer | null = null;
|
||||
private gif: GIF | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor(config: GifExporterConfig);
|
||||
|
||||
async export(): Promise<ExportResult>;
|
||||
cancel(): void;
|
||||
private cleanup(): void;
|
||||
private getEffectiveDuration(totalDuration: number): number;
|
||||
private mapEffectiveToSourceTime(effectiveTimeMs: number): number;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Export Dialog Component Updates
|
||||
|
||||
```typescript
|
||||
// src/components/video-editor/ExportDialog.tsx
|
||||
|
||||
interface ExportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (settings: ExportSettings) => void;
|
||||
progress: ExportProgress | null;
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
aspectRatio: AspectRatio;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
}
|
||||
|
||||
// Internal state
|
||||
interface ExportDialogState {
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Format Selector Component
|
||||
|
||||
```typescript
|
||||
// src/components/video-editor/FormatSelector.tsx
|
||||
|
||||
interface FormatSelectorProps {
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. GIF Options Panel Component
|
||||
|
||||
```typescript
|
||||
// src/components/video-editor/GifOptionsPanel.tsx
|
||||
|
||||
interface GifOptionsPanelProps {
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Export Configuration Flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> FormatSelection
|
||||
FormatSelection --> MP4Options: Select MP4
|
||||
FormatSelection --> GIFOptions: Select GIF
|
||||
MP4Options --> Exporting: Click Export
|
||||
GIFOptions --> Exporting: Click Export
|
||||
Exporting --> Success: Complete
|
||||
Exporting --> Error: Failed
|
||||
Exporting --> Cancelled: User Cancel
|
||||
Success --> [*]
|
||||
Error --> FormatSelection: Retry
|
||||
Cancelled --> FormatSelection
|
||||
```
|
||||
|
||||
### GIF Encoding Pipeline
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as ExportDialog
|
||||
participant GE as GifExporter
|
||||
participant FR as FrameRenderer
|
||||
participant VD as VideoDecoder
|
||||
participant GIF as gif.js Worker
|
||||
|
||||
UI->>GE: export(config)
|
||||
GE->>VD: loadVideo(url)
|
||||
VD-->>GE: videoInfo
|
||||
GE->>FR: initialize()
|
||||
GE->>GIF: new GIF(options)
|
||||
|
||||
loop For each frame
|
||||
GE->>VD: seek(time)
|
||||
GE->>FR: renderFrame(videoFrame)
|
||||
FR-->>GE: canvas
|
||||
GE->>GIF: addFrame(canvas)
|
||||
GE->>UI: onProgress(%)
|
||||
end
|
||||
|
||||
GE->>GIF: render()
|
||||
GIF-->>GE: blob
|
||||
GE-->>UI: ExportResult
|
||||
```
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: Valid Frame Rate Acceptance
|
||||
|
||||
*For any* frame rate value, the GIF_Exporter SHALL accept it if and only if it is one of the valid presets (10, 15, 20, 25, 30 FPS). Invalid frame rates should be rejected with an error.
|
||||
|
||||
**Validates: Requirements 2.2**
|
||||
|
||||
### Property 2: Loop Encoding Correctness
|
||||
|
||||
*For any* GIF export configuration, when loop is enabled the output GIF SHALL have a loop count of 0 (infinite), and when loop is disabled the output GIF SHALL have a loop count of 1 (play once).
|
||||
|
||||
**Validates: Requirements 3.2, 3.3**
|
||||
|
||||
### Property 3: Size Preset Resolution Mapping
|
||||
|
||||
*For any* valid size preset and source video dimensions, the GIF_Exporter SHALL produce output with height matching the preset's max height (or source height if smaller), with width calculated to maintain aspect ratio.
|
||||
|
||||
**Validates: Requirements 4.2**
|
||||
|
||||
### Property 4: Aspect Ratio Preservation
|
||||
|
||||
*For any* source video with aspect ratio R and any size preset, the exported GIF SHALL have an aspect ratio within 0.01 of R.
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 5: Valid GIF Output
|
||||
|
||||
*For any* successful GIF export, the output blob SHALL be a valid GIF file that can be parsed by standard GIF decoders, containing the expected number of frames based on video duration and frame rate.
|
||||
|
||||
**Validates: Requirements 5.3**
|
||||
|
||||
### Property 6: Frame Count Consistency
|
||||
|
||||
*For any* video with effective duration D (excluding trim regions) and frame rate F, the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance).
|
||||
|
||||
**Validates: Requirements 5.1**
|
||||
|
||||
### Property 7: MP4 Export Regression
|
||||
|
||||
*For any* valid MP4 export configuration that worked before this feature, the Video_Exporter SHALL continue to produce valid MP4 output with the same quality characteristics.
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
|
||||
|
||||
|
||||
## Error Handling
|
||||
|
||||
### GIF Export Errors
|
||||
|
||||
| Error Condition | Handling Strategy |
|
||||
|----------------|-------------------|
|
||||
| Video load failure | Display error message, allow retry |
|
||||
| Frame rendering failure | Log error, skip frame, continue if possible |
|
||||
| GIF encoding failure | Display error message with details |
|
||||
| Memory exhaustion | Suggest smaller size preset or lower frame rate |
|
||||
| User cancellation | Clean up resources, reset UI state |
|
||||
| Invalid configuration | Validate before export, show validation errors |
|
||||
|
||||
### Error Messages
|
||||
|
||||
```typescript
|
||||
const GIF_EXPORT_ERRORS = {
|
||||
VIDEO_LOAD_FAILED: 'Failed to load video. Please try again.',
|
||||
ENCODING_FAILED: 'GIF encoding failed. Try reducing size or frame rate.',
|
||||
MEMORY_ERROR: 'Not enough memory. Try a smaller size preset.',
|
||||
CANCELLED: 'Export cancelled.',
|
||||
INVALID_CONFIG: 'Invalid export configuration.',
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests will cover:
|
||||
- GIF configuration validation (frame rate, size preset, loop settings)
|
||||
- Dimension calculation for size presets
|
||||
- Aspect ratio calculation and preservation
|
||||
- Export settings serialization/deserialization
|
||||
- UI component state management
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests will validate the correctness properties using fast-check:
|
||||
|
||||
1. **Frame Rate Validation Property**: Generate random integers, verify only valid frame rates are accepted
|
||||
2. **Loop Encoding Property**: Generate random loop settings, verify GIF metadata matches
|
||||
3. **Size Preset Property**: Generate random video dimensions and presets, verify output dimensions
|
||||
4. **Aspect Ratio Property**: Generate random aspect ratios, verify preservation within tolerance
|
||||
5. **Valid GIF Property**: Generate random export configs, verify output is parseable GIF
|
||||
6. **Frame Count Property**: Generate random durations and frame rates, verify frame count
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end export flow for both MP4 and GIF
|
||||
- Format switching in export dialog
|
||||
- Progress reporting during export
|
||||
- Cancellation handling
|
||||
- Error recovery scenarios
|
||||
|
||||
### Test Configuration
|
||||
|
||||
```typescript
|
||||
// Property test configuration
|
||||
const PBT_CONFIG = {
|
||||
numRuns: 100,
|
||||
seed: Date.now(),
|
||||
};
|
||||
```
|
||||
|
||||
### Testing Library
|
||||
|
||||
- **Property-Based Testing**: fast-check (already available in the ecosystem)
|
||||
- **Unit Testing**: Vitest (existing test framework)
|
||||
- **Component Testing**: React Testing Library
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### GIF Library Selection
|
||||
|
||||
Using `gif.js` for client-side GIF encoding:
|
||||
- Web Worker support for non-blocking encoding
|
||||
- Configurable quality and dithering
|
||||
- Frame delay control for variable frame rates
|
||||
- Loop count configuration
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Memory Management**: Process frames in batches to avoid memory exhaustion
|
||||
2. **Worker Threads**: Use gif.js workers for parallel encoding
|
||||
3. **Progress Reporting**: Report progress after each frame for responsive UI
|
||||
4. **Cancellation**: Check cancelled flag between frames for quick abort
|
||||
|
||||
### File Size Estimation
|
||||
|
||||
Approximate GIF file size formula:
|
||||
```
|
||||
estimatedSize = width × height × frameCount × colorDepthFactor × compressionRatio
|
||||
```
|
||||
|
||||
Where:
|
||||
- colorDepthFactor ≈ 0.5 (for 256 colors with LZW compression)
|
||||
- compressionRatio ≈ 0.3-0.7 (depends on content complexity)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature extends the existing video export functionality to support GIF export as an alternative output format. The export dialog will be redesigned to present users with a format selection menu, allowing them to choose between MP4 video export (with quality options) or GIF export (with GIF-specific options like frame rate, looping, and size).
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Export_Dialog**: The modal UI component that displays export options and progress
|
||||
- **Format_Selector**: The UI component allowing users to choose between MP4 and GIF export formats
|
||||
- **GIF_Exporter**: The module responsible for encoding video frames into animated GIF format
|
||||
- **Frame_Rate**: The number of frames per second in the output GIF (affects file size and smoothness)
|
||||
- **Loop_Setting**: Configuration determining whether the GIF plays once or loops infinitely
|
||||
- **Size_Preset**: Predefined output dimension options for GIF export (e.g., small, medium, large)
|
||||
- **Quality_Preset**: Predefined quality levels for MP4 export (low, medium, high/source)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Export Format Selection
|
||||
|
||||
**User Story:** As a user, I want to choose between exporting as MP4 or GIF, so that I can create the appropriate format for my use case.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user clicks the export button, THE Export_Dialog SHALL display a format selection menu with MP4 and GIF options
|
||||
2. WHEN the user selects MP4 format, THE Export_Dialog SHALL display MP4-specific quality options (low, medium, high/source)
|
||||
3. WHEN the user selects GIF format, THE Export_Dialog SHALL display GIF-specific options (frame rate, loop, size)
|
||||
4. THE Export_Dialog SHALL remember the user's last selected format for the current session
|
||||
|
||||
### Requirement 2: GIF Frame Rate Configuration
|
||||
|
||||
**User Story:** As a user, I want to control the frame rate of my GIF export, so that I can balance between smoothness and file size.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN GIF format is selected, THE Export_Dialog SHALL display a frame rate selector with preset options
|
||||
2. THE GIF_Exporter SHALL support frame rates of 10, 15, 20, 25, and 30 FPS
|
||||
3. WHEN a frame rate is selected, THE Export_Dialog SHALL display an estimated file size indicator
|
||||
4. THE GIF_Exporter SHALL default to 15 FPS for optimal balance between quality and file size
|
||||
|
||||
### Requirement 3: GIF Loop Configuration
|
||||
|
||||
**User Story:** As a user, I want to control whether my GIF loops or plays once, so that I can create the appropriate animation behavior.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN GIF format is selected, THE Export_Dialog SHALL display a loop toggle option
|
||||
2. WHEN loop is enabled, THE GIF_Exporter SHALL encode the GIF to loop infinitely
|
||||
3. WHEN loop is disabled, THE GIF_Exporter SHALL encode the GIF to play once and stop
|
||||
4. THE GIF_Exporter SHALL default to loop enabled
|
||||
|
||||
### Requirement 4: GIF Size Configuration
|
||||
|
||||
**User Story:** As a user, I want to control the output size of my GIF, so that I can optimize for different platforms and use cases.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN GIF format is selected, THE Export_Dialog SHALL display size preset options
|
||||
2. THE GIF_Exporter SHALL support size presets: Small (480p), Medium (720p), Large (1080p), and Original
|
||||
3. WHEN a size preset is selected, THE Export_Dialog SHALL display the actual output dimensions
|
||||
4. THE GIF_Exporter SHALL maintain the video's aspect ratio when resizing
|
||||
5. THE GIF_Exporter SHALL default to Medium (720p) size preset
|
||||
|
||||
### Requirement 5: GIF Export Processing
|
||||
|
||||
**User Story:** As a user, I want to export my edited video as a GIF with all applied effects, so that I can share animated content.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user initiates GIF export, THE GIF_Exporter SHALL process all video frames with applied effects (zoom, crop, annotations, trim)
|
||||
2. WHEN exporting, THE Export_Dialog SHALL display real-time progress with percentage and frame count
|
||||
3. WHEN export completes, THE GIF_Exporter SHALL produce a valid animated GIF file
|
||||
4. IF an error occurs during export, THEN THE Export_Dialog SHALL display a descriptive error message
|
||||
5. WHEN the user cancels export, THE GIF_Exporter SHALL stop processing and clean up resources
|
||||
|
||||
### Requirement 6: GIF Color Optimization
|
||||
|
||||
**User Story:** As a user, I want my GIF to have good color quality despite the 256 color limitation, so that the output looks visually appealing.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE GIF_Exporter SHALL use color quantization to reduce colors to 256 per frame
|
||||
2. THE GIF_Exporter SHALL apply dithering to improve perceived color quality
|
||||
3. THE GIF_Exporter SHALL optimize the color palette for each frame or use a global palette based on content
|
||||
|
||||
### Requirement 7: MP4 Export Preservation
|
||||
|
||||
**User Story:** As a user, I want the existing MP4 export functionality to remain available with its current quality options, so that I can still export high-quality videos.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN MP4 format is selected, THE Export_Dialog SHALL display quality options: Low (720p), Medium (1080p), High/Source (original resolution)
|
||||
2. THE Video_Exporter SHALL maintain all existing MP4 export functionality unchanged
|
||||
3. WHEN MP4 export completes, THE system SHALL save the file with .mp4 extension
|
||||
|
||||
### Requirement 8: Export Dialog UI Design
|
||||
|
||||
**User Story:** As a user, I want a clean and intuitive export interface, so that I can easily configure and initiate exports.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Export_Dialog SHALL display format options as visually distinct selectable cards or tabs
|
||||
2. WHEN a format is selected, THE Export_Dialog SHALL animate the transition to show format-specific options
|
||||
3. THE Export_Dialog SHALL display a prominent "Export" button to initiate the export process
|
||||
4. THE Export_Dialog SHALL maintain the existing dark theme and visual style of the application
|
||||
5. WHEN exporting, THE Export_Dialog SHALL disable format selection and show progress UI
|
||||
@@ -0,0 +1,113 @@
|
||||
# Implementation Plan: GIF Export Feature
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation plan adds GIF export capability to the video editor. The work is organized to build incrementally: first extending types, then implementing the GIF encoder, updating the UI, and finally wiring everything together.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Extend export types and configuration
|
||||
- [x] 1.1 Add GIF export types to src/lib/exporter/types.ts
|
||||
- Add ExportFormat, GifFrameRate, GifSizePreset types
|
||||
- Add GifExportConfig and ExportSettings interfaces
|
||||
- Add GIF_SIZE_PRESETS and GIF_FRAME_RATES constants
|
||||
- _Requirements: 2.2, 4.2_
|
||||
|
||||
- [x] 1.2 Write property test for frame rate validation
|
||||
- **Property 1: Valid Frame Rate Acceptance**
|
||||
- **Validates: Requirements 2.2**
|
||||
|
||||
- [x] 2. Implement GIF exporter module
|
||||
- [x] 2.1 Create src/lib/exporter/gifExporter.ts
|
||||
- Implement GifExporter class with constructor and config
|
||||
- Implement export() method using gif.js library
|
||||
- Implement cancel() and cleanup() methods
|
||||
- Reuse FrameRenderer and VideoFileDecoder from existing pipeline
|
||||
- _Requirements: 5.1, 5.3, 5.5_
|
||||
|
||||
- [x] 2.2 Implement frame extraction and GIF encoding loop
|
||||
- Extract frames at configured frame rate
|
||||
- Apply trim region mapping (reuse from VideoExporter)
|
||||
- Add frames to gif.js encoder with proper delay
|
||||
- Report progress via callback
|
||||
- _Requirements: 5.1, 5.2_
|
||||
|
||||
- [x] 2.3 Implement loop and size configuration
|
||||
- Configure gif.js with loop count (0 for infinite, 1 for once)
|
||||
- Calculate output dimensions based on size preset and aspect ratio
|
||||
- _Requirements: 3.2, 3.3, 4.2, 4.4_
|
||||
|
||||
- [x] 2.4 Write property test for loop encoding
|
||||
- **Property 2: Loop Encoding Correctness**
|
||||
- **Validates: Requirements 3.2, 3.3**
|
||||
|
||||
- [x] 2.5 Write property test for aspect ratio preservation
|
||||
- **Property 4: Aspect Ratio Preservation**
|
||||
- **Validates: Requirements 4.4**
|
||||
|
||||
- [x] 3. Checkpoint - Ensure GIF exporter core works
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Update Export Dialog UI
|
||||
- [x] 4.1 Create FormatSelector component
|
||||
- Create src/components/video-editor/FormatSelector.tsx
|
||||
- Implement MP4/GIF toggle with card-style selection
|
||||
- Style to match existing dark theme
|
||||
- _Requirements: 1.1, 8.1_
|
||||
|
||||
- [x] 4.2 Create GifOptionsPanel component
|
||||
- Create src/components/video-editor/GifOptionsPanel.tsx
|
||||
- Implement frame rate dropdown with preset options
|
||||
- Implement loop toggle switch
|
||||
- Implement size preset selector
|
||||
- Display calculated output dimensions
|
||||
- _Requirements: 2.1, 3.1, 4.1, 4.3_
|
||||
|
||||
- [x] 4.3 Update ExportDialog component
|
||||
- Add format selection state and handlers
|
||||
- Conditionally render MP4 options or GIF options based on format
|
||||
- Update onExport to pass ExportSettings
|
||||
- Disable format selection during export
|
||||
- _Requirements: 1.2, 1.3, 1.4, 8.2, 8.3, 8.5_
|
||||
|
||||
- [x] 5. Integrate GIF export into VideoEditor
|
||||
- [x] 5.1 Update VideoEditor handleExport function
|
||||
- Accept ExportSettings from dialog
|
||||
- Route to VideoExporter or GifExporter based on format
|
||||
- Handle GIF blob saving with .gif extension
|
||||
- _Requirements: 5.3, 7.3_
|
||||
|
||||
- [x] 5.2 Add GIF exporter to index exports
|
||||
- Update src/lib/exporter/index.ts to export GifExporter
|
||||
- Export new types
|
||||
- _Requirements: 5.1_
|
||||
|
||||
- [x] 6. Checkpoint - Ensure full integration works
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 7. Write remaining property tests
|
||||
- [x] 7.1 Write property test for size preset resolution
|
||||
- **Property 3: Size Preset Resolution Mapping**
|
||||
- **Validates: Requirements 4.2**
|
||||
|
||||
- [x] 7.2 Write property test for valid GIF output
|
||||
- **Property 5: Valid GIF Output**
|
||||
- **Validates: Requirements 5.3**
|
||||
|
||||
- [x] 7.3 Write property test for frame count consistency
|
||||
- **Property 6: Frame Count Consistency**
|
||||
- **Validates: Requirements 5.1**
|
||||
|
||||
- [x] 7.4 Write regression test for MP4 export
|
||||
- **Property 7: MP4 Export Regression**
|
||||
- **Validates: Requirements 7.2**
|
||||
|
||||
- [x] 8. Final checkpoint - All tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties
|
||||
- The gif.js library needs to be installed: `npm install gif.js @types/gif.js`
|
||||
+51
-28
@@ -60,13 +60,16 @@ function createHudOverlayWindow() {
|
||||
return win;
|
||||
}
|
||||
function createEditorWindow() {
|
||||
const isMac = process.platform === "darwin";
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...isMac && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 }
|
||||
},
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
@@ -223,12 +226,13 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
|
||||
});
|
||||
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
|
||||
try {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save Exported Video",
|
||||
const mainWindow2 = getMainWindow();
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif ? [{ name: "GIF Image", extensions: ["gif"] }] : [{ name: "MP4 Video", extensions: ["mp4"] }];
|
||||
const result = await dialog.showSaveDialog(mainWindow2 || void 0, {
|
||||
title: isGif ? "Save Exported GIF" : "Save Exported Video",
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters: [
|
||||
{ name: "MP4 Video", extensions: ["mp4"] }
|
||||
],
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"]
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
@@ -316,19 +320,26 @@ let mainWindow = null;
|
||||
let sourceSelectorWindow = null;
|
||||
let tray = null;
|
||||
let selectedSourceName = "";
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: "best" });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
}
|
||||
function updateTrayMenu() {
|
||||
function getTrayIcon(filename) {
|
||||
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: "best"
|
||||
});
|
||||
}
|
||||
function updateTrayMenu(recording = false) {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording ? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
@@ -337,10 +348,27 @@ function updateTrayMenu() {
|
||||
}
|
||||
}
|
||||
}
|
||||
] : [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
];
|
||||
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip(`Recording: ${selectedSourceName}`);
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
}
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
@@ -366,10 +394,10 @@ app.on("activate", () => {
|
||||
app.whenReady().then(async () => {
|
||||
const { ipcMain: ipcMain2 } = await import("electron");
|
||||
ipcMain2.on("hud-overlay-close", () => {
|
||||
if (process.platform === "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
await ensureRecordingsDir();
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
@@ -378,14 +406,9 @@ app.whenReady().then(async () => {
|
||||
() => sourceSelectorWindow,
|
||||
(recording, sourceName) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,12 +130,18 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Save Exported Video',
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith('.gif');
|
||||
const filters = isGif
|
||||
? [{ name: 'GIF Image', extensions: ['gif'] }]
|
||||
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
|
||||
|
||||
const result = await dialog.showSaveDialog(mainWindow || undefined, {
|
||||
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
|
||||
defaultPath: path.join(app.getPath('downloads'), fileName),
|
||||
filters: [
|
||||
{ name: 'MP4 Video', extensions: ['mp4'] }
|
||||
],
|
||||
filters,
|
||||
properties: ['createDirectory', 'showOverwriteConfirmation']
|
||||
});
|
||||
|
||||
@@ -146,8 +152,9 @@ export function registerIpcHandlers(
|
||||
message: 'Export cancelled'
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
|
||||
Generated
+1102
-278
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -10,7 +10,9 @@
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux"
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
@@ -25,6 +27,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -32,6 +35,7 @@
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
@@ -49,6 +53,7 @@
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -56,20 +61,22 @@
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^30.0.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"fast-check": "^4.5.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ExportDialogProps {
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
exportFormat?: 'mp4' | 'gif';
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
@@ -19,6 +20,7 @@ export function ExportDialog({
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = 'mp4',
|
||||
}: ExportDialogProps) {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
@@ -35,6 +37,32 @@ export function ExportDialog({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
|
||||
|
||||
// Determine if we're in the compiling phase (frames done but still exporting)
|
||||
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
|
||||
const isFinalizing = progress?.phase === 'finalizing';
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return 'Please try again';
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
}
|
||||
return 'Compiling GIF... This may take a while';
|
||||
}
|
||||
return 'This may take a moment...';
|
||||
};
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return 'Export Failed';
|
||||
if (isCompiling || isFinalizing) return 'Compiling GIF';
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -42,7 +70,7 @@ export function ExportDialog({
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
@@ -51,7 +79,7 @@ export function ExportDialog({
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">Your video is ready</span>
|
||||
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -67,10 +95,10 @@ export function ExportDialog({
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">
|
||||
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
|
||||
{getTitle()}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
|
||||
{getStatusMessage()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,23 +131,68 @@ export function ExportDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>Progress</span>
|
||||
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
|
||||
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
`${renderProgress}%`
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
`${progress.percentage.toFixed(0)}%`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
{isCompiling || isFinalizing ? (
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
|
||||
style={{
|
||||
animation: 'indeterminate 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
|
||||
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
|
||||
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
|
||||
Processing
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? 'Status' : 'Format'}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +213,9 @@ export function ExportDialog({
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
|
||||
<p className="text-lg text-slate-200 font-medium">
|
||||
{formatLabel} saved successfully!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Film, Image } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExportFormat } from '@/lib/exporter/types';
|
||||
|
||||
interface FormatSelectorProps {
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{
|
||||
value: 'mp4',
|
||||
label: 'MP4 Video',
|
||||
description: 'High quality video file',
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: 'gif',
|
||||
label: 'GIF Animation',
|
||||
description: 'Animated image for sharing',
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onFormatChange(option.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
|
||||
isSelected
|
||||
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
|
||||
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
|
||||
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
|
||||
|
||||
interface GifOptionsPanelProps {
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GifOptionsPanel({
|
||||
frameRate,
|
||||
onFrameRateChange,
|
||||
loop,
|
||||
onLoopChange,
|
||||
sizePreset,
|
||||
onSizePresetChange,
|
||||
outputDimensions,
|
||||
disabled = false,
|
||||
}: GifOptionsPanelProps) {
|
||||
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
|
||||
value: key as GifSizePreset,
|
||||
label: value.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Frame Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Frame Rate
|
||||
</label>
|
||||
<Select
|
||||
value={String(frameRate)}
|
||||
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<SelectItem
|
||||
key={rate.value}
|
||||
value={String(rate.value)}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{rate.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Size Preset */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Output Size
|
||||
</label>
|
||||
<Select
|
||||
value={sizePreset}
|
||||
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{sizePresetOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-slate-500">
|
||||
Output: {outputDimensions.width} × {outputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
|
||||
<p className="text-xs text-slate-500">GIF will play continuously</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={loop}
|
||||
onCheckedChange={onLoopChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import Block from '@uiw/react-color-block';
|
||||
import { Trash2, Download, Crop, X, Bug, Upload, Star } from "lucide-react";
|
||||
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import type { ExportQuality } from "@/lib/exporter";
|
||||
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
|
||||
@@ -70,6 +71,16 @@ interface SettingsPanelProps {
|
||||
videoElement?: HTMLVideoElement | null;
|
||||
exportQuality?: ExportQuality;
|
||||
onExportQualityChange?: (quality: ExportQuality) => void;
|
||||
// Export format settings
|
||||
exportFormat?: ExportFormat;
|
||||
onExportFormatChange?: (format: ExportFormat) => void;
|
||||
gifFrameRate?: GifFrameRate;
|
||||
onGifFrameRateChange?: (rate: GifFrameRate) => void;
|
||||
gifLoop?: boolean;
|
||||
onGifLoopChange?: (loop: boolean) => void;
|
||||
gifSizePreset?: GifSizePreset;
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
@@ -116,6 +127,15 @@ export function SettingsPanel({
|
||||
videoElement,
|
||||
exportQuality = 'good',
|
||||
onExportQualityChange,
|
||||
exportFormat = 'mp4',
|
||||
onExportFormatChange,
|
||||
gifFrameRate = 15,
|
||||
onGifFrameRateChange,
|
||||
gifLoop = true,
|
||||
onGifLoopChange,
|
||||
gifSizePreset = 'medium',
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = { width: 1280, height: 720 },
|
||||
onExport,
|
||||
selectedAnnotationId,
|
||||
annotationRegions = [],
|
||||
@@ -550,43 +570,138 @@ export function SettingsPanel({
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/5">
|
||||
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
|
||||
{/* Export Quality Button Group */}
|
||||
<div className="mb-2.5 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('medium')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'medium'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Low
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('good')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'good'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('source')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'source'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
High
|
||||
</button>
|
||||
{/* Format Selection */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-xs font-medium text-slate-400 uppercase tracking-wider">Export Format</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => onExportFormatChange?.('mp4')}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
|
||||
exportFormat === 'mp4'
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<Film className="w-5 h-5" />
|
||||
<span className="text-xs font-medium">MP4</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportFormatChange?.('gif')}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
|
||||
exportFormat === 'gif'
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<Image className="w-5 h-5" />
|
||||
<span className="text-xs font-medium">GIF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MP4 Quality Options */}
|
||||
{exportFormat === 'mp4' && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
|
||||
<div className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('medium')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'medium'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Low
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('good')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'good'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.('source')}
|
||||
className={cn(
|
||||
"py-2 rounded-lg transition-all text-xs font-medium",
|
||||
exportQuality === 'source'
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
High
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GIF Options */}
|
||||
{exportFormat === 'gif' && (
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* Frame Rate */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-400">Frame Rate</div>
|
||||
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-5 h-auto rounded-xl">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<button
|
||||
key={rate.value}
|
||||
onClick={() => onGifFrameRateChange?.(rate.value)}
|
||||
className={cn(
|
||||
"py-1.5 rounded-lg transition-all text-xs font-medium",
|
||||
gifFrameRate === rate.value
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{rate.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Preset */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-400">Output Size</div>
|
||||
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl">
|
||||
{Object.entries(GIF_SIZE_PRESETS).map(([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
|
||||
className={cn(
|
||||
"py-1.5 rounded-lg transition-all text-xs font-medium",
|
||||
gifSizePreset === key
|
||||
? "bg-white text-black"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{key === 'original' ? 'Orig' : key.charAt(0).toUpperCase() + key.slice(1, 3)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-slate-500">
|
||||
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs font-medium text-slate-200">Loop Animation</span>
|
||||
<Switch
|
||||
checked={gifLoop}
|
||||
onCheckedChange={onGifLoopChange}
|
||||
className="data-[state=checked]:bg-[#34B27B]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
@@ -595,7 +710,7 @@ export function SettingsPanel({
|
||||
className="w-full py-6 text-lg font-semibold flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Export Video</span>
|
||||
<span>Export {exportFormat === 'gif' ? 'GIF' : 'Video'}</span>
|
||||
</Button>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type CropRegion,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
|
||||
@@ -61,6 +61,10 @@ export default function VideoEditor() {
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
const [gifLoop, setGifLoop] = useState(true);
|
||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
|
||||
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
@@ -434,7 +438,7 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
return;
|
||||
@@ -446,7 +450,42 @@ export default function VideoEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build export settings from current state
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS);
|
||||
|
||||
const settings: ExportSettings = {
|
||||
format: exportFormat,
|
||||
quality: exportFormat === 'mp4' ? exportQuality : undefined,
|
||||
gifConfig: exportFormat === 'gif' ? {
|
||||
frameRate: gifFrameRate,
|
||||
loop: gifLoop,
|
||||
sizePreset: gifSizePreset,
|
||||
width: gifDimensions.width,
|
||||
height: gifDimensions.height,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
setShowExportDialog(true);
|
||||
setExportError(null);
|
||||
|
||||
// Start export immediately
|
||||
handleExport(settings);
|
||||
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset]);
|
||||
|
||||
const handleExport = useCallback(async (settings: ExportSettings) => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) {
|
||||
toast.error('Video not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
setExportProgress(null);
|
||||
setExportError(null);
|
||||
@@ -468,138 +507,187 @@ export default function VideoEditor() {
|
||||
const sourceWidth = video.videoWidth || 1920;
|
||||
const sourceHeight = video.videoHeight || 1080;
|
||||
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
if (exportQuality === 'source') {
|
||||
// Use source resolution
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
// Square (1:1): use smaller dimension to avoid codec limits
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
// Landscape: find largest even dimensions that exactly match aspect ratio
|
||||
const baseWidth = Math.floor(sourceWidth / 2) * 2;
|
||||
// Iterate down from baseWidth to find exact match
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
// Portrait: find largest even dimensions that exactly match aspect ratio
|
||||
const baseHeight = Math.floor(sourceHeight / 2) * 2;
|
||||
// Iterate down from baseHeight to find exact match
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportHeight = baseHeight;
|
||||
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Use quality-based target resolution
|
||||
const targetHeight = exportQuality === 'medium' ? 720 : 1080;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2; // Ensure even
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Ensure even
|
||||
|
||||
// Adjust bitrate for lower resolutions
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000; // 10 Mbps for 720p
|
||||
} else if (totalPixels <= 1920 * 1080) {
|
||||
bitrate = 20_000_000; // 20 Mbps for 1080p
|
||||
} else {
|
||||
bitrate = 30_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
// Get preview CONTAINER dimensions for scaling
|
||||
// Annotations render in HTML overlay matching container, not PixiJS canvas
|
||||
const playbackRef = videoPlaybackRef.current;
|
||||
const containerElement = playbackRef?.containerRef?.current;
|
||||
const previewWidth = containerElement?.clientWidth || 1920;
|
||||
const previewHeight = containerElement?.clientHeight || 1080;
|
||||
|
||||
if (settings.format === 'gif' && settings.gifConfig) {
|
||||
// GIF Export
|
||||
const gifExporter = new GifExporter({
|
||||
videoUrl: videoPath,
|
||||
width: settings.gifConfig.width,
|
||||
height: settings.gifConfig.height,
|
||||
frameRate: settings.gifConfig.frameRate,
|
||||
loop: settings.gifConfig.loop,
|
||||
sizePreset: settings.gifConfig.sizePreset,
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurEnabled,
|
||||
borderRadius,
|
||||
padding,
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
exporterRef.current = gifExporter as unknown as VideoExporter;
|
||||
const result = await gifExporter.export();
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
frameRate: 60,
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurEnabled,
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
exporterRef.current = exporter;
|
||||
const result = await exporter.export();
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`Video exported successfully to ${saveResult.path}`);
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.gif`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`GIF exported successfully to ${saveResult.path}`);
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save GIF');
|
||||
toast.error(saveResult.message || 'Failed to save GIF');
|
||||
}
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save video');
|
||||
toast.error(saveResult.message || 'Failed to save video');
|
||||
setExportError(result.error || 'GIF export failed');
|
||||
toast.error(result.error || 'GIF export failed');
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || 'Export failed');
|
||||
toast.error(result.error || 'Export failed');
|
||||
// MP4 Export
|
||||
const quality = settings.quality || exportQuality;
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === 'source') {
|
||||
// Use source resolution
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
// Square (1:1): use smaller dimension to avoid codec limits
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
// Landscape: find largest even dimensions that exactly match aspect ratio
|
||||
const baseWidth = Math.floor(sourceWidth / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportWidth = baseWidth;
|
||||
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
// Portrait: find largest even dimensions that exactly match aspect ratio
|
||||
const baseHeight = Math.floor(sourceHeight / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
|
||||
exportWidth = w;
|
||||
exportHeight = h;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
exportHeight = baseHeight;
|
||||
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
bitrate = 50_000_000;
|
||||
} else if (totalPixels > 2560 * 1440) {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Use quality-based target resolution
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
|
||||
// Adjust bitrate for lower resolutions
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
} else if (totalPixels <= 1920 * 1080) {
|
||||
bitrate = 20_000_000;
|
||||
} else {
|
||||
bitrate = 30_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
frameRate: 60,
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurEnabled,
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
exporterRef.current = exporter;
|
||||
const result = await exporter.export();
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
|
||||
if (saveResult.cancelled) {
|
||||
toast.info('Export cancelled');
|
||||
} else if (saveResult.success) {
|
||||
toast.success(`Video exported successfully to ${saveResult.path}`);
|
||||
} else {
|
||||
setExportError(saveResult.message || 'Failed to save video');
|
||||
toast.error(saveResult.message || 'Failed to save video');
|
||||
}
|
||||
} else {
|
||||
setExportError(result.error || 'Export failed');
|
||||
toast.error(result.error || 'Export failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
@@ -613,6 +701,10 @@ export default function VideoEditor() {
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
exporterRef.current = null;
|
||||
// Reset dialog state to ensure it can be opened again on next export
|
||||
// This fixes the bug where second export doesn't show save dialog
|
||||
setShowExportDialog(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
@@ -771,7 +863,21 @@ export default function VideoEditor() {
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
onExport={handleExport}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
@@ -791,6 +897,7 @@ export default function VideoEditor() {
|
||||
isExporting={isExporting}
|
||||
error={exportError}
|
||||
onCancel={handleCancelExport}
|
||||
exportFormat={exportFormat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import { calculateOutputDimensions } from './gifExporter';
|
||||
import { GIF_SIZE_PRESETS, GifSizePreset } from './types';
|
||||
|
||||
/**
|
||||
* Property 2: Loop Encoding Correctness
|
||||
*
|
||||
* *For any* GIF export configuration, when loop is enabled the output GIF SHALL
|
||||
* have a loop count of 0 (infinite), and when loop is disabled the output GIF
|
||||
* SHALL have a loop count of 1 (play once).
|
||||
*
|
||||
* **Validates: Requirements 3.2, 3.3**
|
||||
*
|
||||
* Feature: gif-export, Property 2: Loop Encoding Correctness
|
||||
*/
|
||||
describe('GIF Exporter', () => {
|
||||
describe('Property 2: Loop Encoding Correctness', () => {
|
||||
/**
|
||||
* Test the loop configuration mapping logic.
|
||||
* In gif.js: repeat=0 means infinite loop, repeat=1 means play once (no loop)
|
||||
*/
|
||||
it('should map loop=true to repeat=0 (infinite) and loop=false to repeat=1 (once)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(),
|
||||
(loopEnabled: boolean) => {
|
||||
// This is the logic used in GifExporter constructor
|
||||
const repeat = loopEnabled ? 0 : 1;
|
||||
|
||||
if (loopEnabled) {
|
||||
// When loop is enabled, repeat should be 0 (infinite loop)
|
||||
expect(repeat).toBe(0);
|
||||
} else {
|
||||
// When loop is disabled, repeat should be 1 (play once)
|
||||
expect(repeat).toBe(1);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should always produce valid repeat values (0 or 1)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(),
|
||||
(loopEnabled: boolean) => {
|
||||
const repeat = loopEnabled ? 0 : 1;
|
||||
expect([0, 1]).toContain(repeat);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 4: Aspect Ratio Preservation
|
||||
*
|
||||
* *For any* source video with aspect ratio R and any size preset, the exported
|
||||
* GIF SHALL have an aspect ratio within 0.01 of R.
|
||||
*
|
||||
* **Validates: Requirements 4.4**
|
||||
*
|
||||
* Feature: gif-export, Property 4: Aspect Ratio Preservation
|
||||
*/
|
||||
describe('Property 4: Aspect Ratio Preservation', () => {
|
||||
const sizePresets: GifSizePreset[] = ['small', 'medium', 'large', 'original'];
|
||||
|
||||
it('should preserve aspect ratio within 0.01 tolerance for all size presets', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }), // sourceWidth
|
||||
fc.integer({ min: 100, max: 4000 }), // sourceHeight
|
||||
fc.constantFrom(...sizePresets),
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const originalAspectRatio = sourceWidth / sourceHeight;
|
||||
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
const outputAspectRatio = width / height;
|
||||
|
||||
// Aspect ratio should be preserved within 0.01 tolerance
|
||||
// (small deviation allowed due to rounding to even numbers)
|
||||
expect(Math.abs(originalAspectRatio - outputAspectRatio)).toBeLessThan(0.02);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original dimensions when source is smaller than preset max height', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 400 }), // sourceWidth (small)
|
||||
fc.integer({ min: 100, max: 400 }), // sourceHeight (small, less than 480p)
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
// For 'small' preset with maxHeight 480, if source is smaller, use original
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'small',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original dimensions for "original" preset regardless of size', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'original',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should scale down to preset max height when source is larger', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1000, max: 4000 }), // sourceWidth (large)
|
||||
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than 720p)
|
||||
(sourceWidth: number, sourceHeight: number) => {
|
||||
// For 'medium' preset with maxHeight 720
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
'medium',
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// Height should be at most 720 (or 722 due to even rounding)
|
||||
expect(height).toBeLessThanOrEqual(722);
|
||||
// Width should be scaled proportionally
|
||||
expect(width).toBeLessThan(sourceWidth);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Property 3: Size Preset Resolution Mapping
|
||||
*
|
||||
* *For any* valid size preset and source video dimensions, the GIF_Exporter SHALL
|
||||
* produce output with height matching the preset's max height (or source height if smaller),
|
||||
* with width calculated to maintain aspect ratio.
|
||||
*
|
||||
* **Validates: Requirements 4.2**
|
||||
*
|
||||
* Feature: gif-export, Property 3: Size Preset Resolution Mapping
|
||||
*/
|
||||
describe('Property 3: Size Preset Resolution Mapping', () => {
|
||||
it('should map size presets to correct max heights', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 800, max: 4000 }), // sourceWidth (large enough to trigger scaling)
|
||||
fc.integer({ min: 800, max: 2000 }), // sourceHeight (larger than all presets except original)
|
||||
fc.constantFrom('small', 'medium', 'large') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
const expectedMaxHeight = GIF_SIZE_PRESETS[sizePreset].maxHeight;
|
||||
|
||||
// Height should be at or below the preset's max height
|
||||
// (allowing +2 for even number rounding)
|
||||
expect(height).toBeLessThanOrEqual(expectedMaxHeight + 2);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source dimensions when smaller than preset', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 400 }), // sourceWidth
|
||||
fc.integer({ min: 100, max: 400 }), // sourceHeight (smaller than 480p 'small' preset)
|
||||
fc.constantFrom('small', 'medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// When source is smaller than preset, use original dimensions
|
||||
expect(width).toBe(sourceWidth);
|
||||
expect(height).toBe(sourceHeight);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce even dimensions for encoder compatibility', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.integer({ min: 100, max: 4000 }),
|
||||
fc.constantFrom('small', 'medium', 'large', 'original') as fc.Arbitrary<GifSizePreset>,
|
||||
(sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset) => {
|
||||
const { width, height } = calculateOutputDimensions(
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sizePreset,
|
||||
GIF_SIZE_PRESETS
|
||||
);
|
||||
|
||||
// When scaling occurs, dimensions should be even
|
||||
// (original dimensions are passed through as-is)
|
||||
if (sourceHeight > GIF_SIZE_PRESETS[sizePreset].maxHeight && sizePreset !== 'original') {
|
||||
expect(width % 2).toBe(0);
|
||||
expect(height % 2).toBe(0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 6: Frame Count Consistency
|
||||
*
|
||||
* *For any* video with effective duration D (excluding trim regions) and frame rate F,
|
||||
* the exported GIF SHALL contain approximately D × F frames (within ±1 frame tolerance).
|
||||
*
|
||||
* **Validates: Requirements 5.1**
|
||||
*
|
||||
* Feature: gif-export, Property 6: Frame Count Consistency
|
||||
*/
|
||||
describe('Property 6: Frame Count Consistency', () => {
|
||||
// Helper function to calculate expected frame count
|
||||
const calculateExpectedFrameCount = (durationSeconds: number, frameRate: number): number => {
|
||||
return Math.ceil(durationSeconds * frameRate);
|
||||
};
|
||||
|
||||
it('should calculate correct frame count for duration and frame rate', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 0.5, max: 60, noNaN: true }), // duration in seconds
|
||||
fc.constantFrom(10, 15, 20, 25, 30), // valid frame rates
|
||||
(duration: number, frameRate: number) => {
|
||||
const expectedFrames = calculateExpectedFrameCount(duration, frameRate);
|
||||
|
||||
// Frame count should be positive
|
||||
expect(expectedFrames).toBeGreaterThan(0);
|
||||
|
||||
// Frame count should be approximately duration * frameRate
|
||||
const approximateFrames = duration * frameRate;
|
||||
expect(Math.abs(expectedFrames - approximateFrames)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce more frames with higher frame rates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 1, max: 30, noNaN: true }), // duration in seconds
|
||||
(duration: number) => {
|
||||
const frames10fps = calculateExpectedFrameCount(duration, 10);
|
||||
const frames30fps = calculateExpectedFrameCount(duration, 30);
|
||||
|
||||
// 30fps should produce approximately 3x more frames than 10fps
|
||||
expect(frames30fps).toBeGreaterThan(frames10fps);
|
||||
expect(frames30fps / frames10fps).toBeCloseTo(3, 0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle trim regions by reducing effective duration', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.float({ min: 5, max: 60, noNaN: true }), // total duration
|
||||
fc.float({ min: 0.5, max: 2, noNaN: true }), // trim duration (smaller than total)
|
||||
fc.constantFrom(10, 15, 20, 25, 30),
|
||||
(totalDuration: number, trimDuration: number, frameRate: number) => {
|
||||
const effectiveDuration = totalDuration - trimDuration;
|
||||
const framesWithTrim = calculateExpectedFrameCount(effectiveDuration, frameRate);
|
||||
const framesWithoutTrim = calculateExpectedFrameCount(totalDuration, frameRate);
|
||||
|
||||
// Trimmed video should have fewer frames
|
||||
expect(framesWithTrim).toBeLessThan(framesWithoutTrim);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Property 5: Valid GIF Output (Configuration Validation)
|
||||
*
|
||||
* *For any* successful GIF export, the output blob SHALL be a valid GIF file.
|
||||
* This test validates the GIF configuration parameters are correctly set up.
|
||||
*
|
||||
* **Validates: Requirements 5.3**
|
||||
*
|
||||
* Feature: gif-export, Property 5: Valid GIF Output
|
||||
*
|
||||
* Note: Full GIF encoding validation requires browser environment with video.
|
||||
* This test validates configuration correctness.
|
||||
*/
|
||||
describe('Property 5: Valid GIF Output (Configuration)', () => {
|
||||
it('should generate valid GIF configuration for all frame rates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(10, 15, 20, 25, 30),
|
||||
fc.integer({ min: 100, max: 1920 }),
|
||||
fc.integer({ min: 100, max: 1080 }),
|
||||
fc.boolean(),
|
||||
(frameRate: number, width: number, height: number, loop: boolean) => {
|
||||
// Validate frame delay calculation (gif.js uses milliseconds)
|
||||
const frameDelay = Math.round(1000 / frameRate);
|
||||
|
||||
// Frame delay should be positive and reasonable
|
||||
expect(frameDelay).toBeGreaterThan(0);
|
||||
expect(frameDelay).toBeLessThanOrEqual(100); // 10fps = 100ms delay
|
||||
|
||||
// Loop configuration
|
||||
const repeat = loop ? 0 : 1;
|
||||
expect([0, 1]).toContain(repeat);
|
||||
|
||||
// Dimensions should be positive
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct frame delays for each frame rate', () => {
|
||||
const expectedDelays: Record<number, number> = {
|
||||
10: 100, // 1000ms / 10fps = 100ms
|
||||
15: 67, // 1000ms / 15fps ≈ 67ms
|
||||
20: 50, // 1000ms / 20fps = 50ms
|
||||
25: 40, // 1000ms / 25fps = 40ms
|
||||
30: 33, // 1000ms / 30fps ≈ 33ms
|
||||
};
|
||||
|
||||
for (const [fps, expectedDelay] of Object.entries(expectedDelays)) {
|
||||
const frameRate = Number(fps);
|
||||
const actualDelay = Math.round(1000 / frameRate);
|
||||
expect(actualDelay).toBe(expectedDelay);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 7: MP4 Export Regression
|
||||
*
|
||||
* *For any* valid MP4 export configuration that worked before this feature,
|
||||
* the Video_Exporter SHALL continue to produce valid MP4 output.
|
||||
*
|
||||
* **Validates: Requirements 7.2**
|
||||
*
|
||||
* Feature: gif-export, Property 7: MP4 Export Regression
|
||||
*
|
||||
* Note: This test validates that MP4 export configuration remains unchanged.
|
||||
*/
|
||||
describe('Property 7: MP4 Export Regression', () => {
|
||||
it('should maintain valid MP4 quality presets', () => {
|
||||
const qualityPresets = ['medium', 'good', 'source'];
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...qualityPresets),
|
||||
(quality: string) => {
|
||||
// Quality presets should be valid
|
||||
expect(['medium', 'good', 'source']).toContain(quality);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate valid MP4 export dimensions', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 640, max: 3840 }), // sourceWidth
|
||||
fc.integer({ min: 480, max: 2160 }), // sourceHeight
|
||||
fc.constantFrom('medium', 'good', 'source'),
|
||||
(sourceWidth: number, sourceHeight: number, quality: string) => {
|
||||
let exportWidth: number;
|
||||
let exportHeight: number;
|
||||
const aspectRatio = sourceWidth / sourceHeight;
|
||||
|
||||
if (quality === 'source') {
|
||||
// Source quality uses original dimensions (may be odd)
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Dimensions should be positive
|
||||
expect(exportWidth).toBeGreaterThan(0);
|
||||
expect(exportHeight).toBeGreaterThan(0);
|
||||
} else {
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatio) / 2) * 2;
|
||||
|
||||
// Dimensions should be positive and even for non-source quality
|
||||
expect(exportWidth).toBeGreaterThan(0);
|
||||
expect(exportHeight).toBeGreaterThan(0);
|
||||
expect(exportWidth % 2).toBe(0);
|
||||
expect(exportHeight % 2).toBe(0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain aspect ratio in MP4 export', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 640, max: 3840 }),
|
||||
fc.integer({ min: 480, max: 2160 }),
|
||||
fc.constantFrom('medium', 'good'),
|
||||
(sourceWidth: number, sourceHeight: number, quality: string) => {
|
||||
const originalAspectRatio = sourceWidth / sourceHeight;
|
||||
const targetHeight = quality === 'medium' ? 720 : 1080;
|
||||
|
||||
const exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
const exportWidth = Math.floor((exportHeight * originalAspectRatio) / 2) * 2;
|
||||
|
||||
const exportAspectRatio = exportWidth / exportHeight;
|
||||
|
||||
// Aspect ratio should be preserved within tolerance (due to even rounding)
|
||||
expect(Math.abs(originalAspectRatio - exportAspectRatio)).toBeLessThan(0.05);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import GIF from 'gif.js';
|
||||
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
|
||||
import { VideoFileDecoder } from './videoDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
|
||||
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
|
||||
|
||||
interface GifExporterConfig {
|
||||
videoUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate output dimensions based on size preset and source dimensions while preserving aspect ratio.
|
||||
* @param sourceWidth - Original video width
|
||||
* @param sourceHeight - Original video height
|
||||
* @param sizePreset - The size preset to use
|
||||
* @param sizePresets - The size presets configuration
|
||||
* @returns The calculated output dimensions
|
||||
*/
|
||||
export function calculateOutputDimensions(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sizePreset: GifSizePreset,
|
||||
sizePresets: typeof GIF_SIZE_PRESETS
|
||||
): { width: number; height: number } {
|
||||
const preset = sizePresets[sizePreset];
|
||||
const maxHeight = preset.maxHeight;
|
||||
|
||||
// If original is smaller than max height or preset is 'original', use source dimensions
|
||||
if (sourceHeight <= maxHeight || sizePreset === 'original') {
|
||||
return { width: sourceWidth, height: sourceHeight };
|
||||
}
|
||||
|
||||
// Calculate scaled dimensions preserving aspect ratio
|
||||
const aspectRatio = sourceWidth / sourceHeight;
|
||||
const newHeight = maxHeight;
|
||||
const newWidth = Math.round(newHeight * aspectRatio);
|
||||
|
||||
// Ensure dimensions are even (required for some encoders)
|
||||
return {
|
||||
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
|
||||
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export class GifExporter {
|
||||
private config: GifExporterConfig;
|
||||
private decoder: VideoFileDecoder | null = null;
|
||||
private renderer: FrameRenderer | null = null;
|
||||
private gif: GIF | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor(config: GifExporterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total duration excluding trim regions (in seconds)
|
||||
*/
|
||||
private getEffectiveDuration(totalDuration: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
const totalTrimDuration = trimRegions.reduce((sum, region) => {
|
||||
return sum + (region.endMs - region.startMs) / 1000;
|
||||
}, 0);
|
||||
return totalDuration - totalTrimDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map effective time (excluding trims) to source time (including trims)
|
||||
*/
|
||||
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
|
||||
const trimRegions = this.config.trimRegions || [];
|
||||
// Sort trim regions by start time
|
||||
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let sourceTimeMs = effectiveTimeMs;
|
||||
|
||||
for (const trim of sortedTrims) {
|
||||
// If the source time hasn't reached this trim region yet, we're done
|
||||
if (sourceTimeMs < trim.startMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the duration of this trim region to the source time
|
||||
const trimDuration = trim.endMs - trim.startMs;
|
||||
sourceTimeMs += trimDuration;
|
||||
}
|
||||
|
||||
return sourceTimeMs;
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
// Initialize decoder and load video
|
||||
this.decoder = new VideoFileDecoder();
|
||||
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
|
||||
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
wallpaper: this.config.wallpaper,
|
||||
zoomRegions: this.config.zoomRegions,
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
showBlur: this.config.showBlur,
|
||||
motionBlurEnabled: this.config.motionBlurEnabled,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
// Initialize GIF encoder
|
||||
// Loop: 0 = infinite loop, 1 = play once (no loop)
|
||||
const repeat = this.config.loop ? 0 : 1;
|
||||
|
||||
this.gif = new GIF({
|
||||
workers: 4,
|
||||
quality: 10,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
workerScript: GIF_WORKER_URL,
|
||||
repeat,
|
||||
background: '#000000',
|
||||
transparent: null,
|
||||
dither: 'FloydSteinberg',
|
||||
});
|
||||
|
||||
// Get the video element for frame extraction
|
||||
const videoElement = this.decoder.getVideoElement();
|
||||
if (!videoElement) {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
// Calculate frame delay in milliseconds (gif.js uses ms)
|
||||
const frameDelay = Math.round(1000 / this.config.frameRate);
|
||||
|
||||
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
|
||||
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
|
||||
console.log('[GifExporter] Total frames to export:', totalFrames);
|
||||
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
|
||||
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
|
||||
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
|
||||
|
||||
// Process frames
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
let frameIndex = 0;
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const i = frameIndex;
|
||||
const timestamp = i * (1_000_000 / this.config.frameRate); // in microseconds
|
||||
|
||||
// Map effective time to source time (accounting for trim regions)
|
||||
const effectiveTimeMs = (i * timeStep) * 1000;
|
||||
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
|
||||
const videoTime = sourceTimeMs / 1000;
|
||||
|
||||
// Seek if needed
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
|
||||
if (needsSeek) {
|
||||
const seekedPromise = new Promise<void>(resolve => {
|
||||
videoElement.addEventListener('seeked', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
videoElement.currentTime = videoTime;
|
||||
await seekedPromise;
|
||||
} else if (i === 0) {
|
||||
// Only for the very first frame, wait for it to be ready
|
||||
await new Promise<void>(resolve => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Render the frame with all effects using source timestamp
|
||||
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
|
||||
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
|
||||
|
||||
videoFrame.close();
|
||||
|
||||
// Get the rendered canvas and add to GIF
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// Add frame to GIF encoder with delay
|
||||
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
|
||||
|
||||
frameIndex++;
|
||||
|
||||
// Update progress
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: frameIndex,
|
||||
totalFrames,
|
||||
percentage: (frameIndex / totalFrames) * 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
}
|
||||
|
||||
// Update progress to show we're now in the finalizing phase
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: totalFrames,
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
});
|
||||
}
|
||||
|
||||
// Render the GIF
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
this.gif!.on('finished', (blob: Blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
|
||||
// Track rendering progress
|
||||
this.gif!.on('progress', (progress: number) => {
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: totalFrames,
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
renderProgress: Math.round(progress * 100),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch
|
||||
this.gif!.render();
|
||||
});
|
||||
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
console.error('GIF Export error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
if (this.gif) {
|
||||
this.gif.abort();
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying decoder:', e);
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying renderer:', e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
|
||||
this.gif = null;
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,23 @@ export { VideoExporter } from './videoExporter';
|
||||
export { VideoFileDecoder } from './videoDecoder';
|
||||
export { FrameRenderer } from './frameRenderer';
|
||||
export { VideoMuxer } from './muxer';
|
||||
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData, ExportQuality } from './types';
|
||||
export { GifExporter, calculateOutputDimensions } from './gifExporter';
|
||||
export type {
|
||||
ExportConfig,
|
||||
ExportProgress,
|
||||
ExportResult,
|
||||
VideoFrameData,
|
||||
ExportQuality,
|
||||
ExportFormat,
|
||||
GifFrameRate,
|
||||
GifSizePreset,
|
||||
GifExportConfig,
|
||||
ExportSettings,
|
||||
} from './types';
|
||||
export {
|
||||
GIF_SIZE_PRESETS,
|
||||
GIF_FRAME_RATES,
|
||||
VALID_GIF_FRAME_RATES,
|
||||
isValidGifFrameRate
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
isValidGifFrameRate,
|
||||
VALID_GIF_FRAME_RATES,
|
||||
GifFrameRate
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Property 1: Valid Frame Rate Acceptance
|
||||
*
|
||||
* *For any* frame rate value, the GIF_Exporter SHALL accept it if and only if
|
||||
* it is one of the valid presets (10, 15, 20, 25, 30 FPS). Invalid frame rates
|
||||
* should be rejected with an error.
|
||||
*
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* Feature: gif-export, Property 1: Valid Frame Rate Acceptance
|
||||
*/
|
||||
describe('GIF Export Types', () => {
|
||||
describe('Property 1: Valid Frame Rate Acceptance', () => {
|
||||
// Property test: Valid frame rates should be accepted
|
||||
it('should accept all valid frame rates (10, 15, 20, 25, 30)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...VALID_GIF_FRAME_RATES),
|
||||
(frameRate: GifFrameRate) => {
|
||||
expect(isValidGifFrameRate(frameRate)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Property test: Invalid frame rates should be rejected
|
||||
it('should reject any frame rate not in the valid set', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer().filter(n => !VALID_GIF_FRAME_RATES.includes(n as GifFrameRate)),
|
||||
(invalidFrameRate: number) => {
|
||||
expect(isValidGifFrameRate(invalidFrameRate)).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Property test: Frame rate validation is deterministic
|
||||
it('should return consistent results for the same input', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 60 }),
|
||||
(frameRate: number) => {
|
||||
const result1 = isValidGifFrameRate(frameRate);
|
||||
const result2 = isValidGifFrameRate(frameRate);
|
||||
expect(result1).toBe(result2);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ export interface ExportProgress {
|
||||
totalFrames: number;
|
||||
percentage: number;
|
||||
estimatedTimeRemaining: number; // in seconds
|
||||
phase?: 'extracting' | 'finalizing'; // Phase of export
|
||||
renderProgress?: number; // 0-100, progress of GIF rendering phase
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -26,3 +28,48 @@ export interface VideoFrameData {
|
||||
}
|
||||
|
||||
export type ExportQuality = 'medium' | 'good' | 'source';
|
||||
|
||||
// GIF Export Types
|
||||
export type ExportFormat = 'mp4' | 'gif';
|
||||
|
||||
export type GifFrameRate = 10 | 15 | 20 | 25 | 30;
|
||||
|
||||
export type GifSizePreset = 'small' | 'medium' | 'large' | 'original';
|
||||
|
||||
export interface GifExportConfig {
|
||||
frameRate: GifFrameRate;
|
||||
loop: boolean;
|
||||
sizePreset: GifSizePreset;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
// MP4 settings
|
||||
quality?: ExportQuality;
|
||||
// GIF settings
|
||||
gifConfig?: GifExportConfig;
|
||||
}
|
||||
|
||||
export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = {
|
||||
small: { maxHeight: 480, label: 'Small (480p)' },
|
||||
medium: { maxHeight: 720, label: 'Medium (720p)' },
|
||||
large: { maxHeight: 1080, label: 'Large (1080p)' },
|
||||
original: { maxHeight: Infinity, label: 'Original' },
|
||||
};
|
||||
|
||||
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
|
||||
{ value: 10, label: '10 FPS - Smaller file' },
|
||||
{ value: 15, label: '15 FPS - Balanced' },
|
||||
{ value: 20, label: '20 FPS - Smooth' },
|
||||
{ value: 25, label: '25 FPS - Very smooth' },
|
||||
{ value: 30, label: '30 FPS - Maximum' },
|
||||
];
|
||||
|
||||
// Valid frame rates for validation
|
||||
export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [10, 15, 20, 25, 30] as const;
|
||||
|
||||
export function isValidGifFrameRate(rate: number): rate is GifFrameRate {
|
||||
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user