Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 415 additions & 0 deletions PHASE_1_MVP.md

Large diffs are not rendered by default.

81 changes: 80 additions & 1 deletion src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"use client";

import { useEffect, useRef, useState, useCallback, RefObject } from "react";
import { EditRecipe, TextOverlay } from "@/lib/types";
import { EditRecipe, TextOverlay, TimelineTrack, MultiTrackEditorState } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import { Camera } from "lucide-react";
Expand All @@ -16,6 +16,9 @@ interface Props {
selectedTextId?: string | null;
onSelectText?: (id: string | null) => void;
onUpdateText?: (id: string, updates: Partial<TextOverlay>) => void;
// Phase 1 MVP: Multi-track support
multiTrackState?: MultiTrackEditorState | null;
multiTrackVideoRefs?: Record<string, RefObject<HTMLVideoElement | null>>;
}

export default function VideoPreview({
Expand All @@ -25,6 +28,8 @@ export default function VideoPreview({
selectedTextId = null,
onSelectText,
onUpdateText,
multiTrackState,
multiTrackVideoRefs,
}: Props) {
const lastId = useRef(0);
const urlRef = useRef<string | null>(null);
Expand All @@ -37,6 +42,9 @@ export default function VideoPreview({
});
const previewContainerRef = useRef<HTMLDivElement>(null);
const onLoadedRef = useRef<(() => void) | null>(null);

// Phase 1 MVP: Multi-track URL management
const multiTrackUrlRefs = useRef<Record<string, string | null>>({});

const handleGrabFrame = useCallback(() => {
const video = videoRef.current;
Expand Down Expand Up @@ -114,6 +122,40 @@ export default function VideoPreview({
};
}, [file, videoRef]);

// Phase 1 MVP: Setup multi-track video sources
useEffect(() => {
if (!multiTrackState || !multiTrackVideoRefs) return;

multiTrackState.timelineTracks.forEach((track) => {
if (track.type !== "video" || !track.source) return;

const videoRef = multiTrackVideoRefs[track.id];
if (!videoRef?.current) return;

// Cleanup old URL
if (multiTrackUrlRefs.current[track.id]) {
URL.revokeObjectURL(multiTrackUrlRefs.current[track.id]!);
}

// Create new URL and load
const url = URL.createObjectURL(track.source);
multiTrackUrlRefs.current[track.id] = url;
videoRef.current.src = url;
videoRef.current.load();

// Auto-play for preview
videoRef.current.play().catch(() => {});
});

return () => {
// Cleanup URLs on unmount
Object.values(multiTrackUrlRefs.current).forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
multiTrackUrlRefs.current = {};
};
}, [multiTrackState, multiTrackVideoRefs]);

useEffect(() => {
if (!videoRef.current || !recipe) return;
videoRef.current.muted = !recipe.keepAudio;
Expand Down Expand Up @@ -240,6 +282,43 @@ export default function VideoPreview({
<track kind="captions" />
</video>

{/* Phase 1 MVP: Multi-track overlay rendering */}
{multiTrackState && multiTrackVideoRefs && multiTrackState.timelineTracks.length > 1 && (
<div className="absolute inset-0 pointer-events-none" role="region" aria-label="Multi-track overlay layers">
{multiTrackState.timelineTracks
.filter((track) => track.visible && track.type === "video" && track.source && track.zIndex > 0)
.sort((a, b) => a.zIndex - b.zIndex)
.map((track) => {
const videoRef = multiTrackVideoRefs[track.id];
if (!videoRef) return null;

return (
<video
key={track.id}
ref={videoRef}
className="absolute"
style={{
left: track.position.x === -1 ? "50%" : `${track.position.x}px`,
top: track.position.y === -1 ? "50%" : `${track.position.y}px`,
width: `${track.scale * 100}%`,
height: "auto",
opacity: track.opacity / 100,
transform:
track.position.x === -1 && track.position.y === -1
? "translate(-50%, -50%)"
: "none",
zIndex: track.zIndex,
}}
muted
playsInline
>
<track kind="captions" />
</video>
);
})}
</div>
)}

{/* Letterbox / Crop overlay */}
{overlay && (
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
Expand Down
40 changes: 38 additions & 2 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
"use client";

import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types";
import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe, TimelineTrack, MultiTrackEditorState } from "@/lib/types";
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
import { suggestPreset } from "@/lib/presetSuggestion";
import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation";
import {
createMultiTrackState,
addTrackToTimeline,
removeTrackFromTimeline,
updateTrackInTimeline,
createTimelineTrack,
validateMultiTrackState,
} from "@/lib/timeline";

const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
const STORAGE_KEY = "reframe:recipe";
Expand Down Expand Up @@ -174,7 +182,29 @@ export function useVideoEditor() {
const [overlaySize, setOverlaySize] = useState(150);
const [overlayOpacity, setOverlayOpacity] = useState(100);
const [currentTime, setCurrentTime] = useState(0);
const updateRecipe = useCallback((patch: Partial<EditRecipe>) => {

// Phase 1 MVP: Multi-track timeline support
const [multiTrackState, setMultiTrackState] = useState<MultiTrackEditorState>(createMultiTrackState);

const addTrack = useCallback((track: TimelineTrack) => {
setMultiTrackState(prev => addTrackToTimeline(prev, track));
}, []);

const removeTrack = useCallback((trackId: string) => {
setMultiTrackState(prev => removeTrackFromTimeline(prev, trackId));
}, []);

const updateTrack = useCallback((trackId: string, updates: Partial<TimelineTrack>) => {
setMultiTrackState(prev => updateTrackInTimeline(prev, trackId, updates));
}, []);

const addVideoTrack = useCallback((videoFile: File, startTime: number = 0) => {
const track = createTimelineTrack("video", videoFile, startTime);
addTrack(track);
return track;
}, [addTrack]);

const updateRecipe = useCallback((patch: Partial<EditRecipe>) => {
setRecipe((prev) => {
const next = { ...prev, ...patch };
// GIF has no audio — force keepAudio off
Expand Down Expand Up @@ -715,5 +745,11 @@ export function useVideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
// Phase 1 MVP: Multi-track timeline support
multiTrackState,
addTrack,
removeTrack,
updateTrack,
addVideoTrack,
};
}
Loading
Loading