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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MediaProperties } from "../media-properties";
2 changes: 1 addition & 1 deletion apps/web/src/components/editor/properties-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function PropertiesPanel() {

return (
<div key={elementId}>
<MediaProperties element={element} />
<MediaProperties element={element} trackId={trackId} />
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <div className="space-y-4 p-5">Media properties</div>;
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 (
<div className="space-y-4 p-5">
<PropertyGroup title="Flip">
<PropertyItemValue>
<div className="flex gap-2">
<Button
variant={flipHorizontal ? "default" : "outline"}
size="sm"
onClick={() => toggleMediaFlip(trackId, element.id, "horizontal")}
>
Flip Horizontal
</Button>
<Button
variant={flipVertical ? "default" : "outline"}
size="sm"
onClick={() => toggleMediaFlip(trackId, element.id, "vertical")}
>
Flip Vertical
</Button>
</div>
</PropertyItemValue>
</PropertyGroup>
</div>
);
}
16 changes: 15 additions & 1 deletion apps/web/src/components/editor/timeline/timeline-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}`}
/>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/hooks/use-frame-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 77 additions & 7 deletions apps/web/src/lib/timeline-renderer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +23,34 @@ export interface RenderContext {

const imageElementCache = new Map<string, HTMLImageElement>();

// 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<HTMLImageElement> {
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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}:`,
Expand Down Expand Up @@ -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") {
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/stores/timeline-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -871,6 +876,61 @@ export const useTimelineStore = create<TimelineStore>((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(
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/types/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down