mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): drag assets from the sidebar onto the timeline (#464)
## Problem Studio still broke down in three concrete authoring flows around timeline assets: - you could import media into Assets, but not drag an already-imported asset from the Assets tab onto the timeline and persist it into source - dragging a file from outside the app onto the timeline only uploaded it into Assets instead of placing it at the dropped time/track - once a clip was on the timeline, there was no reliable keyboard delete flow for removing it safely from source While implementing direct external drops, another real bug showed up: - valid binary uploads like `raycast.mp4` from `Downloads` were being rejected as unsupported media in Studio dev because the Vite API bridge was corrupting multipart request bodies before they reached the upload route ## What this fixes ### Timeline asset placement from inside Studio - asset cards in the Assets tab are draggable - the timeline accepts asset drops even when it already has clips - dropping an asset onto the timeline inserts a new clip into the active composition source at the dropped time / track - asset paths are rewritten relative to the target composition file so drops into sub-compositions resolve correctly - the new clip is persisted immediately and the preview refreshes ### Direct external file drops onto the timeline - dropping a file from outside the app onto the timeline now uploads it and places it onto the dropped track/time in one shot - it no longer stops halfway by only adding the file into Assets - multiple dropped files are placed using the same drop start and successive tracks ### Delete key support - selected timeline clips can now be deleted with `Delete` / `Backspace` - deletion is persisted back to source, not just removed from local state - the delete path now uses a server-side DOM mutation helper with LinkeDOM for structural safety instead of client-side string surgery ### Binary upload fix for media files - the Studio Vite API bridge now forwards non-GET request bodies as raw bytes instead of decoding them as UTF-8 text - that preserves multipart uploads for binary media like MP4s - valid local videos from `Downloads` no longer get rejected as `Unsupported media skipped` just because the dev bridge corrupted the request body - upload validation now probes buffered media through a temp file path that preserves the file extension before saving into the project ## Root cause There were really two separate gaps: ### 1. Asset placement / deletion workflow gaps The timeline and asset systems already existed, but they were disconnected: - `AssetsTab` only supported copy/import flows - `Timeline` only handled raw file import, not positioned placement for existing assets - there was no utility layer for converting a dropped asset into persisted timeline HTML - there was no structurally safe deletion path for arbitrary selected timeline clips ### 2. Binary upload corruption in Studio dev The Studio Vite API bridge rebuilt non-GET request bodies like this: - read each request chunk - call `chunk.toString()` - concatenate into a string - construct the Fetch `Request` from that string body That works for text, but it corrupts multipart binary uploads. By the time the upload route wrote the received file and ran `ffprobe`, otherwise valid MP4s had already been mangled in-flight. ## Behavior - dropping on `index.html` inserts the asset into the root composition - dropping while drilled into a composition inserts into that composition file instead - drop X position maps to `data-start` - drop Y position maps to the current visible track row, with a new bottom track created if the drop lands below existing rows - images default to a short finite duration - audio/video default to their metadata duration when available, with a fallback duration if metadata cannot be read quickly - pressing `Delete` on a selected clip removes that clip from the underlying HTML source and clears selection in Studio - valid uploaded MP4s now survive the Studio dev API bridge intact instead of being rejected during upload validation ## Verification ### Local checks - `bunx oxlint packages/core/src/studio-api/helpers/sourceMutation.ts packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/core/src/studio-api/routes/files.ts packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/sidebar/AssetsTab.tsx packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.config.ts packages/studio/vite.request-body.ts packages/studio/vite.request-body.test.ts` - `bunx oxfmt --check` on the touched files - `bun run --filter @hyperframes/core typecheck` - `bun run --filter @hyperframes/studio typecheck` - `bun test packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.request-body.test.ts` ### Browser / live verification Verified against a live local Studio fixture: - dragging an existing asset from the Assets tab onto the timeline creates a persisted clip at the dropped position - dropping a file from outside the app directly onto the timeline uploads it and creates a persisted clip at the dropped position - selecting a dropped clip and pressing `Delete` removes it from both the live timeline and the saved source HTML - valid MP4 uploads like `raycast.mp4` now succeed through the live Studio upload route instead of being rejected as unsupported media ## Notes - the local `timeline-trio-verify` and `timeline-overlap-debug` projects used for verification are local-only and are not part of this PR - this PR is about asset placement, upload correctness, and deletion safety; it does not broaden into richer editing workflows beyond placing/removing clips from the timeline
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { initSandboxRuntimeModular } from "./init";
|
||||||
|
import type { RuntimeTimelineLike } from "./types";
|
||||||
|
|
||||||
|
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||||
|
const state = { time: 0, paused: true };
|
||||||
|
return {
|
||||||
|
play: () => {
|
||||||
|
state.paused = false;
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
state.paused = true;
|
||||||
|
},
|
||||||
|
seek: (time: number) => {
|
||||||
|
state.time = time;
|
||||||
|
},
|
||||||
|
totalTime: (time: number) => {
|
||||||
|
state.time = time;
|
||||||
|
},
|
||||||
|
time: () => state.time,
|
||||||
|
duration: () => duration,
|
||||||
|
add: () => {},
|
||||||
|
paused: (value?: boolean) => {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
state.paused = value;
|
||||||
|
}
|
||||||
|
return state.paused;
|
||||||
|
},
|
||||||
|
timeScale: () => {},
|
||||||
|
set: () => {},
|
||||||
|
getChildren: () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("initSandboxRuntimeModular", () => {
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
|
||||||
|
globalThis.CSS.escape ??= (value: string) => value;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(window as Window & { __hfRuntimeTeardown?: (() => void) | null }).__hfRuntimeTeardown?.();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete (window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines;
|
||||||
|
delete (window as Window & { __player?: unknown }).__player;
|
||||||
|
delete (window as Window & { __playerReady?: boolean }).__playerReady;
|
||||||
|
delete (window as Window & { __renderReady?: boolean }).__renderReady;
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shorter live child timeline when the authored window is longer", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const child = document.createElement("div");
|
||||||
|
child.setAttribute("data-composition-id", "slide-1");
|
||||||
|
child.setAttribute("data-start", "0");
|
||||||
|
child.setAttribute("data-hf-authored-duration", "14");
|
||||||
|
root.appendChild(child);
|
||||||
|
|
||||||
|
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
|
||||||
|
main: createMockTimeline(20),
|
||||||
|
"slide-1": createMockTimeline(8),
|
||||||
|
};
|
||||||
|
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
const player = (
|
||||||
|
window as Window & {
|
||||||
|
__player?: { renderSeek: (timeSeconds: number) => void };
|
||||||
|
}
|
||||||
|
).__player;
|
||||||
|
expect(player).toBeDefined();
|
||||||
|
|
||||||
|
player?.renderSeek(9);
|
||||||
|
|
||||||
|
expect(child.style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shorter authored host window when the child timeline is longer", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const child = document.createElement("div");
|
||||||
|
child.setAttribute("data-composition-id", "slide-1");
|
||||||
|
child.setAttribute("data-start", "0");
|
||||||
|
child.setAttribute("data-hf-authored-duration", "2");
|
||||||
|
root.appendChild(child);
|
||||||
|
|
||||||
|
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
|
||||||
|
main: createMockTimeline(20),
|
||||||
|
"slide-1": createMockTimeline(8),
|
||||||
|
};
|
||||||
|
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
const player = (
|
||||||
|
window as Window & {
|
||||||
|
__player?: { renderSeek: (timeSeconds: number) => void };
|
||||||
|
}
|
||||||
|
).__player;
|
||||||
|
expect(player).toBeDefined();
|
||||||
|
|
||||||
|
player?.renderSeek(3);
|
||||||
|
|
||||||
|
expect(child.style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -368,13 +368,16 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
return resolver.resolveStartForElement(element, fallback);
|
return resolver.resolveStartForElement(element, fallback);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDurationForElement = (element: Element): number | null => {
|
const resolveDurationForElement = (
|
||||||
|
element: Element,
|
||||||
|
opts?: { includeAuthoredTimingAttrs?: boolean },
|
||||||
|
): number | null => {
|
||||||
const resolver = createRuntimeStartTimeResolver({
|
const resolver = createRuntimeStartTimeResolver({
|
||||||
timelineRegistry: (window.__timelines ?? {}) as Record<
|
timelineRegistry: (window.__timelines ?? {}) as Record<
|
||||||
string,
|
string,
|
||||||
RuntimeTimelineLike | undefined
|
RuntimeTimelineLike | undefined
|
||||||
>,
|
>,
|
||||||
includeAuthoredTimingAttrs: true,
|
includeAuthoredTimingAttrs: opts?.includeAuthoredTimingAttrs ?? true,
|
||||||
});
|
});
|
||||||
return resolver.resolveDurationForElement(element);
|
return resolver.resolveDurationForElement(element);
|
||||||
};
|
};
|
||||||
@@ -1234,18 +1237,28 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const start = resolveStartForElement(rawNode, 0);
|
const start = resolveStartForElement(rawNode, 0);
|
||||||
const duration = resolveDurationForElement(rawNode);
|
let duration = resolveDurationForElement(rawNode);
|
||||||
const end = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
|
||||||
// For composition hosts, use the composition timeline's duration to compute end
|
|
||||||
let computedEnd = end;
|
|
||||||
const compId = rawNode.getAttribute("data-composition-id");
|
const compId = rawNode.getAttribute("data-composition-id");
|
||||||
if (compId && !Number.isFinite(end)) {
|
if (compId) {
|
||||||
const compTimeline = (window.__timelines ?? {})[compId];
|
const compTimeline = (window.__timelines ?? {})[compId];
|
||||||
|
let liveDuration: number | null = null;
|
||||||
if (compTimeline && typeof compTimeline.duration === "function") {
|
if (compTimeline && typeof compTimeline.duration === "function") {
|
||||||
const compDur = compTimeline.duration();
|
const compDur = Number(compTimeline.duration());
|
||||||
if (compDur > 0) computedEnd = start + compDur;
|
if (Number.isFinite(compDur) && compDur > 0) {
|
||||||
|
liveDuration = compDur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composition hosts must respect both the authored clip window in the parent
|
||||||
|
// composition and the child composition's own live timeline duration.
|
||||||
|
if (duration != null && duration > 0 && liveDuration != null) {
|
||||||
|
duration = Math.min(duration, liveDuration);
|
||||||
|
} else if ((duration == null || duration <= 0) && liveDuration != null) {
|
||||||
|
duration = liveDuration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const computedEnd =
|
||||||
|
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
||||||
const isVisibleNow =
|
const isVisibleNow =
|
||||||
state.currentTime >= start &&
|
state.currentTime >= start &&
|
||||||
(Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
|
(Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateUploadedMedia, validateUploadedMediaBuffer } from "./mediaValidation.js";
|
||||||
|
|
||||||
|
describe("validateUploadedMedia", () => {
|
||||||
|
it("passes through non-media files", () => {
|
||||||
|
expect(
|
||||||
|
validateUploadedMedia("/tmp/test.svg", () => ({ status: 0, stdout: "", stderr: "" })),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts video files with a video stream", () => {
|
||||||
|
expect(
|
||||||
|
validateUploadedMedia("/tmp/test.mp4", () => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
|
||||||
|
stderr: "",
|
||||||
|
})),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects video files with no supported video stream", () => {
|
||||||
|
expect(
|
||||||
|
validateUploadedMedia("/tmp/test.mp4", () => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: JSON.stringify({ streams: [] }),
|
||||||
|
stderr: "",
|
||||||
|
})),
|
||||||
|
).toEqual({ ok: false, reason: "no supported video stream found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts audio files with an audio stream", () => {
|
||||||
|
expect(
|
||||||
|
validateUploadedMedia("/tmp/test.wav", () => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: JSON.stringify({ streams: [{ codec_type: "audio" }] }),
|
||||||
|
stderr: "",
|
||||||
|
})),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block upload when ffprobe is unavailable", () => {
|
||||||
|
expect(
|
||||||
|
validateUploadedMedia("/tmp/test.mp4", () => ({
|
||||||
|
status: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
error: { code: "ENOENT" } as NodeJS.ErrnoException,
|
||||||
|
})),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateUploadedMediaBuffer", () => {
|
||||||
|
it("validates media from a temp file that preserves the extension", () => {
|
||||||
|
let inspectedPath = "";
|
||||||
|
expect(
|
||||||
|
validateUploadedMediaBuffer("raycast.mp4", new Uint8Array([0, 1, 2]), (_command, args) => {
|
||||||
|
inspectedPath = args.at(-1) ?? "";
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(inspectedPath).toMatch(/raycast\.mp4$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
|
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||||
|
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||||
|
|
||||||
|
type FfprobeRunner = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
) => {
|
||||||
|
status: number | null;
|
||||||
|
stdout: string | Buffer;
|
||||||
|
stderr: string | Buffer;
|
||||||
|
error?: NodeJS.ErrnoException;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateUploadedMedia(
|
||||||
|
filePath: string,
|
||||||
|
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
|
||||||
|
): { ok: true } | { ok: false; reason: string } {
|
||||||
|
const isVideo = VIDEO_EXT.test(filePath);
|
||||||
|
const isAudio = AUDIO_EXT.test(filePath);
|
||||||
|
if (!isVideo && !isAudio) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runner("ffprobe", [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_type",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.error?.code === "ENOENT") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return { ok: false, reason: "ffprobe failed to read the media file" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(result.stdout || "{}")) as {
|
||||||
|
streams?: Array<{ codec_type?: string }>;
|
||||||
|
};
|
||||||
|
const streams = parsed.streams ?? [];
|
||||||
|
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||||
|
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||||
|
|
||||||
|
if (isVideo && !hasVideo) {
|
||||||
|
return { ok: false, reason: "no supported video stream found" };
|
||||||
|
}
|
||||||
|
if (isAudio && !hasAudio) {
|
||||||
|
return { ok: false, reason: "no supported audio stream found" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "ffprobe returned unreadable media metadata" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUploadedMediaBuffer(
|
||||||
|
fileName: string,
|
||||||
|
buffer: Uint8Array,
|
||||||
|
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
|
||||||
|
): { ok: true } | { ok: false; reason: string } {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "hyperframes-upload-"));
|
||||||
|
const tempPath = join(tempDir, basename(fileName));
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(tempPath, buffer);
|
||||||
|
return validateUploadedMedia(tempPath, runner);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { removeElementFromHtml } from "./sourceMutation.js";
|
||||||
|
|
||||||
|
describe("removeElementFromHtml", () => {
|
||||||
|
it("removes a self-closing element by id", () => {
|
||||||
|
const html = `<!doctype html><html><body><div data-composition-id="main"><img id="photo" src="asset.png" /><div id="rest"></div></div></body></html>`;
|
||||||
|
|
||||||
|
const updated = removeElementFromHtml(html, { id: "photo" });
|
||||||
|
|
||||||
|
expect(updated).not.toContain(`id="photo"`);
|
||||||
|
expect(updated).toContain(`id="rest"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a matched composition host by selector", () => {
|
||||||
|
const html = `<!doctype html><html><body><div data-composition-id="main"><div data-composition-id="scene-a"><span>Scene A</span></div><div data-composition-id="scene-b"></div></div></body></html>`;
|
||||||
|
|
||||||
|
const updated = removeElementFromHtml(html, {
|
||||||
|
selector: '[data-composition-id="scene-a"]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated).not.toContain(`data-composition-id="scene-a"`);
|
||||||
|
expect(updated).toContain(`data-composition-id="scene-b"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports fragment html by returning updated body markup", () => {
|
||||||
|
const html = `<div id="photo"></div><div id="rest"></div>`;
|
||||||
|
|
||||||
|
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
export interface SourceMutationTarget {
|
||||||
|
id?: string | null;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSourceDocument(source: string): { document: Document; wrappedFragment: boolean } {
|
||||||
|
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
|
||||||
|
if (hasDocumentShell) {
|
||||||
|
return { document: parseHTML(source).document, wrappedFragment: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
|
||||||
|
wrappedFragment: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTargetElement(document: Document, target: SourceMutationTarget): Element | null {
|
||||||
|
if (target.id) {
|
||||||
|
const byId = document.getElementById(target.id);
|
||||||
|
if (byId) return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.selector) return null;
|
||||||
|
try {
|
||||||
|
const matches = Array.from(document.querySelectorAll(target.selector));
|
||||||
|
return matches[target.selectorIndex ?? 0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeElementFromHtml(source: string, target: SourceMutationTarget): string {
|
||||||
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
||||||
|
const element = findTargetElement(document, target);
|
||||||
|
if (!element) return source;
|
||||||
|
|
||||||
|
element.remove();
|
||||||
|
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { resolve, dirname, join } from "node:path";
|
import { resolve, dirname, join } from "node:path";
|
||||||
import type { StudioApiAdapter } from "../types.js";
|
import type { StudioApiAdapter } from "../types.js";
|
||||||
|
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||||
import { isSafePath } from "../helpers/safePath.js";
|
import { isSafePath } from "../helpers/safePath.js";
|
||||||
|
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
|
||||||
|
|
||||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -184,6 +186,43 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const project = await adapter.resolveProject(id);
|
||||||
|
if (!project) return c.json({ error: "not found" }, 404);
|
||||||
|
|
||||||
|
const filePath = decodeURIComponent(
|
||||||
|
c.req.path.replace(`/projects/${project.id}/file-mutations/remove-element/`, ""),
|
||||||
|
);
|
||||||
|
if (filePath.includes("\0")) {
|
||||||
|
return c.json({ error: "forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absPath = resolve(project.dir, filePath);
|
||||||
|
if (!isSafePath(project.dir, absPath)) {
|
||||||
|
return c.json({ error: "forbidden" }, 403);
|
||||||
|
}
|
||||||
|
if (!existsSync(absPath)) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => null)) as {
|
||||||
|
target?: { id?: string | null; selector?: string; selectorIndex?: number };
|
||||||
|
} | null;
|
||||||
|
if (!body?.target) {
|
||||||
|
return c.json({ error: "target required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalContent = readFileSync(absPath, "utf-8");
|
||||||
|
const patchedContent = removeElementFromHtml(originalContent, body.target);
|
||||||
|
if (patchedContent === originalContent) {
|
||||||
|
return c.json({ ok: true, changed: false, content: originalContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(absPath, patchedContent, "utf-8");
|
||||||
|
return c.json({ ok: true, changed: true, content: patchedContent });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Rename / Move ──
|
// ── Rename / Move ──
|
||||||
|
|
||||||
api.patch("/projects/:id/files/*", async (c) => {
|
api.patch("/projects/:id/files/*", async (c) => {
|
||||||
@@ -263,6 +302,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
const uploaded: string[] = [];
|
const uploaded: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
|
const invalid: Array<{ name: string; reason: string }> = [];
|
||||||
|
|
||||||
for (const [, value] of formData.entries()) {
|
for (const [, value] of formData.entries()) {
|
||||||
if (!(value instanceof File)) continue;
|
if (!(value instanceof File)) continue;
|
||||||
@@ -299,11 +339,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await value.arrayBuffer());
|
const buffer = Buffer.from(await value.arrayBuffer());
|
||||||
|
const validation = validateUploadedMediaBuffer(finalName, buffer);
|
||||||
|
if (!validation.ok) {
|
||||||
|
invalid.push({ name: finalName, reason: validation.reason });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
writeFileSync(finalPath, buffer);
|
writeFileSync(finalPath, buffer);
|
||||||
uploaded.push(subDir ? join(subDir, finalName) : finalName);
|
uploaded.push(subDir ? join(subDir, finalName) : finalName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true, files: uploaded, skipped }, 201);
|
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+359
-48
@@ -13,6 +13,15 @@ import { LintModal } from "./components/LintModal";
|
|||||||
import type { LintFinding } from "./components/LintModal";
|
import type { LintFinding } from "./components/LintModal";
|
||||||
import { MediaPreview } from "./components/MediaPreview";
|
import { MediaPreview } from "./components/MediaPreview";
|
||||||
import { isMediaFile } from "./utils/mediaTypes";
|
import { isMediaFile } from "./utils/mediaTypes";
|
||||||
|
import {
|
||||||
|
buildTimelineAssetId,
|
||||||
|
buildTimelineAssetInsertHtml,
|
||||||
|
buildTimelineFileDropPlacements,
|
||||||
|
getTimelineAssetKind,
|
||||||
|
insertTimelineAssetIntoSource,
|
||||||
|
resolveTimelineAssetSrc,
|
||||||
|
type TimelineAssetKind,
|
||||||
|
} from "./utils/timelineAssetDrop";
|
||||||
import { CaptionOverlay } from "./captions/components/CaptionOverlay";
|
import { CaptionOverlay } from "./captions/components/CaptionOverlay";
|
||||||
import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
|
import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
|
||||||
import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
||||||
@@ -45,6 +54,56 @@ interface AppToast {
|
|||||||
tone: "error" | "info";
|
tone: "error" | "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
||||||
|
image: 3,
|
||||||
|
video: 5,
|
||||||
|
audio: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectHtmlIds(source: string): string[] {
|
||||||
|
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDroppedAssetDuration(
|
||||||
|
projectId: string,
|
||||||
|
assetPath: string,
|
||||||
|
kind: TimelineAssetKind,
|
||||||
|
): Promise<number> {
|
||||||
|
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
||||||
|
|
||||||
|
const media = document.createElement(kind === "video" ? "video" : "audio");
|
||||||
|
media.preload = "metadata";
|
||||||
|
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
||||||
|
|
||||||
|
const duration = await new Promise<number>((resolve) => {
|
||||||
|
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
||||||
|
const finalize = (value: number) => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
media.addEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
() => {
|
||||||
|
const raw = Number(media.duration);
|
||||||
|
finalize(
|
||||||
|
Number.isFinite(raw) && raw > 0
|
||||||
|
? Math.round(raw * 100) / 100
|
||||||
|
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
media.src = "";
|
||||||
|
media.load();
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main App ──
|
// ── Main App ──
|
||||||
|
|
||||||
export function StudioApp() {
|
export function StudioApp() {
|
||||||
@@ -717,7 +776,135 @@ export function StudioApp() {
|
|||||||
[activeCompPath],
|
[activeCompPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── File Management Handlers ──
|
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
||||||
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||||
|
setAppToast({ message, tone });
|
||||||
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTimelineElementDelete = useCallback(
|
||||||
|
async (element: TimelineElement) => {
|
||||||
|
const pid = projectIdRef.current;
|
||||||
|
if (!pid) throw new Error("No active project");
|
||||||
|
|
||||||
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { content?: string };
|
||||||
|
const originalContent = data.content;
|
||||||
|
if (typeof originalContent !== "string") {
|
||||||
|
throw new Error(`Missing file contents for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchTarget = element.domId
|
||||||
|
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: element.selector
|
||||||
|
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: null;
|
||||||
|
if (!patchTarget) {
|
||||||
|
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTargetPath = targetPath || "index.html";
|
||||||
|
const remainingElements = timelineElements.filter(
|
||||||
|
(timelineElement) =>
|
||||||
|
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id) &&
|
||||||
|
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||||
|
);
|
||||||
|
const trackZIndices = buildTrackZIndexMap(
|
||||||
|
remainingElements.map((timelineElement) => timelineElement.track),
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeResponse = await fetch(
|
||||||
|
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ target: patchTarget }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!removeResponse.ok) {
|
||||||
|
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeData = (await removeResponse.json()) as {
|
||||||
|
changed?: boolean;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
let patchedContent =
|
||||||
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||||
|
for (const timelineElement of remainingElements) {
|
||||||
|
const elementTarget = timelineElement.domId
|
||||||
|
? {
|
||||||
|
id: timelineElement.domId,
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: timelineElement.selector
|
||||||
|
? {
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!elementTarget) continue;
|
||||||
|
const nextZIndex = trackZIndices.get(timelineElement.track);
|
||||||
|
if (nextZIndex == null) continue;
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||||
|
type: "inline-style",
|
||||||
|
property: "z-index",
|
||||||
|
value: String(nextZIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResponse = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: patchedContent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error(`Failed to save ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPathRef.current === targetPath) {
|
||||||
|
setEditingFile({ path: targetPath, content: patchedContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
usePlayerStore
|
||||||
|
.getState()
|
||||||
|
.setElements(
|
||||||
|
timelineElements.filter(
|
||||||
|
(timelineElement) =>
|
||||||
|
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
usePlayerStore.getState().setSelectedElementId(null);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||||
|
showToast(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeCompPath, showToast, timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlockedTimelineEdit = useCallback(
|
||||||
|
(_element: TimelineElement) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
||||||
|
lastBlockedTimelineToastAtRef.current = now;
|
||||||
|
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
||||||
|
},
|
||||||
|
[showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshFileTree = useCallback(async () => {
|
const refreshFileTree = useCallback(async () => {
|
||||||
const pid = projectIdRef.current;
|
const pid = projectIdRef.current;
|
||||||
@@ -727,6 +914,171 @@ export function StudioApp() {
|
|||||||
if (data.files) setFileTree(data.files);
|
if (data.files) setFileTree(data.files);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const uploadProjectFiles = useCallback(
|
||||||
|
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
||||||
|
const pid = projectIdRef.current;
|
||||||
|
const fileList = Array.from(files);
|
||||||
|
if (!pid || fileList.length === 0) return [];
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of fileList) {
|
||||||
|
formData.append("file", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.skipped?.length) {
|
||||||
|
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (data.invalid?.length) {
|
||||||
|
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
|
||||||
|
showToast(`Unsupported media skipped: ${names}`);
|
||||||
|
}
|
||||||
|
await refreshFileTree();
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
return Array.isArray(data.files) ? data.files : [];
|
||||||
|
} else if (res.status === 413) {
|
||||||
|
showToast("Upload rejected: payload too large");
|
||||||
|
} else {
|
||||||
|
showToast(`Upload failed (${res.status})`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Upload failed: network error");
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
[refreshFileTree, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimelineAssetDrop = useCallback(
|
||||||
|
async (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
|
||||||
|
const pid = projectIdRef.current;
|
||||||
|
if (!pid) throw new Error("No active project");
|
||||||
|
|
||||||
|
const kind = getTimelineAssetKind(assetPath);
|
||||||
|
if (!kind) {
|
||||||
|
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = activeCompPath || "index.html";
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { content?: string };
|
||||||
|
const originalContent = data.content;
|
||||||
|
if (typeof originalContent !== "string") {
|
||||||
|
throw new Error(`Missing file contents for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
||||||
|
const normalizedDuration = Number(
|
||||||
|
formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)),
|
||||||
|
);
|
||||||
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
||||||
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
||||||
|
|
||||||
|
const resolvedTargetPath = targetPath || "index.html";
|
||||||
|
const relevantElements = timelineElements.filter(
|
||||||
|
(timelineElement) =>
|
||||||
|
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||||
|
);
|
||||||
|
const trackZIndices = buildTrackZIndexMap([
|
||||||
|
...relevantElements.map((timelineElement) => timelineElement.track),
|
||||||
|
placement.track,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let patchedContent = originalContent;
|
||||||
|
for (const timelineElement of relevantElements) {
|
||||||
|
const elementTarget = timelineElement.domId
|
||||||
|
? {
|
||||||
|
id: timelineElement.domId,
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: timelineElement.selector
|
||||||
|
? {
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!elementTarget) continue;
|
||||||
|
const nextZIndex = trackZIndices.get(timelineElement.track);
|
||||||
|
if (nextZIndex == null) continue;
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||||
|
type: "inline-style",
|
||||||
|
property: "z-index",
|
||||||
|
value: String(nextZIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
patchedContent = insertTimelineAssetIntoSource(
|
||||||
|
patchedContent,
|
||||||
|
buildTimelineAssetInsertHtml({
|
||||||
|
id: newId,
|
||||||
|
assetPath: resolvedAssetSrc,
|
||||||
|
kind,
|
||||||
|
start: normalizedStart,
|
||||||
|
duration: normalizedDuration,
|
||||||
|
track: placement.track,
|
||||||
|
zIndex: trackZIndices.get(placement.track) ?? 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveResponse = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: patchedContent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error(`Failed to save ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPathRef.current === targetPath) {
|
||||||
|
setEditingFile({ path: targetPath, content: patchedContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
||||||
|
showToast(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeCompPath, showToast, timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimelineFileDrop = useCallback(
|
||||||
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||||
|
const uploaded = await uploadProjectFiles(files);
|
||||||
|
if (uploaded.length === 0) return;
|
||||||
|
const placements = buildTimelineFileDropPlacements(
|
||||||
|
placement ?? { start: 0, track: 0 },
|
||||||
|
uploaded.length,
|
||||||
|
);
|
||||||
|
for (const [index, assetPath] of uploaded.entries()) {
|
||||||
|
await handleTimelineAssetDrop(assetPath, placements[index] ?? placements[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleTimelineAssetDrop, uploadProjectFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── File Management Handlers ──
|
||||||
|
|
||||||
const handleCreateFile = useCallback(
|
const handleCreateFile = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
const pid = projectIdRef.current;
|
const pid = projectIdRef.current;
|
||||||
@@ -840,55 +1192,11 @@ export function StudioApp() {
|
|||||||
|
|
||||||
const handleMoveFile = handleRenameFile;
|
const handleMoveFile = handleRenameFile;
|
||||||
|
|
||||||
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
||||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
||||||
setAppToast({ message, tone });
|
|
||||||
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBlockedTimelineEdit = useCallback(
|
|
||||||
(_element: TimelineElement) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
||||||
lastBlockedTimelineToastAtRef.current = now;
|
|
||||||
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
|
||||||
},
|
|
||||||
[showToast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportFiles = useCallback(
|
const handleImportFiles = useCallback(
|
||||||
async (files: FileList, dir?: string) => {
|
async (files: FileList | File[], dir?: string) => {
|
||||||
const pid = projectIdRef.current;
|
void uploadProjectFiles(Array.from(files), dir);
|
||||||
if (!pid || files.length === 0) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of Array.from(files)) {
|
|
||||||
formData.append("file", file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.skipped?.length) {
|
|
||||||
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
||||||
}
|
|
||||||
await refreshFileTree();
|
|
||||||
setRefreshKey((k) => k + 1);
|
|
||||||
} else if (res.status === 413) {
|
|
||||||
showToast("Upload rejected: payload too large");
|
|
||||||
} else {
|
|
||||||
showToast(`Upload failed (${res.status})`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showToast("Upload failed: network error");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[refreshFileTree, showToast],
|
[uploadProjectFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLint = useCallback(async () => {
|
const handleLint = useCallback(async () => {
|
||||||
@@ -1151,6 +1459,9 @@ export function StudioApp() {
|
|||||||
activeCompositionPath={activeCompPath}
|
activeCompositionPath={activeCompPath}
|
||||||
timelineToolbar={timelineToolbar}
|
timelineToolbar={timelineToolbar}
|
||||||
renderClipContent={renderClipContent}
|
renderClipContent={renderClipContent}
|
||||||
|
onDeleteElement={handleTimelineElementDelete}
|
||||||
|
onAssetDrop={handleTimelineAssetDrop}
|
||||||
|
onFileDrop={handleTimelineFileDrop}
|
||||||
onMoveElement={handleTimelineElementMove}
|
onMoveElement={handleTimelineElementMove}
|
||||||
onResizeElement={handleTimelineElementResize}
|
onResizeElement={handleTimelineElementResize}
|
||||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ interface NLELayoutProps {
|
|||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
style: { clip: string; label: string },
|
style: { clip: string; label: string },
|
||||||
) => ReactNode;
|
) => ReactNode;
|
||||||
|
onFileDrop?: (
|
||||||
|
files: File[],
|
||||||
|
placement?: Pick<TimelineElement, "start" | "track">,
|
||||||
|
) => Promise<void> | void;
|
||||||
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
||||||
|
onAssetDrop?: (
|
||||||
|
assetPath: string,
|
||||||
|
placement: Pick<TimelineElement, "start" | "track">,
|
||||||
|
) => Promise<void> | void;
|
||||||
/** Persist timeline move actions back into source HTML */
|
/** Persist timeline move actions back into source HTML */
|
||||||
onMoveElement?: (
|
onMoveElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
@@ -61,6 +70,9 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onIframeRef,
|
onIframeRef,
|
||||||
onCompositionChange,
|
onCompositionChange,
|
||||||
renderClipContent,
|
renderClipContent,
|
||||||
|
onFileDrop,
|
||||||
|
onDeleteElement,
|
||||||
|
onAssetDrop,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onBlockedEditAttempt,
|
onBlockedEditAttempt,
|
||||||
@@ -393,6 +405,9 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onSeek={seek}
|
onSeek={seek}
|
||||||
onDrillDown={handleDrillDown}
|
onDrillDown={handleDrillDown}
|
||||||
renderClipContent={renderClipContent}
|
renderClipContent={renderClipContent}
|
||||||
|
onFileDrop={onFileDrop}
|
||||||
|
onDeleteElement={onDeleteElement}
|
||||||
|
onAssetDrop={onAssetDrop}
|
||||||
onMoveElement={onMoveElement}
|
onMoveElement={onMoveElement}
|
||||||
onResizeElement={onResizeElement}
|
onResizeElement={onResizeElement}
|
||||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useState, useCallback, useRef } from "react";
|
import { memo, useState, useCallback, useRef } from "react";
|
||||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||||
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
||||||
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||||
|
|
||||||
interface AssetsTabProps {
|
interface AssetsTabProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -106,7 +107,13 @@ function AssetCard({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
onClick={() => onCopy(asset)}
|
onClick={() => onCopy(asset)}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
||||||
|
e.dataTransfer.setData("text/plain", asset);
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateTicks,
|
generateTicks,
|
||||||
|
getDefaultDroppedTrack,
|
||||||
getTimelineCanvasHeight,
|
getTimelineCanvasHeight,
|
||||||
|
resolveTimelineAssetDrop,
|
||||||
getTimelinePlayheadLeft,
|
getTimelinePlayheadLeft,
|
||||||
getTimelineScrollLeftForZoomTransition,
|
getTimelineScrollLeftForZoomTransition,
|
||||||
|
shouldHandleTimelineDeleteKey,
|
||||||
shouldAutoScrollTimeline,
|
shouldAutoScrollTimeline,
|
||||||
} from "./Timeline";
|
} from "./Timeline";
|
||||||
import { formatTime } from "../lib/time";
|
import { formatTime } from "../lib/time";
|
||||||
@@ -162,3 +165,73 @@ describe("getTimelineCanvasHeight", () => {
|
|||||||
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldHandleTimelineDeleteKey", () => {
|
||||||
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores modifier shortcuts", () => {
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores input and editable targets", () => {
|
||||||
|
const input = { tagName: "INPUT", isContentEditable: false };
|
||||||
|
const editable = { tagName: "DIV", isContentEditable: true };
|
||||||
|
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
||||||
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultDroppedTrack", () => {
|
||||||
|
it("defaults to track 0 when there are no rows yet", () => {
|
||||||
|
expect(getDefaultDroppedTrack([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new bottom track when dropped below existing rows", () => {
|
||||||
|
expect(getDefaultDroppedTrack([0, 1, 5], 10)).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTimelineAssetDrop", () => {
|
||||||
|
it("maps drop coordinates to a start time and visible track", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineAssetDrop(
|
||||||
|
{
|
||||||
|
rectLeft: 100,
|
||||||
|
rectTop: 200,
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
duration: 10,
|
||||||
|
trackHeight: 72,
|
||||||
|
trackOrder: [0, 3, 7],
|
||||||
|
},
|
||||||
|
432,
|
||||||
|
310,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 3, track: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can create a new bottom track when dropped below the last visible row", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineAssetDrop(
|
||||||
|
{
|
||||||
|
rectLeft: 100,
|
||||||
|
rectTop: 200,
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
duration: 10,
|
||||||
|
trackHeight: 72,
|
||||||
|
trackOrder: [0, 3, 7],
|
||||||
|
},
|
||||||
|
250,
|
||||||
|
600,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1.18, track: 8 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type TimelineTheme,
|
type TimelineTheme,
|
||||||
} from "./timelineTheme";
|
} from "./timelineTheme";
|
||||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||||
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||||
|
|
||||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||||
const GUTTER = 32;
|
const GUTTER = 32;
|
||||||
@@ -140,6 +141,70 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|||||||
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldHandleTimelineDeleteKey(input: {
|
||||||
|
key: string;
|
||||||
|
metaKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
target?: EventTarget | null;
|
||||||
|
}): boolean {
|
||||||
|
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
||||||
|
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
||||||
|
const target =
|
||||||
|
input.target && typeof input.target === "object"
|
||||||
|
? (input.target as {
|
||||||
|
tagName?: string;
|
||||||
|
isContentEditable?: boolean;
|
||||||
|
closest?: (selector: string) => Element | null;
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (target) {
|
||||||
|
const tag = target.tagName?.toLowerCase() ?? "";
|
||||||
|
if (target.isContentEditable) return false;
|
||||||
|
if (["input", "textarea", "select"].includes(tag)) return false;
|
||||||
|
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
||||||
|
if (trackOrder.length === 0) return 0;
|
||||||
|
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
|
||||||
|
if (rowIndex >= trackOrder.length) {
|
||||||
|
return Math.max(...trackOrder) + 1;
|
||||||
|
}
|
||||||
|
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineAssetDrop(
|
||||||
|
input: {
|
||||||
|
rectLeft: number;
|
||||||
|
rectTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
scrollTop: number;
|
||||||
|
pixelsPerSecond: number;
|
||||||
|
duration: number;
|
||||||
|
trackHeight: number;
|
||||||
|
trackOrder: number[];
|
||||||
|
},
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): { start: number; track: number } {
|
||||||
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
||||||
|
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
|
||||||
|
const start = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
||||||
|
);
|
||||||
|
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Component ──────────────────────────────────────────────────── */
|
/* ── Component ──────────────────────────────────────────────────── */
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
/** Called when user seeks via ruler/track click or playhead drag */
|
/** Called when user seeks via ruler/track click or playhead drag */
|
||||||
@@ -154,8 +219,19 @@ interface TimelineProps {
|
|||||||
/** Optional overlay renderer for clips (e.g. badges, cursors) */
|
/** Optional overlay renderer for clips (e.g. badges, cursors) */
|
||||||
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
||||||
/** Called when files are dropped onto the empty timeline */
|
/** Called when files are dropped onto the empty timeline */
|
||||||
onFileDrop?: (files: File[]) => void;
|
onFileDrop?: (
|
||||||
|
files: File[],
|
||||||
|
placement?: { start: number; track: number },
|
||||||
|
) => Promise<void> | void;
|
||||||
|
/** Called when an existing asset is dropped from the Assets tab */
|
||||||
|
onAssetDrop?: (
|
||||||
|
assetPath: string,
|
||||||
|
placement: { start: number; track: number },
|
||||||
|
) => Promise<void> | void;
|
||||||
/** Persist a clip move back into source HTML */
|
/** Persist a clip move back into source HTML */
|
||||||
|
onDeleteElement?: (
|
||||||
|
element: import("../store/playerStore").TimelineElement,
|
||||||
|
) => Promise<void> | void;
|
||||||
onMoveElement?: (
|
onMoveElement?: (
|
||||||
element: import("../store/playerStore").TimelineElement,
|
element: import("../store/playerStore").TimelineElement,
|
||||||
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
||||||
@@ -213,6 +289,8 @@ export const Timeline = memo(function Timeline({
|
|||||||
renderClipContent,
|
renderClipContent,
|
||||||
renderClipOverlay,
|
renderClipOverlay,
|
||||||
onFileDrop,
|
onFileDrop,
|
||||||
|
onAssetDrop,
|
||||||
|
onDeleteElement,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onBlockedEditAttempt,
|
onBlockedEditAttempt,
|
||||||
@@ -263,10 +341,13 @@ export const Timeline = memo(function Timeline({
|
|||||||
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
||||||
resizingClipRef.current = resizingClip;
|
resizingClipRef.current = resizingClip;
|
||||||
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
||||||
|
const deleteInFlightRef = useRef(false);
|
||||||
const onMoveElementRef = useRef(onMoveElement);
|
const onMoveElementRef = useRef(onMoveElement);
|
||||||
onMoveElementRef.current = onMoveElement;
|
onMoveElementRef.current = onMoveElement;
|
||||||
const onResizeElementRef = useRef(onResizeElement);
|
const onResizeElementRef = useRef(onResizeElement);
|
||||||
onResizeElementRef.current = onResizeElement;
|
onResizeElementRef.current = onResizeElement;
|
||||||
|
const onDeleteElementRef = useRef(onDeleteElement);
|
||||||
|
onDeleteElementRef.current = onDeleteElement;
|
||||||
const suppressClickRef = useRef(false);
|
const suppressClickRef = useRef(false);
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
@@ -337,6 +418,12 @@ export const Timeline = memo(function Timeline({
|
|||||||
}
|
}
|
||||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||||
}, [draggedClip, trackOrder]);
|
}, [draggedClip, trackOrder]);
|
||||||
|
const selectedElement = useMemo(
|
||||||
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
||||||
|
[elements, selectedElementId],
|
||||||
|
);
|
||||||
|
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||||
|
selectedElementRef.current = selectedElement;
|
||||||
|
|
||||||
// Calculate effective pixels per second
|
// Calculate effective pixels per second
|
||||||
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
||||||
@@ -743,6 +830,28 @@ export const Timeline = memo(function Timeline({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useMountEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!shouldHandleTimelineDeleteKey(event)) return;
|
||||||
|
const selected = selectedElementRef.current;
|
||||||
|
const onDelete = onDeleteElementRef.current;
|
||||||
|
if (!selected || !onDelete || deleteInFlightRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
deleteInFlightRef.current = true;
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
setShowPopover(false);
|
||||||
|
setRangeSelection(null);
|
||||||
|
Promise.resolve(onDelete(selected)).finally(() => {
|
||||||
|
deleteInFlightRef.current = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -833,6 +942,74 @@ export const Timeline = memo(function Timeline({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
const hasFiles = e.dataTransfer.files.length > 0;
|
||||||
|
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
||||||
|
if (!hasFiles && !hasAsset) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (hasAsset) {
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAssetDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
const rect = scroll?.getBoundingClientRect();
|
||||||
|
const placement =
|
||||||
|
scroll && rect
|
||||||
|
? resolveTimelineAssetDrop(
|
||||||
|
{
|
||||||
|
rectLeft: rect.left,
|
||||||
|
rectTop: rect.top,
|
||||||
|
scrollLeft: scroll.scrollLeft,
|
||||||
|
scrollTop: scroll.scrollTop,
|
||||||
|
pixelsPerSecond: ppsRef.current,
|
||||||
|
duration: durationRef.current,
|
||||||
|
trackHeight: TRACK_H,
|
||||||
|
trackOrder: trackOrderRef.current,
|
||||||
|
},
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||||
|
if (!assetPayload || !onAssetDrop) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(assetPayload) as { path?: string };
|
||||||
|
if (!parsed.path) return;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
const rect = scroll?.getBoundingClientRect();
|
||||||
|
if (!scroll || !rect) return;
|
||||||
|
const placement = resolveTimelineAssetDrop(
|
||||||
|
{
|
||||||
|
rectLeft: rect.left,
|
||||||
|
rectTop: rect.top,
|
||||||
|
scrollLeft: scroll.scrollLeft,
|
||||||
|
scrollTop: scroll.scrollTop,
|
||||||
|
pixelsPerSecond: ppsRef.current,
|
||||||
|
duration: durationRef.current,
|
||||||
|
trackHeight: TRACK_H,
|
||||||
|
trackOrder: trackOrderRef.current,
|
||||||
|
},
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
);
|
||||||
|
void onAssetDrop(parsed.path, placement);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed drag payloads
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAssetDrop, onFileDrop],
|
||||||
|
);
|
||||||
|
|
||||||
if (!timelineReady || elements.length === 0) {
|
if (!timelineReady || elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -840,18 +1017,9 @@ export const Timeline = memo(function Timeline({
|
|||||||
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
||||||
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(e) => {
|
onDragOver={handleAssetDragOver}
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setIsDragOver(false)}
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
onDrop={(e) => {
|
onDrop={handleAssetDrop}
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
||||||
onFileDrop(Array.from(e.dataTransfer.files));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Ruler */}
|
{/* Ruler */}
|
||||||
<div
|
<div
|
||||||
@@ -1015,6 +1183,9 @@ export const Timeline = memo(function Timeline({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
|
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
|
||||||
|
onDragOver={handleAssetDragOver}
|
||||||
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
|
onDrop={handleAssetDrop}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildTimelineFileDropPlacements,
|
||||||
|
buildTimelineAssetInsertHtml,
|
||||||
|
getTimelineAssetKind,
|
||||||
|
insertTimelineAssetIntoSource,
|
||||||
|
resolveTimelineAssetSrc,
|
||||||
|
} from "./timelineAssetDrop";
|
||||||
|
|
||||||
|
describe("getTimelineAssetKind", () => {
|
||||||
|
it("detects image, video, and audio assets", () => {
|
||||||
|
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
||||||
|
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
||||||
|
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTimelineAssetInsertHtml", () => {
|
||||||
|
it("builds an image clip with explicit timing and track", () => {
|
||||||
|
expect(
|
||||||
|
buildTimelineAssetInsertHtml({
|
||||||
|
id: "photo_asset",
|
||||||
|
assetPath: "assets/photo.png",
|
||||||
|
kind: "image",
|
||||||
|
start: 1.25,
|
||||||
|
duration: 3,
|
||||||
|
track: 2,
|
||||||
|
zIndex: 4,
|
||||||
|
}),
|
||||||
|
).toContain('img id="photo_asset"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an audio clip without visual layout styles", () => {
|
||||||
|
const html = buildTimelineAssetInsertHtml({
|
||||||
|
id: "music_asset",
|
||||||
|
assetPath: "assets/music.wav",
|
||||||
|
kind: "audio",
|
||||||
|
start: 0.5,
|
||||||
|
duration: 5,
|
||||||
|
track: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
expect(html).toContain("<audio");
|
||||||
|
expect(html).not.toContain("object-fit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTimelineAssetSrc", () => {
|
||||||
|
it("keeps project-root asset paths for index.html", () => {
|
||||||
|
expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites asset paths relative to sub-compositions", () => {
|
||||||
|
expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
|
||||||
|
"../assets/photo.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTimelineFileDropPlacements", () => {
|
||||||
|
it("uses the dropped start and stacks multiple files onto successive tracks", () => {
|
||||||
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, 3)).toEqual([
|
||||||
|
{ start: 1.5, track: 2 },
|
||||||
|
{ start: 1.5, track: 3 },
|
||||||
|
{ start: 1.5, track: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertTimelineAssetIntoSource", () => {
|
||||||
|
it("appends the new asset inside the root composition", () => {
|
||||||
|
const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
|
||||||
|
const html = insertTimelineAssetIntoSource(
|
||||||
|
source,
|
||||||
|
'<img id="photo_asset" data-start="0" data-duration="3" />',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('<div id="root" data-composition-id="main"><img id="photo_asset"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||||
|
|
||||||
|
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
||||||
|
|
||||||
|
export type TimelineAssetKind = "image" | "video" | "audio";
|
||||||
|
|
||||||
|
export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
|
||||||
|
if (IMAGE_EXT.test(assetPath)) return "image";
|
||||||
|
if (VIDEO_EXT.test(assetPath)) return "video";
|
||||||
|
if (AUDIO_EXT.test(assetPath)) return "audio";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
|
||||||
|
const baseName = assetPath.split("/").pop() ?? "asset";
|
||||||
|
const normalized = baseName
|
||||||
|
.replace(/\.[^.]+$/, "")
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const baseId = normalized || "asset";
|
||||||
|
const ids = new Set(existingIds);
|
||||||
|
if (!ids.has(baseId)) return baseId;
|
||||||
|
let suffix = 2;
|
||||||
|
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
|
||||||
|
return `${baseId}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
|
||||||
|
const targetDir = targetPath.includes("/")
|
||||||
|
? targetPath.slice(0, targetPath.lastIndexOf("/"))
|
||||||
|
: "";
|
||||||
|
if (!targetDir) return assetPath;
|
||||||
|
|
||||||
|
const fromParts = targetDir.split("/").filter(Boolean);
|
||||||
|
const toParts = assetPath.split("/").filter(Boolean);
|
||||||
|
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
|
||||||
|
fromParts.shift();
|
||||||
|
toParts.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = fromParts.map(() => "..");
|
||||||
|
const relative = [...up, ...toParts].join("/");
|
||||||
|
return relative || assetPath.split("/").pop() || assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineFileDropPlacements(
|
||||||
|
placement: { start: number; track: number },
|
||||||
|
count: number,
|
||||||
|
): Array<{ start: number; track: number }> {
|
||||||
|
return Array.from({ length: Math.max(0, count) }, (_, index) => ({
|
||||||
|
start: placement.start,
|
||||||
|
track: placement.track + index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAssetInsertHtml(input: {
|
||||||
|
id: string;
|
||||||
|
assetPath: string;
|
||||||
|
kind: TimelineAssetKind;
|
||||||
|
start: number;
|
||||||
|
duration: number;
|
||||||
|
track: number;
|
||||||
|
zIndex: number;
|
||||||
|
}): string {
|
||||||
|
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||||
|
|
||||||
|
if (input.kind === "image") {
|
||||||
|
return `<img ${sharedAttrs} style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.kind === "video") {
|
||||||
|
return `<video ${sharedAttrs} muted playsinline style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}"></video>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||||
|
const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
|
||||||
|
const match = rootOpenTag.exec(source);
|
||||||
|
if (!match || match.index == null) {
|
||||||
|
throw new Error("No composition root found in target source");
|
||||||
|
}
|
||||||
|
const insertAt = match.index + match[0].length;
|
||||||
|
return `${source.slice(0, insertAt)}${assetHtml}${source.slice(insertAt)}`;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
RenderJobState,
|
RenderJobState,
|
||||||
} from "@hyperframes/core/studio-api";
|
} from "@hyperframes/core/studio-api";
|
||||||
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
||||||
|
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||||
|
|
||||||
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
|
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
listProjects() {
|
listProjects() {
|
||||||
|
if (!existsSync(dataDir)) return [];
|
||||||
const sessionsDir = resolve(dataDir, "../sessions");
|
const sessionsDir = resolve(dataDir, "../sessions");
|
||||||
const sessionMap = new Map<string, { sessionId: string; title: string }>();
|
const sessionMap = new Map<string, { sessionId: string; title: string }>();
|
||||||
if (existsSync(sessionsDir)) {
|
if (existsSync(sessionsDir)) {
|
||||||
@@ -445,13 +447,10 @@ function devProjectApi(): Plugin {
|
|||||||
url.pathname = url.pathname.slice(4);
|
url.pathname = url.pathname.slice(4);
|
||||||
|
|
||||||
// Read body for non-GET/HEAD
|
// Read body for non-GET/HEAD
|
||||||
let body: string | undefined;
|
let body: Buffer | undefined;
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
body = await new Promise<string>((resolve) => {
|
const bytes = await readNodeRequestBody(req);
|
||||||
let data = "";
|
body = bytes.byteLength > 0 ? bytes : undefined;
|
||||||
req.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
|
||||||
req.on("end", () => resolve(data));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||||
|
|
||||||
|
describe("readNodeRequestBody", () => {
|
||||||
|
it("preserves binary request bytes", async () => {
|
||||||
|
const source = Buffer.from([0x00, 0xff, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||||
|
const body = await readNodeRequestBody(
|
||||||
|
Readable.from([source.subarray(0, 3), source.subarray(3)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Buffer.compare(body, source)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty buffer when the request has no body", async () => {
|
||||||
|
const body = await readNodeRequestBody(Readable.from([]));
|
||||||
|
|
||||||
|
expect(body.byteLength).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export async function readNodeRequestBody(
|
||||||
|
req: AsyncIterable<string | Uint8Array>,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user