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 { LogoRevealBlock } from "./blocks/LogoReveal";
|
||||
import { StatCounterBlock } from "./blocks/StatCounter";
|
||||
import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
[LogoRevealBlock.id]: LogoRevealBlock,
|
||||
[StatCounterBlock.id]: StatCounterBlock,
|
||||
[ProductShowcaseBlock.id]: ProductShowcaseBlock,
|
||||
};
|
||||
|
||||
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
||||
|
||||
Reference in New Issue
Block a user