Skip to content
Closed
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
4 changes: 1 addition & 3 deletions packages/studio/src/components/StudioGlobalDragOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ export function StudioGlobalDragOverlay() {
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
<span className="text-sm font-medium text-studio-accent">Drop to add at the playhead</span>
</div>
</div>
);
Expand Down
11 changes: 8 additions & 3 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,14 @@ export function StudioRightPanel({

return (
<>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
the absolutely-positioned inner hit area. */}
<div
role="separator"
aria-label="Resize inspector panel"
aria-orientation="vertical"
tabIndex={0}
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
style={{ touchAction: "none" }}
onPointerDown={(e) => handlePanelResizeStart("right", e)}
onPointerMove={handlePanelResizeMove}
Expand All @@ -465,10 +467,13 @@ export function StudioRightPanel({
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
}}
>
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
<div
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
style={{ width: rightWidth }}
>
{captionEditMode ? (
Expand Down
9 changes: 8 additions & 1 deletion packages/studio/src/components/sidebar/BlocksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { usePlayerStore } from "../../player";
import { formatTime } from "../../player/lib/time";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
export interface BlockPreviewInfo {
videoUrl?: string;
posterUrl?: string;
Expand Down Expand Up @@ -383,10 +384,16 @@ function BlockCard({
return (
<div
className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name }));
e.dataTransfer.setData("text/plain", name);
handleLeave(); // cancel the hover-preview timer so it doesn't fire mid-drag
}}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
>
{/* Thumbnail */}
<div className="aspect-video w-full overflow-hidden relative">
{hovered && videoUrl ? (
<video
Expand Down
82 changes: 55 additions & 27 deletions packages/studio/src/hooks/useBlockHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Block drop/add handlers for the Studio.
* Extracted from App.tsx to keep file sizes under the 600-line limit.
*/
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { addBlockToProject } from "../utils/blockInstaller";
Expand Down Expand Up @@ -83,17 +83,41 @@ export function useBlockHandlers({
],
);

// Block installs hit the server and end in a full preview reload; without a
// guard, repeat drops while one is in flight stack duplicate installs.
const installingBlockRef = useRef(false);
const runBlockInstall = useCallback(
async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
if (installingBlockRef.current) {
blockCtx.showToast("A block is already installing — one moment…", "info");
return null;
}
installingBlockRef.current = true;
blockCtx.showToast(`Adding ${blockName}…`, "info");
try {
return await install();
} finally {
installingBlockRef.current = false;
}
},
[blockCtx],
);

const handleAddBlock = useCallback(
(blockName: string) => {
if (!projectId) return;
// fallow-ignore-next-line complexity
void (async () => {
const result = await addBlockToProject({
projectId,
blockName,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
const result = await runBlockInstall(blockName, () =>
addBlockToProject({
projectId,
blockName,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
}),
);
if (result === null) return;
const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
if (params?.length) {
setActiveBlockParams({
Expand All @@ -107,37 +131,41 @@ export function useBlockHandlers({
}
})();
},
[projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab],
[projectId, blockCtx, previewIframeRef, runBlockInstall, setRightCollapsed, setRightPanelTab],
);

const handleTimelineBlockDrop = useCallback(
(blockName: string, placement: { start: number; track: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
placement,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
void runBlockInstall(blockName, () =>
addBlockToProject({
projectId,
blockName,
placement,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
}),
);
},
[projectId, blockCtx, previewIframeRef],
[projectId, blockCtx, previewIframeRef, runBlockInstall],
);

const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
visualPosition: position,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
void runBlockInstall(blockName, () =>
addBlockToProject({
projectId,
blockName,
visualPosition: position,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
}),
);
},
[projectId, blockCtx, previewIframeRef],
[projectId, blockCtx, previewIframeRef, runBlockInstall],
);

return {
Expand Down
108 changes: 72 additions & 36 deletions packages/studio/src/hooks/useMusicBeatAnalysis.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { usePlayerStore } from "../player/store/playerStore";
import { isMusicTrack } from "../utils/timelineInspector";
import { resolveBeatSourceTrack } from "../utils/timelineInspector";
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
import { mergeUserBeats } from "../utils/beatEditing";
Expand Down Expand Up @@ -52,6 +52,44 @@ async function resolveBeats(
return { ...detected, hasFile: false };
}

/** True when the beats file for a track exists and holds at least one beat. */
async function readHasSavedBeats(io: ProjectIo, beatPath: string): Promise<boolean> {
try {
const content = await io.readOptionalProjectFile(beatPath);
const parsed = content ? parseBeats(content) : null;
return !!(parsed && parsed.times.length > 0);
} catch {
return false;
}
}

type MusicAnalysis = Awaited<ReturnType<typeof analyzeMusicFromUrl>>;

/**
* Analyze a track (memoized per URL) and fold in any saved beat edits. Null on
* decode/analysis failure — the caller then clears state and drops the cache
* entry (only when the effect is still live).
*/
async function loadBeatAnalysis(
musicSrc: string,
beatPath: string,
io: ProjectIo,
): Promise<{ analysis: MusicAnalysis; times: number[]; strengths: number[] } | null> {
let promise = analysisCache.get(musicSrc);
if (!promise) {
promise = analyzeMusicFromUrl(musicSrc);
cacheAnalysis(musicSrc, promise);
}
try {
const analysis = await promise;
const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
const { times, strengths } = await resolveBeats(beatPath, detected, io);
return { analysis, times, strengths };
} catch {
return null;
}
}

export function useMusicBeatAnalysis(): void {
const elements = usePlayerStore((s) => s.elements);
const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
Expand All @@ -71,9 +109,12 @@ export function useMusicBeatAnalysis(): void {
? { readOptionalProjectFile, writeProjectFile }
: null;

const musicSrc = useMemo(() => {
const el = elements.find((e) => isMusicTrack(e));
return el?.src ?? null;
const { musicSrc, isFallbackTrack } = useMemo(() => {
const resolved = resolveBeatSourceTrack(elements);
return {
musicSrc: resolved?.element.src ?? null,
isFallbackTrack: resolved?.isFallback ?? false,
};
}, [elements]);

// ── Load: decode for strength data, then use the saved beat file if present,
Expand All @@ -95,50 +136,45 @@ export function useMusicBeatAnalysis(): void {
const beatPath = beatFilePathForSrc(musicSrc);
const io = ioRef.current;

// Only run expensive audio decode + beat analysis when the user has an
// explicit beats file saved. Without one, skip entirely — no surprise
// green lines on the timeline after dragging unrelated assets.
// For explicitly tagged/named music tracks: only run expensive audio decode
// + beat analysis when the user has an explicit beats file saved. Without
// one, skip entirely — no surprise green lines on the timeline after
// dragging unrelated assets.
//
// For fallback tracks (audio dropped from Finder with no role/music-id):
// always run analysis so the Beat tool becomes usable immediately.
(async () => {
if (!beatPath || !io) return;
let hasSavedBeats = false;
try {
const content = await io.readOptionalProjectFile(beatPath);
const parsed = content ? parseBeats(content) : null;
hasSavedBeats = !!(parsed && parsed.times.length > 0);
} catch {
/* no file */
if (!isFallbackTrack) {
const hasSavedBeats = await readHasSavedBeats(io, beatPath);
if (cancelled) return;
if (!hasSavedBeats) {
setBeatAnalysis(null);
return;
}
}
if (cancelled) return;
if (!hasSavedBeats) {

const result = await loadBeatAnalysis(musicSrc, beatPath, io);
if (cancelled) return;
if (!result) {
setBeatAnalysis(null);
analysisCache.delete(musicSrc);
return;
}

let promise = analysisCache.get(musicSrc);
if (!promise) {
promise = analyzeMusicFromUrl(musicSrc);
cacheAnalysis(musicSrc, promise);
}
try {
const analysis = await promise;
const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
const { times, strengths } = await resolveBeats(beatPath, detected, io);
if (cancelled) return;
setBeatEdits(null);
resetBeatHistory();
setBeatAnalysis({ ...analysis, beatTimes: times, beatStrengths: strengths });
} catch {
if (!cancelled) {
setBeatAnalysis(null);
analysisCache.delete(musicSrc);
}
}
setBeatEdits(null);
resetBeatHistory();
setBeatAnalysis({
...result.analysis,
beatTimes: result.times,
beatStrengths: result.strengths,
});
})();

return () => {
cancelled = true;
};
}, [musicSrc, setBeatAnalysis, setBeatEdits, resetBeatHistory]);
}, [musicSrc, isFallbackTrack, setBeatAnalysis, setBeatEdits, resetBeatHistory]);

// ── Persist: register a debounced writer fired by every beat edit/undo/redo.
// Flushes any pending write on cleanup so the last edit is never lost. ──
Expand Down
30 changes: 24 additions & 6 deletions packages/studio/src/hooks/useRenderClipContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createElement } from "react";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import type { TimelineElement } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import { ImageThumbnail } from "../player/components/ImageThumbnail";
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";

export function normalizeCompositionSrc(
compSrc: string,
Expand Down Expand Up @@ -51,8 +53,16 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
*/
function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
const srcRelative = resolvePreviewRelative(el.src, pid);
const audioUrl = srcRelative ? `/api/projects/${pid}/preview/${srcRelative}` : (el.src ?? "");
const waveformUrl = srcRelative ? `/api/projects/${pid}/waveform/${srcRelative}` : undefined;
// Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative
// returns the DECODED path, so it must be re-encoded here.
const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
const audioUrl = encodedRelative
? `/api/projects/${pid}/preview/${encodedRelative}`
: (el.src ?? "");
const waveformUrl = encodedRelative
? `/api/projects/${pid}/waveform/${encodedRelative}`
: undefined;
const { start, end } = trimFractions(el);
return createElement(AudioWaveform, {
audioUrl,
Expand Down Expand Up @@ -100,7 +110,7 @@ export function useRenderClipContent({
// instead of capturing the master at a time when the comp is fading in.
if (compSrc) {
return createElement(CompositionThumbnail, {
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`,
label: "",
labelColor: style.label,

Expand Down Expand Up @@ -138,9 +148,17 @@ export function useRenderClipContent({
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);

if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = el.src.startsWith("http")
? el.src
: `/api/projects/${pid}/preview/${el.src}`;
const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
// Still images can't be decoded by VideoThumbnail's <video> extractor
// (the error event fires and the shimmer never resolves) — render the
// image itself as the strip.
if (el.tag === "img") {
return createElement(ImageThumbnail, {
imageSrc: mediaSrc,
label: "",
labelColor: style.label,
});
}
return createElement(VideoThumbnail, {
videoSrc: mediaSrc,
label: "",
Expand Down
Loading
Loading