feat(studio): per-scene loop plays on hover (scene.demo end-to-end)
Wires the per-scene loop video all the way to the scene card: - studio-svc: SavedSceneResponse now includes Demo (was stored + copied but never serialized); MapSceneResponse passes s.Demo. - Scene type gains image?/demo?; parseScene reads them from the loaded scene data. - SceneThumbnailBlock shows scene.image as the still and plays scene.demo (muted, looped) on hover, resetting on mouse-leave. Existing projects backfilled (saved_scenes.image/demo from content.scenes). Both services rebuilt + deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -473,7 +473,7 @@ public class StudioService(StudioDbContext db)
|
||||
);
|
||||
|
||||
private static SavedSceneResponse MapSceneResponse(SavedScene s) => new(
|
||||
s.Id, s.OriginalSceneId, s.Key, s.Title, s.Image, s.SceneType,
|
||||
s.Id, s.OriginalSceneId, s.Key, s.Title, s.Image, s.Demo, s.SceneType,
|
||||
s.Sort, s.SceneLengthSec, s.MinDurationSec, s.MaxDurationSec,
|
||||
s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.SelectedColorPresetId,
|
||||
s.Contents.Select(MapContentResponse).ToList(),
|
||||
|
||||
@@ -62,6 +62,7 @@ public record SavedSceneResponse(
|
||||
string Key,
|
||||
string? Title,
|
||||
string? Image,
|
||||
string? Demo,
|
||||
string SceneType,
|
||||
int Sort,
|
||||
decimal SceneLengthSec,
|
||||
|
||||
@@ -52,6 +52,7 @@ export function SceneThumbnailBlock({
|
||||
const [editName, setEditName] = useState(scene.name);
|
||||
const resizeRef = useRef({ startX: 0, startDuration: scene.duration });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const duration = resizeDuration ?? scene.duration;
|
||||
const width = Math.max(80, duration * pxPerSecond);
|
||||
@@ -134,6 +135,16 @@ export function SceneThumbnailBlock({
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
void videoRef.current?.play().catch(() => {});
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
v.pause();
|
||||
v.currentTime = 0;
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative h-20 w-full cursor-pointer overflow-hidden rounded-lg",
|
||||
isActive
|
||||
@@ -141,10 +152,10 @@ export function SceneThumbnailBlock({
|
||||
: "hover:ring-1 hover:ring-gray-300"
|
||||
)}
|
||||
>
|
||||
{/* Background: real thumbnail or gradient placeholder */}
|
||||
{scene.thumbnailUrl ? (
|
||||
{/* Background: template still, Konva preview, or gradient placeholder */}
|
||||
{scene.image || scene.thumbnailUrl ? (
|
||||
<Image
|
||||
src={scene.thumbnailUrl}
|
||||
src={(scene.image ?? scene.thumbnailUrl) as string}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
@@ -155,6 +166,19 @@ export function SceneThumbnailBlock({
|
||||
<div className="absolute inset-0" style={gradient} />
|
||||
)}
|
||||
|
||||
{/* Hover: play the template's per-scene loop preview */}
|
||||
{scene.demo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={scene.demo}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Dark overlay so text labels stay readable */}
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ function parseScene(value: unknown): Scene | null {
|
||||
: DEFAULT_SCENE_DURATION,
|
||||
layers,
|
||||
transitionType,
|
||||
image: typeof value.image === "string" ? value.image : undefined,
|
||||
demo: typeof value.demo === "string" ? value.demo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface Scene {
|
||||
transitionType?: SceneTransition;
|
||||
/** Konva canvas preview (data URL), generated for the active scene */
|
||||
thumbnailUrl?: string;
|
||||
/** Template-provided static preview still (content.scenes.image) */
|
||||
image?: string;
|
||||
/** Template-provided ~1.5s loop preview video (content.scenes.demo) */
|
||||
demo?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENE_DURATION = 5;
|
||||
|
||||
Reference in New Issue
Block a user