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:
soroush.asadi
2026-06-25 07:26:01 +03:30
parent b1a51cb01b
commit 8c4bc2c626
3 changed files with 104 additions and 0 deletions
@@ -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,
};
+27
View File
@@ -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\")";
+2
View File
@@ -11,6 +11,7 @@ import { BarChartBlock } from "./blocks/BarChart";
import { StompBlock } from "./blocks/Stomp"; import { StompBlock } from "./blocks/Stomp";
import { DeviceMockupBlock } from "./blocks/DeviceMockup"; import { DeviceMockupBlock } from "./blocks/DeviceMockup";
import { ProductShowcaseBlock } from "./blocks/ProductShowcase"; import { ProductShowcaseBlock } from "./blocks/ProductShowcase";
import { PaperCutBlock } from "./blocks/PaperCut";
/** /**
* 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
@@ -30,6 +31,7 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
[BarChartBlock.id]: BarChartBlock, [BarChartBlock.id]: BarChartBlock,
[StompBlock.id]: StompBlock, [StompBlock.id]: StompBlock,
[DeviceMockupBlock.id]: DeviceMockupBlock, [DeviceMockupBlock.id]: DeviceMockupBlock,
[PaperCutBlock.id]: PaperCutBlock,
}; };
export const BLOCK_LIST = Object.values(SCENE_BLOCKS); export const BLOCK_LIST = Object.values(SCENE_BLOCKS);