diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor-header.tsx | 15 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 13 | ||||
| -rw-r--r-- | apps/web/src/components/editor/properties-panel/index.tsx | 36 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-track.tsx | 37 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 17 | ||||
| -rw-r--r-- | apps/web/src/constants/timeline-constants.ts | 28 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-playhead.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/media-processing.ts | 38 | ||||
| -rw-r--r-- | apps/web/src/lib/time.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 23 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 10 | ||||
| -rw-r--r-- | apps/web/src/types/project.ts | 1 |
13 files changed, 196 insertions, 39 deletions
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 666ddaed..c4081c75 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -31,13 +31,24 @@ export function EditorHeader() { const centerContent = (
<div className="flex items-center gap-2 text-xs">
- <span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
+ <span>
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
- <Button size="sm" variant="primary" className="h-7 text-xs" onClick={handleExport}>
+ <Button
+ size="sm"
+ variant="primary"
+ className="h-7 text-xs"
+ onClick={handleExport}
+ >
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 5003e79b..894b6257 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -390,6 +390,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); const { currentPreset, isOriginal, @@ -420,11 +421,19 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { )} > <span className="text-primary tabular-nums"> - {formatTimeCode(currentTime, "HH:MM:SS:CS")} + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} </span> <span className="opacity-50">/</span> <span className="tabular-nums"> - {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")} + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} </span> </p> </div> diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 0e6cb8a8..9e28850f 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -9,13 +9,28 @@ import { useMediaStore } from "@/stores/media-store"; import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../ui/select"; +import { FPS_PRESETS } from "@/constants/timeline-constants"; export function PropertiesPanel() { - const { activeProject } = useProjectStore(); + const { activeProject, updateProjectFps } = useProjectStore(); const { getDisplayName, canvasSize } = useAspectRatio(); const { selectedElements, tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + if (!isNaN(fps) && fps > 0) { + updateProjectFps(fps); + } + }; + const emptyView = ( <div className="space-y-4 p-5"> {/* Media Properties */} @@ -26,7 +41,24 @@ export function PropertiesPanel() { label="Resolution:" value={`${canvasSize.width} × ${canvasSize.height}`} /> - <PropertyItem label="Frame rate:" value="30.00fps" /> + <div className="flex justify-between items-center"> + <Label className="text-xs text-muted-foreground">Frame rate:</Label> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-32 h-6 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map(({ value, label }) => ( + <SelectItem key={value} value={value} className="text-xs"> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> </div> </div> ); diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 7e2e93e0..c3a16b9a 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -17,7 +17,11 @@ import type { TimelineElement as TimelineElementType, DragData, } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + snapTimeToFrame, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; export function TimelineTrackContent({ track, @@ -80,7 +84,10 @@ export function TimelineTrackContent({ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const snappedTime = Math.round(adjustedTime * 10) / 10; + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(adjustedTime, projectFps); updateDragTime(snappedTime); }; @@ -342,7 +349,9 @@ export function TimelineTrackContent({ if (dragData.type === "text") { // Text elements have default duration of 5 seconds const newElementDuration = 5; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -361,7 +370,9 @@ export function TimelineTrackContent({ ); if (mediaItem) { const newElementDuration = mediaItem.duration || 5; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -401,7 +412,9 @@ export function TimelineTrackContent({ movingElement.duration - movingElement.trimStart - movingElement.trimEnd; - const snappedTime = Math.round(dropTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(dropTime, projectFps); const movingElementEnd = snappedTime + movingElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -428,13 +441,17 @@ export function TimelineTrackContent({ if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; setWouldOverlap(true); - setDropPosition(Math.round(dropTime * 10) / 10); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + setDropPosition(snapTimeToFrame(dropTime, projectFps)); return; } e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; setWouldOverlap(false); - setDropPosition(Math.round(dropTime * 10) / 10); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + setDropPosition(snapTimeToFrame(dropTime, projectFps)); }; const handleTrackDragEnter = (e: React.DragEvent) => { @@ -502,7 +519,9 @@ export function TimelineTrackContent({ const mouseY = e.clientY - rect.top; // Get Y position relative to this track const newStartTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const snappedTime = Math.round(newStartTime * 10) / 10; + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(newStartTime, projectFps); // Calculate drop position relative to tracks const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); @@ -548,7 +567,7 @@ export function TimelineTrackContent({ const adjustedStartTime = snappedTime - clickOffsetTime; const finalStartTime = Math.max( 0, - Math.round(adjustedStartTime * 10) / 10 + snapTimeToFrame(adjustedStartTime, projectFps) ); // Check for overlaps with existing elements (excluding the moving element itself) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 4fee16fc..a2b798e1 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -81,14 +81,8 @@ export function Timeline() { } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); - const { - currentTime, - duration, - seek, - setDuration, - isPlaying, - toggle, - } = usePlaybackStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); @@ -215,7 +209,7 @@ export function Timeline() { scrollLeft = tracksContent.scrollLeft; } - const time = Math.max( + const rawTime = Math.max( 0, Math.min( duration, @@ -224,6 +218,11 @@ export function Timeline() { ) ); + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const { snapTimeToFrame } = require("@/constants/timeline-constants"); + const time = snapTimeToFrame(rawTime, projectFps); + seek(time); }, [ diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 26cd865c..92045c5e 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -76,3 +76,31 @@ export const TIMELINE_CONSTANTS = { DEFAULT_TEXT_DURATION: 5, ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], } as const; + +// FPS presets for project settings +export const FPS_PRESETS = [ + { value: "24", label: "24 fps (Film)" }, + { value: "25", label: "25 fps (PAL)" }, + { value: "30", label: "30 fps (NTSC)" }, + { value: "60", label: "60 fps (High)" }, + { value: "120", label: "120 fps (Slow-mo)" }, +] as const; + +// Frame snapping utilities +export function timeToFrame(time: number, fps: number): number { + return Math.round(time * fps); +} + +export function frameToTime(frame: number, fps: number): number { + return frame / fps; +} + +export function snapTimeToFrame(time: number, fps: number): number { + if (fps <= 0) return time; // Fallback for invalid FPS + const frame = timeToFrame(time, fps); + return frameToTime(frame, fps); +} + +export function getFrameDuration(fps: number): number { + return 1 / fps; +} diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 61adaf92..9a82c23f 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,3 +1,5 @@ +import { snapTimeToFrame } from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
@@ -69,7 +71,11 @@ export function useTimelinePlayhead({ if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left;
- const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ // Use frame snapping for playhead scrubbing
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const time = snapTimeToFrame(rawTime, projectFps);
setScrubTime(time);
seek(time); // update video preview in real time
},
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 4c1294ae..353c3113 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -6,7 +6,7 @@ import { getImageDimensions, type MediaItem, } from "@/stores/media-store"; -// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled +import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} @@ -33,6 +33,7 @@ export async function processMediaFiles( let duration: number | undefined; let width: number | undefined; let height: number | undefined; + let fps: number | undefined; try { if (fileType === "image") { @@ -41,17 +42,31 @@ export async function processMediaFiles( width = dimensions.width; height = dimensions.height; } else if (fileType === "video") { - // Use basic thumbnail generation for now - const videoResult = await generateVideoThumbnail(file); - thumbnailUrl = videoResult.thumbnailUrl; - width = videoResult.width; - height = videoResult.height; - } else if (fileType === "audio") { - // For audio, we don't set width/height (they'll be undefined) - } + try { + // Use FFmpeg for comprehensive video info extraction + const videoInfo = await getVideoInfo(file); + duration = videoInfo.duration; + width = videoInfo.width; + height = videoInfo.height; + fps = videoInfo.fps; - // Get duration for videos and audio (if not already set by FFmpeg) - if ((fileType === "video" || fileType === "audio") && !duration) { + // Generate thumbnail using FFmpeg + thumbnailUrl = await generateThumbnail(file, 1); + } catch (error) { + console.warn( + "FFmpeg processing failed, falling back to basic processing:", + error + ); + // Fallback to basic processing + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + width = videoResult.width; + height = videoResult.height; + duration = await getMediaDuration(file); + // FPS will remain undefined for fallback + } + } else if (fileType === "audio") { + // For audio, we don't set width/height/fps (they'll be undefined) duration = await getMediaDuration(file); } @@ -64,6 +79,7 @@ export async function processMediaFiles( duration, width, height, + fps, }); // Yield back to the event loop to keep the UI responsive diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index e2db28c2..b3147946 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -1,14 +1,16 @@ // Time-related utility functions
-// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
export const formatTimeCode = (
timeInSeconds: number,
- format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
+ fps: number = 30
): string => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+ const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) {
case "MM:SS":
@@ -17,5 +19,7 @@ export const formatTimeCode = ( return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:FF":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 76cdd638..600b42f2 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -14,6 +14,7 @@ export interface MediaItem { duration?: number; // For video/audio duration width?: number; // For video/image width height?: number; // For video/image height + fps?: number; // For video frame rate // Text-specific properties content?: string; // Text content fontSize?: number; // Font size diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 913c6810..3cc2a1dc 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -25,6 +25,7 @@ interface ProjectStore { type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
+ updateProjectFps: (fps: number) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
@@ -293,4 +294,26 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ });
}
},
+
+ updateProjectFps: async (fps: number) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ fps,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project FPS:", error);
+ toast.error("Failed to update project FPS", {
+ description: "Please try again",
+ });
+ }
+ },
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 8c934392..33847baa 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -370,7 +370,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } as TimelineElement; // Type assertion since we trust the caller passes valid data // If this is the first element and it's a media element, automatically set the project canvas size - // to match the media's aspect ratio + // to match the media's aspect ratio and FPS (for videos) if (isFirstElement && newElement.type === "media") { const mediaStore = useMediaStore.getState(); const mediaItem = mediaStore.mediaItems.find( @@ -386,6 +386,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { getMediaAspectRatio(mediaItem) ); } + + // Set project FPS from the first video element + if (mediaItem && mediaItem.type === "video" && mediaItem.fps) { + const projectStore = useProjectStore.getState(); + if (projectStore.activeProject) { + projectStore.updateProjectFps(mediaItem.fps); + } + } } updateTracksAndSave( diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index f259662e..58d37b03 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -8,4 +8,5 @@ export interface TProject { backgroundColor?: string; backgroundType?: "color" | "blur"; blurIntensity?: number; // in pixels (4, 8, 18) + fps?: number; } |
