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:
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user