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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
docs/
DOCS_GUIDELINES.md
packages/producer/tests/
*.generated.ts
17 changes: 9 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions packages/producer/src/services/fontData.generated.ts

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
Expand All @@ -44,9 +46,12 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"puppeteer-core": "^24.40.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^3.2.4",
"zustand": "^5.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
175 changes: 175 additions & 0 deletions packages/studio/src/player/components/CompositionThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
*
* Requests multiple thumbnails at different timestamps across the clip duration
* and tiles them horizontally — like VideoThumbnail does for video clips.
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
*
* Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count
* when the clip width changes (zoom).
*/

import { memo, useRef, useState, useCallback, useEffect } from "react";

const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES = 6;

interface CompositionThumbnailProps {
previewUrl: string;
label: string;
labelColor: string;
seekTime?: number;
duration?: number;
width?: number;
height?: number;
}

export const CompositionThumbnail = memo(function CompositionThumbnail({
previewUrl,
label,
labelColor,
seekTime = 0.4,
duration = 5,
width = 1920,
height = 1080,
}: CompositionThumbnailProps) {
const [visible, setVisible] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const [loadedFrames, setLoadedFrames] = useState<Set<number>>(new Set());
const ioRef = useRef<IntersectionObserver | null>(null);
const roRef = useRef<ResizeObserver | null>(null);

const setRef = useCallback((el: HTMLDivElement | null) => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
if (!el) return;

// Walk up to data-clip parent for accurate width (max 5 levels to avoid overshoot)
let target: HTMLElement = el;
let parent = el.parentElement;
let depth = 0;
while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
parent = parent.parentElement;
depth++;
}
if (parent?.hasAttribute("data-clip")) target = parent;

requestAnimationFrame(() => {
const w = target.clientWidth || target.getBoundingClientRect().width;
if (w > 0) setContainerWidth(w);
});

ioRef.current = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
ioRef.current?.disconnect();
requestAnimationFrame(() => {
const w = target.clientWidth || target.getBoundingClientRect().width;
if (w > 0) setContainerWidth(w);
});
}
},
{ rootMargin: "300px" },
);
ioRef.current.observe(el);

roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
roRef.current.observe(target);
}, []);

useEffect(
() => () => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
},
[],
);

// Convert preview URL to thumbnail base URL
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");

// Calculate frame layout
const aspect = width / height;
const frameW = Math.round(CLIP_HEIGHT * aspect);
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);

// Generate timestamps spread across the clip duration.
// Start at 30% into the scene to skip entrance animations (opacity:0 → 1).
// End at 90% to avoid catching exit animations.
const timestamps: number[] = [];
const startOffset = duration * 0.3;
const endOffset = duration * 0.9;
const range = endOffset - startOffset;
for (let i = 0; i < uniqueFrames; i++) {
const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1);
timestamps.push(seekTime + startOffset + frac * range);
}

const hasAnyFrame = loadedFrames.size > 0;

return (
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
{/* Film strip */}
{visible && (
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
const uniqueIdx = i % uniqueFrames;
const t = timestamps[uniqueIdx];
const url = `${thumbnailBase}?t=${t.toFixed(2)}`;
return (
<div
key={i}
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
style={{ width: frameW }}
>
<img
src={url}
alt=""
draggable={false}
loading="lazy"
onLoad={() => setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))}
className="absolute inset-0 w-full h-full object-cover"
style={{
opacity: loadedFrames.has(uniqueIdx) ? 1 : 0,
transition: "opacity 200ms ease-out",
}}
/>
</div>
);
})}
</div>
)}

{/* Shimmer while loading */}
{(!visible || !hasAnyFrame) && (
<div
className="absolute inset-0 animate-pulse"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}

{/* Label */}
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div>
);
});
81 changes: 81 additions & 0 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// TimelineClip — Visual clip component for the NLE timeline.

import { memo, type ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";

interface TimelineClipProps {
el: TimelineElement;
pps: number;
trackH: number;
clipY: number;
isSelected: boolean;
isHovered: boolean;
hasCustomContent: boolean;
style: { clip: string; label: string };
isComposition: boolean;
onHoverStart: () => void;
onHoverEnd: () => void;
onClick: (e: React.MouseEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
children?: ReactNode;
}

export const TimelineClip = memo(function TimelineClip({
el,
pps,
clipY,
isSelected,
isHovered,
hasCustomContent,
style,
isComposition,
onHoverStart,
onHoverEnd,
onClick,
onDoubleClick,
children,
}: TimelineClipProps) {
const leftPx = el.start * pps;
const widthPx = Math.max(el.duration * pps, 4);

return (
<div
data-clip="true"
className={hasCustomContent ? "absolute" : "absolute flex items-center"}
style={{
left: leftPx,
width: widthPx,
top: clipY,
bottom: clipY,
borderRadius: 5,
backgroundColor: hasCustomContent ? (isComposition ? "#111" : style.clip) : style.clip,
backgroundImage:
isComposition && !hasCustomContent
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected
? "2px solid rgba(255,255,255,0.9)"
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
: isHovered
? "0 1px 4px rgba(0,0,0,0.3)"
: "none",
transition: "border-color 120ms, box-shadow 120ms",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
cursor: "pointer",
}}
title={
isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
}
onPointerEnter={onHoverStart}
onPointerLeave={onHoverEnd}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{children}
</div>
);
});
Loading
Loading