feat(remotion): craft kit (stop-motion + paper-cut) + PaperCut block
The real visible quality leap — a handmade craft aesthetic code can't fake by being smooth: - craft.ts: useStopMotion (quantize the frame to "on twos/threes" + per-step jitter → choppy handmade motion), paperShadow (layered cast shadows for paper depth), PAPER_TEXTURE (procedural fibrous paper grain). - PaperCut block: a layered paper-cut landscape — sun + 4 brand-coloured paper hills with real cast shadows + paper grain, rising into place on stop-motion timing with an idle wobble, + paper-cut title/subtitle. Re-flows 16:9/1:1/9:16. Registry now has 13 blocks. Verified: warm Yalda render (fits the Persian/seasonal moat) + a stop-motion demo clip showing the on-threes choppy rise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useVideoConfig } from "remotion";
|
||||
import { FONT } from "../../lib/fonts";
|
||||
import { hexToRgba, mixHex } from "../../lib/anim";
|
||||
import { Vignette } from "../chrome";
|
||||
import { useStopMotion, paperShadow, PAPER_TEXTURE } from "../craft";
|
||||
import type { BlockProps, SceneBlock } from "../types";
|
||||
|
||||
const PaperCut: React.FC<BlockProps> = ({ data, colors, L, index, total, durationInFrames }) => {
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const { stepped, jitter } = useStopMotion(3);
|
||||
|
||||
const out = interpolate(stepped, [durationInFrames - 12, durationInFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const opacity = 1 - out;
|
||||
|
||||
// each layer rises into place on stepped (handmade) timing
|
||||
const rise = (delay: number) => {
|
||||
const sp = spring({ frame: stepped - delay, fps, config: { damping: 16, stiffness: 70 } });
|
||||
return interpolate(sp, [0, 1], [height * 0.55, 0]);
|
||||
};
|
||||
|
||||
// brand-derived paper palette: light sky → darkening hills
|
||||
const sky = mixHex(colors.backgroundColor, "#fff6e8", 0.5);
|
||||
const sun = mixHex(colors.accentColor, "#ffd27a", 0.45);
|
||||
const hills = [
|
||||
{ y: height * 0.5, c: mixHex(colors.secondaryColor, "#ffffff", 0.45), depth: 1, delay: 0 },
|
||||
{ y: height * 0.62, c: mixHex(colors.secondaryColor, colors.accentColor, 0.5), depth: 1.6, delay: 3 },
|
||||
{ y: height * 0.74, c: colors.accentColor, depth: 2.3, delay: 6 },
|
||||
{ y: height * 0.86, c: mixHex(colors.accentColor, "#3a2418", 0.4), depth: 3.2, delay: 9 },
|
||||
];
|
||||
const hillPath = (y: number, amp: number) =>
|
||||
`M0 ${height} L0 ${y} C ${width * 0.33} ${y - amp} ${width * 0.66} ${y + amp * 0.7} ${width} ${y - amp * 0.2} L ${width} ${height} Z`;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity, fontFamily: FONT, background: `linear-gradient(180deg, ${sky}, ${mixHex(sky, colors.secondaryColor, 0.2)})` }}>
|
||||
{/* sun */}
|
||||
<div style={{ position: "absolute", left: width * 0.5 - L.vmin(150), top: height * 0.16 + jitter(99, L.vmin(4)), width: L.vmin(300), height: L.vmin(300), borderRadius: "50%", background: sun, filter: paperShadow(1.2) }} />
|
||||
|
||||
{/* layered paper hills (stop-motion rise + idle wobble) */}
|
||||
{hills.map((h, i) => (
|
||||
<svg key={i} width={width} height={height} style={{ position: "absolute", inset: 0, transform: `translate(${jitter(i * 7 + 1, L.vmin(3))}px, ${rise(h.delay) + jitter(i * 7, L.vmin(3))}px)`, filter: paperShadow(h.depth) }}>
|
||||
<path d={hillPath(h.y, L.vmin(70 - i * 8))} fill={h.c} />
|
||||
</svg>
|
||||
))}
|
||||
|
||||
{/* paper grain over the whole scene */}
|
||||
<AbsoluteFill style={{ pointerEvents: "none", opacity: 0.09, mixBlendMode: "multiply", backgroundImage: PAPER_TEXTURE, backgroundSize: "220px 220px" }} />
|
||||
|
||||
{/* title block (paper-cut text) */}
|
||||
<AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.1 }}>
|
||||
<div style={{ direction: "rtl", transform: `translate(${jitter(200, L.vmin(2))}px, ${interpolate(spring({ frame: stepped - 4, fps, config: { damping: 14, stiffness: 90 } }), [0, 1], [L.vmin(40), 0])}px)`, fontWeight: 900, fontSize: L.pick(L.vmin(96), L.vmin(84), L.vmin(78)), color: mixHex(colors.textColor, "#3a2418", 0.2), textAlign: "center", letterSpacing: -1, textShadow: `0 ${L.vmin(6)}px ${L.vmin(2)}px ${hexToRgba("#3a2418", 0.18)}` }}>
|
||||
{data.title}
|
||||
</div>
|
||||
<div style={{ direction: "rtl", opacity: interpolate(stepped, [12, 24], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }), marginTop: L.vmin(14), fontWeight: 500, fontSize: L.pick(L.vmin(34), L.vmin(32), L.vmin(30)), color: hexToRgba(mixHex(colors.textColor, "#3a2418", 0.2), 0.7), textAlign: "center" }}>
|
||||
{data.subtitle}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
<Vignette />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaperCutBlock: SceneBlock = {
|
||||
id: "PaperCut",
|
||||
label: "کاغذبری (پیپرکات + استاپموشن)",
|
||||
component: PaperCut,
|
||||
fields: [
|
||||
{ key: "title", label: "عنوان", type: "text", default: "داستان شما" },
|
||||
{ key: "subtitle", label: "زیرعنوان", type: "text", default: "ساختهشده با عشق، لایه به لایه" },
|
||||
],
|
||||
defaultDurationSec: 5,
|
||||
minDurationSec: 3,
|
||||
maxDurationSec: 9,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { rand } from "../lib/anim";
|
||||
|
||||
/**
|
||||
* Craft kit — the primitives that give the *handmade* look code can't fake by being
|
||||
* smooth: stop-motion timing (quantize the frame so motion plays "on twos/threes"
|
||||
* with a per-step wobble) and paper-cut depth (layered drop-shadows + a fibrous
|
||||
* paper-grain texture). Used by the PaperCut block; reusable by any block.
|
||||
*/
|
||||
|
||||
/** Stop-motion timing: step the frame so animation holds for `stride` frames
|
||||
* (choppy, handmade), plus a deterministic per-step jitter for the wobble. */
|
||||
export function useStopMotion(stride = 3) {
|
||||
const frame = useCurrentFrame();
|
||||
const stepped = Math.floor(frame / stride) * stride;
|
||||
const jitter = (seed: number, amount: number) => (rand(stepped * 13.7 + seed) - 0.5) * amount;
|
||||
return { stepped, jitter };
|
||||
}
|
||||
|
||||
/** Layered-paper cast shadow for paper-cut depth (deeper layer = softer/larger). */
|
||||
export const paperShadow = (depth: number) =>
|
||||
`drop-shadow(0 ${Math.round(depth * 5)}px ${Math.round(depth * 8)}px rgba(40,28,18,0.22))`;
|
||||
|
||||
/** Procedural fibrous paper grain — stretched fractal noise (vertical fibers),
|
||||
* laid over each paper layer for a real torn-card texture. Deterministic. */
|
||||
export const PAPER_TEXTURE =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.012 0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)'/%3E%3C/svg%3E\")";
|
||||
@@ -11,6 +11,7 @@ import { BarChartBlock } from "./blocks/BarChart";
|
||||
import { StompBlock } from "./blocks/Stomp";
|
||||
import { DeviceMockupBlock } from "./blocks/DeviceMockup";
|
||||
import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
|
||||
import { PaperCutBlock } from "./blocks/PaperCut";
|
||||
|
||||
/**
|
||||
* The scene-block registry. A FlexStory template is an ordered list of these
|
||||
@@ -30,6 +31,7 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
|
||||
[BarChartBlock.id]: BarChartBlock,
|
||||
[StompBlock.id]: StompBlock,
|
||||
[DeviceMockupBlock.id]: DeviceMockupBlock,
|
||||
[PaperCutBlock.id]: PaperCutBlock,
|
||||
};
|
||||
|
||||
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
||||
|
||||
Reference in New Issue
Block a user