From 22f8565206de93ccf7ec707fb47d2c420554f421 Mon Sep 17 00:00:00 2001 From: Joep Duin <165405982+joepduin@users.noreply.github.com> Date: Sat, 25 Oct 2025 16:57:20 +0000 Subject: [PATCH 1/2] feat: add media flip controls --- .../flip/media-flip-controls.tsx | 45 ++++++++++ .../editor/properties-panel/index.tsx | 2 +- .../properties-panel/media-properties.tsx | 46 +++++++++- .../editor/timeline/timeline-element.tsx | 15 +++- apps/web/src/hooks/use-frame-cache.ts | 8 ++ apps/web/src/lib/timeline-renderer.ts | 83 +++++++++++++++++-- apps/web/src/stores/timeline-store.ts | 59 +++++++++++++ apps/web/src/types/timeline.ts | 6 ++ 8 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/editor/properties-panel/flip/media-flip-controls.tsx 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..90e833d07 --- /dev/null +++ b/apps/web/src/components/editor/properties-panel/flip/media-flip-controls.tsx @@ -0,0 +1,45 @@ +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"; + +interface MediaPropertiesProps { + element: MediaElement; + trackId: string; +} + +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/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..90e833d07 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,45 @@ -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; +} + +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..248ddd298 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -172,6 +172,15 @@ export function TimelineElement({ ); } + 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 +204,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..22a7a5ffd 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,10 @@ export function useFrameCache(options: FrameCacheOptions = {}) { trimStart: element.trimStart, trimEnd: element.trimEnd, mediaId: mediaElement.mediaId, + 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..ba0ff3400 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,33 @@ export interface RenderContext { const imageElementCache = new Map(); +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 +137,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 +165,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 +196,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 +215,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 +238,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 +277,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..dc902263f 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,60 @@ export const useTimelineStore = create((set, get) => { ); }, + 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..5a0e51084 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -14,11 +14,17 @@ interface BaseTimelineElement { hidden?: boolean; } +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 From 6781df514f922b2858279ccaaffb2d882fb552e1 Mon Sep 17 00:00:00 2001 From: Joep Duin <165405982+joepduin@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:30:28 +0000 Subject: [PATCH 2/2] feat: add media flip controls --- .../flip/media-flip-controls.tsx | 46 +------------------ .../properties-panel/media-properties.tsx | 1 + .../editor/timeline/timeline-element.tsx | 1 + apps/web/src/hooks/use-frame-cache.ts | 1 + apps/web/src/lib/timeline-renderer.ts | 1 + apps/web/src/stores/timeline-store.ts | 1 + apps/web/src/types/timeline.ts | 1 + 7 files changed, 7 insertions(+), 45 deletions(-) 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 index 90e833d07..922590779 100644 --- 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 @@ -1,45 +1 @@ -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"; - -interface MediaPropertiesProps { - element: MediaElement; - trackId: string; -} - -export function MediaProperties({ element, trackId }: MediaPropertiesProps) { - const toggleMediaFlip = useTimelineStore((state) => state.toggleMediaFlip); - const flipHorizontal = !!element.transform?.flipHorizontal; - const flipVertical = !!element.transform?.flipVertical; - - return ( -
- - -
- - -
-
-
-
- ); -} +export { MediaProperties } from "../media-properties"; 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 90e833d07..f294671da 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -13,6 +13,7 @@ interface MediaPropertiesProps { 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; diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 248ddd298..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,7 @@ 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 = diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts index 22a7a5ffd..02fa72d4d 100644 --- a/apps/web/src/hooks/use-frame-cache.ts +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -89,6 +89,7 @@ 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, diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index ba0ff3400..61f834f7b 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -23,6 +23,7 @@ export interface RenderContext { const imageElementCache = new Map(); +// Applies flip transforms around the draw rectangle before rendering content. function drawWithFlip( ctx: CanvasRenderingContext2D, transform: MediaTransform | undefined, diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index dc902263f..d66533615 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -876,6 +876,7 @@ 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); diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 5a0e51084..d97d90537 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -14,6 +14,7 @@ interface BaseTimelineElement { hidden?: boolean; } +// Describes optional flip flags to mirror media during rendering. export interface MediaTransform { flipHorizontal?: boolean; flipVertical?: boolean;