diff --git a/public/template-media/CharacterStory-16x9.mp4 b/public/template-media/CharacterStory-16x9.mp4 new file mode 100644 index 0000000..4d36f8d Binary files /dev/null and b/public/template-media/CharacterStory-16x9.mp4 differ diff --git a/public/template-media/CharacterStory-16x9.png b/public/template-media/CharacterStory-16x9.png new file mode 100644 index 0000000..9c397f3 Binary files /dev/null and b/public/template-media/CharacterStory-16x9.png differ diff --git a/public/template-media/CharacterStory-1x1.mp4 b/public/template-media/CharacterStory-1x1.mp4 new file mode 100644 index 0000000..09eb3c7 Binary files /dev/null and b/public/template-media/CharacterStory-1x1.mp4 differ diff --git a/public/template-media/CharacterStory-1x1.png b/public/template-media/CharacterStory-1x1.png new file mode 100644 index 0000000..e212e02 Binary files /dev/null and b/public/template-media/CharacterStory-1x1.png differ diff --git a/public/template-media/CharacterStory-9x16.mp4 b/public/template-media/CharacterStory-9x16.mp4 new file mode 100644 index 0000000..2a7ea7f Binary files /dev/null and b/public/template-media/CharacterStory-9x16.mp4 differ diff --git a/public/template-media/CharacterStory-9x16.png b/public/template-media/CharacterStory-9x16.png new file mode 100644 index 0000000..4111f4b Binary files /dev/null and b/public/template-media/CharacterStory-9x16.png differ diff --git a/public/template-media/CharacterStory.mp4 b/public/template-media/CharacterStory.mp4 new file mode 100644 index 0000000..4d36f8d Binary files /dev/null and b/public/template-media/CharacterStory.mp4 differ diff --git a/scripts/seed_remotion_templates.py b/scripts/seed_remotion_templates.py index 8c46a66..d527db6 100644 --- a/scripts/seed_remotion_templates.py +++ b/scripts/seed_remotion_templates.py @@ -16,6 +16,29 @@ def q(s): return "'" + str(s).replace("'", "''") + "'" ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")] CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پس‌زمینه", "textColor": "رنگ متن"} +SCENE_SECONDS = 3 # CharacterStory: per-scene duration + +# CharacterStory: 13 explainer beats → 26 content fields (title + caption per scene). +# Keys s{N}_title / s{N}_text match the globally-unique Remotion props. +CS_BEATS = [ + ("داستان شما", "روایت خود را در سیزده صحنه بسازید"), + ("معرفی", "شخصیت یا برند خود را معرفی کنید"), + ("شروع ماجرا", "همه‌چیز از یک ایده آغاز شد"), + ("یک چالش", "اما یک مشکل سر راه پیدا شد"), + ("جست‌وجو", "به دنبال یک راه‌حل گشتیم"), + ("قدم اول", "اولین قدم را برداشتیم"), + ("یک مانع", "همه‌چیز آسان نبود"), + ("نقطهٔ عطف", "و سپس همه‌چیز تغییر کرد"), + ("ایده", "راه‌حل را پیدا کردیم"), + ("اقدام", "دست به کار شدیم"), + ("اوج داستان", "بزرگ‌ترین لحظه فرا رسید"), + ("نتیجه", "و به هدف رسیدیم"), + ("پایان", "همین حالا داستان خود را بسازید"), +] +CS_TEXTS = [] +for _i, (_t, _c) in enumerate(CS_BEATS, 1): + CS_TEXTS.append((f"s{_i}_title", f"صحنهٔ {_i} — عنوان", _t)) + CS_TEXTS.append((f"s{_i}_text", f"صحنهٔ {_i} — متن", _c)) # id, slug, name(fa), desc(fa), dur, [(textKey,title,value)], (accent,secondary,bg) T = [ @@ -53,6 +76,8 @@ T = [ [("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")), ("AppShowcase3D","fr-app-showcase","معرفی اپلیکیشن سه‌بعدی","نمایش سه‌بعدی و حرفه‌ای اپلیکیشن روی گوشی پرچم‌دار با نورپردازی استودیویی",6, [("appName","نام اپلیکیشن","اپلیکیشن شما"),("tagline","شعار","تجربه‌ای روان، سریع و زیبا"),("cta","دکمه","همین حالا دانلود کنید")],("#3b82f6","#8b5cf6","#f4f5f7")), + ("CharacterStory","fr-character-story","داستان شخصیتی (۱۳ صحنه)","روایت داستان شما در سیزده صحنهٔ متحرک با شخصیت؛ تصویرسازی مدرن و مینیمال، صحنه‌ها کاملاً قابل ویرایش و انعطاف‌پذیر",39, + CS_TEXTS,("#cf8a76","#6f9d96","#ece4d6")), ] # Optional Media (image) content elements per template — these surface in the @@ -65,11 +90,16 @@ MEDIA = { # Per-template text colour (default white for dark backgrounds; dark for light studios). TEXTCOLORS = { "AppShowcase3D": "#0f172a", + "CharacterStory": "#2b3a55", } # Templates that ship a distinct preview video PER aspect (so the detail page shows # the matching render, not the 16:9 cropped). Others reuse the single 16:9 preview. -PERASPECT_VIDEO = {"AppShowcase3D"} +PERASPECT_VIDEO = {"AppShowcase3D", "CharacterStory"} + +# Templates whose content is split across MANY scenes (key c1..cN), one editable +# scene card per beat. value = scene count; texts are assigned 2-per-scene in order. +MULTISCENE = {"CharacterStory": len(CS_BEATS)} def swatch_svg(colors): rects = "".join(f'' for i, c in enumerate(colors)) @@ -104,17 +134,30 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T): "project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES (" f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(pvideo)},{w},{h},{q(aspstr)}," f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);") - out.append( - "INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES (" - f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);") - for pos, (k, title, val) in enumerate(texts): + nscenes = MULTISCENE.get(tid, 1) + if nscenes > 1: + # one editable scene card per beat; 2 text fields (title+caption) each. + for sc in range(1, nscenes + 1): + skid = uid(f"s-{tid}-{asp}-{sc}") + out.append( + "INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES (" + f"{q(skid)},{q(pid)},{q('c'+str(sc))},{q('صحنه '+str(sc))},{q(swatch_svg([accent,sec,bg,txt]))},{SCENE_SECONDS},{sc-1});") + for pos, (k, title, val) in enumerate(texts[(sc - 1) * 2: sc * 2]): + out.append( + "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" + f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(skid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);") + else: out.append( - "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" - f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);") - for mpos, (k, title) in enumerate(MEDIA.get(tid, [])): - out.append( - "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" - f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);") + "INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES (" + f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,txt]))},{dur},0);") + for pos, (k, title, val) in enumerate(texts): + out.append( + "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" + f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);") + for mpos, (k, title) in enumerate(MEDIA.get(tid, [])): + out.append( + "INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES (" + f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);") for si, (k, hexv) in enumerate(colors): out.append( "INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES (" diff --git a/services/remotion/src/compositions/CharacterStory.tsx b/services/remotion/src/compositions/CharacterStory.tsx new file mode 100644 index 0000000..3037120 --- /dev/null +++ b/services/remotion/src/compositions/CharacterStory.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { + AbsoluteFill, + Sequence, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, + Easing, +} from "remotion"; +import { z } from "zod"; +import { colorSchema } from "../lib/branding"; +import { FONT } from "../lib/fonts"; +import { useLayout, type Layout } from "../lib/aspect"; +import { hexToRgba, mixHex, rand } from "../lib/anim"; + +export const SCENE_COUNT = 13; +export const SCENE_SECONDS = 3; + +// 13 scenes × (title + caption). Globally-unique keys so the flat studio→Remotion +// binding (GetRenderBindings) maps each to its own prop without collisions. +const sceneFields: Record = {}; +for (let i = 1; i <= SCENE_COUNT; i++) { + sceneFields[`s${i}_title`] = z.string(); + sceneFields[`s${i}_text`] = z.string(); +} +export const characterStorySchema = z.object({ ...sceneFields, ...colorSchema }); +type Props = z.infer; + +// Default "how-it-works" explainer beats (Persian) — the user replaces these. +const DEFAULT_BEATS: Array<[string, string]> = [ + ["داستان شما", "روایت خود را در سیزده صحنه بسازید"], + ["معرفی", "شخصیت یا برند خود را معرفی کنید"], + ["شروع ماجرا", "همه‌چیز از یک ایده آغاز شد"], + ["یک چالش", "اما یک مشکل سر راه پیدا شد"], + ["جست‌وجو", "به دنبال یک راه‌حل گشتیم"], + ["قدم اول", "اولین قدم را برداشتیم"], + ["یک مانع", "همه‌چیز آسان نبود"], + ["نقطهٔ عطف", "و سپس همه‌چیز تغییر کرد"], + ["ایده", "راه‌حل را پیدا کردیم"], + ["اقدام", "دست به کار شدیم"], + ["اوج داستان", "بزرگ‌ترین لحظه فرا رسید"], + ["نتیجه", "و به هدف رسیدیم"], + ["پایان", "همین حالا داستان خود را بسازید"], +]; + +export const characterStoryDefaults: Props = (() => { + const o: Record = {}; + for (let i = 1; i <= SCENE_COUNT; i++) { + o[`s${i}_title`] = DEFAULT_BEATS[i - 1][0]; + o[`s${i}_text`] = DEFAULT_BEATS[i - 1][1]; + } + // Sophisticated muted palette: muted coral · soft teal · warm sand · deep navy. + o.accentColor = "#cf8a76"; + o.secondaryColor = "#6f9d96"; + o.backgroundColor = "#ece4d6"; + o.textColor = "#2b3a55"; + return o as unknown as Props; +})(); + +// ── Cute flat character with per-scene poses ───────────────────────────────── +type Pose = "wave" | "point" | "think" | "walk" | "present" | "celebrate" | "idle"; +const POSE_CYCLE: Pose[] = ["wave", "present", "walk", "think", "walk", "point", "think", "celebrate", "present", "walk", "celebrate", "present", "wave"]; + +// Refined editorial figure: elegant proportions, gradient shading, minimal calm face. +const StoryCharacter: React.FC<{ pose: Pose; outfit: string; local: number; idn: number }> = ({ pose, outfit, local, idn }) => { + const bob = Math.sin(local / 8) * 2.2; + const walk = pose === "walk" ? Math.sin(local / 5) * 22 : 0; + const cel = pose === "celebrate" ? Math.abs(Math.sin(local / 6)) : 0; + // Left arm: +angle raises up-and-OUT; -angle swings across/to-face. Right mirrored. + let lA = 12 + walk, rA = -12 - walk; + if (pose === "wave") { lA = 148 + Math.sin(local / 4) * 14; rA = -16; } + else if (pose === "celebrate") { lA = 150 + cel * 12; rA = -150 - cel * 12; } + else if (pose === "point") { lA = 96; rA = 10; } + else if (pose === "present") { lA = 54; rA = -54; } + else if (pose === "think") { lA = -148; rA = -10; } + else if (pose === "idle") { lA = 14; rA = -14; } + + const skin = "#e3b48d", skinD = "#cf9a72", hair = "#33303c", pants = "#3a4664", pantsD = "#2c3650"; + const topL = mixHex(outfit, "#ffffff", 0.16), topD = mixHex(outfit, "#000000", 0.22); + const id = (s: string) => `cs${idn}_${s}`; + const arm = (ang: number, sign: number) => ( + + + + + ); + const leg = (sign: number, ang: number) => ( + + + + + ); + return ( + + + + + + + + {arm(rA, 1)} + {leg(-1, walk * 0.4)} + {leg(1, -walk * 0.4)} + + + {arm(lA, -1)} + + + + + + {pose === "think" + ? + : } + + + + ); +}; + +// ── One story scene (a beat) ───────────────────────────────────────────────── +const StoryScene: React.FC<{ L: Layout; index: number; total: number; pose: Pose; title: string; text: string; accent: string; secondary: string; bg: string; textColor: string; durFrames: number }> = ({ L, index, total, pose, title, text, accent, secondary, bg, textColor, durFrames }) => { + const local = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + + // in/out slide+fade (the scene enters from the start side, exits to the other) + const inP = spring({ frame: local, fps, config: { damping: 18, stiffness: 90 } }); + const outP = interpolate(local, [durFrames - 12, durFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const slideIn = interpolate(inP, [0, 1], [L.vmin(80), 0]); + const slideOut = interpolate(outP, [0, 1], [0, -L.vmin(120)]); + const opacity = Math.min(interpolate(local, [0, 10], [0, 1], { extrapolateRight: "clamp" }), 1 - outP); + + // per-scene muted accents (alternating) for the abstract editorial backdrop + const hue = index % 2 ? accent : secondary; + const hue2 = index % 2 ? secondary : accent; + + // layout: wide → character left, text right; tall/square → character top, text below + const charSize = L.pick(L.vmin(450), L.vmin(430), L.vmin(500)); + const charX = L.pick(width * 0.3, width * 0.5, width * 0.5); + const charY = L.pick(height * 0.6, height * 0.46, height * 0.42); + const groundY = charY + L.vmin(76); + const drift = Math.sin(local / 40) * L.vmin(18); + + const titleSp = spring({ frame: local - 6, fps, config: { damping: 16, stiffness: 110 } }); + const titleY = interpolate(titleSp, [0, 1], [L.vmin(34), 0]); + const textOp = interpolate(local, [16, 32], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const kickerOp = interpolate(local, [2, 16], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const fa = (n: number) => String(n).padStart(2, "0").replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); + const lineW = interpolate(kickerOp, [0, 1], [0, L.vmin(46)]); + + return ( + + {/* muted editorial backdrop */} + + {/* soft depth blobs (CSS-blurred) */} + +
+
+ + + {/* abstract ground mound the character stands on */} + + + {/* sparse elegant particles */} + {Array.from({ length: 7 }).map((_, k) => { + const px = rand(index * 9 + k) * width; + const py = height * (0.12 + rand(index + k) * 0.5) + Math.sin((local + k * 24) / 30) * L.vmin(12); + return ; + })} + {/* character */} + + + + + + {/* text block */} +
+ {/* kicker: scene number + accent rule */} +
+
+
+ {fa(index + 1)} / {fa(total)} +
+
+
+ {title} +
+
+ {text} +
+
+ + {/* progress: scene k / total */} +
+ {Array.from({ length: total }).map((_, k) => ( +
+ ))} +
+ + {/* finishing: vignette + subtle grain */} + + + + ); +}; + +export const CharacterStory: React.FC = (props) => { + const { fps } = useVideoConfig(); + const L = useLayout(); + const p = props as unknown as Record; + const accent = props.accentColor, secondary = props.secondaryColor, bg = props.backgroundColor, textColor = props.textColor; + + const durFrames = Math.round(SCENE_SECONDS * fps); + const trans = 12; + + // Active scenes = non-empty (empty-skip → flexible length within 13). + const scenes: Array<{ i: number; title: string; text: string }> = []; + for (let i = 1; i <= SCENE_COUNT; i++) { + const title = (p[`s${i}_title`] ?? "").trim(); + const text = (p[`s${i}_text`] ?? "").trim(); + if (title || text) scenes.push({ i, title, text }); + } + if (scenes.length === 0) scenes.push({ i: 1, title: "", text: "" }); + + return ( + + {scenes.map((sc, idx) => ( + + + + ))} + + ); +}; diff --git a/services/remotion/src/templates.tsx b/services/remotion/src/templates.tsx index 134c83e..d0047ef 100644 --- a/services/remotion/src/templates.tsx +++ b/services/remotion/src/templates.tsx @@ -25,6 +25,7 @@ import { Nowruz3D, nowruz3DSchema } from "./compositions/Nowruz3D"; import { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D"; import { Promo3D, promo3DSchema } from "./compositions/Promo3D"; import { AppShowcase3D, appShowcase3DSchema } from "./compositions/AppShowcase3D"; +import { CharacterStory, characterStorySchema, characterStoryDefaults } from "./compositions/CharacterStory"; export interface TemplateDef { /** Base id; the registered composition ids are `${id}-${aspect}`. */ @@ -251,4 +252,13 @@ export const TEMPLATES: TemplateDef[] = [ textColor: "#0f172a", }, }, + { + id: "CharacterStory", + name: "داستان شخصیتی (۱۳ صحنه)", + description: "قالب داستان‌گویی منعطف با شخصیت متحرک؛ تا ۱۳ صحنهٔ قابل‌ویرایش (صحنه‌های خالی نمایش داده نمی‌شوند)", + component: CharacterStory, + schema: characterStorySchema, + durationSec: 39, + defaultProps: characterStoryDefaults, + }, ]; diff --git a/src/components/templates/TemplateDetailPreview.tsx b/src/components/templates/TemplateDetailPreview.tsx index da8f9d3..013c43b 100644 --- a/src/components/templates/TemplateDetailPreview.tsx +++ b/src/components/templates/TemplateDetailPreview.tsx @@ -10,7 +10,14 @@ import { getVideoTemplateImageSrc, } from "@/lib/video-templates-catalog"; import { cn } from "@/lib/utils"; -import { BrandedVideoPlayer } from "@/components/templates/BrandedVideoPlayer"; +import dynamic from "next/dynamic"; + +// plyr-react references `document` at import time, which crashes server rendering +// (Next still SSRs client components for the initial HTML). Load it client-only. +const BrandedVideoPlayer = dynamic( + () => import("@/components/templates/BrandedVideoPlayer").then((m) => m.BrandedVideoPlayer), + { ssr: false } +); interface TemplateDetailPreviewProps { template: VideoCatalogTemplate;