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:
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user