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