refactor: add FPS and remove turbopack (yes, fuck you turbopack)
This commit is contained in:
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.17",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
},
|
||||
[
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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
|
||||
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;
|
||||
|
||||
// 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;
|
||||
} else if (fileType === "audio") {
|
||||
// For audio, we don't set width/height (they'll be undefined)
|
||||
duration = await getMediaDuration(file);
|
||||
// FPS will remain undefined for fallback
|
||||
}
|
||||
|
||||
// Get duration for videos and audio (if not already set by FFmpeg)
|
||||
if ((fileType === "video" || fileType === "audio") && !duration) {
|
||||
} 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
|
||||
|
@ -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")}`;
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
@ -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(
|
||||
|
@ -8,4 +8,5 @@ export interface TProject {
|
||||
backgroundColor?: string;
|
||||
backgroundType?: "color" | "blur";
|
||||
blurIntensity?: number; // in pixels (4, 8, 18)
|
||||
fps?: number;
|
||||
}
|
||||
|
Reference in New Issue
Block a user