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:
soroush.asadi
2026-06-24 21:46:07 +03:30
parent e4fd936953
commit 055d8365fe
5 changed files with 35 additions and 4 deletions
@@ -473,7 +473,7 @@ public class StudioService(StudioDbContext db)
); );
private static SavedSceneResponse MapSceneResponse(SavedScene s) => new( 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.Sort, s.SceneLengthSec, s.MinDurationSec, s.MaxDurationSec,
s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.SelectedColorPresetId, s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.SelectedColorPresetId,
s.Contents.Select(MapContentResponse).ToList(), s.Contents.Select(MapContentResponse).ToList(),
@@ -62,6 +62,7 @@ public record SavedSceneResponse(
string Key, string Key,
string? Title, string? Title,
string? Image, string? Image,
string? Demo,
string SceneType, string SceneType,
int Sort, int Sort,
decimal SceneLengthSec, decimal SceneLengthSec,
@@ -52,6 +52,7 @@ export function SceneThumbnailBlock({
const [editName, setEditName] = useState(scene.name); const [editName, setEditName] = useState(scene.name);
const resizeRef = useRef({ startX: 0, startDuration: scene.duration }); const resizeRef = useRef({ startX: 0, startDuration: scene.duration });
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const duration = resizeDuration ?? scene.duration; const duration = resizeDuration ?? scene.duration;
const width = Math.max(80, duration * pxPerSecond); const width = Math.max(80, duration * pxPerSecond);
@@ -134,6 +135,16 @@ export function SceneThumbnailBlock({
onSelect(); onSelect();
} }
}} }}
onMouseEnter={() => {
void videoRef.current?.play().catch(() => {});
}}
onMouseLeave={() => {
const v = videoRef.current;
if (v) {
v.pause();
v.currentTime = 0;
}
}}
className={cn( className={cn(
"group relative h-20 w-full cursor-pointer overflow-hidden rounded-lg", "group relative h-20 w-full cursor-pointer overflow-hidden rounded-lg",
isActive isActive
@@ -141,10 +152,10 @@ export function SceneThumbnailBlock({
: "hover:ring-1 hover:ring-gray-300" : "hover:ring-1 hover:ring-gray-300"
)} )}
> >
{/* Background: real thumbnail or gradient placeholder */} {/* Background: template still, Konva preview, or gradient placeholder */}
{scene.thumbnailUrl ? ( {scene.image || scene.thumbnailUrl ? (
<Image <Image
src={scene.thumbnailUrl} src={(scene.image ?? scene.thumbnailUrl) as string}
alt="" alt=""
fill fill
unoptimized unoptimized
@@ -155,6 +166,19 @@ export function SceneThumbnailBlock({
<div className="absolute inset-0" style={gradient} /> <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 */} {/* Dark overlay so text labels stay readable */}
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
+2
View File
@@ -140,6 +140,8 @@ function parseScene(value: unknown): Scene | null {
: DEFAULT_SCENE_DURATION, : DEFAULT_SCENE_DURATION,
layers, layers,
transitionType, transitionType,
image: typeof value.image === "string" ? value.image : undefined,
demo: typeof value.demo === "string" ? value.demo : undefined,
}; };
} }
+4
View File
@@ -30,6 +30,10 @@ export interface Scene {
transitionType?: SceneTransition; transitionType?: SceneTransition;
/** Konva canvas preview (data URL), generated for the active scene */ /** Konva canvas preview (data URL), generated for the active scene */
thumbnailUrl?: string; 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; export const DEFAULT_SCENE_DURATION = 5;