feat(remotion): +ProductShowcase block (phone/browser device mockup)

Adds the product/app-showcase template type the engine was missing: a 2.5D device
frame (rounded phone with notch, or a browser window with traffic-lights + URL bar)
holding an uploaded screenshot, with title/subtitle and the shared Three backdrop.
Fields: screenshot, title, subtitle, device (phone|browser). Registry now 9 blocks.
Verified via FlexStory props-override stills (both device modes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 20:18:06 +03:30
parent a48633741e
commit 7394c5ce78
2 changed files with 92 additions and 0 deletions
@@ -0,0 +1,90 @@
import React from "react";
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { FONT } from "../../lib/fonts";
import { hexToRgba, mixHex } from "../../lib/anim";
import { ThreeBackdrop, Grain, Vignette, ProgressDots, Kicker, useSceneTransition } from "../chrome";
import type { BlockProps, SceneBlock } from "../types";
const resolveSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
const ProductShowcase: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { opacity, slide } = useSceneTransition(durationInFrames, L);
const sp = spring({ frame: frame - 2, fps, config: { damping: 16, stiffness: 90 } });
const rise = interpolate(sp, [0, 1], [L.vmin(70), 0]);
const scale = interpolate(sp, [0, 1], [0.88, 1]);
const float = Math.sin(frame / 28) * L.vmin(7);
const browser = (data.device || "phone") === "browser";
// device geometry
const phoneW = L.pick(L.vmin(330), L.vmin(310), L.vmin(360));
const phoneH = phoneW * 2.05;
const browW = L.pick(L.vmin(720), L.vmin(640), L.vmin(700));
const browH = browW * 0.62;
const w = browser ? browW : phoneW;
const h = browser ? browH : phoneH;
const rim = L.vmin(browser ? 8 : 12);
const radius = L.vmin(browser ? 16 : 44);
const Screen = data.screenshot ? (
<Img src={resolveSrc(data.screenshot)} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: `linear-gradient(150deg, ${mixHex(colors.accentColor, "#ffffff", 0.45)}, ${mixHex(colors.secondaryColor, "#ffffff", 0.5)})`, color: hexToRgba(colors.textColor, 0.35), fontSize: L.vmin(48) }}>
</div>
);
const Device = (
<div style={{ transform: `translateY(${float + rise}px) scale(${scale})`, width: w, height: h, borderRadius: radius, background: "#10141f", padding: rim, boxShadow: `0 ${L.vmin(40)}px ${L.vmin(80)}px ${hexToRgba("#1f2937", 0.32)}` }}>
<div style={{ width: "100%", height: "100%", borderRadius: radius - rim, overflow: "hidden", background: "#fff", position: "relative" }}>
{browser && (
<div style={{ height: L.vmin(34), background: "#e9edf3", display: "flex", alignItems: "center", gap: L.vmin(8), paddingInline: L.vmin(14) }}>
{["#fb7185", "#fbbf24", "#34d399"].map((c) => (
<span key={c} style={{ width: L.vmin(11), height: L.vmin(11), borderRadius: 999, background: c }} />
))}
<span style={{ marginInlineStart: L.vmin(12), flex: 1, height: L.vmin(16), borderRadius: 999, background: "#fff" }} />
</div>
)}
<div style={{ position: "absolute", inset: 0, top: browser ? L.vmin(34) : 0 }}>{Screen}</div>
{!browser && <div style={{ position: "absolute", top: L.vmin(10), left: "50%", transform: "translateX(-50%)", width: L.vmin(110), height: L.vmin(20), borderRadius: 999, background: "#10141f" }} />}
</div>
</div>
);
const Text = (
<div style={{ direction: "rtl", textAlign: L.isWide ? "right" : "center", transform: `translateX(${slide}px)`, maxWidth: L.isWide ? L.vmin(560) : L.vmin(900) }}>
<Kicker index={index} total={total} colors={colors} L={L} slide={0} />
<div style={{ fontWeight: 800, fontSize: L.pick(L.vmin(68), L.vmin(60), L.vmin(58)), color: colors.textColor, lineHeight: 1.18, letterSpacing: -0.5 }}>{data.title}</div>
<div style={{ marginTop: L.vmin(16), fontWeight: 400, fontSize: L.pick(L.vmin(31), L.vmin(30), L.vmin(29)), color: hexToRgba(colors.textColor, 0.64), lineHeight: 1.7 }}>{data.subtitle}</div>
</div>
);
return (
<AbsoluteFill style={{ opacity, fontFamily: FONT, backgroundColor: colors.backgroundColor }}>
<ThreeBackdrop colors={colors} />
<AbsoluteFill style={{ display: "flex", flexDirection: L.isWide ? "row-reverse" : "column", alignItems: "center", justifyContent: "center", gap: L.pick(L.vmin(70), L.vmin(36), L.vmin(44)), padding: L.vmin(70) }}>
{Device}
{Text}
</AbsoluteFill>
<ProgressDots index={index} total={total} colors={colors} L={L} />
<Vignette />
<Grain />
</AbsoluteFill>
);
};
export const ProductShowcaseBlock: SceneBlock = {
id: "ProductShowcase",
label: "معرفی محصول/اپ",
component: ProductShowcase,
fields: [
{ key: "screenshot", label: "اسکرین‌شات", type: "image", default: "" },
{ key: "title", label: "عنوان", type: "text", default: "محصول شما" },
{ key: "subtitle", label: "توضیح", type: "text", default: "ویژگی کلیدی محصول را اینجا بنویسید", multiline: true },
{ key: "device", label: "قاب (phone/browser)", type: "text", default: "phone" },
],
defaultDurationSec: 4,
minDurationSec: 2,
maxDurationSec: 8,
};
+2
View File
@@ -7,6 +7,7 @@ import { SlideshowBlock } from "./blocks/Slideshow";
import { OutroCTABlock } from "./blocks/OutroCTA"; import { OutroCTABlock } from "./blocks/OutroCTA";
import { LogoRevealBlock } from "./blocks/LogoReveal"; import { LogoRevealBlock } from "./blocks/LogoReveal";
import { StatCounterBlock } from "./blocks/StatCounter"; import { StatCounterBlock } from "./blocks/StatCounter";
import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
/** /**
* The scene-block registry. A FlexStory template is an ordered list of these * The scene-block registry. A FlexStory template is an ordered list of these
@@ -22,6 +23,7 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
[OutroCTABlock.id]: OutroCTABlock, [OutroCTABlock.id]: OutroCTABlock,
[LogoRevealBlock.id]: LogoRevealBlock, [LogoRevealBlock.id]: LogoRevealBlock,
[StatCounterBlock.id]: StatCounterBlock, [StatCounterBlock.id]: StatCounterBlock,
[ProductShowcaseBlock.id]: ProductShowcaseBlock,
}; };
export const BLOCK_LIST = Object.values(SCENE_BLOCKS); export const BLOCK_LIST = Object.values(SCENE_BLOCKS);