feat(remotion): Instagram channel-promo template + taste system + design-quality kit
The reference-round workflow, run end to end for a real template: Taste system (how we learn the user's taste, persisted): - references/TASTE_PROFILE.md (living design contract) + references/README.md (the daily loop) + a "reference round" stage in docs/TEMPLATE_BRIEF.md (provide refs or I suggest+mock directions). Design-quality before/after: - HeroDemo — the fix recipe vs the faint default: layered-depth background, a proper big video type scale, and a bold composed focal object. (Backgrounds were naked, text too small, scenes had no objects.) - YaldaSofreh3D + IGPromoDirections + IGProfileMock — reference-match proofs (low-poly 3D, 3 IG-promo style directions, the realistic IG-light page). Instagram channel-promo template (the deliverable — a flexible 5-scene FlexStory): - igkit + 5 blocks: IGIntro, IGProfile (realistic IG-light profile, scales to all aspects), IGFeed (post grid), IGStats (animated count-up), IGFollowCTA (Follow taps to "Following"). - FlexStory gains a `finish` toggle so the IG-light scenes render clean (no brand grade). INSTAGRAM_PROMO preset + 3 aspect comps in Root. Verified: a still of every scene at 9:16 renders clean; full preview MP4 rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,32 @@ Each round ≈ one `AskUserQuestion` call (≤4 questions), mostly multiple-choi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## The reference round — lock the look (runs with Round 2)
|
||||||
|
|
||||||
|
Before **any** building, lock the art direction against real visuals. Two modes, used together:
|
||||||
|
|
||||||
|
- **You provide** — I ask: *"Got references for this? Paste them (+ one line of why)."* I extract
|
||||||
|
the DNA into [`references/TASTE_PROFILE.md`](../references/TASTE_PROFILE.md), so the build
|
||||||
|
inherits *your* taste, not my defaults.
|
||||||
|
- **I suggest** — for the chosen type I propose **2–4 named style directions** AND **mock each up
|
||||||
|
as a cheap still / SVG** (no external images needed), so you pick from real visuals — not words.
|
||||||
|
I can also **web-search** live examples / asset packs / current trends and **link** them for you
|
||||||
|
to open and react to.
|
||||||
|
|
||||||
|
**Output:** a locked **direction** (style + palette + motifs + reference notes) written into the
|
||||||
|
Spec's `art` block and appended to the taste board. Then the gate continues: build → **cheap
|
||||||
|
still → you approve** → full render.
|
||||||
|
|
||||||
|
**Honest constraints:**
|
||||||
|
- I can web-search and *link* real references, but rendering arbitrary external images inline is
|
||||||
|
unreliable from this environment — so the dependable "suggest" engine is **me mocking the
|
||||||
|
directions myself** (as today's storyboards/characters were). Web search supplements, doesn't
|
||||||
|
replace it.
|
||||||
|
- **Footage:** I suggest CC0 sources + search/link, but footage is **vendored-CC0** (manual fetch)
|
||||||
|
or the **editable-backdrop** architecture — we can't AI-generate it (no GPU). Flag this per brief.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Template Spec (the output)
|
## Template Spec (the output)
|
||||||
|
|
||||||
The Q&A produces this structure, saved to `services/remotion/briefs/<slug>.md`:
|
The Q&A produces this structure, saved to `services/remotion/briefs/<slug>.md`:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# references/ — the taste-capture drop zone
|
||||||
|
|
||||||
|
Drop design references here so they become **codified taste**, not one-off chat images.
|
||||||
|
|
||||||
|
- `TASTE_PROFILE.md` — the living design contract. I distill the recurring **design DNA**
|
||||||
|
from everything in this folder and consult it on every build.
|
||||||
|
- `characters/` — drop **character pack files** here (PNG cutouts, SVG, `.blend`). I vendor
|
||||||
|
the usable ones into the asset library (honoring the license firewall) and build templates
|
||||||
|
*with* them — rather than redrawing from scratch.
|
||||||
|
- `boards/` — optional moodboards / style references grouped by topic.
|
||||||
|
|
||||||
|
When you add something, a one-line "why I like it" (the line, the palette, the mood, the
|
||||||
|
attitude) sharpens the profile far more than the image alone.
|
||||||
|
|
||||||
|
## The daily loop (our ritual)
|
||||||
|
|
||||||
|
Paste taste images (or a "I love / I hate this") any day. Each drop runs the loop:
|
||||||
|
|
||||||
|
1. **You paste** — references + one line of *why* (style / palette / mood / attitude).
|
||||||
|
2. **I extract** the design DNA → update `TASTE_PROFILE.md` (the board grows, the through-line
|
||||||
|
sharpens) + a memory so it survives across sessions.
|
||||||
|
3. **I apply** it → upgrade the shared **kits** (backgrounds, type scale, props, composition)
|
||||||
|
and the `flat-artist` skill so the *actual output* moves toward your taste.
|
||||||
|
4. **I prove** it → a cheap still / before-after so you can see the move; you approve or reject,
|
||||||
|
which sharpens the profile again.
|
||||||
|
|
||||||
|
Two modes so it's sustainable:
|
||||||
|
- **Calibration days** (most days) — just steps 1–2. Cheap. Absorb the DNA, grow the board.
|
||||||
|
- **Apply passes** (periodic) — steps 3–4. Roll the accumulated taste into the kits/blocks so
|
||||||
|
the whole catalogue jumps a level at once.
|
||||||
|
|
||||||
|
What actually compounds: not my raw drawing (that's fixed), but the **system** — the codified
|
||||||
|
profile, the reusable kits, the prop/character libraries, the skill rules. That gets richer every
|
||||||
|
day, so the output keeps improving even though the base model doesn't.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Taste Profile — character & illustration
|
||||||
|
|
||||||
|
> The living design contract. References go in, recurring **design DNA** comes out, and
|
||||||
|
> every build consults this file. The vaguer the input, the weaker the steer; the more
|
||||||
|
> concrete (actual files, exact palettes, "I like X because Y"), the sharper.
|
||||||
|
>
|
||||||
|
> How it persists: this doc + the `feedback-character-design-taste` memory + the
|
||||||
|
> `flat-artist` skill. It compounds — every approve/reject sharpens it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference board
|
||||||
|
|
||||||
|
### 2026-06-24 — first character drop (4 refs)
|
||||||
|
|
||||||
|
**R1 — 3D clay / toy characters** ("ANONYMOUZ", *Blender + PNG files*)
|
||||||
|
Rounded soft-clay 3D, **minimal or featureless faces** (sunglasses, simple noses), strongly
|
||||||
|
**diverse + inclusive** (skin tones, ages, a wheelchair user, body types), everyday activities
|
||||||
|
(camera, gym, skate, gaming, shopping), **bright casual** clothing. The popular Gumroad/UI8
|
||||||
|
"3D casual character" look.
|
||||||
|
|
||||||
|
**R2 — bold flat vector** (winter / sports pack)
|
||||||
|
**Chunky confident shapes**, **saturated primaries** (red / blue / yellow / navy) on light-blue
|
||||||
|
panels, **dynamic athletic poses**, minimal faces (often hidden by goggles/helmets), thick forms.
|
||||||
|
Alegria-adjacent but **bolder and more saturated**.
|
||||||
|
|
||||||
|
**R3 — modern thick-outline cartoon** (sunglasses kid, sunset)
|
||||||
|
**Thick black outlines**, flat cel fills, **warm gradient backgrounds** (orange→pink), chill/cool
|
||||||
|
mood, simple expressive features, light environmental detail (wires/poles). Contemporary "cool"
|
||||||
|
cartoon.
|
||||||
|
|
||||||
|
**R4 — NFT / streetwear bold cartoon** (bearded, teal skin, surreal companions)
|
||||||
|
**Thick black outlines**, **hyper-saturated flat fills**, surreal/edgy characters with attitude,
|
||||||
|
solid color backgrounds. Doodles / Cool-Cats-adjacent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core taste signal (the through-line)
|
||||||
|
|
||||||
|
Across all four, consistently:
|
||||||
|
|
||||||
|
- **Bold & saturated** — strong, confident color. **Not** muted, pastel, or washed-out.
|
||||||
|
- **Thick outlines** (in the 2D refs) — clean, heavy black line.
|
||||||
|
- **Minimal / stylized faces** — features simplified, abstracted, or hidden (shades, goggles).
|
||||||
|
- **Clean confident shapes** — rounded, deliberate forms (3D clay or flat).
|
||||||
|
- **Attitude & personality** — cool, expressive, characterful. **Not** corporate-bland.
|
||||||
|
- **Modern / on-trend** — every ref is a *current* style (3D clay, bold flat, NFT cartoon).
|
||||||
|
- **Diverse & inclusive** — especially R1.
|
||||||
|
|
||||||
|
## Anti-patterns (what to AVOID — the look that failed before)
|
||||||
|
|
||||||
|
- Muted / pastel / desaturated palettes.
|
||||||
|
- Soft, thin, timid line work; vague forms.
|
||||||
|
- Over-detailed, "trying to be realistic" faces → reads amateur ("the 5-year-old").
|
||||||
|
- Corporate flat-design blandness with no personality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sourcing strategy (honest path to this quality)
|
||||||
|
|
||||||
|
These references are **purchasable / artist asset packs**, not styles I can reliably hand-redraw
|
||||||
|
at this bar. The reliable path:
|
||||||
|
|
||||||
|
1. **Vendor the actual packs** the user owns. R1 even ships **PNG files** → those cutouts drop
|
||||||
|
straight into the 2.5D engine as character layers (fastest win). Honor the license firewall
|
||||||
|
(`docs/ASSET_LIBRARY.md`, `public/illustrations/assets.json`).
|
||||||
|
2. **Build motion / composition / templates around vendored characters** — that's our strength.
|
||||||
|
**Don't** redraw characters from scratch (that's the hand-code ceiling).
|
||||||
|
3. For any character I *do* author, match the through-line: **thick outline + saturated fill +
|
||||||
|
minimal face + confident shape.**
|
||||||
|
|
||||||
|
## Feasibility per style (in our engine)
|
||||||
|
|
||||||
|
| Style | Path |
|
||||||
|
|---|---|
|
||||||
|
| R1 3D clay | **Blender** for the source (it literally ships `.blend`); **PNG cutouts vendor directly** into 2.5D today. Three.js can *approach* soft-clay (rounded geo + soft mat) but the polished look = Blender. |
|
||||||
|
| R2 bold flat | **Matchable as SVG** — thick shapes + saturated fills; best **vendored** as a pack for consistency/volume. |
|
||||||
|
| R3 thick-outline cartoon | **Matchable as SVG** (heavy stroke + flat cel + gradient bg). Volume → vendor. |
|
||||||
|
| R4 NFT bold cartoon | **Matchable as SVG**; surreal characters are hard to author at scale → **vendor**. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status / open questions
|
||||||
|
- [ ] Which style is the **primary** direction (or several as tiers)?
|
||||||
|
- [ ] Does the user **own the files** for these packs (to vendor), or are they inspiration only?
|
||||||
|
- [ ] Drop pack files into `references/characters/` → I vendor + ledger them.
|
||||||
@@ -2,11 +2,15 @@ import { Composition } from "remotion";
|
|||||||
import { ASPECTS } from "./lib/aspect";
|
import { ASPECTS } from "./lib/aspect";
|
||||||
import { TEMPLATES } from "./templates";
|
import { TEMPLATES } from "./templates";
|
||||||
import { Three3DTest } from "./compositions/Three3DTest";
|
import { Three3DTest } from "./compositions/Three3DTest";
|
||||||
|
import { YaldaSofreh3D } from "./compositions/YaldaSofreh3D";
|
||||||
|
import { HeroDemo } from "./compositions/HeroDemo";
|
||||||
|
import { IGPromoDirections, igPromoSchema } from "./compositions/IGPromoDirections";
|
||||||
|
import { IGProfileMock } from "./compositions/IGProfileMock";
|
||||||
import { AssetSheet } from "./compositions/AssetSheet";
|
import { AssetSheet } from "./compositions/AssetSheet";
|
||||||
import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes";
|
import { StoryScenes, STORY_SCENES_DURATION } from "./compositions/StoryScenes";
|
||||||
import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory";
|
import { FlexStory, flexStorySchema, flexStoryDefaults, calcFlexStoryMetadata } from "./compositions/FlexStory";
|
||||||
import { LogoMotion3D, logoMotion3DSchema, logoMotion3DDefaults } from "./compositions/LogoMotion3D";
|
import { LogoMotion3D, logoMotion3DSchema, logoMotion3DDefaults } from "./compositions/LogoMotion3D";
|
||||||
import { CHARACTER_JOURNEY } from "./scenes/presets";
|
import { CHARACTER_JOURNEY, INSTAGRAM_PROMO } from "./scenes/presets";
|
||||||
import {
|
import {
|
||||||
IlluminatedCircles,
|
IlluminatedCircles,
|
||||||
illuminatedCirclesSchema,
|
illuminatedCirclesSchema,
|
||||||
@@ -133,6 +137,51 @@ export const RemotionRoot: React.FC = () => {
|
|||||||
height={720}
|
height={720}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Design-quality before/after demo (rich bg + big type + bold object) */}
|
||||||
|
{ASPECTS.map((a) => (
|
||||||
|
<Composition
|
||||||
|
key={`HeroDemo-${a.id}`}
|
||||||
|
id={`HeroDemo-${a.id}`}
|
||||||
|
component={HeroDemo}
|
||||||
|
durationInFrames={5 * FPS}
|
||||||
|
fps={FPS}
|
||||||
|
width={a.width}
|
||||||
|
height={a.height}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Instagram profile mock — realistic light-theme page (gate still) */}
|
||||||
|
<Composition
|
||||||
|
id="IGProfileMock"
|
||||||
|
component={IGProfileMock}
|
||||||
|
durationInFrames={150}
|
||||||
|
fps={FPS}
|
||||||
|
width={1080}
|
||||||
|
height={1920}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Instagram promo — reference round (3 style directions to pick from) */}
|
||||||
|
<Composition
|
||||||
|
id="IGPromoDir"
|
||||||
|
component={IGPromoDirections}
|
||||||
|
durationInFrames={150}
|
||||||
|
fps={FPS}
|
||||||
|
width={1080}
|
||||||
|
height={1920}
|
||||||
|
schema={igPromoSchema}
|
||||||
|
defaultProps={{ variant: "A" as const }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Low-poly Yalda sofreh — reference-match challenge */}
|
||||||
|
<Composition
|
||||||
|
id="YaldaSofreh3D"
|
||||||
|
component={YaldaSofreh3D}
|
||||||
|
durationInFrames={150}
|
||||||
|
fps={30}
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dev preview: vendored CC0 character library (not a customer template) */}
|
{/* Dev preview: vendored CC0 character library (not a customer template) */}
|
||||||
<Composition
|
<Composition
|
||||||
id="AssetSheet"
|
id="AssetSheet"
|
||||||
@@ -189,6 +238,22 @@ export const RemotionRoot: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* InstagramPromo — "follow our channel" template (realistic IG-light page). */}
|
||||||
|
{ASPECTS.map((a) => (
|
||||||
|
<Composition
|
||||||
|
key={`InstagramPromo-${a.id}`}
|
||||||
|
id={`InstagramPromo-${a.id}`}
|
||||||
|
component={FlexStory}
|
||||||
|
durationInFrames={18 * FPS}
|
||||||
|
fps={FPS}
|
||||||
|
width={a.width}
|
||||||
|
height={a.height}
|
||||||
|
schema={flexStorySchema}
|
||||||
|
defaultProps={INSTAGRAM_PROMO}
|
||||||
|
calculateMetadata={calcFlexStoryMetadata}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Branded templates — each registered in all three aspects. A template may
|
{/* Branded templates — each registered in all three aspects. A template may
|
||||||
supply a dedicated component per aspect (componentsByAspect) when its
|
supply a dedicated component per aspect (componentsByAspect) when its
|
||||||
design differs structurally; otherwise the shared `component` adapts
|
design differs structurally; otherwise the shared `component` adapts
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const flexStorySchema = z.object({
|
|||||||
music: z.string().optional(), // path/url of the music bed; "" = silent
|
music: z.string().optional(), // path/url of the music bed; "" = silent
|
||||||
musicVolume: z.number().optional(),
|
musicVolume: z.number().optional(),
|
||||||
sfx: z.boolean().optional(), // transition whoosh + outro chime
|
sfx: z.boolean().optional(), // transition whoosh + outro chime
|
||||||
|
finish: z.boolean().optional(), // cinematic grade + FinishPass (default on; off = clean/light)
|
||||||
...colorSchema,
|
...colorSchema,
|
||||||
});
|
});
|
||||||
type Props = z.infer<typeof flexStorySchema>;
|
type Props = z.infer<typeof flexStorySchema>;
|
||||||
@@ -67,6 +68,7 @@ export const FlexStory: React.FC<Props> = (props) => {
|
|||||||
const music = props.music === undefined ? "audio/music-ambient.mp3" : props.music;
|
const music = props.music === undefined ? "audio/music-ambient.mp3" : props.music;
|
||||||
const musicVolume = props.musicVolume ?? 0.6;
|
const musicVolume = props.musicVolume ?? 0.6;
|
||||||
const sfx = props.sfx ?? true;
|
const sfx = props.sfx ?? true;
|
||||||
|
const finish = props.finish ?? true;
|
||||||
|
|
||||||
// Precompute each scene's start frame + duration (shared by visuals + SFX).
|
// Precompute each scene's start frame + duration (shared by visuals + SFX).
|
||||||
const starts: number[] = [];
|
const starts: number[] = [];
|
||||||
@@ -79,7 +81,7 @@ export const FlexStory: React.FC<Props> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT, filter: GRADE_FILTER }}>
|
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT, filter: finish ? GRADE_FILTER : "saturate(1.02)" }}>
|
||||||
{music ? <Audio src={resolveAudio(music)} loop volume={musicVolume} /> : null}
|
{music ? <Audio src={resolveAudio(music)} loop volume={musicVolume} /> : null}
|
||||||
|
|
||||||
{scenes.map((sc, i) => {
|
{scenes.map((sc, i) => {
|
||||||
@@ -103,8 +105,8 @@ export const FlexStory: React.FC<Props> = (props) => {
|
|||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{/* Cinematic finish over every scene — the shared quality floor. */}
|
{/* Cinematic finish over every scene — the shared quality floor (off = clean/light). */}
|
||||||
<FinishPass colors={colors} />
|
{finish ? <FinishPass colors={colors} /> : null}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { useLayout } from "../lib/aspect";
|
||||||
|
import { FONT } from "../lib/fonts";
|
||||||
|
import { hexToRgba, mixHex } from "../lib/anim";
|
||||||
|
import { Grain, Vignette } from "../scenes/chrome";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeroDemo — the "after" for the design-quality fix: a DESIGNED hero, not text on
|
||||||
|
* a void. Three deliberate fixes vs the calm default:
|
||||||
|
* 1. Background = layered depth (gradient + big soft glows + a bold patterned panel)
|
||||||
|
* with real atmosphere, instead of 2 ghost blobs.
|
||||||
|
* 2. Type = a proper video scale — title huge, subtitle big + high-contrast.
|
||||||
|
* 3. A bold composed focal OBJECT (the play disc + orbit + mini cards), thick-outline
|
||||||
|
* saturated, arranged by thirds — so the frame reads as a composition.
|
||||||
|
*/
|
||||||
|
const C = { kicker: "فلترندر", title: "ویدیوهای حرفهای بسازید", subtitle: "قالبهای آمادهٔ فارسی، رندر ابری در چند دقیقه" };
|
||||||
|
const COL = { accent: "#ff5a3c", secondary: "#7c5cff", bg: "#11132a", text: "#ffffff" };
|
||||||
|
|
||||||
|
export const HeroDemo: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const L = useLayout();
|
||||||
|
const ink = mixHex(COL.bg, "#000010", 0.55);
|
||||||
|
const stroke = mixHex(COL.bg, "#05060f", 0.6);
|
||||||
|
|
||||||
|
const titleSp = spring({ frame: frame - 4, fps, config: { damping: 18, stiffness: 110 } });
|
||||||
|
const titleY = interpolate(titleSp, [0, 1], [L.vmin(50), 0]);
|
||||||
|
const subOp = interpolate(frame, [16, 34], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const objSp = spring({ frame: frame - 2, fps, config: { damping: 14, stiffness: 90 } });
|
||||||
|
const float = Math.sin(frame / 26) * L.vmin(10);
|
||||||
|
|
||||||
|
// RTL editorial split: bold object on the LEFT, right-aligned text on the RIGHT.
|
||||||
|
const objSize = L.pick(L.vmin(470), L.vmin(620), L.vmin(680));
|
||||||
|
const objLeft = L.pick(L.vmin(120), L.vmin(190), L.vmin(150));
|
||||||
|
const objTop = L.pick(L.vmin(305), L.vmin(150), L.vmin(1100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `linear-gradient(150deg, ${mixHex(COL.bg, COL.secondary, 0.12)} 0%, ${ink} 60%, ${mixHex(COL.bg, "#000010", 0.7)} 100%)` }}>
|
||||||
|
{/* 1 — background depth: big soft glows + a faint bold dot-grid panel */}
|
||||||
|
<div style={{ position: "absolute", left: "-12%", top: "-18%", width: "55%", height: "70%", borderRadius: "50%", background: hexToRgba(COL.accent, 0.5), filter: `blur(${L.vmin(150)}px)` }} />
|
||||||
|
<div style={{ position: "absolute", right: "-10%", bottom: "-22%", width: "55%", height: "75%", borderRadius: "50%", background: hexToRgba(COL.secondary, 0.45), filter: `blur(${L.vmin(160)}px)` }} />
|
||||||
|
<AbsoluteFill style={{ opacity: 0.12, backgroundImage: `radial-gradient(${hexToRgba(COL.text, 0.9)} ${L.vmin(2.4)}px, transparent ${L.vmin(2.6)}px)`, backgroundSize: `${L.vmin(46)}px ${L.vmin(46)}px`, maskImage: "radial-gradient(80% 80% at 30% 60%, black, transparent 75%)", WebkitMaskImage: "radial-gradient(80% 80% at 30% 60%, black, transparent 75%)" }} />
|
||||||
|
|
||||||
|
{/* 2 — bold composed focal object: glow + orbit + play disc + two mini template cards */}
|
||||||
|
<div style={{ position: "absolute", left: objLeft, top: objTop + float, width: objSize, height: objSize, transform: `scale(${interpolate(objSp, [0, 1], [0.7, 1])})` }}>
|
||||||
|
<div style={{ position: "absolute", inset: "8%", borderRadius: "50%", background: hexToRgba(COL.accent, 0.5), filter: `blur(${L.vmin(70)}px)` }} />
|
||||||
|
<svg width={objSize} height={objSize} viewBox="0 0 100 100" style={{ position: "absolute", inset: 0, filter: `drop-shadow(0 ${L.vmin(24)}px ${L.vmin(40)}px ${hexToRgba("#000010", 0.5)})` }}>
|
||||||
|
<circle cx="50" cy="50" r="40" fill="none" stroke={hexToRgba(COL.text, 0.22)} strokeWidth="1.4" strokeDasharray="5 5" />
|
||||||
|
<circle cx="50" cy="50" r="31" fill={COL.accent} stroke={stroke} strokeWidth="2.4" />
|
||||||
|
<circle cx="50" cy="50" r="31" fill="none" stroke={hexToRgba(COL.text, 0.5)} strokeWidth="1" transform="translate(-1.2,-1.6)" />
|
||||||
|
<path d="M43 38 L65 50 L43 62 Z" fill={COL.text} stroke={stroke} strokeWidth="2.2" strokeLinejoin="round" />
|
||||||
|
<circle cx="86" cy="34" r="3.4" fill={COL.secondary} stroke={stroke} strokeWidth="1.6" />
|
||||||
|
<g transform="rotate(15 16 30)"><path d="M14 26 l2.4 5 5 2.4 -5 2.4 -2.4 5 -2.4 -5 -5 -2.4 5 -2.4 z" fill={COL.text} stroke={stroke} strokeWidth="1.4" strokeLinejoin="round" /></g>
|
||||||
|
</svg>
|
||||||
|
{/* mini "template" cards floating on the disc */}
|
||||||
|
<div style={{ position: "absolute", right: "-4%", top: "8%", width: "34%", height: "26%", background: COL.secondary, borderRadius: L.vmin(16), border: `${L.vmin(5)}px solid ${stroke}`, boxShadow: `0 ${L.vmin(16)}px ${L.vmin(30)}px ${hexToRgba("#000010", 0.45)}`, transform: "rotate(8deg)", padding: L.vmin(14) }}>
|
||||||
|
<div style={{ width: "70%", height: L.vmin(10), background: hexToRgba(COL.text, 0.85), borderRadius: 999 }} />
|
||||||
|
<div style={{ width: "45%", height: L.vmin(10), background: hexToRgba(COL.text, 0.5), borderRadius: 999, marginTop: L.vmin(10) }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ position: "absolute", left: "-2%", bottom: "6%", width: "30%", height: "23%", background: mixHex(COL.accent, "#ffffff", 0.15), borderRadius: L.vmin(16), border: `${L.vmin(5)}px solid ${stroke}`, boxShadow: `0 ${L.vmin(16)}px ${L.vmin(30)}px ${hexToRgba("#000010", 0.45)}`, transform: "rotate(-10deg)" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 — type: right-aligned RTL editorial block, big + high-contrast */}
|
||||||
|
<AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", justifyContent: "center", textAlign: "right", direction: "rtl", padding: L.pick(`0 ${L.vmin(110)}px 0 42%`, `${L.vmin(120)}px ${L.vmin(80)}px`, `0 ${L.vmin(70)}px ${L.vmin(140)}px`) }}>
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: L.vmin(12), background: COL.accent, color: "#fff", fontWeight: 800, fontSize: L.vmin(30), padding: `${L.vmin(12)}px ${L.vmin(26)}px`, borderRadius: 999, border: `${L.vmin(4)}px solid ${stroke}`, boxShadow: `0 ${L.vmin(10)}px ${L.vmin(22)}px ${hexToRgba(COL.accent, 0.4)}` }}>
|
||||||
|
<span style={{ width: L.vmin(12), height: L.vmin(12), borderRadius: 999, background: "#fff" }} />
|
||||||
|
{C.kicker}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(30), fontWeight: 900, fontSize: L.pick(L.vmin(146), L.vmin(150), L.vmin(132)), lineHeight: 1.0, letterSpacing: -2, color: COL.text, transform: `translateY(${titleY}px)`, textShadow: `0 ${L.vmin(8)}px ${L.vmin(30)}px ${hexToRgba("#000010", 0.45)}`, maxWidth: L.pick(L.vmin(1000), L.vmin(900), L.vmin(960)) }}>
|
||||||
|
ویدیوهای <span style={{ color: COL.accent }}>حرفهای</span> بسازید
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(28), fontWeight: 500, fontSize: L.pick(L.vmin(50), L.vmin(48), L.vmin(46)), lineHeight: 1.5, color: hexToRgba(COL.text, 0.9), opacity: subOp, maxWidth: L.vmin(1050) }}>
|
||||||
|
{C.subtitle}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
|
||||||
|
<Vignette />
|
||||||
|
<Grain />
|
||||||
|
<AbsoluteFill style={{ pointerEvents: "none", filter: "contrast(1.06) saturate(1.12)" }} />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill } from "remotion";
|
||||||
|
import { FONT } from "../lib/fonts";
|
||||||
|
import { hexToRgba } from "../lib/anim";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGProfileMock — an authentic Instagram profile page (LIGHT theme) inside a phone,
|
||||||
|
* as the centrepiece of a "follow our page" promo. Real IG chrome: status bar, the
|
||||||
|
* username header, avatar + stats, bio, Follow/Message buttons, story highlights,
|
||||||
|
* the grid tabs and the posts grid. Everything here is an editable field later.
|
||||||
|
*/
|
||||||
|
const IGLOGO = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)";
|
||||||
|
const BLUE = "#0095f6";
|
||||||
|
const HANDLE = "flat.studio";
|
||||||
|
const NAME = "استودیو فلت";
|
||||||
|
const CAT = "هنر و طراحی";
|
||||||
|
const BIO = ["هر روز یک طرح تازه ✨", "آموزش، قالب و الهام برای طراحان", "سفارش و دانلود 👇"];
|
||||||
|
const LINK = "flat.studio/shop";
|
||||||
|
const HILITES = ["جدید", "قالبها", "آموزش", "نمونهکار"];
|
||||||
|
const POSTS = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff", "#ff5a3c", "#3aa0ff", "#ffb23c"];
|
||||||
|
|
||||||
|
const IgCamera: React.FC<{ s: number }> = ({ s }) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="url(#ig)" strokeWidth="2">
|
||||||
|
<defs><linearGradient id="ig" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stopColor="#f09433" /><stop offset="0.5" stopColor="#dc2743" /><stop offset="1" stopColor="#bc1888" /></linearGradient></defs>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="6" /><circle cx="12" cy="12" r="5" /><circle cx="17.5" cy="6.5" r="1.2" fill="url(#ig)" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stat: React.FC<{ n: string; l: string }> = ({ n, l }) => (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 38, color: "#000" }}>{n}</div>
|
||||||
|
<div style={{ fontWeight: 400, fontSize: 27, color: "#262626" }}>{l}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IGProfileMock: React.FC = () => {
|
||||||
|
const S = 770; // phone screen inner width
|
||||||
|
const cell = (S - 12) / 3;
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: "linear-gradient(165deg,#fdfcfb,#f3edf5)" }}>
|
||||||
|
<div style={{ position: "absolute", left: "-12%", top: "-6%", width: "55%", height: "34%", borderRadius: "50%", background: hexToRgba("#dc2743", 0.16), filter: "blur(150px)" }} />
|
||||||
|
<div style={{ position: "absolute", right: "-14%", bottom: "2%", width: "58%", height: "36%", borderRadius: "50%", background: hexToRgba("#7c5cff", 0.16), filter: "blur(160px)" }} />
|
||||||
|
|
||||||
|
{/* promo header: IG logo + headline */}
|
||||||
|
<div style={{ position: "absolute", top: 96, left: 0, right: 0, display: "flex", flexDirection: "column", alignItems: "center", gap: 18 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<IgCamera s={64} />
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 52, background: IGLOGO, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent" }}>Instagram</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ direction: "rtl", fontWeight: 900, fontSize: 64, color: "#15151a" }}>صفحهٔ ما را دنبال کنید</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* phone */}
|
||||||
|
<div style={{ position: "absolute", left: "50%", top: 300, transform: "translateX(-50%)", width: S + 36, borderRadius: 64, background: "#0c0c0f", padding: 18, boxShadow: "0 50px 100px rgba(20,12,30,0.4)" }}>
|
||||||
|
<div style={{ width: S, borderRadius: 48, background: "#fff", overflow: "hidden" }}>
|
||||||
|
{/* status bar */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 34px 6px", fontSize: 26, fontWeight: 700, color: "#000" }}>
|
||||||
|
<span>۹:۴۱</span><span style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 24 }}>▮▮▮ <span style={{ fontSize: 22 }}>WiFi</span> <span style={{ border: "2px solid #000", borderRadius: 5, padding: "2px 6px", fontSize: 18 }}>۸۴٪</span></span>
|
||||||
|
</div>
|
||||||
|
{/* username header */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "12px 28px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, direction: "ltr" }}>
|
||||||
|
<span style={{ fontSize: 26 }}>🔒</span><span style={{ fontWeight: 800, fontSize: 36, color: "#000" }}>{HANDLE}</span><span style={{ fontSize: 24, color: "#000" }}>▾</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 26, fontSize: 42, color: "#000" }}><span>+</span><span>☰</span></div>
|
||||||
|
</div>
|
||||||
|
{/* profile: avatar + stats */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 30, padding: "12px 34px" }}>
|
||||||
|
<div style={{ width: 176, height: 176, borderRadius: "50%", background: IGLOGO, padding: 6 }}>
|
||||||
|
<div style={{ width: "100%", height: "100%", borderRadius: "50%", background: "#eee", border: "5px solid #fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 78 }}>🎨</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: "flex", justifyContent: "space-around" }}>
|
||||||
|
<Stat n="۳۲۰" l="پست" /><Stat n="۲۴٫۸ هزار" l="دنبالکننده" /><Stat n="۱۸۰" l="دنبالشده" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* name + bio */}
|
||||||
|
<div style={{ direction: "rtl", padding: "6px 34px 0", color: "#000" }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 32 }}>{NAME}</div>
|
||||||
|
<div style={{ fontSize: 27, color: "#737373", marginTop: 2 }}>{CAT}</div>
|
||||||
|
{BIO.map((b, i) => <div key={i} style={{ fontSize: 28, marginTop: 4 }}>{b}</div>)}
|
||||||
|
<div style={{ fontSize: 28, color: "#00376b", fontWeight: 600, marginTop: 4, direction: "ltr", textAlign: "right" }}>{LINK}</div>
|
||||||
|
</div>
|
||||||
|
{/* buttons */}
|
||||||
|
<div style={{ display: "flex", gap: 12, padding: "20px 34px 8px" }}>
|
||||||
|
<div style={{ flex: 1, height: 76, borderRadius: 12, background: BLUE, color: "#fff", fontWeight: 800, fontSize: 30, display: "flex", alignItems: "center", justifyContent: "center" }}>دنبال کردن</div>
|
||||||
|
<div style={{ flex: 1, height: 76, borderRadius: 12, background: "#efefef", color: "#000", fontWeight: 700, fontSize: 30, display: "flex", alignItems: "center", justifyContent: "center" }}>پیام</div>
|
||||||
|
<div style={{ width: 76, height: 76, borderRadius: 12, background: "#efefef", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 34 }}>👤</div>
|
||||||
|
</div>
|
||||||
|
{/* highlights */}
|
||||||
|
<div style={{ display: "flex", gap: 28, padding: "16px 34px", direction: "rtl" }}>
|
||||||
|
{HILITES.map((h, i) => (
|
||||||
|
<div key={i} style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ width: 120, height: 120, borderRadius: "50%", border: "2px solid #dbdbdb", background: i % 2 ? "#f3e9df" : "#e9eef5", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 46 }}>{["✨", "🗂️", "🎓", "🖼️"][i]}</div>
|
||||||
|
<div style={{ fontSize: 24, color: "#000", marginTop: 8 }}>{h}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* tabs */}
|
||||||
|
<div style={{ display: "flex", borderTop: "1px solid #dbdbdb", marginTop: 6 }}>
|
||||||
|
{["▦", "▶", "𓏬"].map((t, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, textAlign: "center", padding: "18px 0", fontSize: 38, color: i === 0 ? "#000" : "#b3b3b3", borderTop: i === 0 ? "2px solid #000" : "none", marginTop: -1 }}>{t}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* posts grid */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap: 6 }}>
|
||||||
|
{POSTS.map((c, i) => (
|
||||||
|
<div key={i} style={{ width: cell, height: cell, background: c, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
|
||||||
|
<div style={{ width: cell * 0.36, height: cell * 0.36, borderRadius: 12, background: hexToRgba("#fff", 0.4) }} />
|
||||||
|
{i % 4 === 0 ? <span style={{ position: "absolute", top: 10, right: 12, color: "#fff", fontSize: 30 }}>▶</span> : i % 4 === 1 ? <span style={{ position: "absolute", top: 10, right: 12, color: "#fff", fontSize: 26 }}>⧉</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AbsoluteFill, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../lib/fonts";
|
||||||
|
import { hexToRgba } from "../lib/anim";
|
||||||
|
import { Grain, Vignette } from "../scenes/chrome";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGPromoDirections — the reference round for an Instagram channel promo. Three
|
||||||
|
* named style directions rendered as real stills so the look can be PICKED before
|
||||||
|
* we build the full template. 9:16 (Instagram-native).
|
||||||
|
* A — Device / phone-first (clean, trustworthy product look)
|
||||||
|
* B — Bold kinetic (energetic, on-trend, matches the bold taste)
|
||||||
|
* C — Premium glass (elegant, agency / dark)
|
||||||
|
*/
|
||||||
|
export const igPromoSchema = z.object({ variant: z.enum(["A", "B", "C"]) });
|
||||||
|
|
||||||
|
const IG = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)";
|
||||||
|
const C = { handle: "@flat.studio", name: "استودیو فلت", tagline: "هر روز یک طرح تازه", followers: "۲۴٫۸ هزار", cta: "دنبال کنید" };
|
||||||
|
const GRID = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"];
|
||||||
|
|
||||||
|
const StatCol: React.FC<{ n: string; l: string; dark?: boolean }> = ({ n, l, dark }) => (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 40, color: dark ? "#fff" : "#15151a" }}>{n}</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 26, color: dark ? hexToRgba("#fff", 0.6) : hexToRgba("#15151a", 0.55) }}>{l}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FollowBtn: React.FC<{ w?: number; big?: boolean }> = ({ w = 360, big }) => (
|
||||||
|
<div style={{ width: w, height: big ? 108 : 92, borderRadius: 999, background: IG, display: "flex", alignItems: "center", justifyContent: "center", gap: 14, color: "#fff", fontWeight: 800, fontSize: big ? 46 : 40, boxShadow: `0 18px 40px ${hexToRgba("#dc2743", 0.45)}`, border: "5px solid #ffffff22" }}>
|
||||||
|
<span style={{ fontSize: big ? 48 : 42 }}>+</span>{C.cta}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Avatar: React.FC<{ size: number }> = ({ size }) => (
|
||||||
|
<div style={{ width: size, height: size, borderRadius: "50%", background: IG, padding: size * 0.05, boxShadow: `0 10px 30px ${hexToRgba("#cc2366", 0.4)}` }}>
|
||||||
|
<div style={{ width: "100%", height: "100%", borderRadius: "50%", background: "#2a2440", border: "5px solid #fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.42 }}>📷</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Grid: React.FC<{ cell: number; gap: number; radius?: number }> = ({ cell, gap, radius = 14 }) => (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap }}>
|
||||||
|
{GRID.map((c, i) => (
|
||||||
|
<div key={i} style={{ width: cell, height: cell, borderRadius: radius, background: c, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div style={{ width: cell * 0.34, height: cell * 0.34, borderRadius: 8, background: hexToRgba("#fff", 0.35) }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── A — phone-first ──────────────────────────────────────────────────────────
|
||||||
|
const VariantA: React.FC = () => (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: "linear-gradient(160deg,#fbf6f1,#f4e9f2)" }}>
|
||||||
|
<div style={{ position: "absolute", left: "-15%", top: "-8%", width: "60%", height: "40%", borderRadius: "50%", background: hexToRgba("#dc2743", 0.22), filter: "blur(160px)" }} />
|
||||||
|
<div style={{ position: "absolute", right: "-15%", bottom: "4%", width: "60%", height: "40%", borderRadius: "50%", background: hexToRgba("#7c5cff", 0.2), filter: "blur(170px)" }} />
|
||||||
|
<div style={{ direction: "rtl", position: "absolute", top: 150, left: 0, right: 0, textAlign: "center" }}>
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 12, background: IG, color: "#fff", fontWeight: 800, fontSize: 30, padding: "12px 28px", borderRadius: 999 }}>اینستاگرام</div>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: 96, color: "#15151a", marginTop: 22, letterSpacing: -2 }}>ما را دنبال کنید</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ position: "absolute", left: "50%", top: 430, transform: "translateX(-50%)", width: 620, height: 1180, borderRadius: 70, background: "#15151a", padding: 18, boxShadow: "0 50px 90px rgba(20,10,30,0.4)" }}>
|
||||||
|
<div style={{ width: "100%", height: "100%", borderRadius: 56, background: "#fff", direction: "rtl", padding: "40px 34px", display: "flex", flexDirection: "column", alignItems: "center", gap: 26 }}>
|
||||||
|
<Avatar size={200} />
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 46, color: "#15151a" }}>{C.name}</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 32, color: hexToRgba("#15151a", 0.5), marginTop: -14 }}>{C.handle}</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-around", width: "100%", marginTop: 6 }}>
|
||||||
|
<StatCol n="۳۲۰" l="پست" /><StatCol n={C.followers} l="دنبالکننده" /><StatCol n="۱۸۰" l="دنبالشده" />
|
||||||
|
</div>
|
||||||
|
<FollowBtn w={500} />
|
||||||
|
<div style={{ marginTop: 8 }}><Grid cell={150} gap={10} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Vignette /><Grain />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── B — bold kinetic ─────────────────────────────────────────────────────────
|
||||||
|
const Card: React.FC<{ x: number; y: number; rot: number; c: string; s?: number }> = ({ x, y, rot, c, s = 230 }) => (
|
||||||
|
<div style={{ position: "absolute", left: x, top: y, width: s, height: s * 1.18, borderRadius: 24, background: c, border: "7px solid #0d0b1a", transform: `rotate(${rot}deg)`, boxShadow: "0 24px 40px rgba(0,0,0,0.4)", padding: 18 }}>
|
||||||
|
<div style={{ width: "55%", height: 14, background: hexToRgba("#fff", 0.85), borderRadius: 999 }} />
|
||||||
|
<div style={{ width: "35%", height: 14, background: hexToRgba("#fff", 0.5), borderRadius: 999, marginTop: 12 }} />
|
||||||
|
<div style={{ position: "absolute", bottom: 16, right: 16, fontSize: 40 }}>♥</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const VariantB: React.FC = () => (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: "linear-gradient(155deg,#241433,#140d26 55%,#0c0a1c)" }}>
|
||||||
|
<div style={{ position: "absolute", left: "-12%", top: "-6%", width: "60%", height: "42%", borderRadius: "50%", background: hexToRgba("#dc2743", 0.5), filter: "blur(150px)" }} />
|
||||||
|
<div style={{ position: "absolute", right: "-12%", bottom: "-4%", width: "60%", height: "42%", borderRadius: "50%", background: hexToRgba("#7c5cff", 0.45), filter: "blur(160px)" }} />
|
||||||
|
<AbsoluteFill style={{ opacity: 0.12, backgroundImage: `radial-gradient(${hexToRgba("#fff", 0.9)} 2.6px, transparent 2.8px)`, backgroundSize: "50px 50px", maskImage: "radial-gradient(75% 60% at 50% 45%, black, transparent 78%)", WebkitMaskImage: "radial-gradient(75% 60% at 50% 45%, black, transparent 78%)" }} />
|
||||||
|
<Card x={70} y={250} rot={-12} c="#ff5a3c" /><Card x={760} y={300} rot={11} c="#16b5a0" s={250} />
|
||||||
|
<Card x={60} y={1230} rot={9} c="#ffb23c" s={250} /><Card x={770} y={1300} rot={-10} c="#3aa0ff" />
|
||||||
|
<div style={{ position: "absolute", inset: 0, direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", textAlign: "center", padding: "0 60px" }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 40, color: hexToRgba("#fff", 0.8) }}>{C.tagline}</div>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: 132, color: "#fff", letterSpacing: -2, lineHeight: 1.0, marginTop: 16, textShadow: "0 10px 40px rgba(0,0,0,0.5)" }}>{C.name}</div>
|
||||||
|
<div style={{ direction: "ltr", fontWeight: 800, fontSize: 60, color: "#ff9a3c", marginTop: 10 }}>{C.handle}</div>
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 14, background: hexToRgba("#fff", 0.1), border: `2px solid ${hexToRgba("#fff", 0.25)}`, borderRadius: 999, padding: "14px 30px", color: "#fff", fontWeight: 700, fontSize: 38, marginTop: 34 }}>♥ {C.followers} دنبالکننده</div>
|
||||||
|
<div style={{ marginTop: 34 }}><FollowBtn w={440} big /></div>
|
||||||
|
</div>
|
||||||
|
<Vignette /><Grain />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── C — premium glass ────────────────────────────────────────────────────────
|
||||||
|
const VariantC: React.FC = () => (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: "radial-gradient(120% 80% at 50% 0%,#1a1530,#0c0a14 60%,#08070e)" }}>
|
||||||
|
<div style={{ position: "absolute", left: "50%", top: "30%", width: "90%", height: "45%", transform: "translateX(-50%)", borderRadius: "50%", background: hexToRgba("#cc2366", 0.4), filter: "blur(180px)" }} />
|
||||||
|
<div style={{ position: "absolute", right: "20%", top: "8%", width: 260, height: 260, borderRadius: 50, background: IG, opacity: 0.5, filter: "blur(60px)" }} />
|
||||||
|
<div style={{ position: "absolute", inset: 0, direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "0 70px" }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 38, letterSpacing: 6, color: hexToRgba("#fff", 0.55), marginBottom: 40 }}>به ما بپیوندید</div>
|
||||||
|
<div style={{ width: "100%", borderRadius: 48, background: hexToRgba("#ffffff", 0.06), border: `2px solid ${hexToRgba("#fff", 0.14)}`, boxShadow: "0 40px 90px rgba(0,0,0,0.5)", padding: "70px 50px", display: "flex", flexDirection: "column", alignItems: "center", gap: 28 }}>
|
||||||
|
<Avatar size={230} />
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 58, color: "#fff" }}>{C.name}</div>
|
||||||
|
<div style={{ direction: "ltr", fontWeight: 500, fontSize: 38, color: hexToRgba("#fff", 0.55), marginTop: -16 }}>{C.handle}</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 34, color: hexToRgba("#fff", 0.7), textAlign: "center", lineHeight: 1.5 }}>{C.tagline}</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-around", width: "100%", marginTop: 10, paddingTop: 30, borderTop: `2px solid ${hexToRgba("#fff", 0.1)}` }}>
|
||||||
|
<StatCol n="۳۲۰" l="پست" dark /><StatCol n={C.followers} l="دنبالکننده" dark /><StatCol n="۱۸۰" l="دنبالشده" dark />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}><FollowBtn w={520} big /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Vignette /><Grain />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IGPromoDirections: React.FC<z.infer<typeof igPromoSchema>> = ({ variant }) => {
|
||||||
|
useVideoConfig();
|
||||||
|
return variant === "A" ? <VariantA /> : variant === "B" ? <VariantB /> : <VariantC />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { ThreeCanvas } from "@remotion/three";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import { Environment, Lightformer } from "@react-three/drei";
|
||||||
|
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
// Challenge: match the low-poly isometric Yalda sofreh reference. Flat-shaded
|
||||||
|
// low-poly geometry in @remotion/three, isometric-ish camera, warm candle light +
|
||||||
|
// bloom, on a deep-red paisley ground.
|
||||||
|
|
||||||
|
const Rig: React.FC = () => {
|
||||||
|
const { camera } = useThree();
|
||||||
|
camera.position.set(7.5, 6.2, 7.5);
|
||||||
|
camera.lookAt(0, 0.7, 0);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mat = (color: string, opts: Partial<THREE.MeshStandardMaterialParameters> = {}) => (
|
||||||
|
<meshStandardMaterial color={color} flatShading roughness={0.62} metalness={0.06} {...opts} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// a watermelon slice — extruded wedge (red flesh) + green rind edge + seeds
|
||||||
|
const Slice: React.FC<{ position: [number, number, number]; rotation: [number, number, number] }> = ({ position, rotation }) => {
|
||||||
|
const flesh = useMemo(() => {
|
||||||
|
const s = new THREE.Shape();
|
||||||
|
s.moveTo(-0.7, 0);
|
||||||
|
s.lineTo(0.7, 0);
|
||||||
|
s.absarc(0, 0, 0.7, 0, Math.PI, false);
|
||||||
|
return new THREE.ExtrudeGeometry(s, { depth: 0.16, bevelEnabled: false });
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<group position={position} rotation={rotation}>
|
||||||
|
<mesh geometry={flesh}>{mat("#e0322a")}</mesh>
|
||||||
|
<mesh position={[0, 0.35, 0.08]} rotation={[0, 0, 0]}>
|
||||||
|
<torusGeometry args={[0.72, 0.06, 6, 16, Math.PI]} />
|
||||||
|
{mat("#2f7d3a")}
|
||||||
|
</mesh>
|
||||||
|
{[[-0.3, 0.3], [0.3, 0.3], [0, 0.5], [-0.15, 0.15], [0.15, 0.15]].map(([x, y], i) => (
|
||||||
|
<mesh key={i} position={[x, y, 0.17]} scale={[1, 1.5, 0.4]}>
|
||||||
|
<sphereGeometry args={[0.05, 6, 6]} />
|
||||||
|
{mat("#201018", { roughness: 0.4 })}
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Pomegranate: React.FC<{ position: [number, number, number] }> = ({ position }) => (
|
||||||
|
<group position={position}>
|
||||||
|
<mesh castShadow>
|
||||||
|
<icosahedronGeometry args={[0.55, 1]} />
|
||||||
|
{mat("#c0271f")}
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.55, 0]}>
|
||||||
|
<coneGeometry args={[0.18, 0.3, 5]} />
|
||||||
|
{mat("#8e2018")}
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Candle: React.FC<{ position: [number, number, number] }> = ({ position }) => (
|
||||||
|
<group position={position}>
|
||||||
|
<mesh>
|
||||||
|
<cylinderGeometry args={[0.16, 0.18, 0.7, 10]} />
|
||||||
|
{mat("#f3ead2")}
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.5, 0]}>
|
||||||
|
<coneGeometry args={[0.09, 0.26, 7]} />
|
||||||
|
<meshStandardMaterial color="#ffd27a" emissive="#ffb437" emissiveIntensity={3} flatShading />
|
||||||
|
</mesh>
|
||||||
|
<pointLight position={[0, 0.6, 0]} intensity={6} distance={4} color="#ffb04a" />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const YaldaSofreh3D: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { width, height } = useVideoConfig();
|
||||||
|
const spin = frame * 0.0;
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#b3262b" }}>
|
||||||
|
<ThreeCanvas width={width} height={height} camera={{ position: [7.5, 6.2, 7.5], fov: 30 }} style={{ position: "absolute", inset: 0 }}>
|
||||||
|
<Rig />
|
||||||
|
<ambientLight intensity={0.45} />
|
||||||
|
<directionalLight position={[5, 9, 4]} intensity={1.6} color="#fff2dc" castShadow />
|
||||||
|
<Environment resolution={128}>
|
||||||
|
<Lightformer intensity={1.4} position={[0, 5, 2]} scale={[8, 3, 1]} color="#ffd9a0" />
|
||||||
|
<Lightformer intensity={1} position={[-4, 2, 3]} scale={[3, 5, 1]} color="#ff8a5a" />
|
||||||
|
</Environment>
|
||||||
|
|
||||||
|
<group rotation={[0, spin, 0]}>
|
||||||
|
{/* paisley-red ground */}
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.02, 0]} receiveShadow>
|
||||||
|
<planeGeometry args={[26, 26]} />
|
||||||
|
<meshStandardMaterial color="#a32226" roughness={0.9} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* low table + cloth */}
|
||||||
|
<mesh position={[0, 0.35, 0]}>
|
||||||
|
<boxGeometry args={[5.2, 0.3, 4.2]} />
|
||||||
|
{mat("#7a4a28")}
|
||||||
|
</mesh>
|
||||||
|
{[[-2.3, 1.8], [2.3, 1.8], [-2.3, -1.8], [2.3, -1.8]].map(([x, z], i) => (
|
||||||
|
<mesh key={i} position={[x, 0.1, z]}>
|
||||||
|
<cylinderGeometry args={[0.12, 0.12, 0.5, 6]} />
|
||||||
|
{mat("#5e3820")}
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
<mesh position={[0, 0.52, 0]}>
|
||||||
|
<boxGeometry args={[5.0, 0.08, 4.0]} />
|
||||||
|
{mat("#1f5fa6")}
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* mirror */}
|
||||||
|
<group position={[0, 1.3, -1.3]}>
|
||||||
|
<mesh><boxGeometry args={[1.5, 1.9, 0.12]} />{mat("#8a5a2e")}</mesh>
|
||||||
|
<mesh position={[0, 0, 0.08]}><boxGeometry args={[1.2, 1.6, 0.04]} /><meshStandardMaterial color="#cfe6f0" metalness={0.6} roughness={0.2} flatShading /></mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* watermelon (low-poly) + slices */}
|
||||||
|
<mesh position={[-1.7, 1.2, 0.3]} castShadow>
|
||||||
|
<icosahedronGeometry args={[0.95, 1]} />
|
||||||
|
{mat("#1f6b2e")}
|
||||||
|
</mesh>
|
||||||
|
<Slice position={[-0.4, 0.62, 1.1]} rotation={[-1.1, 0.3, 0]} />
|
||||||
|
<Slice position={[0.4, 0.62, 1.3]} rotation={[-1.2, -0.4, 0]} />
|
||||||
|
|
||||||
|
<Pomegranate position={[0.1, 0.95, -0.2]} />
|
||||||
|
<Pomegranate position={[1.9, 0.95, 1.2]} />
|
||||||
|
|
||||||
|
<Candle position={[-1.0, 0.9, -0.7]} />
|
||||||
|
<Candle position={[1.0, 0.9, -0.7]} />
|
||||||
|
|
||||||
|
{/* Hafez book */}
|
||||||
|
<group position={[1.7, 0.7, 0.2]} rotation={[0, -0.3, 0]}>
|
||||||
|
<mesh><boxGeometry args={[1.0, 0.22, 1.4]} />{mat("#6b3f1c")}</mesh>
|
||||||
|
<mesh position={[0, 0.12, 0]}><boxGeometry args={[0.5, 0.02, 0.7]} /><meshStandardMaterial color="#d4a83c" emissive="#a8842c" emissiveIntensity={0.5} flatShading /></mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<EffectComposer>
|
||||||
|
<Bloom intensity={1.1} luminanceThreshold={0.5} luminanceSmoothing={0.4} mipmapBlur />
|
||||||
|
<Vignette eskil={false} offset={0.3} darkness={0.55} />
|
||||||
|
</EffectComposer>
|
||||||
|
</ThreeCanvas>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../../lib/fonts";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
import { IgGlows } from "./igkit";
|
||||||
|
import type { BlockProps, SceneBlock } from "../types";
|
||||||
|
|
||||||
|
const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff"];
|
||||||
|
const resolve = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
|
||||||
|
const isImg = (u: string) => !!u && (/^https?:\/\//.test(u) || u.includes("/"));
|
||||||
|
|
||||||
|
const IGFeed: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const capSp = spring({ frame, fps, config: { damping: 16, stiffness: 110 } });
|
||||||
|
const posts = [data.post1, data.post2, data.post3, data.post4, data.post5, data.post6];
|
||||||
|
const cell = L.pick(L.vmin(300), L.vmin(300), L.vmin(300));
|
||||||
|
const gap = L.vmin(20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `linear-gradient(170deg, ${colors.backgroundColor}, ${hexToRgba(colors.secondaryColor, 0.06)})`, opacity: Math.min(1, out) }}>
|
||||||
|
<IgGlows />
|
||||||
|
<AbsoluteFill style={{ direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: L.vmin(60) }}>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: L.pick(L.vmin(76), L.vmin(72), L.vmin(70)), color: colors.textColor, marginBottom: L.vmin(40), transform: `translateY(${interpolate(capSp, [0, 1], [L.vmin(40), 0])}px)`, opacity: capSp, textAlign: "center" }}>
|
||||||
|
{data.caption}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap }}>
|
||||||
|
{posts.map((p, i) => {
|
||||||
|
const pop = spring({ frame: frame - 6 - i * 4, fps, config: { damping: 14, stiffness: 130 } });
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ width: cell, height: cell, borderRadius: L.vmin(24), overflow: "hidden", background: PH[i % PH.length], transform: `scale(${interpolate(pop, [0, 1], [0.5, 1])})`, opacity: pop, boxShadow: `0 ${L.vmin(16)}px ${L.vmin(30)}px ${hexToRgba("#1a1020", 0.18)}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{isImg(p) ? <Img src={resolve(p)} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <div style={{ width: "40%", height: "40%", borderRadius: L.vmin(14), background: hexToRgba("#fff", 0.45) }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IGFeedBlock: SceneBlock = {
|
||||||
|
id: "IGFeed",
|
||||||
|
label: "نمایش محتوا (شبکهٔ پستها)",
|
||||||
|
component: IGFeed,
|
||||||
|
fields: [
|
||||||
|
{ key: "caption", label: "عنوان بخش", type: "text", default: "محتوای ما را ببینید" },
|
||||||
|
{ key: "post1", label: "پست ۱", type: "image", default: "" },
|
||||||
|
{ key: "post2", label: "پست ۲", type: "image", default: "" },
|
||||||
|
{ key: "post3", label: "پست ۳", type: "image", default: "" },
|
||||||
|
{ key: "post4", label: "پست ۴", type: "image", default: "" },
|
||||||
|
{ key: "post5", label: "پست ۵", type: "image", default: "" },
|
||||||
|
{ key: "post6", label: "پست ۶", type: "image", default: "" },
|
||||||
|
],
|
||||||
|
defaultDurationSec: 4,
|
||||||
|
minDurationSec: 2,
|
||||||
|
maxDurationSec: 8,
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../../lib/fonts";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
import { IgGlows, IgCamera, IG_GRAD } from "./igkit";
|
||||||
|
import type { BlockProps, SceneBlock } from "../types";
|
||||||
|
|
||||||
|
const IGFollowCTA: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const headSp = spring({ frame, fps, config: { damping: 16, stiffness: 110 } });
|
||||||
|
const handleSp = spring({ frame: frame - 8, fps, config: { damping: 15, stiffness: 110 } });
|
||||||
|
const btnSp = spring({ frame: frame - 16, fps, config: { damping: 13, stiffness: 120 } });
|
||||||
|
// tap the button ~70% through: a quick press blip, then flip to "followed".
|
||||||
|
const tapAt = Math.round(durationInFrames * 0.62);
|
||||||
|
const press = interpolate(frame, [tapAt - 3, tapAt, tapAt + 4], [1, 0.92, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const followed = frame >= tapAt;
|
||||||
|
const footOp = interpolate(frame, [tapAt + 6, tapAt + 18], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `linear-gradient(160deg, ${colors.backgroundColor}, ${hexToRgba(colors.accentColor, 0.08)})`, opacity: Math.min(1, out) }}>
|
||||||
|
<IgGlows />
|
||||||
|
<AbsoluteFill style={{ direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", textAlign: "center", padding: L.vmin(70) }}>
|
||||||
|
<div style={{ opacity: headSp, transform: `scale(${interpolate(headSp, [0, 1], [0.7, 1])})` }}><IgCamera s={L.vmin(96)} id="ctacam" /></div>
|
||||||
|
<div style={{ marginTop: L.vmin(26), fontWeight: 900, fontSize: L.pick(L.vmin(110), L.vmin(104), L.vmin(96)), lineHeight: 1.05, letterSpacing: -2, color: colors.textColor, transform: `translateY(${interpolate(headSp, [0, 1], [L.vmin(50), 0])}px)`, maxWidth: L.vmin(1100) }}>
|
||||||
|
{data.headline}
|
||||||
|
</div>
|
||||||
|
<div style={{ direction: "ltr", marginTop: L.vmin(20), fontWeight: 800, fontSize: L.pick(L.vmin(58), L.vmin(56), L.vmin(54)), background: IG_GRAD, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent", opacity: handleSp, transform: `translateY(${interpolate(handleSp, [0, 1], [L.vmin(30), 0])}px)` }}>
|
||||||
|
{data.handle}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(46), transform: `scale(${interpolate(btnSp, [0, 1], [0.6, 1]) * press})`, opacity: btnSp }}>
|
||||||
|
<div style={{ minWidth: L.vmin(440), height: L.vmin(118), borderRadius: 999, background: followed ? "#efefef" : IG_GRAD, color: followed ? "#15151a" : "#fff", fontWeight: 800, fontSize: L.vmin(46), display: "flex", alignItems: "center", justifyContent: "center", gap: L.vmin(14), padding: `0 ${L.vmin(40)}px`, boxShadow: followed ? "none" : `0 ${L.vmin(20)}px ${L.vmin(44)}px ${hexToRgba("#dc2743", 0.45)}`, border: followed ? `2px solid ${hexToRgba("#15151a", 0.15)}` : `${L.vmin(5)}px solid #ffffff22` }}>
|
||||||
|
{followed ? <>✓ {data.followedLabel}</> : <>+ {data.buttonLabel}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(40), fontWeight: 600, fontSize: L.pick(L.vmin(42), L.vmin(40), L.vmin(38)), color: hexToRgba(colors.textColor, 0.6), opacity: footOp }}>
|
||||||
|
{data.footer}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IGFollowCTABlock: SceneBlock = {
|
||||||
|
id: "IGFollowCTA",
|
||||||
|
label: "فراخوان دنبالکردن",
|
||||||
|
component: IGFollowCTA,
|
||||||
|
fields: [
|
||||||
|
{ key: "headline", label: "تیتر فراخوان", type: "text", default: "همین حالا دنبال کنید" },
|
||||||
|
{ key: "handle", label: "آیدی صفحه", type: "text", default: "@flat.studio" },
|
||||||
|
{ key: "buttonLabel", label: "متن دکمه", type: "text", default: "دنبال کردن" },
|
||||||
|
{ key: "followedLabel", label: "متن بعد از دنبال", type: "text", default: "دنبال شد" },
|
||||||
|
{ key: "footer", label: "زیرنویس", type: "text", default: "لینک در بایو 👆" },
|
||||||
|
],
|
||||||
|
defaultDurationSec: 3,
|
||||||
|
minDurationSec: 2,
|
||||||
|
maxDurationSec: 5,
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../../lib/fonts";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
import { IgGlows, IgWordmark } from "./igkit";
|
||||||
|
import type { BlockProps, SceneBlock } from "../types";
|
||||||
|
|
||||||
|
const IGIntro: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const logo = spring({ frame, fps, config: { damping: 13, stiffness: 110 } });
|
||||||
|
const headSp = spring({ frame: frame - 8, fps, config: { damping: 16, stiffness: 110 } });
|
||||||
|
const subOp = interpolate(frame, [20, 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `linear-gradient(165deg, ${colors.backgroundColor}, ${hexToRgba(colors.accentColor, 0.06)})`, opacity: Math.min(1, out) }}>
|
||||||
|
<IgGlows />
|
||||||
|
<AbsoluteFill style={{ direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", textAlign: "center", padding: L.vmin(60) }}>
|
||||||
|
<div style={{ transform: `scale(${interpolate(logo, [0, 1], [0.6, 1])})`, opacity: logo }}><IgWordmark L={L} /></div>
|
||||||
|
<div style={{ marginTop: L.vmin(34), display: "inline-flex", alignItems: "center", gap: L.vmin(10), background: colors.accentColor, color: "#fff", fontWeight: 800, fontSize: L.vmin(28), padding: `${L.vmin(10)}px ${L.vmin(26)}px`, borderRadius: 999, opacity: logo }}>
|
||||||
|
{data.badge}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(28), fontWeight: 900, fontSize: L.pick(L.vmin(120), L.vmin(112), L.vmin(104)), lineHeight: 1.05, letterSpacing: -2, color: colors.textColor, transform: `translateY(${interpolate(headSp, [0, 1], [L.vmin(50), 0])}px)`, maxWidth: L.vmin(1100) }}>
|
||||||
|
{data.headline}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(24), fontWeight: 500, fontSize: L.pick(L.vmin(46), L.vmin(44), L.vmin(42)), color: hexToRgba(colors.textColor, 0.62), opacity: subOp, maxWidth: L.vmin(950) }}>
|
||||||
|
{data.subtitle}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IGIntroBlock: SceneBlock = {
|
||||||
|
id: "IGIntro",
|
||||||
|
label: "شروع (لوگوی اینستاگرام)",
|
||||||
|
component: IGIntro,
|
||||||
|
fields: [
|
||||||
|
{ key: "badge", label: "نشان", type: "text", default: "اینستاگرام" },
|
||||||
|
{ key: "headline", label: "تیتر", type: "text", default: "صفحهٔ ما را دنبال کنید" },
|
||||||
|
{ key: "subtitle", label: "زیرعنوان", type: "text", default: "هر روز یک طرح تازه", multiline: true },
|
||||||
|
],
|
||||||
|
defaultDurationSec: 3,
|
||||||
|
minDurationSec: 2,
|
||||||
|
maxDurationSec: 5,
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../../lib/fonts";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
import { IgGlows, IgWordmark, IG_GRAD, IG_BLUE } from "./igkit";
|
||||||
|
import type { BlockProps, SceneBlock } from "../types";
|
||||||
|
|
||||||
|
const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"];
|
||||||
|
const resolve = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
|
||||||
|
const isImg = (u: string) => !!u && (/^https?:\/\//.test(u) || u.includes("/"));
|
||||||
|
|
||||||
|
const IGProfile: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, width, height } = useVideoConfig();
|
||||||
|
const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
|
||||||
|
// phone sized to fit the frame; everything inside scales off the screen width.
|
||||||
|
const phoneH = height * L.pick(0.78, 0.8, 0.74);
|
||||||
|
const phoneW = Math.min(phoneH * 0.49, width * 0.86);
|
||||||
|
const bezel = phoneW * 0.046;
|
||||||
|
const screenW = phoneW - bezel * 2;
|
||||||
|
const u = screenW / 770;
|
||||||
|
const px = (n: number) => n * u;
|
||||||
|
const cell = (screenW - px(12)) / 3;
|
||||||
|
|
||||||
|
const rise = spring({ frame, fps, config: { damping: 18, stiffness: 90 } });
|
||||||
|
const phoneY = interpolate(rise, [0, 1], [height * 0.06, 0]);
|
||||||
|
const ring = 1 + Math.sin(frame / 12) * 0.025;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `linear-gradient(165deg, ${colors.backgroundColor}, ${hexToRgba("#dc2743", 0.05)})`, opacity: Math.min(1, out) }}>
|
||||||
|
<IgGlows />
|
||||||
|
<AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: L.vmin(28) }}>
|
||||||
|
{/* promo header */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: L.vmin(16), opacity: rise }}>
|
||||||
|
<IgWordmark L={L} id="profcam" />
|
||||||
|
<div style={{ direction: "rtl", fontWeight: 900, fontSize: L.pick(L.vmin(56), L.vmin(60), L.vmin(62)), color: colors.textColor }}>{data.headline}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* phone */}
|
||||||
|
<div style={{ width: phoneW, borderRadius: px(64), background: "#0c0c0f", padding: bezel, boxShadow: `0 ${px(50)}px ${px(100)}px ${hexToRgba("#180c22", 0.4)}`, transform: `translateY(${phoneY}px)`, opacity: rise }}>
|
||||||
|
<div style={{ width: screenW, borderRadius: px(48), background: "#fff", overflow: "hidden", color: "#000" }}>
|
||||||
|
{/* status bar */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: `${px(16)}px ${px(34)}px ${px(6)}px`, fontSize: px(26), fontWeight: 700 }}>
|
||||||
|
<span>۹:۴۱</span><span style={{ display: "flex", gap: px(10), alignItems: "center", fontSize: px(22) }}>▮▮▮ WiFi <span style={{ border: `${px(2)}px solid #000`, borderRadius: px(5), padding: `${px(2)}px ${px(6)}px`, fontSize: px(18) }}>۸۴٪</span></span>
|
||||||
|
</div>
|
||||||
|
{/* username header */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: `${px(12)}px ${px(28)}px` }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: px(10), direction: "ltr" }}>
|
||||||
|
<span style={{ fontSize: px(26) }}>🔒</span><span style={{ fontWeight: 800, fontSize: px(36) }}>{data.handle}</span><span style={{ fontSize: px(24) }}>▾</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: px(26), fontSize: px(42) }}><span>+</span><span>☰</span></div>
|
||||||
|
</div>
|
||||||
|
{/* avatar + stats */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: px(30), padding: `${px(12)}px ${px(34)}px` }}>
|
||||||
|
<div style={{ width: px(176), height: px(176), borderRadius: "50%", background: IG_GRAD, padding: px(6), transform: `scale(${ring})` }}>
|
||||||
|
<div style={{ width: "100%", height: "100%", borderRadius: "50%", background: "#eee", border: `${px(5)}px solid #fff`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", fontSize: px(78) }}>
|
||||||
|
{isImg(data.avatar) ? <Img src={resolve(data.avatar)} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : "🎨"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: "flex", justifyContent: "space-around" }}>
|
||||||
|
{[[data.posts, "پست"], [data.followers, "دنبالکننده"], [data.following, "دنبالشده"]].map(([n, l], i) => (
|
||||||
|
<div key={i} style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: px(38) }}>{n}</div>
|
||||||
|
<div style={{ fontWeight: 400, fontSize: px(27), color: "#262626" }}>{l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* name + bio */}
|
||||||
|
<div style={{ direction: "rtl", padding: `${px(6)}px ${px(34)}px 0` }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: px(32) }}>{data.name}</div>
|
||||||
|
<div style={{ fontSize: px(27), color: "#737373", marginTop: px(2) }}>{data.category}</div>
|
||||||
|
{[data.bio1, data.bio2, data.bio3].filter(Boolean).map((b, i) => <div key={i} style={{ fontSize: px(28), marginTop: px(4) }}>{b}</div>)}
|
||||||
|
<div style={{ fontSize: px(28), color: "#00376b", fontWeight: 600, marginTop: px(4), direction: "ltr", textAlign: "right" }}>{data.link}</div>
|
||||||
|
</div>
|
||||||
|
{/* buttons */}
|
||||||
|
<div style={{ display: "flex", gap: px(12), padding: `${px(20)}px ${px(34)}px ${px(8)}px` }}>
|
||||||
|
<div style={{ flex: 1, height: px(76), borderRadius: px(12), background: IG_BLUE, color: "#fff", fontWeight: 800, fontSize: px(30), display: "flex", alignItems: "center", justifyContent: "center" }}>{data.followLabel}</div>
|
||||||
|
<div style={{ flex: 1, height: px(76), borderRadius: px(12), background: "#efefef", fontWeight: 700, fontSize: px(30), display: "flex", alignItems: "center", justifyContent: "center" }}>{data.messageLabel}</div>
|
||||||
|
<div style={{ width: px(76), height: px(76), borderRadius: px(12), background: "#efefef", display: "flex", alignItems: "center", justifyContent: "center", fontSize: px(34) }}>👤</div>
|
||||||
|
</div>
|
||||||
|
{/* highlights */}
|
||||||
|
<div style={{ display: "flex", gap: px(28), padding: `${px(16)}px ${px(34)}px`, direction: "rtl" }}>
|
||||||
|
{[data.hi1, data.hi2, data.hi3, data.hi4].map((h, i) => (
|
||||||
|
<div key={i} style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ width: px(120), height: px(120), borderRadius: "50%", border: `${px(2)}px solid #dbdbdb`, background: i % 2 ? "#f3e9df" : "#e9eef5", display: "flex", alignItems: "center", justifyContent: "center", fontSize: px(46) }}>{["✨", "🗂️", "🎓", "🖼️"][i]}</div>
|
||||||
|
<div style={{ fontSize: px(24), marginTop: px(8) }}>{h}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* tabs */}
|
||||||
|
<div style={{ display: "flex", borderTop: "1px solid #dbdbdb" }}>
|
||||||
|
{["▦", "▶", "𓏬"].map((t, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, textAlign: "center", padding: `${px(18)}px 0`, fontSize: px(38), color: i === 0 ? "#000" : "#b3b3b3", borderTop: i === 0 ? "2px solid #000" : "none", marginTop: -1 }}>{t}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* posts grid */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap: px(6) }}>
|
||||||
|
{PH.map((c, i) => (
|
||||||
|
<div key={i} style={{ width: cell, height: cell, background: c, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
|
||||||
|
<div style={{ width: cell * 0.36, height: cell * 0.36, borderRadius: px(12), background: hexToRgba("#fff", 0.4) }} />
|
||||||
|
{i % 4 === 0 ? <span style={{ position: "absolute", top: px(10), right: px(12), color: "#fff", fontSize: px(30) }}>▶</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IGProfileBlock: SceneBlock = {
|
||||||
|
id: "IGProfile",
|
||||||
|
label: "صفحهٔ اینستاگرام (واقعی)",
|
||||||
|
component: IGProfile,
|
||||||
|
fields: [
|
||||||
|
{ key: "headline", label: "تیتر بالای صفحه", type: "text", default: "صفحهٔ ما را دنبال کنید" },
|
||||||
|
{ key: "handle", label: "آیدی", type: "text", default: "flat.studio" },
|
||||||
|
{ key: "name", label: "نام صفحه", type: "text", default: "استودیو فلت" },
|
||||||
|
{ key: "category", label: "دستهبندی", type: "text", default: "هنر و طراحی" },
|
||||||
|
{ key: "bio1", label: "بیو — خط ۱", type: "text", default: "هر روز یک طرح تازه ✨" },
|
||||||
|
{ key: "bio2", label: "بیو — خط ۲", type: "text", default: "آموزش، قالب و الهام برای طراحان" },
|
||||||
|
{ key: "bio3", label: "بیو — خط ۳", type: "text", default: "سفارش و دانلود 👇" },
|
||||||
|
{ key: "link", label: "لینک", type: "text", default: "flat.studio/shop" },
|
||||||
|
{ key: "posts", label: "تعداد پست", type: "text", default: "۳۲۰" },
|
||||||
|
{ key: "followers", label: "دنبالکننده", type: "text", default: "۲۴٫۸ هزار" },
|
||||||
|
{ key: "following", label: "دنبالشده", type: "text", default: "۱۸۰" },
|
||||||
|
{ key: "hi1", label: "هایلایت ۱", type: "text", default: "جدید" },
|
||||||
|
{ key: "hi2", label: "هایلایت ۲", type: "text", default: "قالبها" },
|
||||||
|
{ key: "hi3", label: "هایلایت ۳", type: "text", default: "آموزش" },
|
||||||
|
{ key: "hi4", label: "هایلایت ۴", type: "text", default: "نمونهکار" },
|
||||||
|
{ key: "followLabel", label: "متن دکمهٔ دنبال", type: "text", default: "دنبال کردن" },
|
||||||
|
{ key: "messageLabel", label: "متن دکمهٔ پیام", type: "text", default: "پیام" },
|
||||||
|
{ key: "avatar", label: "تصویر پروفایل", type: "image", default: "" },
|
||||||
|
],
|
||||||
|
defaultDurationSec: 5,
|
||||||
|
minDurationSec: 3,
|
||||||
|
maxDurationSec: 8,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { FONT } from "../../lib/fonts";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
import { IgGlows, IG_GRAD } from "./igkit";
|
||||||
|
import type { BlockProps, SceneBlock } from "../types";
|
||||||
|
|
||||||
|
const faToEn = (s: string) => s.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/٫/g, ".").replace(/[٬,]/g, "");
|
||||||
|
const enToFa = (s: string) => s.replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]).replace(/\./g, "٫");
|
||||||
|
/** Split a leading number (Persian or latin) from its suffix, animate it up. */
|
||||||
|
const animStat = (target: string, t: number): string => {
|
||||||
|
const m = target.match(/^[\s]*([\d۰-۹.,٬٫]+)(.*)$/);
|
||||||
|
if (!m) return target;
|
||||||
|
const num = parseFloat(faToEn(m[1]));
|
||||||
|
if (!isFinite(num)) return target;
|
||||||
|
const v = num * t;
|
||||||
|
const txt = num % 1 !== 0 ? v.toFixed(1) : Math.round(v).toLocaleString("en-US");
|
||||||
|
return enToFa(txt) + m[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Big: React.FC<{ v: string; l: string; L: BlockProps["L"]; t: number; accent: string }> = ({ v, l, L, t, accent }) => (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: L.pick(L.vmin(150), L.vmin(140), L.vmin(132)), lineHeight: 1, background: IG_GRAD, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent", letterSpacing: -2 }}>{animStat(v, t)}</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: L.pick(L.vmin(40), L.vmin(38), L.vmin(36)), color: hexToRgba(accent, 0.95), marginTop: L.vmin(6) }}>{l}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IGStats: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const out = interpolate(frame, [durationInFrames - 10, durationInFrames], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const t = interpolate(spring({ frame, fps, config: { damping: 20, stiffness: 60 } }), [0, 1], [0, 1]);
|
||||||
|
const side = (i: number) => spring({ frame: frame - 14 - i * 6, fps, config: { damping: 16, stiffness: 110 } });
|
||||||
|
const proofOp = interpolate(frame, [26, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||||
|
const dark = colors.textColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ fontFamily: FONT, background: `radial-gradient(120% 90% at 50% 16%, ${colors.backgroundColor}, ${hexToRgba(colors.secondaryColor, 0.07)})`, opacity: Math.min(1, out) }}>
|
||||||
|
<IgGlows />
|
||||||
|
<AbsoluteFill style={{ direction: "rtl", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: L.vmin(70) }}>
|
||||||
|
<Big v={data.bigValue} l={data.bigLabel} L={L} t={t} accent={colors.accentColor} />
|
||||||
|
<div style={{ display: "flex", gap: L.pick(L.vmin(120), L.vmin(90), L.vmin(80)), marginTop: L.vmin(60) }}>
|
||||||
|
{[[data.stat2Value, data.stat2Label, 0], [data.stat3Value, data.stat3Label, 1]].map(([v, l, i]) => (
|
||||||
|
<div key={i as number} style={{ textAlign: "center", transform: `translateY(${interpolate(side(i as number), [0, 1], [L.vmin(40), 0])}px)`, opacity: side(i as number) }}>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: L.pick(L.vmin(86), L.vmin(80), L.vmin(76)), color: dark }}>{animStat(v as string, t)}</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: L.pick(L.vmin(34), L.vmin(32), L.vmin(31)), color: hexToRgba(dark, 0.55), marginTop: L.vmin(4) }}>{l as string}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: L.vmin(64), fontWeight: 700, fontSize: L.pick(L.vmin(48), L.vmin(46), L.vmin(44)), color: dark, opacity: proofOp, textAlign: "center", maxWidth: L.vmin(1000) }}>
|
||||||
|
{data.proofLine}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IGStatsBlock: SceneBlock = {
|
||||||
|
id: "IGStats",
|
||||||
|
label: "اعتمادسازی (آمار صفحه)",
|
||||||
|
component: IGStats,
|
||||||
|
fields: [
|
||||||
|
{ key: "bigValue", label: "عدد اصلی", type: "text", default: "۲۴۸۰۰" },
|
||||||
|
{ key: "bigLabel", label: "برچسب عدد اصلی", type: "text", default: "دنبالکننده" },
|
||||||
|
{ key: "stat2Value", label: "آمار ۲ — مقدار", type: "text", default: "۱۲۰۰۰۰۰" },
|
||||||
|
{ key: "stat2Label", label: "آمار ۲ — برچسب", type: "text", default: "پسند" },
|
||||||
|
{ key: "stat3Value", label: "آمار ۳ — مقدار", type: "text", default: "۳۲۰" },
|
||||||
|
{ key: "stat3Label", label: "آمار ۳ — برچسب", type: "text", default: "پست" },
|
||||||
|
{ key: "proofLine", label: "جملهٔ اعتماد", type: "text", default: "به جمع هزاران دنبالکنندهٔ ما بپیوندید", multiline: true },
|
||||||
|
],
|
||||||
|
defaultDurationSec: 3,
|
||||||
|
minDurationSec: 2,
|
||||||
|
maxDurationSec: 6,
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Layout } from "../../lib/aspect";
|
||||||
|
import { hexToRgba } from "../../lib/anim";
|
||||||
|
|
||||||
|
/** Shared Instagram primitives used across the IG-promo scene blocks. */
|
||||||
|
export const IG_GRAD = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)";
|
||||||
|
export const IG_BLUE = "#0095f6";
|
||||||
|
|
||||||
|
/** The IG camera glyph in the brand gradient. */
|
||||||
|
export const IgCamera: React.FC<{ s: number; id?: string }> = ({ s, id = "iggrad" }) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke={`url(#${id})`} strokeWidth="2">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={id} x1="0" y1="1" x2="1" y2="0">
|
||||||
|
<stop offset="0" stopColor="#f09433" /><stop offset="0.5" stopColor="#dc2743" /><stop offset="1" stopColor="#bc1888" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="6" /><circle cx="12" cy="12" r="5" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1.2" fill={`url(#${id})`} stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Camera glyph + "Instagram" wordmark (gradient). */
|
||||||
|
export const IgWordmark: React.FC<{ L: Layout; id?: string }> = ({ L, id }) => (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(16) }}>
|
||||||
|
<IgCamera s={L.vmin(62)} id={id} />
|
||||||
|
<div style={{ fontWeight: 800, fontSize: L.vmin(52), background: IG_GRAD, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent" }}>Instagram</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Soft IG-tinted glows for a clean light backdrop. */
|
||||||
|
export const IgGlows: React.FC = () => (
|
||||||
|
<>
|
||||||
|
<div style={{ position: "absolute", left: "-14%", top: "-8%", width: "58%", height: "36%", borderRadius: "50%", background: hexToRgba("#dc2743", 0.16), filter: "blur(150px)" }} />
|
||||||
|
<div style={{ position: "absolute", right: "-16%", bottom: "0%", width: "60%", height: "38%", borderRadius: "50%", background: hexToRgba("#7c5cff", 0.16), filter: "blur(160px)" }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Convert "۲۴٫۸ هزار" style strings straight through; helper kept for parity. */
|
||||||
|
export const faNum = (s: string) => s;
|
||||||
@@ -21,3 +21,28 @@ const journeyScenes: SceneInstance[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const CHARACTER_JOURNEY = { scenes: journeyScenes, ...warm };
|
export const CHARACTER_JOURNEY = { scenes: journeyScenes, ...warm };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InstagramPromo — a "follow our channel" promo built on the realistic light-theme
|
||||||
|
* IG profile. Flexible: add/duplicate/delete/reorder (e.g. more content scenes).
|
||||||
|
* finish:false keeps the IG-light look clean (no brand grade).
|
||||||
|
*/
|
||||||
|
const igScenes: SceneInstance[] = [
|
||||||
|
{ blockId: "IGIntro", durationSec: 3, props: { badge: "اینستاگرام", headline: "صفحهٔ ما را دنبال کنید", subtitle: "هر روز یک طرح تازه" } },
|
||||||
|
{ blockId: "IGProfile", durationSec: 5, props: { headline: "صفحهٔ ما را دنبال کنید", handle: "flat.studio", name: "استودیو فلت", category: "هنر و طراحی", bio1: "هر روز یک طرح تازه ✨", bio2: "آموزش، قالب و الهام برای طراحان", bio3: "سفارش و دانلود 👇", link: "flat.studio/shop", posts: "۳۲۰", followers: "۲۴٫۸ هزار", following: "۱۸۰", hi1: "جدید", hi2: "قالبها", hi3: "آموزش", hi4: "نمونهکار", followLabel: "دنبال کردن", messageLabel: "پیام", avatar: "" } },
|
||||||
|
{ blockId: "IGFeed", durationSec: 4, props: { caption: "محتوای ما را ببینید", post1: "", post2: "", post3: "", post4: "", post5: "", post6: "" } },
|
||||||
|
{ blockId: "IGStats", durationSec: 3, props: { bigValue: "۲۴۸۰۰", bigLabel: "دنبالکننده", stat2Value: "۱۲۰۰۰۰۰", stat2Label: "پسند", stat3Value: "۳۲۰", stat3Label: "پست", proofLine: "به جمع هزاران دنبالکنندهٔ ما بپیوندید" } },
|
||||||
|
{ blockId: "IGFollowCTA", durationSec: 3, props: { headline: "همین حالا دنبال کنید", handle: "@flat.studio", buttonLabel: "دنبال کردن", followedLabel: "دنبال شد", footer: "لینک در بایو 👆" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INSTAGRAM_PROMO = {
|
||||||
|
scenes: igScenes,
|
||||||
|
accentColor: "#dc2743",
|
||||||
|
secondaryColor: "#7c5cff",
|
||||||
|
backgroundColor: "#f7f4fa",
|
||||||
|
textColor: "#15151a",
|
||||||
|
music: "audio/music-ambient.mp3",
|
||||||
|
musicVolume: 0.6,
|
||||||
|
sfx: true,
|
||||||
|
finish: false,
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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";
|
import { PaperCutBlock } from "./blocks/PaperCut";
|
||||||
|
import { IGIntroBlock } from "./blocks/IGIntro";
|
||||||
|
import { IGProfileBlock } from "./blocks/IGProfile";
|
||||||
|
import { IGFeedBlock } from "./blocks/IGFeed";
|
||||||
|
import { IGStatsBlock } from "./blocks/IGStats";
|
||||||
|
import { IGFollowCTABlock } from "./blocks/IGFollowCTA";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -32,6 +37,11 @@ export const SCENE_BLOCKS: Record<string, SceneBlock> = {
|
|||||||
[StompBlock.id]: StompBlock,
|
[StompBlock.id]: StompBlock,
|
||||||
[DeviceMockupBlock.id]: DeviceMockupBlock,
|
[DeviceMockupBlock.id]: DeviceMockupBlock,
|
||||||
[PaperCutBlock.id]: PaperCutBlock,
|
[PaperCutBlock.id]: PaperCutBlock,
|
||||||
|
[IGIntroBlock.id]: IGIntroBlock,
|
||||||
|
[IGProfileBlock.id]: IGProfileBlock,
|
||||||
|
[IGFeedBlock.id]: IGFeedBlock,
|
||||||
|
[IGStatsBlock.id]: IGStatsBlock,
|
||||||
|
[IGFollowCTABlock.id]: IGFollowCTABlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
export const BLOCK_LIST = Object.values(SCENE_BLOCKS);
|
||||||
|
|||||||
Reference in New Issue
Block a user