refactor: add FPS and remove turbopack (yes, fuck you turbopack)

This commit is contained in:
Maze Winther
2025-07-12 12:48:31 +02:00
parent 0726c27221
commit 8545d95070
14 changed files with 197 additions and 40 deletions

View File

@ -4,7 +4,7 @@
"private": true, "private": true,
"packageManager": "bun@1.2.17", "packageManager": "bun@1.2.17",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",

View File

@ -31,13 +31,24 @@ export function EditorHeader() {
const centerContent = ( const centerContent = (
<div className="flex items-center gap-2 text-xs"> <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> </div>
); );
const rightContent = ( const rightContent = (
<nav className="flex items-center gap-2"> <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" /> <Download className="h-4 w-4" />
<span className="text-sm">Export</span> <span className="text-sm">Export</span>
</Button> </Button>

View File

@ -390,6 +390,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { isPlaying, toggle, currentTime } = usePlaybackStore();
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
const { getTotalDuration } = useTimelineStore(); const { getTotalDuration } = useTimelineStore();
const { activeProject } = useProjectStore();
const { const {
currentPreset, currentPreset,
isOriginal, isOriginal,
@ -420,11 +421,19 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
)} )}
> >
<span className="text-primary tabular-nums"> <span className="text-primary tabular-nums">
{formatTimeCode(currentTime, "HH:MM:SS:CS")} {formatTimeCode(
currentTime,
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span> </span>
<span className="opacity-50">/</span> <span className="opacity-50">/</span>
<span className="tabular-nums"> <span className="tabular-nums">
{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")} {formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span> </span>
</p> </p>
</div> </div>

View File

@ -9,13 +9,28 @@ import { useMediaStore } from "@/stores/media-store";
import { AudioProperties } from "./audio-properties"; import { AudioProperties } from "./audio-properties";
import { MediaProperties } from "./media-properties"; import { MediaProperties } from "./media-properties";
import { TextProperties } from "./text-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() { export function PropertiesPanel() {
const { activeProject } = useProjectStore(); const { activeProject, updateProjectFps } = useProjectStore();
const { getDisplayName, canvasSize } = useAspectRatio(); const { getDisplayName, canvasSize } = useAspectRatio();
const { selectedElements, tracks } = useTimelineStore(); const { selectedElements, tracks } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
if (!isNaN(fps) && fps > 0) {
updateProjectFps(fps);
}
};
const emptyView = ( const emptyView = (
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
{/* Media Properties */} {/* Media Properties */}
@ -26,7 +41,24 @@ export function PropertiesPanel() {
label="Resolution:" label="Resolution:"
value={`${canvasSize.width} × ${canvasSize.height}`} 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>
</div> </div>
); );

View File

@ -17,7 +17,11 @@ import type {
TimelineElement as TimelineElementType, TimelineElement as TimelineElementType,
DragData, DragData,
} from "@/types/timeline"; } 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({ export function TimelineTrackContent({
track, track,
@ -80,7 +84,10 @@ export function TimelineTrackContent({
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
); );
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); 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); updateDragTime(snappedTime);
}; };
@ -342,7 +349,9 @@ export function TimelineTrackContent({
if (dragData.type === "text") { if (dragData.type === "text") {
// Text elements have default duration of 5 seconds // Text elements have default duration of 5 seconds
const newElementDuration = 5; 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; const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => { wouldOverlap = track.elements.some((existingElement) => {
@ -361,7 +370,9 @@ export function TimelineTrackContent({
); );
if (mediaItem) { if (mediaItem) {
const newElementDuration = mediaItem.duration || 5; 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; const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => { wouldOverlap = track.elements.some((existingElement) => {
@ -401,7 +412,9 @@ export function TimelineTrackContent({
movingElement.duration - movingElement.duration -
movingElement.trimStart - movingElement.trimStart -
movingElement.trimEnd; 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; const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => { wouldOverlap = track.elements.some((existingElement) => {
@ -428,13 +441,17 @@ export function TimelineTrackContent({
if (wouldOverlap) { if (wouldOverlap) {
e.dataTransfer.dropEffect = "none"; e.dataTransfer.dropEffect = "none";
setWouldOverlap(true); setWouldOverlap(true);
setDropPosition(Math.round(dropTime * 10) / 10); const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
return; return;
} }
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false); 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) => { 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 mouseY = e.clientY - rect.top; // Get Y position relative to this track
const newStartTime = const newStartTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); 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 // Calculate drop position relative to tracks
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
@ -548,7 +567,7 @@ export function TimelineTrackContent({
const adjustedStartTime = snappedTime - clickOffsetTime; const adjustedStartTime = snappedTime - clickOffsetTime;
const finalStartTime = Math.max( const finalStartTime = Math.max(
0, 0,
Math.round(adjustedStartTime * 10) / 10 snapTimeToFrame(adjustedStartTime, projectFps)
); );
// Check for overlaps with existing elements (excluding the moving element itself) // Check for overlaps with existing elements (excluding the moving element itself)

View File

@ -81,14 +81,8 @@ export function Timeline() {
} = useTimelineStore(); } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
currentTime, usePlaybackStore();
duration,
seek,
setDuration,
isPlaying,
toggle,
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@ -215,7 +209,7 @@ export function Timeline() {
scrollLeft = tracksContent.scrollLeft; scrollLeft = tracksContent.scrollLeft;
} }
const time = Math.max( const rawTime = Math.max(
0, 0,
Math.min( Math.min(
duration, 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); seek(time);
}, },
[ [

View File

@ -76,3 +76,31 @@ export const TIMELINE_CONSTANTS = {
DEFAULT_TEXT_DURATION: 5, DEFAULT_TEXT_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const; } 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;
}

View File

@ -1,3 +1,5 @@
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps { interface UseTimelinePlayheadProps {
@ -69,7 +71,11 @@ export function useTimelinePlayhead({
if (!ruler) return; if (!ruler) return;
const rect = ruler.getBoundingClientRect(); const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left; 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); setScrubTime(time);
seek(time); // update video preview in real time seek(time); // update video preview in real time
}, },

View File

@ -6,7 +6,7 @@ import {
getImageDimensions, getImageDimensions,
type MediaItem, type MediaItem,
} from "@/stores/media-store"; } 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"> {} export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
@ -33,6 +33,7 @@ export async function processMediaFiles(
let duration: number | undefined; let duration: number | undefined;
let width: number | undefined; let width: number | undefined;
let height: number | undefined; let height: number | undefined;
let fps: number | undefined;
try { try {
if (fileType === "image") { if (fileType === "image") {
@ -41,17 +42,31 @@ export async function processMediaFiles(
width = dimensions.width; width = dimensions.width;
height = dimensions.height; height = dimensions.height;
} else if (fileType === "video") { } else if (fileType === "video") {
// Use basic thumbnail generation for now try {
const videoResult = await generateVideoThumbnail(file); // Use FFmpeg for comprehensive video info extraction
thumbnailUrl = videoResult.thumbnailUrl; const videoInfo = await getVideoInfo(file);
width = videoResult.width; duration = videoInfo.duration;
height = videoResult.height; width = videoInfo.width;
} else if (fileType === "audio") { height = videoInfo.height;
// For audio, we don't set width/height (they'll be undefined) fps = videoInfo.fps;
}
// Get duration for videos and audio (if not already set by FFmpeg) // Generate thumbnail using FFmpeg
if ((fileType === "video" || fileType === "audio") && !duration) { 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); duration = await getMediaDuration(file);
} }
@ -64,6 +79,7 @@ export async function processMediaFiles(
duration, duration,
width, width,
height, height,
fps,
}); });
// Yield back to the event loop to keep the UI responsive // Yield back to the event loop to keep the UI responsive

View File

@ -1,14 +1,16 @@
// Time-related utility functions // 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 = ( export const formatTimeCode = (
timeInSeconds: number, 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 => { ): string => {
const hours = Math.floor(timeInSeconds / 3600); const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60); const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60); const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100); const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) { switch (format) {
case "MM:SS": 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")}`; return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS": 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")}`; 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")}`;
} }
}; };

View File

@ -14,6 +14,7 @@ export interface MediaItem {
duration?: number; // For video/audio duration duration?: number; // For video/audio duration
width?: number; // For video/image width width?: number; // For video/image width
height?: number; // For video/image height height?: number; // For video/image height
fps?: number; // For video frame rate
// Text-specific properties // Text-specific properties
content?: string; // Text content content?: string; // Text content
fontSize?: number; // Font size fontSize?: number; // Font size

View File

@ -25,6 +25,7 @@ interface ProjectStore {
type: "color" | "blur", type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number } options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>; ) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
} }
export const useProjectStore = create<ProjectStore>((set, get) => ({ 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",
});
}
},
})); }));

View File

@ -370,7 +370,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} as TimelineElement; // Type assertion since we trust the caller passes valid data } 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 // 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") { if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState(); const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaItems.find( const mediaItem = mediaStore.mediaItems.find(
@ -386,6 +386,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
getMediaAspectRatio(mediaItem) 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( updateTracksAndSave(

View File

@ -8,4 +8,5 @@ export interface TProject {
backgroundColor?: string; backgroundColor?: string;
backgroundType?: "color" | "blur"; backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18) blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
} }