feat(remotion): IG promo posts accept images AND video

- igkit: a Media component that detects video by extension and renders a frame via
  OffthreadVideo (muted), else an Img — so any post slot takes images or reels.
- IGProfile: the profile-page grid is now editable — 6 post media fields (was static
  colour placeholders); videos get a ▶ reel badge.
- IGFeed: post slots now accept video too; labels say «عکس/ویدیو».

Verified: a profile still with an image cell + a video cell + avatar image renders
both correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 09:37:34 +03:30
parent 7ed2ccc414
commit 38229185a7
3 changed files with 36 additions and 18 deletions
+9 -11
View File
@@ -1,13 +1,11 @@
import React from "react"; import React from "react";
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { FONT } from "../../lib/fonts"; import { FONT } from "../../lib/fonts";
import { hexToRgba } from "../../lib/anim"; import { hexToRgba } from "../../lib/anim";
import { IgGlows } from "./igkit"; import { IgGlows, Media, isMedia } from "./igkit";
import type { BlockProps, SceneBlock } from "../types"; import type { BlockProps, SceneBlock } from "../types";
const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff"]; 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 IGFeed: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
@@ -30,7 +28,7 @@ const IGFeed: React.FC<BlockProps> = ({ data, colors, L, durationInFrames }) =>
const pop = spring({ frame: frame - 6 - i * 4, fps, config: { damping: 14, stiffness: 130 } }); const pop = spring({ frame: frame - 6 - i * 4, fps, config: { damping: 14, stiffness: 130 } });
return ( 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" }}> <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) }} />} {isMedia(p) ? <Media src={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>
); );
})} })}
@@ -46,12 +44,12 @@ export const IGFeedBlock: SceneBlock = {
component: IGFeed, component: IGFeed,
fields: [ fields: [
{ key: "caption", label: "عنوان بخش", type: "text", default: "محتوای ما را ببینید" }, { key: "caption", label: "عنوان بخش", type: "text", default: "محتوای ما را ببینید" },
{ key: "post1", label: "پست ۱", type: "image", default: "" }, { key: "post1", label: "پست ۱ (عکس/ویدیو)", type: "image", default: "" },
{ key: "post2", label: "پست ۲", type: "image", default: "" }, { key: "post2", label: "پست ۲ (عکس/ویدیو)", type: "image", default: "" },
{ key: "post3", label: "پست ۳", type: "image", default: "" }, { key: "post3", label: "پست ۳ (عکس/ویدیو)", type: "image", default: "" },
{ key: "post4", label: "پست ۴", type: "image", default: "" }, { key: "post4", label: "پست ۴ (عکس/ویدیو)", type: "image", default: "" },
{ key: "post5", label: "پست ۵", type: "image", default: "" }, { key: "post5", label: "پست ۵ (عکس/ویدیو)", type: "image", default: "" },
{ key: "post6", label: "پست ۶", type: "image", default: "" }, { key: "post6", label: "پست ۶ (عکس/ویدیو)", type: "image", default: "" },
], ],
defaultDurationSec: 4, defaultDurationSec: 4,
minDurationSec: 2, minDurationSec: 2,
@@ -2,7 +2,7 @@ import React from "react";
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { FONT } from "../../lib/fonts"; import { FONT } from "../../lib/fonts";
import { hexToRgba } from "../../lib/anim"; import { hexToRgba } from "../../lib/anim";
import { IgGlows, IgWordmark, IG_GRAD, IG_BLUE } from "./igkit"; import { IgGlows, IgWordmark, IG_GRAD, IG_BLUE, Media, isMedia, isVideo } from "./igkit";
import type { BlockProps, SceneBlock } from "../types"; import type { BlockProps, SceneBlock } from "../types";
const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"]; const PH = ["#ff5a3c", "#7c5cff", "#16b5a0", "#ffb23c", "#ef5da8", "#3aa0ff", "#ff7a59", "#4cd4b0", "#a06bff"];
@@ -22,6 +22,7 @@ const IGProfile: React.FC<BlockProps> = ({ data, colors, L, durationInFrames })
const u = screenW / 770; const u = screenW / 770;
const px = (n: number) => n * u; const px = (n: number) => n * u;
const cell = (screenW - px(12)) / 3; const cell = (screenW - px(12)) / 3;
const posts = [data.post1, data.post2, data.post3, data.post4, data.post5, data.post6];
const rise = spring({ frame, fps, config: { damping: 18, stiffness: 90 } }); const rise = spring({ frame, fps, config: { damping: 18, stiffness: 90 } });
const phoneY = interpolate(rise, [0, 1], [height * 0.06, 0]); const phoneY = interpolate(rise, [0, 1], [height * 0.06, 0]);
@@ -97,12 +98,15 @@ const IGProfile: React.FC<BlockProps> = ({ data, colors, L, durationInFrames })
</div> </div>
{/* posts grid */} {/* posts grid */}
<div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap: px(6) }}> <div style={{ display: "grid", gridTemplateColumns: `repeat(3, ${cell}px)`, gap: px(6) }}>
{PH.map((c, i) => ( {PH.map((c, i) => {
<div key={i} style={{ width: cell, height: cell, background: c, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}> const p = posts[i];
<div style={{ width: cell * 0.36, height: cell * 0.36, borderRadius: px(12), background: hexToRgba("#fff", 0.4) }} /> return (
{i % 4 === 0 ? <span style={{ position: "absolute", top: px(10), right: px(12), color: "#fff", fontSize: px(30) }}></span> : null} <div key={i} style={{ width: cell, height: cell, background: c, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
{isMedia(p) ? <Media src={p} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <div style={{ width: cell * 0.36, height: cell * 0.36, borderRadius: px(12), background: hexToRgba("#fff", 0.4) }} />}
{isVideo(p) || (!isMedia(p) && i % 4 === 0) ? <span style={{ position: "absolute", top: px(10), right: px(12), color: "#fff", fontSize: px(30), textShadow: "0 1px 3px rgba(0,0,0,0.4)" }}></span> : null}
</div> </div>
))} );
})}
</div> </div>
</div> </div>
</div> </div>
@@ -134,6 +138,12 @@ export const IGProfileBlock: SceneBlock = {
{ key: "followLabel", label: "متن دکمهٔ دنبال", type: "text", default: "دنبال کردن" }, { key: "followLabel", label: "متن دکمهٔ دنبال", type: "text", default: "دنبال کردن" },
{ key: "messageLabel", label: "متن دکمهٔ پیام", type: "text", default: "پیام" }, { key: "messageLabel", label: "متن دکمهٔ پیام", type: "text", default: "پیام" },
{ key: "avatar", label: "تصویر پروفایل", type: "image", default: "" }, { key: "avatar", label: "تصویر پروفایل", type: "image", 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: 5, defaultDurationSec: 5,
minDurationSec: 3, minDurationSec: 3,
@@ -1,7 +1,17 @@
import React from "react"; import React from "react";
import { Img, OffthreadVideo, staticFile } from "remotion";
import type { Layout } from "../../lib/aspect"; import type { Layout } from "../../lib/aspect";
import { hexToRgba } from "../../lib/anim"; import { hexToRgba } from "../../lib/anim";
/** A post slot that accepts both images AND videos (IG reels). Detects video by
* extension and renders a frame via OffthreadVideo; otherwise an Img. */
const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?|$)/i;
export const isMedia = (u: string) => !!u && (/^https?:\/\//.test(u) || u.includes("/"));
export const isVideo = (u: string) => VIDEO_RE.test(u);
export const mediaSrc = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
export const Media: React.FC<{ src: string; style?: React.CSSProperties }> = ({ src, style }) =>
isVideo(src) ? <OffthreadVideo src={mediaSrc(src)} muted style={style} /> : <Img src={mediaSrc(src)} style={style} />;
/** Shared Instagram primitives used across the IG-promo scene blocks. */ /** 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_GRAD = "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)";
export const IG_BLUE = "#0095f6"; export const IG_BLUE = "#0095f6";