diff --git a/apps/web/src/components/editor/properties-panel/flip/media-flip-controls.tsx b/apps/web/src/components/editor/properties-panel/flip/media-flip-controls.tsx new file mode 100644 index 000000000..922590779 --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/flip/media-flip-controls.tsx @@ -0,0 +1 @@ +export { MediaProperties } from "../media-properties"; diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 150f95124..e03f0524a 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -38,7 +38,7 @@ export function PropertiesPanel() { return (
- +
); } diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index 4ea28bb24..f294671da 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,46 @@ -import { MediaElement } from "@/types/timeline"; +import { Button } from "@/components/ui/button"; +import { useTimelineStore } from "@/stores/timeline-store"; +import type { MediaElement } from "@/types/timeline"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; -export function MediaProperties({ element }: { element: MediaElement }) { - return
Media properties
; +interface MediaPropertiesProps { + element: MediaElement; + trackId: string; +} + +// Renders flip controls for a media element so users can mirror footage. +export function MediaProperties({ element, trackId }: MediaPropertiesProps) { + const toggleMediaFlip = useTimelineStore((state) => state.toggleMediaFlip); + const flipHorizontal = !!element.transform?.flipHorizontal; + const flipVertical = !!element.transform?.flipVertical; + + return ( +
+ + +
+ + +
+
+
+
+ ); } diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index ca47d3ca9..75637e78f 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -172,6 +172,16 @@ export function TimelineElement({ ); } + // Mirror the thumbnail when the media element is flipped on either axis. + const flipHorizontal = + element.type === "media" && element.transform?.flipHorizontal; + const flipVertical = + element.type === "media" && element.transform?.flipVertical; + const previewTransform = + flipHorizontal || flipVertical + ? `scale(${flipHorizontal ? -1 : 1}, ${flipVertical ? -1 : 1})` + : undefined; + if ( mediaItem.type === "image" || (mediaItem.type === "video" && mediaItem.thumbnailUrl) @@ -195,8 +205,12 @@ export function TimelineElement({ backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", + backgroundPosition: flipHorizontal + ? "right center" + : "left center", pointerEvents: "none", + transform: previewTransform, + transformOrigin: "center", }} aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} /> diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts index cce187f1b..02fa72d4d 100644 --- a/apps/web/src/hooks/use-frame-cache.ts +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -49,6 +49,10 @@ export function useFrameCache(options: FrameCacheOptions = {}) { trimStart: number; trimEnd: number; mediaId?: string; + transform?: { + flipHorizontal: boolean; + flipVertical: boolean; + }; // Text-specific properties content?: string; fontSize?: number; @@ -85,6 +89,11 @@ export function useFrameCache(options: FrameCacheOptions = {}) { trimStart: element.trimStart, trimEnd: element.trimEnd, mediaId: mediaElement.mediaId, + // Record flip flags so cached frames refresh when mirrors change. + transform: { + flipHorizontal: !!mediaElement.transform?.flipHorizontal, + flipVertical: !!mediaElement.transform?.flipVertical, + }, }); } else if (element.type === "text") { const textElement = element as TextElement; diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index b2650b7cf..61f834f7b 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,4 +1,8 @@ -import type { TimelineTrack } from "@/types/timeline"; +import type { + MediaElement, + MediaTransform, + TimelineTrack, +} from "@/types/timeline"; import type { MediaFile } from "@/types/media"; import type { BlurIntensity } from "@/types/project"; import { videoCache } from "./video-cache"; @@ -19,6 +23,34 @@ export interface RenderContext { const imageElementCache = new Map(); +// Applies flip transforms around the draw rectangle before rendering content. +function drawWithFlip( + ctx: CanvasRenderingContext2D, + transform: MediaTransform | undefined, + drawX: number, + drawY: number, + drawW: number, + drawH: number, + draw: () => void +) { + const flipHorizontal = !!transform?.flipHorizontal; + const flipVertical = !!transform?.flipVertical; + + if (!flipHorizontal && !flipVertical) { + draw(); + return; + } + + ctx.save(); + const centerX = drawX + drawW / 2; + const centerY = drawY + drawH / 2; + ctx.translate(centerX, centerY); + ctx.scale(flipHorizontal ? -1 : 1, flipVertical ? -1 : 1); + ctx.translate(-centerX, -centerY); + draw(); + ctx.restore(); +} + async function getImageElement( mediaItem: MediaFile ): Promise { @@ -106,8 +138,13 @@ export async function renderTimelineFrame({ (mediaItem.type === "video" || mediaItem.type === "image") ); }); - if (bgCandidate && bgCandidate.mediaItem) { - const { element, mediaItem } = bgCandidate; + if ( + bgCandidate && + bgCandidate.mediaItem && + bgCandidate.element.type === "media" + ) { + const element = bgCandidate.element as MediaElement; + const mediaItem = bgCandidate.mediaItem; try { if (mediaItem.type === "video") { const localTime = time - element.startTime + element.trimStart; @@ -129,7 +166,15 @@ export async function renderTimelineFrame({ const drawY = (canvasHeight - drawH) / 2; ctx.save(); ctx.filter = `blur(${blurPx}px)`; - ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + drawWithFlip( + ctx, + element.transform, + drawX, + drawY, + drawW, + drawH, + () => ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH) + ); ctx.restore(); } } else if (mediaItem.type === "image") { @@ -152,7 +197,15 @@ export async function renderTimelineFrame({ const drawY = (canvasHeight - drawH) / 2; ctx.save(); ctx.filter = `blur(${blurPx}px)`; - ctx.drawImage(img, drawX, drawY, drawW, drawH); + drawWithFlip( + ctx, + element.transform, + drawX, + drawY, + drawW, + drawH, + () => ctx.drawImage(img, drawX, drawY, drawW, drawH) + ); ctx.restore(); } } catch { @@ -163,6 +216,7 @@ export async function renderTimelineFrame({ for (const { element, mediaItem } of active) { if (element.type === "media" && mediaItem) { + const mediaElement = element as MediaElement; if (mediaItem.type === "video") { try { const localTime = time - element.startTime + element.trimStart; @@ -185,7 +239,15 @@ export async function renderTimelineFrame({ const drawX = (canvasWidth - drawW) / 2; const drawY = (canvasHeight - drawH) / 2; - ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + drawWithFlip( + ctx, + mediaElement.transform, + drawX, + drawY, + drawW, + drawH, + () => ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH) + ); } catch (error) { console.warn( `Failed to render video frame for ${mediaItem.name}:`, @@ -216,7 +278,15 @@ export async function renderTimelineFrame({ const drawH = mediaH * containScale; const drawX = (canvasWidth - drawW) / 2; const drawY = (canvasHeight - drawH) / 2; - ctx.drawImage(img, drawX, drawY, drawW, drawH); + drawWithFlip( + ctx, + mediaElement.transform, + drawX, + drawY, + drawW, + drawH, + () => ctx.drawImage(img, drawX, drawY, drawW, drawH) + ); } } if (element.type === "text") { diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index aeeb65c5d..d66533615 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -127,6 +127,11 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; + toggleMediaFlip: ( + trackId: string, + elementId: string, + axis: "horizontal" | "vertical" + ) => void; splitAndKeepLeft: ( trackId: string, elementId: string, @@ -871,6 +876,61 @@ export const useTimelineStore = create((set, get) => { ); }, + // Toggles the requested flip axis on a media element, persisting the transform. + toggleMediaFlip: (trackId, elementId, axis) => { + const { _tracks } = get(); + const targetTrack = _tracks.find((track) => track.id === trackId); + const targetElement = targetTrack?.elements.find( + (element) => element.id === elementId + ); + + if (!targetElement || targetElement.type !== "media") { + return; + } + + get().pushHistory(); + + updateTracksAndSave( + _tracks.map((track) => { + if (track.id !== trackId) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => { + if (element.id !== elementId || element.type !== "media") { + return element; + } + + const currentTransform = element.transform || {}; + const key = + axis === "horizontal" ? "flipHorizontal" : "flipVertical"; + const toggledValue = !currentTransform[key]; + const nextTransform = { + flipHorizontal: + key === "flipHorizontal" + ? toggledValue + : !!currentTransform.flipHorizontal, + flipVertical: + key === "flipVertical" + ? toggledValue + : !!currentTransform.flipVertical, + }; + + const hasAnyFlip = + nextTransform.flipHorizontal || nextTransform.flipVertical; + + return { + ...element, + transform: hasAnyFlip ? nextTransform : undefined, + }; + }), + }; + }) + ); + }, + updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 61ba6af0c..d97d90537 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -14,11 +14,18 @@ interface BaseTimelineElement { hidden?: boolean; } +// Describes optional flip flags to mirror media during rendering. +export interface MediaTransform { + flipHorizontal?: boolean; + flipVertical?: boolean; +} + // Media element that references MediaStore export interface MediaElement extends BaseTimelineElement { type: "media"; mediaId: string; muted?: boolean; + transform?: MediaTransform; } // Text element with embedded text data