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(
|
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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user