Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@ai-sdk/anthropic": "^4.0.4",
"@ai-sdk/openai": "^4.0.4",
"@ai-sdk/openai-compatible": "^3.0.2",
"@dnd-kit/react": "^0.5.0",
"@paper-design/shaders-react": "^0.0.78",
"@reel/edl": "0.1.0",
"@remotion/bundler": "^4.0.0",
Expand Down
65 changes: 35 additions & 30 deletions app/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type CSSProperties, memo, useEffect } from "react";
import { DragDropProvider } from "@dnd-kit/react";
import { EditorHeader } from "./components/EditorHeader";
import { FloatingToolbar } from "./components/FloatingToolbar";
import { PreviewStage } from "./components/PreviewStage";
Expand Down Expand Up @@ -141,45 +142,49 @@ export function App(): JSX.Element {
{view === "home" ? (
<Home />
) : (
<div
className={`editor-shell ${panelsHidden ? "panels-hidden" : ""}`}
style={
{
"--right-panel-w": `${panelSizes.right}px`,
"--tl-h": `${panelSizes.timeline}px`,
} as CSSProperties
}
>
<EditorHeaderM />
{/* Figma 346:9830: the timeline lives INSIDE the left column (it ends
// The provider spans the Assets tab (drag sources) and the timeline
// lanes (drop targets); the Timeline monitors drops itself.
<DragDropProvider>
<div
className={`editor-shell ${panelsHidden ? "panels-hidden" : ""}`}
style={
{
"--right-panel-w": `${panelSizes.right}px`,
"--tl-h": `${panelSizes.timeline}px`,
} as CSSProperties
}
>
<EditorHeaderM />
{/* Figma 346:9830: the timeline lives INSIDE the left column (it ends
where the panel starts); the right panel runs full height. */}
<div className="editor-main">
<div className="editor-left">
<div className="editor-stage-wrap">
<PreviewStageM />
{!panelsHidden && <FloatingToolbarM />}
<div className="editor-main">
<div className="editor-left">
<div className="editor-stage-wrap">
<PreviewStageM />
{!panelsHidden && <FloatingToolbarM />}
</div>
{!panelsHidden && (
<>
<PanelResizer panel="timeline" />
<TimelineM />
</>
)}
</div>
{!panelsHidden && (
<>
<PanelResizer panel="timeline" />
<TimelineM />
<PanelResizer panel="right" />
<EditorPanelM />
</>
)}
</div>
{!panelsHidden && (
<>
<PanelResizer panel="right" />
<EditorPanelM />
</>
<ExportModal />
{!hasEdl && (
<div className="boot">
{loadError ? `Could not load project: ${loadError}` : "Loading project…"}
</div>
)}
</div>
<ExportModal />
{!hasEdl && (
<div className="boot">
{loadError ? `Could not load project: ${loadError}` : "Loading project…"}
</div>
)}
</div>
</DragDropProvider>
)}
{notices.length > 0 && (
<div className="toast-stack" role="region" aria-label="Notifications">
Expand Down
127 changes: 88 additions & 39 deletions app/src/renderer/src/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { type DragEvent, type MouseEvent, useRef, useState } from "react";
import { type MouseEvent, type ReactNode, useRef, useState } from "react";
import { useDragDropMonitor, useDroppable } from "@dnd-kit/react";
import { durationSeconds, MAX_TIMELINE_SEC, type Asset, type Track } from "@reel/edl";
import { useEditor } from "../store";
import { addAssets, addTrack, renameTrack } from "../lib/edl-edit";
import { pathsFrom } from "../lib/files";
import {
ASSET_MIME,
assetDurationFor,
clipsOf,
commit,
Expand Down Expand Up @@ -56,10 +56,56 @@ export function Timeline(): JSX.Element {
// NOTE: the Space play/pause shortcut lives in App.tsx (editor-level), not
// here — this component unmounts in Cmd+\ focus mode.

const pxPerSec = PX_PER_SEC * zoom;

// Drops from the Assets tab (dnd-kit). The monitor registers once with a
// stable callback; the ref keeps the handler's closures (zoom, store) fresh
// across renders. Reads go through getState so a stale EDL can't be used.
const dropAssetAt = (assetId: string, kind: string, trackId: string, sec: number) => {
const s = useEditor.getState();
const asset = s.edl?.assets.find((a) => a.id === assetId);
if (!asset) return;
const at = round(sec);
if (kind === "video") {
s.updateEdl((d) => {
const t = d.tracks.find((x) => x.id === trackId);
if (t?.type === "video") {
t.clips.push({
id: `c-${assetId}-${Date.now().toString(36)}`,
assetId,
start: at,
in: 0,
out: round(asset.durationSec ?? 3),
volume: 1,
});
}
});
} else if (kind === "audio") {
s.updateEdl((d) => placeAudioOnTrack(d, trackId, assetId, asset.durationSec, at));
}
};
const dropRef = useRef(dropAssetAt);
dropRef.current = dropAssetAt;
const pxPerSecRef = useRef(pxPerSec);
pxPerSecRef.current = pxPerSec;

useDragDropMonitor({
onDragEnd(event) {
if (event.canceled) return;
const { source, target, position } = event.operation;
if (!source || !target) return;
const data = source.data as { assetId?: string; kind?: string } | undefined;
const lane = target.data as { trackId?: string } | undefined;
if (!data?.assetId || !data.kind || !lane?.trackId) return;
const rect = (target as { element?: Element }).element?.getBoundingClientRect();
const sec = rect ? Math.max(0, (position.current.x - rect.left) / pxPerSecRef.current) : 0;
dropRef.current(data.assetId, data.kind, lane.trackId, sec);
},
});

if (!edl) return <section className="tl" />;

const fps = edl.format.fps;
const pxPerSec = PX_PER_SEC * zoom;
// Clamp so a corrupt/hostile EDL can never drive the tick loop or lane width
// unbounded (schema bounds timings too; this is belt-and-braces).
const dur = Math.min(Math.max(durationSeconds(edl), 6), MAX_TIMELINE_SEC);
Expand Down Expand Up @@ -237,34 +283,6 @@ export function Timeline(): JSX.Element {
});
};

// Drops from the Assets tab (existing assets).
const onAssetDrop = (e: DragEvent<HTMLDivElement>, track: LaneTrack) => {
const raw = e.dataTransfer.getData(ASSET_MIME);
if (!raw) return;
e.preventDefault();
const { assetId, kind } = JSON.parse(raw) as { assetId: string; kind: string };
const asset = edl.assets.find((a) => a.id === assetId);
if (!asset) return;
const at = round(laneSec(e));
if (track.type === "video" && kind === "video") {
updateEdl((d) => {
const t = d.tracks.find((x) => x.id === track.id);
if (t?.type === "video") {
t.clips.push({
id: `c-${assetId}-${Date.now().toString(36)}`,
assetId,
start: at,
in: 0,
out: round(asset.durationSec ?? 3),
volume: 1,
});
}
});
} else if (track.type === "audio" && kind === "audio") {
updateEdl((d) => placeAudioOnTrack(d, track.id, assetId, asset.durationSec, at));
}
};

const ticks: number[] = [];
for (let s = 0; s <= Math.ceil(dur); s += 2) ticks.push(s);

Expand Down Expand Up @@ -356,19 +374,15 @@ export function Timeline(): JSX.Element {
: undefined
}
/>
<div
className={`tl-lane tl-lane-${track.type}`}
style={{ width: lanePx }}
<DroppableLane
track={track}
lanePx={lanePx}
onMouseDown={(e) => {
if (track.type === "text") sketchText(e, track);
}}
onClick={(e) => {
if (track.type !== "text") pickForLane(e, track);
}}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(ASSET_MIME)) e.preventDefault();
}}
onDrop={(e) => onAssetDrop(e, track)}
>
{clipsOf(track).map((clip) => {
const geom = geomFor(track.type, clip, drag);
Expand Down Expand Up @@ -416,7 +430,7 @@ export function Timeline(): JSX.Element {
{track.clips.length === 0 && !ghost && (
<div className="tl-lane-hint">{emptyHint(track.type)}</div>
)}
</div>
</DroppableLane>
</div>
))}

Expand Down Expand Up @@ -456,6 +470,41 @@ export function Timeline(): JSX.Element {
);
}

/* ---------- droppable lane (dnd-kit target for Assets-tab drags) ---------- */

function DroppableLane({
track,
lanePx,
onMouseDown,
onClick,
children,
}: {
track: LaneTrack;
lanePx: number;
onMouseDown: (e: MouseEvent<HTMLDivElement>) => void;
onClick: (e: MouseEvent<HTMLDivElement>) => void;
children: ReactNode;
}): JSX.Element {
const { ref, isDropTarget } = useDroppable({
id: `lane-${track.id}`,
// Type-based acceptance: video lanes take video assets, audio lanes take
// audio; text lanes accept nothing (their clips are sketched, not dropped).
accept: track.type === "video" ? ["video"] : track.type === "audio" ? ["audio"] : [],
data: { trackId: track.id },
});
return (
<div
ref={ref}
className={`tl-lane tl-lane-${track.type} ${isDropTarget ? "drop-target" : ""}`}
style={{ width: lanePx }}
onMouseDown={onMouseDown}
onClick={onClick}
>
{children}
</div>
);
}

/* ---------- chip content per track type ---------- */

function ChipBody({
Expand Down
77 changes: 47 additions & 30 deletions app/src/renderer/src/components/panel/AssetsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type DragEvent, useRef, useState } from "react";
import { useDraggable } from "@dnd-kit/react";
import type { Asset } from "@reel/edl";
import { useEditor } from "../../store";
import { addAssets, addAudioClip } from "../../lib/edl-edit";
import { ASSET_MIME } from "../../lib/timeline-geometry";
import { pathsFrom } from "../../lib/files";
import { Icon, Menu, MenuHeader, MenuItem, MenuSection } from "../ui";

Expand Down Expand Up @@ -124,23 +125,7 @@ export function AssetsTab(): JSX.Element {
{clips.length > 0 && (
<div className="panel-grid">
{clips.map((a) => (
<div
key={a.id}
className="panel-cell"
title={`${a.src} — drag onto the timeline`}
draggable
onDragStart={(e) =>
e.dataTransfer.setData(ASSET_MIME, JSON.stringify({ assetId: a.id, kind: a.kind }))
}
>
{slug &&
(a.kind === "image" ? (
<img src={`reel-asset://${slug}/${a.src}`} alt="" />
) : (
<video src={`reel-asset://${slug}/${a.proxySrc ?? a.src}`} muted preload="metadata" />
))}
<span className="panel-cell-name">{a.src.replace(/^assets\//, "")}</span>
</div>
<DraggableClipCell key={a.id} asset={a} slug={slug} />
))}
</div>
)}
Expand Down Expand Up @@ -184,22 +169,54 @@ export function AssetsTab(): JSX.Element {
) : (
<div className="panel-list">
{audio.map((a) => (
<div
key={a.id}
className="panel-row"
title={`${a.src} — drag onto an audio layer`}
draggable
onDragStart={(e) =>
e.dataTransfer.setData(ASSET_MIME, JSON.stringify({ assetId: a.id, kind: a.kind }))
}
>
<Icon name="voice-high" size={14} />
<span className="panel-row-name">{a.src.replace(/^assets\//, "")}</span>
</div>
<DraggableAudioRow key={a.id} asset={a} />
))}
</div>
)}
</div>
</div>
);
}

/** dnd-kit drag source: a clip cell the timeline's video lanes accept. */
function DraggableClipCell({ asset, slug }: { asset: Asset; slug: string | null }): JSX.Element {
const { ref, isDragging } = useDraggable({
id: `asset-${asset.id}`,
type: asset.kind,
data: { assetId: asset.id, kind: asset.kind },
});
return (
<div
ref={ref}
className={`panel-cell ${isDragging ? "dragging" : ""}`}
title={`${asset.src} — drag onto the timeline`}
>
{slug &&
(asset.kind === "image" ? (
<img src={`reel-asset://${slug}/${asset.src}`} alt="" />
) : (
<video src={`reel-asset://${slug}/${asset.proxySrc ?? asset.src}`} muted preload="metadata" />
))}
<span className="panel-cell-name">{asset.src.replace(/^assets\//, "")}</span>
</div>
);
}

/** dnd-kit drag source: an audio row the timeline's audio lanes accept. */
function DraggableAudioRow({ asset }: { asset: Asset }): JSX.Element {
const { ref, isDragging } = useDraggable({
id: `asset-${asset.id}`,
type: asset.kind,
data: { assetId: asset.id, kind: asset.kind },
});
return (
<div
ref={ref}
className={`panel-row ${isDragging ? "dragging" : ""}`}
title={`${asset.src} — drag onto an audio layer`}
>
<Icon name="voice-high" size={14} />
<span className="panel-row-name">{asset.src.replace(/^assets\//, "")}</span>
</div>
);
}
1 change: 0 additions & 1 deletion app/src/renderer/src/lib/timeline-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { Edl, Track } from "@reel/edl";
export const LABEL_W = 68;
export const PX_PER_SEC = 60;
export const MIN_DUR = 0.2;
export const ASSET_MIME = "application/x-aperture-asset";

export type DragMode = "move" | "left" | "right";
/** Timeline lanes render clip-bearing tracks only (captions burn in via preview). */
Expand Down
Loading
Loading