feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,8 @@ public class TemplateService(ContentDbContext db)
|
||||
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
|
||||
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
|
||||
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
|
||||
RenderEngine = string.IsNullOrWhiteSpace(req.RenderEngine) ? "AfterEffects" : req.RenderEngine,
|
||||
RenderRemotionComp = req.RenderRemotionComp,
|
||||
IsPublished = req.IsPublished, Sort = req.Sort
|
||||
};
|
||||
|
||||
@@ -225,6 +227,7 @@ public class TemplateService(ContentDbContext db)
|
||||
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
|
||||
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
|
||||
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
|
||||
RenderEngine = src.RenderEngine, RenderRemotionComp = src.RenderRemotionComp,
|
||||
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
|
||||
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
|
||||
IsPublished = false, Sort = src.Sort,
|
||||
@@ -359,6 +362,8 @@ public class TemplateService(ContentDbContext db)
|
||||
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
|
||||
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
|
||||
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
|
||||
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
|
||||
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
|
||||
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
|
||||
@@ -402,6 +407,8 @@ public class TemplateService(ContentDbContext db)
|
||||
if (req.Image != null) project.Image = req.Image;
|
||||
if (req.FullDemo != null) project.FullDemo = req.FullDemo;
|
||||
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
|
||||
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||
project.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
@@ -472,7 +479,8 @@ public class TemplateService(ContentDbContext db)
|
||||
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
||||
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
|
||||
p.IsPublished, p.Sort,
|
||||
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp
|
||||
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp,
|
||||
p.RenderEngine, p.RenderRemotionComp
|
||||
);
|
||||
|
||||
/// <summary>Browse/search all projects (template items) across containers.</summary>
|
||||
@@ -489,7 +497,8 @@ public class TemplateService(ContentDbContext db)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(p => new ProjectListItemResponse(
|
||||
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
|
||||
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort))
|
||||
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort,
|
||||
p.RenderEngine, p.RenderRemotionComp))
|
||||
.ToListAsync();
|
||||
return new PagedResponse<ProjectListItemResponse>(items,
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
|
||||
@@ -529,6 +538,8 @@ public class TemplateService(ContentDbContext db)
|
||||
if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5;
|
||||
if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
|
||||
if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp;
|
||||
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||
if (req.Folder != null) project.Folder = req.Folder;
|
||||
project.AepUploadedAt = DateTime.UtcNow;
|
||||
project.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -541,6 +552,7 @@ public class TemplateService(ContentDbContext db)
|
||||
p.OriginalWidth, p.OriginalHeight, p.Aspect,
|
||||
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
||||
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
|
||||
p.RenderEngine, p.RenderRemotionComp,
|
||||
p.SharedLayerImage, p.IsPublished, p.Sort,
|
||||
p.Scenes.Select(MapScene).ToList(),
|
||||
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
|
||||
|
||||
@@ -95,6 +95,11 @@ public class Project
|
||||
public decimal VipFactor { get; set; } = 1.0m;
|
||||
public string RenderAepComp { get; set; } = "flatrender";
|
||||
|
||||
/// <summary>Render engine for this template: "AfterEffects" (default) or "Remotion".</summary>
|
||||
public string RenderEngine { get; set; } = "AfterEffects";
|
||||
/// <summary>For Remotion templates, the composition id to render (e.g. "KineticQuote").</summary>
|
||||
public string? RenderRemotionComp { get; set; }
|
||||
|
||||
public string? SharedLayerImage { get; set; }
|
||||
public string? SharedColorsSvg { get; set; }
|
||||
public string? SharedColorPresetsSvg { get; set; }
|
||||
|
||||
@@ -307,6 +307,8 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
||||
e.Property(x => x.Resolution).HasColumnName("resolution");
|
||||
e.Property(x => x.VipFactor).HasColumnName("vip_factor");
|
||||
e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp");
|
||||
e.Property(x => x.RenderEngine).HasColumnName("render_engine");
|
||||
e.Property(x => x.RenderRemotionComp).HasColumnName("render_remotion_comp");
|
||||
e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image");
|
||||
e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg");
|
||||
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
|
||||
|
||||
@@ -213,7 +213,9 @@ public record CreateProjectRequest(
|
||||
decimal VipFactor,
|
||||
string RenderAepComp,
|
||||
bool IsPublished,
|
||||
int Sort
|
||||
int Sort,
|
||||
string? RenderEngine = null,
|
||||
string? RenderRemotionComp = null
|
||||
);
|
||||
|
||||
public record UpdateProjectRequest(
|
||||
@@ -239,7 +241,9 @@ public record UpdateProjectRequest(
|
||||
string? SharedColorsSvg,
|
||||
string? SharedColorPresetsSvg,
|
||||
bool IsPublished,
|
||||
int Sort
|
||||
int Sort,
|
||||
string? RenderEngine = null,
|
||||
string? RenderRemotionComp = null
|
||||
);
|
||||
|
||||
// Partial update — only non-null fields are applied, so editing an aspect/resolution
|
||||
@@ -260,7 +264,9 @@ public record SetAepRequest(
|
||||
string? AepFileMd5,
|
||||
long? AepFileSizeBytes,
|
||||
string? RenderAepComp,
|
||||
string? Folder
|
||||
string? Folder,
|
||||
string? RenderEngine = null,
|
||||
string? RenderRemotionComp = null
|
||||
);
|
||||
|
||||
public record PatchProjectRequest(
|
||||
@@ -279,7 +285,9 @@ public record PatchProjectRequest(
|
||||
int? Sort,
|
||||
string? Image,
|
||||
string? FullDemo,
|
||||
string? SharedColorsSvg
|
||||
string? SharedColorsSvg,
|
||||
string? RenderEngine = null,
|
||||
string? RenderRemotionComp = null
|
||||
);
|
||||
|
||||
// ── CMS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -131,7 +131,9 @@ public record ProjectResponse(
|
||||
int Sort,
|
||||
string? AepFileUrl,
|
||||
long? AepFileSizeBytes,
|
||||
string RenderAepComp
|
||||
string RenderAepComp,
|
||||
string RenderEngine,
|
||||
string? RenderRemotionComp
|
||||
);
|
||||
|
||||
public record ProjectListItemResponse(
|
||||
@@ -146,7 +148,9 @@ public record ProjectListItemResponse(
|
||||
string? AepFileUrl,
|
||||
string RenderAepComp,
|
||||
bool IsPublished,
|
||||
int Sort
|
||||
int Sort,
|
||||
string RenderEngine,
|
||||
string? RenderRemotionComp
|
||||
);
|
||||
|
||||
public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort);
|
||||
@@ -171,6 +175,8 @@ public record ProjectDetailResponse(
|
||||
string Resolution,
|
||||
decimal VipFactor,
|
||||
string RenderAepComp,
|
||||
string RenderEngine,
|
||||
string? RenderRemotionComp,
|
||||
string? SharedLayerImage,
|
||||
bool IsPublished,
|
||||
int Sort,
|
||||
|
||||
@@ -515,6 +515,13 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
|
||||
}
|
||||
|
||||
// Default empty engine to AfterEffects for backwards-compat with older
|
||||
// orchestrators that don't send the field yet.
|
||||
engine := job.Engine
|
||||
if engine == "" {
|
||||
engine = runner.EngineAfterEffects
|
||||
}
|
||||
|
||||
rJob := &runner.Job{
|
||||
JobID: job.JobID,
|
||||
SavedProjectID: job.SavedProjectID,
|
||||
@@ -523,6 +530,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
FrameRate: job.FrameRate,
|
||||
HasMusic: job.HasMusic,
|
||||
HasVoiceover: job.HasVoiceover,
|
||||
Engine: engine,
|
||||
RemotionDir: a.cfg.RemotionProjectDir,
|
||||
AEPFilePath: aepPath,
|
||||
CompName: job.CompName,
|
||||
AfterFxPath: a.cfg.AfterFxPath,
|
||||
|
||||
@@ -152,6 +152,9 @@ type ClaimedJob struct {
|
||||
FrameRate int `json:"frame_rate"`
|
||||
HasMusic bool `json:"has_music"`
|
||||
HasVoiceover bool `json:"has_voiceover"`
|
||||
// Engine selects the render engine: "AfterEffects" (default) or "Remotion".
|
||||
// For Remotion jobs CompName is the composition id and AEPDownloadURL is empty.
|
||||
Engine string `json:"engine,omitempty"`
|
||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
|
||||
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
|
||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||
|
||||
@@ -85,6 +85,10 @@ type Config struct {
|
||||
// WorkDir is the scratch directory for render temp files and AE project copies.
|
||||
WorkDir string
|
||||
|
||||
// RemotionProjectDir is the Remotion project root (package.json + src/index.ts)
|
||||
// used by the code-based render engine. Empty disables Remotion jobs on this node.
|
||||
RemotionProjectDir string
|
||||
|
||||
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
|
||||
HeartbeatIntervalSec int
|
||||
|
||||
@@ -115,6 +119,7 @@ func Load() (*Config, error) {
|
||||
AEPath: getEnv("AE_PATH", ""),
|
||||
AfterFxPath: getEnv("AFTERFX_PATH", ""),
|
||||
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
||||
RemotionProjectDir: getEnv("REMOTION_PROJECT_DIR", ""),
|
||||
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
||||
AEVersion: getEnv("AE_VERSION", "2024"),
|
||||
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Remotion render engine.
|
||||
//
|
||||
// FlatRender supports two template engines that both produce a web-playable MP4:
|
||||
//
|
||||
// - AfterEffects (EngineAfterEffects) — aerender.exe renders a .aep template,
|
||||
// bindings are written into the project first; see runner.go / binder.go.
|
||||
// - Remotion (EngineRemotion) — a code-based React/Remotion composition
|
||||
// is rendered with `npx remotion render`; bindings become --props; this file.
|
||||
//
|
||||
// The two engines are interchangeable from the job loop's point of view: Run()
|
||||
// dispatches on Job.Engine and each returns the path to an MP4 on disk.
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine identifiers. These mirror the values the orchestrator stores per
|
||||
// template (content.templates.render_engine) and sends on the claimed job.
|
||||
const (
|
||||
EngineAfterEffects = "AfterEffects"
|
||||
EngineRemotion = "Remotion"
|
||||
)
|
||||
|
||||
// Remotion prints "Rendered <done>/<total>" while drawing frames and
|
||||
// "Stitched <done>/<total>" while muxing them into the MP4. We parse both to
|
||||
// build a real percentage.
|
||||
var (
|
||||
reRemRendered = regexp.MustCompile(`Rendered\s+(\d+)/(\d+)`)
|
||||
reRemStitched = regexp.MustCompile(`Stitched\s+(\d+)/(\d+)`)
|
||||
)
|
||||
|
||||
// npxCmd returns the platform-appropriate npx launcher.
|
||||
func npxCmd() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "npx.cmd"
|
||||
}
|
||||
return "npx"
|
||||
}
|
||||
|
||||
// remotionProps maps the user's bindings into a Remotion props JSON object.
|
||||
// For code-based templates the binding Key is the composition's schema field
|
||||
// (logoText, accentColor, …) and Value is the user's edited string. Anything the
|
||||
// user didn't touch falls back to the composition's defaultProps.
|
||||
func remotionProps(job *Job) (string, error) {
|
||||
props := make(map[string]string, len(job.Bindings))
|
||||
for _, b := range job.Bindings {
|
||||
if b.Key == "" {
|
||||
continue
|
||||
}
|
||||
props[b.Key] = b.Value
|
||||
}
|
||||
data, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// crlfSplit is a bufio.SplitFunc that breaks on either \n or \r so we capture
|
||||
// each progress-bar repaint (Remotion redraws the bar with \r, not \n).
|
||||
func crlfSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
for i, b := range data {
|
||||
if b == '\n' || b == '\r' {
|
||||
return i + 1, data[:i], nil
|
||||
}
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil // request more data
|
||||
}
|
||||
|
||||
// RunRemotion renders a code-based (Remotion) template to MP4.
|
||||
//
|
||||
// - remotionDir is the Remotion project root (has package.json + src/index.ts).
|
||||
// - job.CompName is the Remotion composition id (e.g. "KineticQuote").
|
||||
// - job.Bindings become --props.
|
||||
// - job.Resolution selects an output height tier (free=360p … 4k).
|
||||
//
|
||||
// Returns the path to the rendered MP4. Progress + periodic previews are streamed
|
||||
// through the same callbacks the AE engine uses, so the UI is engine-agnostic.
|
||||
func RunRemotion(ctx context.Context, remotionDir string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||
if remotionDir == "" {
|
||||
return "", fmt.Errorf("remotion project dir not set (REMOTION_PROJECT_DIR)")
|
||||
}
|
||||
if job.CompName == "" {
|
||||
return "", fmt.Errorf("remotion render requires a composition id (CompName)")
|
||||
}
|
||||
if st, err := os.Stat(remotionDir); err != nil || !st.IsDir() {
|
||||
return "", fmt.Errorf("remotion project dir not found: %s", remotionDir)
|
||||
}
|
||||
|
||||
propsJSON, err := remotionProps(job)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build props: %w", err)
|
||||
}
|
||||
|
||||
// Render at the composition's native resolution, then downscale to the quality
|
||||
// tier with ffmpeg (scale=-2:h preserves aspect). Remotion's --height flag
|
||||
// overrides height but keeps the native width, which squishes non-matching
|
||||
// aspect ratios — so we deliberately scale in the same ffmpeg post-step the AE
|
||||
// engine uses. This also keeps one place to stamp the free-tier watermark later.
|
||||
nativePath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".native.mp4"
|
||||
entry := filepath.Join("src", "index.ts")
|
||||
args := []string{
|
||||
"remotion", "render", entry, job.CompName, nativePath,
|
||||
"--props=" + propsJSON,
|
||||
"--log=info",
|
||||
}
|
||||
|
||||
log.Printf("[remotion] job %s → comp %q, props %s (cwd=%s)", job.JobID, job.CompName, propsJSON, remotionDir)
|
||||
cmd := exec.CommandContext(ctx, npxCmd(), args...)
|
||||
cmd.Dir = remotionDir
|
||||
|
||||
// Merge stdout+stderr into one pipe — Remotion writes the progress bar to
|
||||
// stderr and structured logs to stdout; we want both.
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
var curFrame, totalFrames, stitched, totalStitch int64
|
||||
var phase atomic.Int32 // 0=bundling 1=rendering 2=stitching
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
sc.Split(crlfSplit)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
_, _ = io.WriteString(os.Stdout, "[remotion] "+line+"\n")
|
||||
if m := reRemRendered.FindStringSubmatch(line); m != nil {
|
||||
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
atomic.StoreInt64(&curFrame, cur)
|
||||
atomic.StoreInt64(&totalFrames, tot)
|
||||
phase.Store(1)
|
||||
}
|
||||
if m := reRemStitched.FindStringSubmatch(line); m != nil {
|
||||
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
atomic.StoreInt64(&stitched, cur)
|
||||
atomic.StoreInt64(&totalStitch, tot)
|
||||
phase.Store(2)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = pw.Close()
|
||||
return "", fmt.Errorf("start remotion: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
werr := cmd.Wait()
|
||||
_ = pw.Close() // unblock the scanner goroutine
|
||||
done <- werr
|
||||
}()
|
||||
|
||||
_ = onProgress(ctx, 4, "در حال آمادهسازی قالب…") // "Preparing template…"
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
lastPreview := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case werr := <-done:
|
||||
if werr != nil {
|
||||
return "", fmt.Errorf("remotion render exit: %w", werr)
|
||||
}
|
||||
if st, serr := os.Stat(nativePath); serr != nil || st.Size() == 0 {
|
||||
return "", fmt.Errorf("remotion finished but produced no output at %s", nativePath)
|
||||
}
|
||||
// Downscale to the quality tier (aspect-preserving). When ffmpeg is
|
||||
// missing or the tier is unknown, ship the native render unchanged.
|
||||
h := resolutionHeight(job.Resolution)
|
||||
if h > 0 && ffmpegPath() != "" {
|
||||
_ = onProgress(ctx, 96, "در حال بهینهسازی کیفیت…") // "Optimizing quality…"
|
||||
mp4, terr := transcodeToMP4(ctx, nativePath, outputPath, h)
|
||||
if terr != nil {
|
||||
log.Printf("[remotion] tier transcode failed (%v) — shipping native render", terr)
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return nativePath, nil
|
||||
}
|
||||
_ = os.Remove(nativePath)
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return mp4, nil
|
||||
}
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return nativePath, nil
|
||||
case <-ticker.C:
|
||||
pct, msg := remotionProgress(phase.Load(),
|
||||
atomic.LoadInt64(&curFrame), atomic.LoadInt64(&totalFrames),
|
||||
atomic.LoadInt64(&stitched), atomic.LoadInt64(&totalStitch))
|
||||
_ = onProgress(ctx, pct, msg)
|
||||
if onPreview != nil && time.Since(lastPreview) >= 8*time.Second {
|
||||
lastPreview = time.Now()
|
||||
if perr := onPreview(ctx, GeneratePreviewB64(pct, job.Quality, job.Resolution)); perr != nil {
|
||||
log.Printf("[remotion] preview push error: %v", perr)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
_ = cmd.Process.Kill()
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remotionProgress maps the render phase + frame counts to a 4–96 percentage
|
||||
// (leaving headroom for the orchestrator's upload step) plus a Persian message.
|
||||
func remotionProgress(phase int32, cur, total, stch, stchTotal int64) (int, string) {
|
||||
switch phase {
|
||||
case 2: // stitching → 70..96
|
||||
if stchTotal > 0 {
|
||||
frac := float64(stch) / float64(stchTotal)
|
||||
return 70 + int(frac*26), fmt.Sprintf("در حال ساخت ویدیو… %d از %d", stch, stchTotal)
|
||||
}
|
||||
return 70, "در حال ساخت ویدیو…"
|
||||
case 1: // rendering frames → 8..70
|
||||
if total > 0 {
|
||||
frac := float64(cur) / float64(total)
|
||||
return 8 + int(frac*62), fmt.Sprintf("در حال رندر… فریم %d از %d", cur, total)
|
||||
}
|
||||
return 8, "در حال رندر…"
|
||||
default: // bundling
|
||||
return 5, "در حال کامپایل قالب…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remotionProjectDir resolves the repo's services/remotion directory relative to
|
||||
// this test package (services/node-agent/internal/runner), or skips the test when
|
||||
// it (or npx) is unavailable — keeps the test green on CI nodes without the
|
||||
// Remotion project checked out.
|
||||
func remotionProjectDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion"))
|
||||
if err != nil {
|
||||
t.Fatalf("abs: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil {
|
||||
t.Skipf("remotion project not found at %s (skipping)", dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRemotionProps(t *testing.T) {
|
||||
job := &Job{Bindings: []Binding{
|
||||
{Key: "logoText", Value: "HELLO"},
|
||||
{Key: "accentColor", Value: "#22d3ee"},
|
||||
{Key: "", Value: "ignored"}, // empty keys are dropped
|
||||
}}
|
||||
got, err := remotionProps(job)
|
||||
if err != nil {
|
||||
t.Fatalf("remotionProps: %v", err)
|
||||
}
|
||||
want := `{"accentColor":"#22d3ee","logoText":"HELLO"}`
|
||||
if got != want {
|
||||
t.Fatalf("props = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotionProgress(t *testing.T) {
|
||||
cases := []struct {
|
||||
phase int32
|
||||
cur, total, stch, stchTot int64
|
||||
wantMin, wantMax int
|
||||
}{
|
||||
{0, 0, 0, 0, 0, 5, 5}, // bundling
|
||||
{1, 90, 180, 0, 0, 30, 45}, // half the frames rendered
|
||||
{2, 90, 180, 90, 180, 80, 90}, // half stitched
|
||||
}
|
||||
for _, c := range cases {
|
||||
pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot)
|
||||
if pct < c.wantMin || pct > c.wantMax {
|
||||
t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRemotion_EndToEnd renders a real composition through the engine and
|
||||
// asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run
|
||||
// RunRemotion -timeout 6m`. Skipped automatically without the project or npx.
|
||||
func TestRunRemotion_EndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping end-to-end render in -short mode")
|
||||
}
|
||||
remDir := remotionProjectDir(t)
|
||||
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "engine-out.mp4")
|
||||
job := &Job{
|
||||
JobID: "test-remotion-e2e",
|
||||
Engine: EngineRemotion,
|
||||
CompName: "KineticQuote",
|
||||
Quality: "free",
|
||||
Resolution: "360p", // exercises the height tier mapping
|
||||
Bindings: []Binding{
|
||||
{Key: "quote", Value: "Two engines, one output."},
|
||||
{Key: "author", Value: "Engine Test"},
|
||||
{Key: "accentColor", Value: "#22d3ee"},
|
||||
},
|
||||
}
|
||||
|
||||
var lastPct int
|
||||
onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil }
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRemotion: %v", err)
|
||||
}
|
||||
st, err := os.Stat(got)
|
||||
if err != nil {
|
||||
t.Fatalf("stat output: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("output file is empty")
|
||||
}
|
||||
if lastPct < 90 {
|
||||
t.Errorf("final progress only reached %d%%", lastPct)
|
||||
}
|
||||
t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct)
|
||||
}
|
||||
|
||||
// TestRun_RemotionEngine exercises the real integration point the node-agent uses:
|
||||
// runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path
|
||||
// (which would otherwise trigger the AE mock), it must route to the Remotion engine
|
||||
// and produce a real MP4.
|
||||
func TestRun_RemotionEngine(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping end-to-end render in -short mode")
|
||||
}
|
||||
remDir := remotionProjectDir(t)
|
||||
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||
}
|
||||
|
||||
job := &Job{
|
||||
JobID: "test-run-dispatch",
|
||||
Engine: EngineRemotion,
|
||||
RemotionDir: remDir,
|
||||
CompName: "KineticQuote",
|
||||
Quality: "free",
|
||||
Resolution: "360p",
|
||||
Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}},
|
||||
}
|
||||
noop := func(context.Context, int, string) error { return nil }
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// aePath empty: an AE job would mock here; a Remotion job must still render for real.
|
||||
got, err := Run(ctx, "", t.TempDir(), job, noop, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run (remotion engine): %v", err)
|
||||
}
|
||||
st, err := os.Stat(got)
|
||||
if err != nil || st.Size() == 0 {
|
||||
t.Fatalf("no output from Run: %v", err)
|
||||
}
|
||||
if string(mustRead(t, got)[:4]) == "mock" {
|
||||
t.Fatal("Run produced the AE mock output instead of a real Remotion render")
|
||||
}
|
||||
t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size())
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type Job struct {
|
||||
FrameRate int
|
||||
HasMusic bool
|
||||
HasVoiceover bool
|
||||
// Engine selects the render engine: EngineAfterEffects (default, "" treated as
|
||||
// AE for backwards-compat) or EngineRemotion (code-based React templates).
|
||||
Engine string
|
||||
// RemotionDir is the Remotion project root, used only when Engine == EngineRemotion.
|
||||
RemotionDir string
|
||||
// AEPFilePath is the local path to the downloaded .aep project file.
|
||||
// In a full implementation the agent downloads this from MinIO before calling Run.
|
||||
AEPFilePath string
|
||||
@@ -75,6 +80,12 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
|
||||
}
|
||||
outputPath := filepath.Join(outputDir, "output.mp4")
|
||||
|
||||
// Engine dispatch. Remotion is fully self-contained (Node + Chrome), so it
|
||||
// never touches the AE / mock paths below.
|
||||
if strings.EqualFold(job.Engine, EngineRemotion) {
|
||||
return RunRemotion(ctx, job.RemotionDir, job, outputPath, onProgress, onPreview)
|
||||
}
|
||||
|
||||
// Mock render when AE isn't installed (aePath empty) OR when this job has no
|
||||
// template project to render (AEPFilePath empty — the template bundle wasn't
|
||||
// uploaded/promoted yet). Mock drives progress+preview to completion so the job
|
||||
|
||||
Generated
+3319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "flatrender-remotion",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "FlatRender code-based (Remotion) video template renderer",
|
||||
"scripts": {
|
||||
"dev": "remotion studio",
|
||||
"render": "remotion render",
|
||||
"still": "remotion still",
|
||||
"upgrade": "remotion upgrade"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.1.2",
|
||||
"@react-three/postprocessing": "^3.0.4",
|
||||
"@remotion/cli": "4.0.290",
|
||||
"@remotion/three": "^4.0.290",
|
||||
"@remotion/zod-types": "4.0.290",
|
||||
"@types/three": "^0.171.0",
|
||||
"postprocessing": "^6.39.1",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"remotion": "4.0.290",
|
||||
"three": "^0.171.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.0.0",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
// Higher quality concurrency defaults for the logo-intro previews.
|
||||
Config.setConcurrency(4);
|
||||
|
||||
// Remotion's bundled Chrome Headless Shell download is geo-blocked (403) from
|
||||
// Iran, so point it at the locally-installed Chrome instead. Override with the
|
||||
// REMOTION_BROWSER env var on machines where Chrome lives elsewhere.
|
||||
Config.setBrowserExecutable(
|
||||
process.env.REMOTION_BROWSER ??
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
);
|
||||
|
||||
// Required for WebGL / Three.js (@remotion/three) templates to render headless.
|
||||
// "angle" works with the local Chrome; the node-agent inherits this from config.
|
||||
Config.setChromiumOpenGlRenderer("angle");
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Composition } from "remotion";
|
||||
import { ASPECTS } from "./lib/aspect";
|
||||
import { TEMPLATES } from "./templates";
|
||||
import { Three3DTest } from "./compositions/Three3DTest";
|
||||
import {
|
||||
IlluminatedCircles,
|
||||
illuminatedCirclesSchema,
|
||||
} from "./compositions/IlluminatedCircles";
|
||||
import {
|
||||
KineticQuote,
|
||||
kineticQuoteSchema,
|
||||
} from "./compositions/KineticQuote";
|
||||
import {
|
||||
GradientPromo,
|
||||
gradientPromoSchema,
|
||||
} from "./compositions/GradientPromo";
|
||||
import {
|
||||
VerticalStory,
|
||||
verticalStorySchema,
|
||||
} from "./compositions/VerticalStory";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Logo intro — 16:9 */}
|
||||
<Composition
|
||||
id="IlluminatedCircles"
|
||||
component={IlluminatedCircles}
|
||||
durationInFrames={FPS * 6}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
schema={illuminatedCirclesSchema}
|
||||
defaultProps={{
|
||||
logoText: "FLATRENDER",
|
||||
tagline: "MOTION MADE SIMPLE",
|
||||
accentColor: "#3ba7ff",
|
||||
secondaryColor: "#a855f7",
|
||||
backgroundColor: "#04060f",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Kinetic typography quote — 1:1 social */}
|
||||
<Composition
|
||||
id="KineticQuote"
|
||||
component={KineticQuote}
|
||||
durationInFrames={FPS * 7}
|
||||
fps={FPS}
|
||||
width={1080}
|
||||
height={1080}
|
||||
schema={kineticQuoteSchema}
|
||||
defaultProps={{
|
||||
quote: "Great motion design is felt long before it is noticed.",
|
||||
author: "FlatRender Studio",
|
||||
accentColor: "#22d3ee",
|
||||
secondaryColor: "#6366f1",
|
||||
backgroundColor: "#0a0a12",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Marketing / sale promo — 16:9 */}
|
||||
<Composition
|
||||
id="GradientPromo"
|
||||
component={GradientPromo}
|
||||
durationInFrames={FPS * 6}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
schema={gradientPromoSchema}
|
||||
defaultProps={{
|
||||
eyebrow: "Limited time offer",
|
||||
headline: "Make videos that move people.",
|
||||
subheadline:
|
||||
"Customizable code-based templates, rendered in the cloud in minutes.",
|
||||
ctaText: "Start free →",
|
||||
badgeText: "50% OFF",
|
||||
accentColor: "#fb7185",
|
||||
secondaryColor: "#f59e0b",
|
||||
backgroundColor: "#0c0a14",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Vertical social story — 9:16 */}
|
||||
<Composition
|
||||
id="VerticalStory"
|
||||
component={VerticalStory}
|
||||
durationInFrames={FPS * 6}
|
||||
fps={FPS}
|
||||
width={1080}
|
||||
height={1920}
|
||||
schema={verticalStorySchema}
|
||||
defaultProps={{
|
||||
kicker: "New drop",
|
||||
line1: "Your story.",
|
||||
line2: "Your style.",
|
||||
line3: "One tap.",
|
||||
ctaText: "Swipe up",
|
||||
accentColor: "#34d399",
|
||||
secondaryColor: "#3b82f6",
|
||||
backgroundColor: "#060b0a",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 3D feasibility test */}
|
||||
<Composition
|
||||
id="Three3DTest"
|
||||
component={Three3DTest}
|
||||
durationInFrames={120}
|
||||
fps={30}
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
|
||||
{/* Branded templates — each registered in all three aspects. */}
|
||||
{TEMPLATES.flatMap((tpl) =>
|
||||
ASPECTS.map((a) => (
|
||||
<Composition
|
||||
key={`${tpl.id}-${a.id}`}
|
||||
id={`${tpl.id}-${a.id}`}
|
||||
component={tpl.component}
|
||||
durationInFrames={Math.round(FPS * tpl.durationSec)}
|
||||
fps={FPS}
|
||||
width={a.width}
|
||||
height={a.height}
|
||||
schema={tpl.schema}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
defaultProps={tpl.defaultProps as any}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import * as THREE from "three";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
import { StudioEnv, StudioFloor, StudioLights, StudioEffects, Confetti3D } from "../lib/three-kit";
|
||||
|
||||
export const birthday3DSchema = z.object({
|
||||
greeting: z.string(),
|
||||
name: z.string(),
|
||||
message: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof birthday3DSchema>;
|
||||
|
||||
const Candle: React.FC<{ x: number; z: number; i: number; accent: string }> = ({ x, z, i, accent }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const flick = 1 + Math.sin(frame / 4 + i) * 0.18;
|
||||
return (
|
||||
<group position={[x, 0.95, z]}>
|
||||
<mesh castShadow>
|
||||
<cylinderGeometry args={[0.04, 0.045, 0.4, 16]} />
|
||||
<meshStandardMaterial color={i % 2 ? "#ffffff" : accent} roughness={0.5} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.28, 0]} scale={[1, flick, 1]}>
|
||||
<coneGeometry args={[0.04, 0.15, 16]} />
|
||||
<meshStandardMaterial color="#ffd27a" emissive="#ffae3b" emissiveIntensity={3} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0.34, 0]} intensity={1.6 * flick} color="#ffb14d" distance={2.5} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Cake: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
|
||||
const cream = "#fbeede";
|
||||
const frost = accent;
|
||||
const candleN = 5;
|
||||
return (
|
||||
<group position={[0, -0.55, 0]}>
|
||||
{/* plate */}
|
||||
<mesh position={[0, 0.04, 0]} receiveShadow castShadow>
|
||||
<cylinderGeometry args={[1.15, 1.15, 0.08, 48]} />
|
||||
<meshStandardMaterial color="#e8e8ee" roughness={0.25} metalness={0.4} />
|
||||
</mesh>
|
||||
{/* tier 1 */}
|
||||
<mesh position={[0, 0.34, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.92, 0.95, 0.52, 48]} />
|
||||
<meshStandardMaterial color={cream} roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.6, 0]}>
|
||||
<torusGeometry args={[0.92, 0.07, 16, 48]} />
|
||||
<meshStandardMaterial color={frost} roughness={0.45} />
|
||||
</mesh>
|
||||
{/* tier 2 */}
|
||||
<mesh position={[0, 0.82, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.62, 0.66, 0.46, 48]} />
|
||||
<meshStandardMaterial color={mixHex(cream, frost, 0.15)} roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<torusGeometry args={[0.62, 0.06, 16, 48]} />
|
||||
<meshStandardMaterial color={secondary} roughness={0.45} />
|
||||
</mesh>
|
||||
{/* cherries */}
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<mesh key={i} position={[Math.cos((i / 8) * Math.PI * 2) * 0.62, 1.06, Math.sin((i / 8) * Math.PI * 2) * 0.62]}>
|
||||
<sphereGeometry args={[0.05, 16, 16]} />
|
||||
<meshStandardMaterial color="#e23b3b" roughness={0.3} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* candles */}
|
||||
{Array.from({ length: candleN }).map((_, i) => {
|
||||
const a = (i / candleN) * Math.PI * 2;
|
||||
return <Candle key={i} i={i} x={Math.cos(a) * 0.32} z={Math.sin(a) * 0.32} accent={accent} />;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Balloon: React.FC<{ x: number; z: number; i: number; color: string }> = ({ x, z, i, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const bob = Math.sin(frame / 30 + i) * 0.25;
|
||||
const sway = Math.sin(frame / 40 + i * 2) * 0.1;
|
||||
const baseY = 1.4 + (i % 3) * 0.5;
|
||||
return (
|
||||
<group position={[x + sway, baseY + bob, z]}>
|
||||
<mesh castShadow>
|
||||
<sphereGeometry args={[0.38, 24, 24]} />
|
||||
<meshStandardMaterial color={color} roughness={0.25} metalness={0.05} emissive={color} emissiveIntensity={0.06} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.4, 0]}>
|
||||
<coneGeometry args={[0.05, 0.1, 12]} />
|
||||
<meshStandardMaterial color={color} roughness={0.3} />
|
||||
</mesh>
|
||||
<mesh position={[0, -1.0, 0]}>
|
||||
<cylinderGeometry args={[0.005, 0.005, 1.1, 6]} />
|
||||
<meshStandardMaterial color="#ffffff" opacity={0.5} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = spring({ frame: frame - 8, fps, config: { damping: 14, stiffness: 60 } });
|
||||
const orbit = Math.sin(frame / 110) * 0.2;
|
||||
const balloonColors = [accent, secondary, "#fde047", "#34d399", "#60a5fa"];
|
||||
return (
|
||||
<group rotation={[0, orbit, 0]} scale={enter}>
|
||||
<StudioLights accent={accent} secondary={secondary} />
|
||||
<StudioEnv />
|
||||
<StudioFloor color="#241d33" />
|
||||
<Cake accent={accent} secondary={secondary} />
|
||||
{[[-2.2, -0.5], [2.2, -0.6], [-1.7, -1.6], [1.8, -1.4], [0, -2.2]].map((p, i) => (
|
||||
<Balloon key={i} i={i} x={p[0]} z={p[1]} color={balloonColors[i % balloonColors.length]} />
|
||||
))}
|
||||
<Confetti3D colors={[accent, secondary, "#fde047", "#34d399", "#ffffff"]} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export const Birthday3D: React.FC<Props> = ({
|
||||
greeting,
|
||||
name,
|
||||
message,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const gOp = interpolate(frame, [12, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const namePop = spring({ frame: frame - 30, fps, config: { damping: 10, stiffness: 120 } });
|
||||
const nameScale = interpolate(namePop, [0, 1], [0.4, 1]);
|
||||
const msgOp = interpolate(frame, [150, 172], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor }}>
|
||||
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 35%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 40%, ${backgroundColor} 74%)` }} />
|
||||
<ThreeCanvas
|
||||
width={width}
|
||||
height={height}
|
||||
camera={{ position: [0, 2.3, 5.7], fov: 50 }}
|
||||
shadows
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
|
||||
>
|
||||
<Scene accent={accentColor} secondary={secondaryColor} />
|
||||
<StudioEffects bloom={0.6} focus={0.014} bokeh={3} vignette={0.55} />
|
||||
</ThreeCanvas>
|
||||
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.05 }}>
|
||||
<div style={{ opacity: gOp, fontWeight: 700, fontSize: L.vmin(44), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
|
||||
{greeting}
|
||||
</div>
|
||||
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(6)}px 0`, fontWeight: 900, fontSize: L.vmin(100), lineHeight: 1.05, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(3)}px ${L.vmin(10)}px ${hexToRgba("#1a0a14", 0.6)})` }}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ opacity: msgOp, fontWeight: 600, fontSize: L.vmin(28), color: hexToRgba(textColor, 0.92), textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
|
||||
{message}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const countdownSchema = z.object({
|
||||
title: z.string(),
|
||||
// coerce so a string binding ("5") from the studio still validates as a number
|
||||
startNumber: z.coerce.number().int().min(1).max(9),
|
||||
goText: z.string(),
|
||||
subtitle: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof countdownSchema>;
|
||||
|
||||
export const Countdown: React.FC<Props> = ({
|
||||
title,
|
||||
startNumber,
|
||||
goText,
|
||||
subtitle,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const titleR = useReveal(6, { from: 24 });
|
||||
|
||||
// Count down one number per second after a short intro.
|
||||
const introF = Math.round(fps * 1.2);
|
||||
const elapsed = Math.max(0, frame - introF);
|
||||
const sec = Math.floor(elapsed / fps);
|
||||
const current = startNumber - sec; // >0 → number, <=0 → GO
|
||||
const localInSec = (elapsed % fps) / fps;
|
||||
|
||||
// Each tick pops in and fades/scales out.
|
||||
const pop = spring({ frame: (elapsed % fps), fps, config: { damping: 12, stiffness: 130, mass: 0.7 } });
|
||||
const scaleIn = interpolate(pop, [0, 1], [0.4, 1]);
|
||||
const scaleOut = interpolate(localInSec, [0.7, 1], [1, 1.4], { extrapolateLeft: "clamp" });
|
||||
const fadeOut = interpolate(localInSec, [0.75, 1], [1, 0], { extrapolateLeft: "clamp" });
|
||||
|
||||
const isGo = current <= 0;
|
||||
const ringProgress = 1 - localInSec;
|
||||
const ringR = L.vmin(220);
|
||||
const circ = 2 * Math.PI * ringR;
|
||||
|
||||
const sub = useReveal(introF + 4, { from: 24 });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
|
||||
|
||||
<div style={{ position: "absolute", top: L.vmin(120), left: 0, right: 0, textAlign: "center", opacity: titleR.opacity, transform: `translateY(${titleR.y}px)`, fontWeight: 800, fontSize: L.vmin(44), color: hexToRgba(textColor, 0.9) }}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
{/* Progress ring */}
|
||||
{!isGo && (
|
||||
<svg width={ringR * 2.4} height={ringR * 2.4} viewBox={`${-ringR * 1.2} ${-ringR * 1.2} ${ringR * 2.4} ${ringR * 2.4}`} style={{ position: "absolute" }}>
|
||||
<circle cx={0} cy={0} r={ringR} fill="none" stroke={hexToRgba(textColor, 0.12)} strokeWidth={L.vmin(6)} />
|
||||
<circle cx={0} cy={0} r={ringR} fill="none" stroke={accentColor} strokeWidth={L.vmin(6)} strokeLinecap="round" strokeDasharray={`${circ * ringProgress} ${circ}`} transform="rotate(-90)" style={{ filter: `drop-shadow(0 0 ${L.vmin(8)}px ${accentColor})` }} />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<div style={{ transform: `scale(${isGo ? scaleIn : scaleIn * scaleOut})`, opacity: isGo ? 1 : fadeOut, fontWeight: 900, fontSize: isGo ? L.vmin(150) : L.vmin(260), lineHeight: 1, backgroundImage: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 0 ${L.vmin(30)}px ${hexToRgba(accentColor, 0.6)})` }}>
|
||||
{isGo ? goText : current}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
<div style={{ position: "absolute", bottom: L.vmin(140), left: 0, right: 0, textAlign: "center", opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78) }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const eventInviteSchema = z.object({
|
||||
kicker: z.string(),
|
||||
eventTitle: z.string(),
|
||||
date: z.string(),
|
||||
location: z.string(),
|
||||
cta: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof eventInviteSchema>;
|
||||
|
||||
export const EventInvite: React.FC<Props> = ({
|
||||
kicker,
|
||||
eventTitle,
|
||||
date,
|
||||
location,
|
||||
cta,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const L = useLayout();
|
||||
|
||||
const kick = useReveal(8, { from: 22 });
|
||||
const title = useReveal(22, { from: 44 });
|
||||
const meta = useReveal(44, { from: 26 });
|
||||
const ctaR = useReveal(64, { from: 22, damping: 12 });
|
||||
|
||||
// Elegant double border that draws in.
|
||||
const borderInset = interpolate(frame, [0, 30], [L.vmin(40), L.vmin(70)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
const borderOp = interpolate(frame, [0, 24], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={12} nebula />
|
||||
|
||||
{/* Ornamental frame */}
|
||||
<div style={{ position: "absolute", inset: borderInset, border: `${L.vmin(2)}px solid ${hexToRgba(accentColor, 0.5)}`, borderRadius: L.vmin(10), opacity: borderOp }} />
|
||||
<div style={{ position: "absolute", inset: borderInset + L.vmin(10), border: `${L.vmin(1)}px solid ${hexToRgba(secondaryColor, 0.35)}`, borderRadius: L.vmin(8), opacity: borderOp }} />
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(110) }}>
|
||||
<div style={{ opacity: kick.opacity, transform: `translateY(${kick.y}px)`, fontWeight: 600, fontSize: L.vmin(26), letterSpacing: L.vmin(8), color: accentColor, marginBottom: L.vmin(22) }}>
|
||||
{kicker}
|
||||
</div>
|
||||
<div style={{ opacity: title.opacity, transform: `translateY(${title.y}px)`, fontWeight: 900, fontSize: L.vmin(92), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(880), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
||||
{eventTitle}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(40), opacity: meta.opacity, transform: `translateY(${meta.y}px)`, display: "flex", gap: L.vmin(40), flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<Meta L={L} icon="📅" label={date} color={textColor} accent={accentColor} />
|
||||
<Meta L={L} icon="📍" label={location} color={textColor} accent={accentColor} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(52), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.55)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
|
||||
{cta}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const Meta: React.FC<{ L: ReturnType<typeof useLayout>; icon: string; label: string; color: string; accent: string }> = ({ L, icon, label, color, accent }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(12), padding: `${L.vmin(12)}px ${L.vmin(24)}px`, borderRadius: 999, background: hexToRgba(accent, 0.1), border: `${L.vmin(1.5)}px solid ${hexToRgba(accent, 0.3)}`, fontWeight: 600, fontSize: L.vmin(28), color }}>
|
||||
<span style={{ fontSize: L.vmin(30) }}>{icon}</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
|
||||
export const glitterRevealSchema = z.object({
|
||||
brandText: z.string(),
|
||||
tagline: z.string(),
|
||||
/** Optional logo image URL. When empty the FlatRender brand mark is used. */
|
||||
logoUrl: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof glitterRevealSchema>;
|
||||
|
||||
// ── Default FlatRender brand mark (used when the user hasn't uploaded a logo) ──
|
||||
const DefaultLogo: React.FC<{ size: number }> = ({ size }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#2563EB" />
|
||||
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="white" />
|
||||
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="white" />
|
||||
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="white" fillOpacity="0.75" />
|
||||
<path d="M30 29L35.5 32L30 35Z" fill="white" fillOpacity="0.9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Deterministic glitter field — each particle flies in from the edge, gathers at
|
||||
// the logo, then disperses into an ambient orbit (the classic glitter-dust reveal).
|
||||
const GLITTER = Array.from({ length: 150 }).map((_, i) => ({
|
||||
i,
|
||||
angleIn: rand(i) * Math.PI * 2,
|
||||
distIn: 520 + rand(i + 7) * 460,
|
||||
// gather target: a tight cluster over the logo
|
||||
tx: (rand(i + 11) - 0.5) * 360,
|
||||
ty: (rand(i + 19) - 0.5) * 240,
|
||||
// ambient orbit it settles into
|
||||
ambAngle: rand(i + 23) * Math.PI * 2,
|
||||
ambR: 230 + rand(i + 29) * 320,
|
||||
size: 1.6 + rand(i + 3) * 4.5,
|
||||
delay: (i % 18) * 0.9,
|
||||
speed: 0.4 + rand(i + 5) * 1.2,
|
||||
}));
|
||||
|
||||
const Glitter: React.FC<{ accent: string; secondary: string; gold: string }> = ({
|
||||
accent,
|
||||
secondary,
|
||||
gold,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<svg width={width} height={height} style={{ overflow: "visible" }}>
|
||||
{GLITTER.map((p) => {
|
||||
const conv = interpolate(frame, [p.delay, p.delay + 34], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const disp = interpolate(frame, [46, 86], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
});
|
||||
// start (far out) → gather cluster → ambient orbit
|
||||
const sx = cx + Math.cos(p.angleIn) * L.vmin(p.distIn);
|
||||
const sy = cy + Math.sin(p.angleIn) * L.vmin(p.distIn);
|
||||
const gx = cx + L.vmin(p.tx);
|
||||
const gy = cy + L.vmin(p.ty);
|
||||
const ax = cx + Math.cos(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
|
||||
const ay = cy + Math.sin(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
|
||||
const tgtX = gx + (ax - gx) * disp;
|
||||
const tgtY = gy + (ay - gy) * disp;
|
||||
const x = sx + (tgtX - sx) * conv;
|
||||
const y = sy + (tgtY - sy) * conv;
|
||||
|
||||
const twinkle = 0.3 + 0.7 * Math.abs(Math.sin((frame + p.i * 13) / (6 + (p.i % 5))));
|
||||
const appear = interpolate(frame, [p.delay, p.delay + 10], [0, 1], { extrapolateRight: "clamp" });
|
||||
const c = p.i % 4 === 0 ? gold : p.i % 3 === 0 ? secondary : accent;
|
||||
const r = L.vmin(p.size) * (0.7 + conv * 0.5);
|
||||
return (
|
||||
<circle
|
||||
key={p.i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r}
|
||||
fill={c}
|
||||
opacity={twinkle * appear}
|
||||
style={{ filter: `drop-shadow(0 0 ${r * 2.6}px ${c})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const GlitterReveal: React.FC<Props> = ({
|
||||
brandText,
|
||||
tagline,
|
||||
logoUrl,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const gold = "#fcd34d";
|
||||
|
||||
// Logo reveal (the glitter gathers ~frame 44, then the logo emerges).
|
||||
const logoSpring = spring({ frame: frame - 42, fps, config: { damping: 13, stiffness: 95, mass: 0.9 } });
|
||||
const logoScale = interpolate(logoSpring, [0, 1], [0.55, 1]);
|
||||
const logoOpacity = interpolate(frame, [42, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
// Bright convergence flash.
|
||||
const flash = interpolate(frame, [40, 47, 60], [0, 0.85, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
// Core glow that breathes behind the logo.
|
||||
const glow = interpolate(frame, [44, 70], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const breathe = 1 + 0.05 * Math.sin(frame / 16);
|
||||
|
||||
// Shine sweep across the logo at reveal.
|
||||
const sweepX = interpolate(frame, [58, 88], [-L.vmin(360), L.vmin(360)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
|
||||
const sweepOp = interpolate(frame, [58, 66, 82, 90], [0, 0.9, 0.9, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
// Text.
|
||||
const brandY = interpolate(frame, [70, 92], [L.vmin(70), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
const brandOpacity = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagOpacity = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagSpacing = interpolate(frame, [92, 120], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
const logoSize = L.vmin(240);
|
||||
const hasLogo = Boolean(logoUrl && logoUrl.trim().length > 0);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
|
||||
{/* Deep radial backdrop */}
|
||||
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 45%, ${hexToRgba(accentColor, 0.16)} 0%, ${hexToRgba(secondaryColor, 0.06)} 32%, ${backgroundColor} 66%)` }} />
|
||||
|
||||
{/* Core glow */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ width: logoSize * 2.2 * glow * breathe, height: logoSize * 2.2 * glow * breathe, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba(accentColor, 0.5)} 0%, ${hexToRgba(gold, 0.18)} 35%, transparent 70%)`, filter: `blur(${L.vmin(10)}px)` }} />
|
||||
</AbsoluteFill>
|
||||
|
||||
<Glitter accent={accentColor} secondary={secondaryColor} gold={gold} />
|
||||
|
||||
{/* Logo */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ transform: `scale(${logoScale})`, opacity: logoOpacity, filter: `drop-shadow(0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)})`, display: "flex", alignItems: "center", justifyContent: "center", width: logoSize, height: logoSize }}>
|
||||
{hasLogo ? (
|
||||
<Img src={logoUrl} style={{ maxWidth: logoSize, maxHeight: logoSize, objectFit: "contain" }} />
|
||||
) : (
|
||||
<DefaultLogo size={logoSize} />
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Convergence flash */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", pointerEvents: "none" }}>
|
||||
<div style={{ width: logoSize * 2.4, height: logoSize * 2.4, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba("#ffffff", flash)} 0%, ${hexToRgba(gold, flash * 0.6)} 25%, transparent 60%)`, mixBlendMode: "screen" }} />
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Shine sweep */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
|
||||
<div style={{ position: "absolute", width: L.vmin(140), height: logoSize * 1.4, transform: `translateX(${sweepX}px) rotate(18deg)`, background: `linear-gradient(90deg, transparent, ${hexToRgba(mixHex(textColor, gold, 0.4), 0.95)}, transparent)`, filter: `blur(${L.vmin(18)}px)`, opacity: sweepOp, mixBlendMode: "screen" }} />
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Brand text + tagline */}
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", flexDirection: "column", paddingBottom: L.vmin(130) }}>
|
||||
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOpacity, fontWeight: 900, fontSize: L.vmin(82), color: textColor, textAlign: "center", textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.7)}` }}>
|
||||
{brandText}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(18), opacity: tagOpacity, fontWeight: 500, fontSize: L.vmin(26), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.8), textAlign: "center" }}>
|
||||
{tagline}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
import { z } from "zod";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
|
||||
export const gradientPromoSchema = z.object({
|
||||
eyebrow: z.string(),
|
||||
headline: z.string(),
|
||||
subheadline: z.string(),
|
||||
ctaText: z.string(),
|
||||
badgeText: z.string(),
|
||||
accentColor: zColor(),
|
||||
secondaryColor: zColor(),
|
||||
backgroundColor: zColor(),
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof gradientPromoSchema>;
|
||||
|
||||
// ── Drifting mesh-gradient blobs ─────────────────────────────────────────────
|
||||
|
||||
const BLOBS = [
|
||||
{ baseX: 0.2, baseY: 0.3, r: 520, useAccent: true },
|
||||
{ baseX: 0.78, baseY: 0.28, r: 460, useAccent: false },
|
||||
{ baseX: 0.62, baseY: 0.8, r: 580, useAccent: true },
|
||||
{ baseX: 0.12, baseY: 0.82, r: 420, useAccent: false },
|
||||
];
|
||||
|
||||
const MeshBackground: React.FC<{
|
||||
bg: string;
|
||||
accent: string;
|
||||
secondary: string;
|
||||
}> = ({ bg, accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
|
||||
{BLOBS.map((b, i) => {
|
||||
const dx = Math.sin(frame / (50 + i * 12) + rand(i) * 6) * 70;
|
||||
const dy = Math.cos(frame / (60 + i * 9) + rand(i + 4) * 6) * 60;
|
||||
const color = b.useAccent ? accent : secondary;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: b.baseX * width - b.r / 2 + dx,
|
||||
top: b.baseY * height - b.r / 2 + dy,
|
||||
width: b.r,
|
||||
height: b.r,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${hexToRgba(
|
||||
color,
|
||||
0.5
|
||||
)} 0%, transparent 68%)`,
|
||||
filter: "blur(40px)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Subtle grain/vignette to ground the gradients */}
|
||||
<AbsoluteFill
|
||||
style={{ boxShadow: "inset 0 0 600px 180px rgba(0,0,0,0.55)" }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Spinning offer badge in the corner ───────────────────────────────────────
|
||||
|
||||
const Badge: React.FC<{ text: string; accent: string; secondary: string }> = ({
|
||||
text,
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({
|
||||
frame: frame - 26,
|
||||
fps,
|
||||
config: { damping: 11, mass: 0.6, stiffness: 140 },
|
||||
});
|
||||
const scale = interpolate(pop, [0, 1], [0, 1]);
|
||||
const wobble = Math.sin(frame / 16) * 6;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 90,
|
||||
right: 130,
|
||||
width: 190,
|
||||
height: 190,
|
||||
transform: `scale(${scale}) rotate(${wobble - 12}deg)`,
|
||||
borderRadius: "50%",
|
||||
background: `linear-gradient(135deg, ${accent}, ${secondary})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
boxShadow: `0 0 50px ${hexToRgba(accent, 0.6)}`,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 30,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: 1,
|
||||
color: "#fff",
|
||||
padding: 18,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GradientPromo: React.FC<Props> = ({
|
||||
eyebrow,
|
||||
headline,
|
||||
subheadline,
|
||||
ctaText,
|
||||
badgeText,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const reveal = (delay: number) =>
|
||||
spring({ frame: frame - delay, fps, config: { damping: 18, stiffness: 90 } });
|
||||
|
||||
const eyebrowOp = interpolate(reveal(6), [0, 1], [0, 1]);
|
||||
const eyebrowX = interpolate(reveal(6), [0, 1], [-40, 0]);
|
||||
|
||||
const headSpring = reveal(14);
|
||||
const headY = interpolate(headSpring, [0, 1], [60, 0]);
|
||||
const headOp = interpolate(headSpring, [0, 1], [0, 1]);
|
||||
|
||||
const subOp = interpolate(reveal(28), [0, 1], [0, 1]);
|
||||
const subY = interpolate(reveal(28), [0, 1], [30, 0]);
|
||||
|
||||
const ctaSpring = reveal(40);
|
||||
const ctaScale = interpolate(ctaSpring, [0, 1], [0.7, 1]);
|
||||
const ctaOp = interpolate(ctaSpring, [0, 1], [0, 1]);
|
||||
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 12);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<MeshBackground
|
||||
bg={backgroundColor}
|
||||
accent={accentColor}
|
||||
secondary={secondaryColor}
|
||||
/>
|
||||
<Badge text={badgeText} accent={accentColor} secondary={secondaryColor} />
|
||||
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
paddingLeft: 150,
|
||||
paddingRight: 150,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateX(${eyebrowX}px)`,
|
||||
opacity: eyebrowOp,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: 26,
|
||||
letterSpacing: 8,
|
||||
textTransform: "uppercase",
|
||||
color: mixHex(accentColor, secondaryColor, 0.5),
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${headY}px)`,
|
||||
opacity: headOp,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 110,
|
||||
lineHeight: 1.02,
|
||||
letterSpacing: -2,
|
||||
color: "#fff",
|
||||
maxWidth: 1100,
|
||||
textShadow: `0 6px 40px ${hexToRgba(accentColor, 0.4)}`,
|
||||
}}
|
||||
>
|
||||
{headline}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${subY}px)`,
|
||||
opacity: subOp,
|
||||
marginTop: 30,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 32,
|
||||
lineHeight: 1.4,
|
||||
color: hexToRgba("#ffffff", 0.75),
|
||||
maxWidth: 820,
|
||||
}}
|
||||
>
|
||||
{subheadline}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 56,
|
||||
transform: `scale(${ctaScale})`,
|
||||
transformOrigin: "left center",
|
||||
opacity: ctaOp,
|
||||
alignSelf: "flex-start",
|
||||
padding: "22px 56px",
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`,
|
||||
boxShadow: `0 0 ${30 + ctaGlow * 40}px ${hexToRgba(
|
||||
accentColor,
|
||||
ctaGlow
|
||||
)}`,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: 30,
|
||||
letterSpacing: 1,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{ctaText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba, rand } from "../lib/anim";
|
||||
|
||||
export const happyBirthdaySchema = z.object({
|
||||
greeting: z.string(),
|
||||
name: z.string(),
|
||||
message: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof happyBirthdaySchema>;
|
||||
|
||||
const CONFETTI = Array.from({ length: 60 });
|
||||
|
||||
export const HappyBirthday: React.FC<Props> = ({
|
||||
greeting,
|
||||
name,
|
||||
message,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const greet = useReveal(8, { from: 30 });
|
||||
const namePop = spring({ frame: frame - 26, fps, config: { damping: 10, stiffness: 120, mass: 0.8 } });
|
||||
const nameScale = interpolate(namePop, [0, 1], [0.3, 1]);
|
||||
const msg = useReveal(56, { from: 24 });
|
||||
const colors = [accentColor, secondaryColor, "#fde047", "#fb7185", "#34d399"];
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={0} />
|
||||
|
||||
{/* Confetti rain */}
|
||||
<AbsoluteFill>
|
||||
{CONFETTI.map((_, i) => {
|
||||
const startDelay = (i % 12) * 2;
|
||||
const t = Math.max(0, frame - startDelay);
|
||||
const x = rand(i) * width + Math.sin((frame + i * 20) / 18) * L.vmin(30);
|
||||
const y = ((rand(i + 5) * height) + t * (2 + rand(i) * 3) * L.unit) % (height + 40) - 20;
|
||||
const sz = L.vmin(8 + (i % 4) * 4);
|
||||
const rot = (frame + i * 30) * (i % 2 ? 4 : -4);
|
||||
return <div key={i} style={{ position: "absolute", left: x, top: y, width: sz, height: sz * 0.6, background: colors[i % colors.length], transform: `rotate(${rot}deg)`, opacity: 0.9, borderRadius: i % 3 === 0 ? "50%" : 2 }} />;
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(70) }}>
|
||||
<div style={{ fontSize: L.vmin(70), marginBottom: L.vmin(10) }}>🎂</div>
|
||||
<div style={{ opacity: greet.opacity, transform: `translateY(${greet.y}px)`, fontWeight: 700, fontSize: L.vmin(48), color: hexToRgba(textColor, 0.9), textAlign: "center" }}>
|
||||
{greeting}
|
||||
</div>
|
||||
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(14)}px 0`, fontWeight: 900, fontSize: L.vmin(120), lineHeight: 1.05, textAlign: "center", color: textColor, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(6)}px ${L.vmin(30)}px ${hexToRgba(accentColor, 0.5)})` }}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ opacity: msg.opacity, transform: `translateY(${msg.y}px)`, fontWeight: 500, fontSize: L.vmin(32), color: hexToRgba(textColor, 0.82), textAlign: "center", maxWidth: L.vmin(820) }}>
|
||||
{message}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import * as THREE from "three";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
|
||||
export const hero3DSchema = z.object({
|
||||
brandText: z.string(),
|
||||
tagline: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof hero3DSchema>;
|
||||
|
||||
const SHAPES = ["icosa", "octa", "dodeca", "box", "torus"] as const;
|
||||
|
||||
// One floating polyhedron, drifting + self-rotating (animated off the timeline,
|
||||
// not R3F's render loop, so renders stay deterministic).
|
||||
const FloatingShape: React.FC<{ i: number; accent: string; secondary: string }> = ({ i, accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const kind = SHAPES[i % SHAPES.length];
|
||||
const ang = rand(i) * Math.PI * 2;
|
||||
const radius = 2.6 + rand(i + 5) * 2.4;
|
||||
const depth = -1 - rand(i + 9) * 4;
|
||||
const x = Math.cos(ang + frame * 0.004 * (0.5 + rand(i) * 0.6)) * radius;
|
||||
const y = Math.sin(ang * 1.7 + frame * 0.006) * (1.4 + rand(i + 3) * 1.4);
|
||||
const s = 0.18 + rand(i + 7) * 0.35;
|
||||
const col = i % 2 === 0 ? accent : secondary;
|
||||
const appear = interpolate(frame, [8 + i * 2, 36 + i * 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<mesh position={[x, y, depth]} rotation={[frame * 0.02 * (1 + rand(i)), frame * 0.025, 0]} scale={s * appear}>
|
||||
{kind === "icosa" && <icosahedronGeometry args={[1, 0]} />}
|
||||
{kind === "octa" && <octahedronGeometry args={[1, 0]} />}
|
||||
{kind === "dodeca" && <dodecahedronGeometry args={[1, 0]} />}
|
||||
{kind === "box" && <boxGeometry args={[1.4, 1.4, 1.4]} />}
|
||||
{kind === "torus" && <torusGeometry args={[0.9, 0.32, 16, 32]} />}
|
||||
<meshStandardMaterial color={col} metalness={0.5} roughness={0.25} flatShading emissive={col} emissiveIntensity={0.12} />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
const Bokeh: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<group>
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const x = (rand(i) - 0.5) * 12;
|
||||
const y = (rand(i + 11) - 0.5) * 7;
|
||||
const z = -6 - rand(i + 4) * 5;
|
||||
const tw = 0.3 + 0.5 * Math.abs(Math.sin((frame + i * 20) / 25));
|
||||
const col = i % 3 === 0 ? secondary : accent;
|
||||
return (
|
||||
<mesh key={i} position={[x, y, z]} scale={0.25 + rand(i + 2) * 0.5}>
|
||||
<sphereGeometry args={[1, 12, 12]} />
|
||||
<meshBasicMaterial color={col} transparent opacity={tw * 0.5} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const pop = spring({ frame: frame - 6, fps, config: { damping: 12, stiffness: 80, mass: 1 } });
|
||||
const heroScale = interpolate(pop, [0, 1], [0, 1.35]);
|
||||
const heroSpin = frame * 0.02;
|
||||
|
||||
const heroColor = useMemo(() => mixHex(accent, secondary, 0.25), [accent, secondary]);
|
||||
|
||||
return (
|
||||
<group rotation={[0, Math.sin(frame / 90) * 0.25, 0]}>
|
||||
<ambientLight intensity={0.45} />
|
||||
<directionalLight position={[4, 6, 6]} intensity={2.2} color="#ffffff" />
|
||||
<pointLight position={[-5, -1, 4]} intensity={45} color={secondary} />
|
||||
<pointLight position={[5, 2, 2]} intensity={35} color={accent} />
|
||||
|
||||
<Bokeh accent={accent} secondary={secondary} />
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<FloatingShape key={i} i={i} accent={accent} secondary={secondary} />
|
||||
))}
|
||||
|
||||
{/* Hero faceted gem */}
|
||||
<mesh rotation={[heroSpin * 0.6, heroSpin, heroSpin * 0.2]} scale={heroScale}>
|
||||
<icosahedronGeometry args={[1, 0]} />
|
||||
<meshStandardMaterial
|
||||
color={heroColor}
|
||||
metalness={0.55}
|
||||
roughness={0.14}
|
||||
flatShading
|
||||
emissive={accent}
|
||||
emissiveIntensity={0.18}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Inner glow core */}
|
||||
<mesh scale={heroScale * 0.55}>
|
||||
<sphereGeometry args={[1, 24, 24]} />
|
||||
<meshBasicMaterial color={mixHex(accent, "#ffffff", 0.4)} transparent opacity={0.5} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export const Hero3D: React.FC<Props> = ({
|
||||
brandText,
|
||||
tagline,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const brandY = interpolate(frame, [70, 92], [L.vmin(60), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
const brandOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagOp = interpolate(frame, [92, 114], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagSpacing = interpolate(frame, [92, 122], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor }}>
|
||||
{/* gradient vignette behind the 3D */}
|
||||
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 42%, ${hexToRgba(accentColor, 0.18)} 0%, ${hexToRgba(secondaryColor, 0.05)} 35%, ${backgroundColor} 70%)` }} />
|
||||
|
||||
<ThreeCanvas
|
||||
width={width}
|
||||
height={height}
|
||||
camera={{ position: [0, 0, 7], fov: 55 }}
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
|
||||
>
|
||||
<Scene accent={accentColor} secondary={secondaryColor} />
|
||||
</ThreeCanvas>
|
||||
|
||||
{/* 2D text overlay (crisp Persian via Vazirmatn) */}
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-end", paddingBottom: L.vmin(170) }}>
|
||||
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOp, fontWeight: 900, fontSize: L.vmin(92), color: textColor, textShadow: `0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)}` }}>
|
||||
{brandText}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(18), opacity: tagOp, fontWeight: 500, fontSize: L.vmin(28), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.82) }}>
|
||||
{tagline}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
import { z } from "zod";
|
||||
|
||||
export const illuminatedCirclesSchema = z.object({
|
||||
logoText: z.string(),
|
||||
tagline: z.string(),
|
||||
accentColor: zColor(),
|
||||
secondaryColor: zColor(),
|
||||
backgroundColor: zColor(),
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof illuminatedCirclesSchema>;
|
||||
|
||||
// ── Small helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mix two hex colors by t (0..1). Cheap linear blend, good enough for glows. */
|
||||
function mixHex(a: string, b: string, t: number): string {
|
||||
const pa = a.replace("#", "");
|
||||
const pb = b.replace("#", "");
|
||||
const ai = parseInt(pa, 16);
|
||||
const bi = parseInt(pb, 16);
|
||||
const ar = (ai >> 16) & 255;
|
||||
const ag = (ai >> 8) & 255;
|
||||
const ab = ai & 255;
|
||||
const br = (bi >> 16) & 255;
|
||||
const bg = (bi >> 8) & 255;
|
||||
const bb = bi & 255;
|
||||
const r = Math.round(ar + (br - ar) * t);
|
||||
const g = Math.round(ag + (bg - ag) * t);
|
||||
const bl = Math.round(ab + (bb - ab) * t);
|
||||
return `rgb(${r}, ${g}, ${bl})`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const p = hex.replace("#", "");
|
||||
const i = parseInt(p, 16);
|
||||
const r = (i >> 16) & 255;
|
||||
const g = (i >> 8) & 255;
|
||||
const b = i & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
// ── Background: deep radial gradient + drifting nebula + vignette ─────────────
|
||||
|
||||
const Background: React.FC<{ bg: string; accent: string; secondary: string }> = ({
|
||||
bg,
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const drift = Math.sin(frame / 60) * 40;
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: bg }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(
|
||||
accent,
|
||||
0.18
|
||||
)} 0%, ${hexToRgba(secondary, 0.06)} 28%, ${bg} 62%)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${50 + drift / 20}% 70%, ${hexToRgba(
|
||||
secondary,
|
||||
0.1
|
||||
)} 0%, transparent 45%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Vignette */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
boxShadow: "inset 0 0 600px 200px rgba(0,0,0,0.85)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Concentric illuminated rings ─────────────────────────────────────────────
|
||||
|
||||
const RING_DEFS = [
|
||||
{ r: 150, speed: 0.5, dash: "2 14", width: 2, op: 0.9 },
|
||||
{ r: 230, speed: -0.32, dash: "1 22", width: 1.5, op: 0.7 },
|
||||
{ r: 320, speed: 0.22, dash: "3 28", width: 2.5, op: 0.85 },
|
||||
{ r: 420, speed: -0.16, dash: "1 40", width: 1.5, op: 0.55 },
|
||||
{ r: 520, speed: 0.12, dash: "2 60", width: 1.5, op: 0.4 },
|
||||
];
|
||||
|
||||
const Rings: React.FC<{ accent: string; secondary: string }> = ({
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const entrance = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 14, mass: 0.9, stiffness: 90 },
|
||||
});
|
||||
const scale = interpolate(entrance, [0, 1], [0.55, 1]);
|
||||
const groupOpacity = interpolate(frame, [0, 28], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: groupOpacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={1200}
|
||||
height={1200}
|
||||
viewBox="-600 -600 1200 1200"
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor={accent} />
|
||||
<stop offset="55%" stopColor={mixHex(accent, secondary, 0.5)} />
|
||||
<stop offset="100%" stopColor={secondary} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{RING_DEFS.map((ring, i) => {
|
||||
const rot = frame * ring.speed;
|
||||
// Each ring reveals its dash over the first ~30 frames.
|
||||
const circ = 2 * Math.PI * ring.r;
|
||||
const draw = interpolate(frame, [4 + i * 4, 34 + i * 4], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
return (
|
||||
<g key={i} transform={`rotate(${rot})`}>
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={ring.r}
|
||||
fill="none"
|
||||
stroke="url(#ringGrad)"
|
||||
strokeWidth={ring.width}
|
||||
strokeDasharray={`${circ * draw} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
opacity={ring.op}
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 6px ${hexToRgba(accent, 0.9)})`,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Orbiting illuminated particles ───────────────────────────────────────────
|
||||
|
||||
const PARTICLES = Array.from({ length: 28 }).map((_, i) => {
|
||||
// Deterministic pseudo-random placement (no Math.random — keeps renders stable).
|
||||
const a = (i * 137.508 * Math.PI) / 180; // golden angle
|
||||
const ringRadius = 150 + ((i * 53) % 380);
|
||||
const size = 2 + ((i * 17) % 5);
|
||||
const speed = 0.15 + ((i % 5) * 0.06) * (i % 2 === 0 ? 1 : -1);
|
||||
const phase = (i * 41) % 360;
|
||||
return { a, ringRadius, size, speed, phase, idx: i };
|
||||
});
|
||||
|
||||
const Particles: React.FC<{ accent: string; secondary: string }> = ({
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const appear = interpolate(frame, [18, 50], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ justifyContent: "center", alignItems: "center", opacity: appear }}
|
||||
>
|
||||
<svg width={1400} height={1400} viewBox="-700 -700 1400 1400">
|
||||
{PARTICLES.map((p) => {
|
||||
const ang = p.a + (frame * p.speed * Math.PI) / 180;
|
||||
const x = Math.cos(ang) * p.ringRadius;
|
||||
const y = Math.sin(ang) * p.ringRadius;
|
||||
const twinkle =
|
||||
0.4 + 0.6 * Math.abs(Math.sin((frame + p.phase) / 9));
|
||||
const color = p.idx % 3 === 0 ? secondary : accent;
|
||||
return (
|
||||
<circle
|
||||
key={p.idx}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={p.size}
|
||||
fill={color}
|
||||
opacity={twinkle}
|
||||
style={{ filter: `drop-shadow(0 0 ${p.size * 2.5}px ${color})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Central core glow that pulses behind the logo ────────────────────────────
|
||||
|
||||
const CoreGlow: React.FC<{ accent: string; secondary: string }> = ({
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const grow = interpolate(frame, [30, 70], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const breathe = 1 + 0.06 * Math.sin(frame / 14);
|
||||
const size = 460 * grow * breathe;
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${hexToRgba(
|
||||
accent,
|
||||
0.55
|
||||
)} 0%, ${hexToRgba(secondary, 0.25)} 35%, transparent 70%)`,
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Sweeping light flare across the logo at reveal ───────────────────────────
|
||||
|
||||
const LightSweep: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const x = interpolate(frame, [62, 92], [-900, 900], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
});
|
||||
const op = interpolate(frame, [62, 70, 88, 96], [0, 0.85, 0.85, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 220,
|
||||
height: 420,
|
||||
transform: `translateX(${x}px) rotate(18deg)`,
|
||||
background:
|
||||
"linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent)",
|
||||
filter: "blur(26px)",
|
||||
opacity: op,
|
||||
mixBlendMode: "screen",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Logo + tagline reveal ────────────────────────────────────────────────────
|
||||
|
||||
const LogoReveal: React.FC<{ logoText: string; tagline: string; accent: string }> = ({
|
||||
logoText,
|
||||
tagline,
|
||||
accent,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const logoSpring = spring({
|
||||
frame: frame - 55,
|
||||
fps,
|
||||
config: { damping: 16, mass: 1, stiffness: 80 },
|
||||
});
|
||||
const logoScale = interpolate(logoSpring, [0, 1], [1.25, 1]);
|
||||
const logoOpacity = interpolate(frame, [55, 78], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const blur = interpolate(frame, [55, 84], [26, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
|
||||
const tagOpacity = interpolate(frame, [92, 116], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const tagSpacing = interpolate(frame, [92, 130], [22, 10], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${logoScale})`,
|
||||
opacity: logoOpacity,
|
||||
filter: `blur(${blur}px)`,
|
||||
fontFamily:
|
||||
"'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 116,
|
||||
letterSpacing: 8,
|
||||
color: "#ffffff",
|
||||
textShadow: `0 0 18px ${hexToRgba(accent, 0.9)}, 0 0 48px ${hexToRgba(
|
||||
accent,
|
||||
0.6
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
{logoText}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 26,
|
||||
opacity: tagOpacity,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 26,
|
||||
letterSpacing: tagSpacing,
|
||||
color: hexToRgba("#ffffff", 0.82),
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Composition root ─────────────────────────────────────────────────────────
|
||||
|
||||
export const IlluminatedCircles: React.FC<Props> = ({
|
||||
logoText,
|
||||
tagline,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
}) => {
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background
|
||||
bg={backgroundColor}
|
||||
accent={accentColor}
|
||||
secondary={secondaryColor}
|
||||
/>
|
||||
<CoreGlow accent={accentColor} secondary={secondaryColor} />
|
||||
<Rings accent={accentColor} secondary={secondaryColor} />
|
||||
<Particles accent={accentColor} secondary={secondaryColor} />
|
||||
<LogoReveal logoText={logoText} tagline={tagline} accent={accentColor} />
|
||||
<LightSweep />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const instaPromoSchema = z.object({
|
||||
handle: z.string(),
|
||||
headline: z.string(),
|
||||
subtext: z.string(),
|
||||
cta: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof instaPromoSchema>;
|
||||
|
||||
export const InstaPromo: React.FC<Props> = ({
|
||||
handle,
|
||||
headline,
|
||||
subtext,
|
||||
cta,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const L = useLayout();
|
||||
const card = useReveal(8, { from: 60, damping: 14 });
|
||||
const head = useReveal(26, { from: 36 });
|
||||
const ctaR = useReveal(52, { from: 24, damping: 12 });
|
||||
const heart = interpolate(frame % 60, [0, 15, 30], [1, 1.25, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
||||
{/* Profile chip */}
|
||||
<div style={{ opacity: card.opacity, transform: `scale(${card.scale})`, display: "flex", alignItems: "center", gap: L.vmin(16), padding: `${L.vmin(14)}px ${L.vmin(26)}px`, borderRadius: 999, background: hexToRgba(textColor, 0.06), border: `${L.vmin(1.5)}px solid ${hexToRgba(textColor, 0.15)}` }}>
|
||||
<div style={{ width: L.vmin(56), height: L.vmin(56), borderRadius: "50%", background: `conic-gradient(from ${frame * 2}deg, ${accentColor}, ${secondaryColor}, ${accentColor})`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: L.vmin(44), height: L.vmin(44), borderRadius: "50%", background: backgroundColor, display: "flex", alignItems: "center", justifyContent: "center", fontSize: L.vmin(24) }}>📸</div>
|
||||
</div>
|
||||
<span style={{ fontWeight: 800, fontSize: L.vmin(30), color: textColor, direction: "ltr" }}>{handle}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(48), opacity: head.opacity, transform: `translateY(${head.y}px)`, fontWeight: 900, fontSize: L.vmin(78), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(820), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
||||
{headline}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(22), opacity: head.opacity, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center", maxWidth: L.vmin(720) }}>
|
||||
{subtext}
|
||||
</div>
|
||||
|
||||
{/* Floating reactions */}
|
||||
<div style={{ position: "absolute", top: `calc(50% - ${L.vmin(220)}px)`, right: `calc(50% - ${L.vmin(360)}px)`, fontSize: L.vmin(48), transform: `scale(${heart})` }}>❤️</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(54), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.6)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
|
||||
{cta}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
import { z } from "zod";
|
||||
import { hexToRgba, mixHex } from "../lib/anim";
|
||||
|
||||
export const kineticQuoteSchema = z.object({
|
||||
quote: z.string(),
|
||||
author: z.string(),
|
||||
accentColor: zColor(),
|
||||
secondaryColor: zColor(),
|
||||
backgroundColor: zColor(),
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof kineticQuoteSchema>;
|
||||
|
||||
// ── Slowly rotating gradient sheen behind the text ───────────────────────────
|
||||
|
||||
const SheenBackground: React.FC<{
|
||||
bg: string;
|
||||
accent: string;
|
||||
secondary: string;
|
||||
}> = ({ bg, accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const angle = (frame * 0.4) % 360;
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: bg }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(${angle}deg, ${hexToRgba(
|
||||
accent,
|
||||
0.16
|
||||
)}, transparent 55%, ${hexToRgba(secondary, 0.14)})`,
|
||||
}}
|
||||
/>
|
||||
{/* Soft top glow */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 18%, ${hexToRgba(
|
||||
accent,
|
||||
0.22
|
||||
)} 0%, transparent 50%)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{ boxShadow: "inset 0 0 500px 160px rgba(0,0,0,0.7)" }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Word-by-word reveal of the quote ─────────────────────────────────────────
|
||||
|
||||
const Quote: React.FC<{ quote: string; accent: string; secondary: string }> = ({
|
||||
quote,
|
||||
accent,
|
||||
secondary,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const words = quote.split(/\s+/).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
maxWidth: 880,
|
||||
gap: "0 18px",
|
||||
fontFamily: "'Georgia', 'Times New Roman', serif",
|
||||
fontWeight: 600,
|
||||
fontSize: 64,
|
||||
lineHeight: 1.28,
|
||||
color: "#fff",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{words.map((w, i) => {
|
||||
const start = 12 + i * 4;
|
||||
const s = spring({
|
||||
frame: frame - start,
|
||||
fps,
|
||||
config: { damping: 18, mass: 0.7, stiffness: 110 },
|
||||
});
|
||||
const y = interpolate(s, [0, 1], [28, 0]);
|
||||
const op = interpolate(s, [0, 1], [0, 1]);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
transform: `translateY(${y}px)`,
|
||||
opacity: op,
|
||||
color: i % 5 === 2 ? mixHex(accent, secondary, 0.4) : "#fff",
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const KineticQuote: React.FC<Props> = ({
|
||||
quote,
|
||||
author,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const words = quote.split(/\s+/).filter(Boolean);
|
||||
|
||||
// The decorative rule + author appear once the quote has finished landing.
|
||||
const tail = 12 + words.length * 4 + 8;
|
||||
const ruleW = interpolate(frame, [tail, tail + 18], [0, 120], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const authorOp = interpolate(frame, [tail + 10, tail + 30], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<SheenBackground
|
||||
bg={backgroundColor}
|
||||
accent={accentColor}
|
||||
secondary={secondaryColor}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
{/* Opening quotation mark */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Georgia', serif",
|
||||
fontSize: 160,
|
||||
lineHeight: 0.4,
|
||||
marginBottom: 36,
|
||||
color: hexToRgba(accentColor, 0.85),
|
||||
opacity: interpolate(frame, [0, 14], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
“
|
||||
</div>
|
||||
|
||||
<Quote quote={quote} accent={accentColor} secondary={secondaryColor} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: ruleW,
|
||||
height: 3,
|
||||
marginTop: 48,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 22,
|
||||
opacity: authorOp,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 28,
|
||||
letterSpacing: 4,
|
||||
textTransform: "uppercase",
|
||||
color: hexToRgba("#ffffff", 0.78),
|
||||
}}
|
||||
>
|
||||
{author}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
|
||||
export const logoMotionSchema = z.object({
|
||||
brandText: z.string(),
|
||||
tagline: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof logoMotionSchema>;
|
||||
|
||||
export const LogoMotion: React.FC<Props> = ({
|
||||
brandText,
|
||||
tagline,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
// Background: radial brand glow + drifting nebula.
|
||||
const drift = Math.sin(frame / 50) * 30;
|
||||
|
||||
// Logo entrance.
|
||||
const logoSpring = spring({ frame, fps, config: { damping: 14, stiffness: 90, mass: 0.9 } });
|
||||
const ringScale = interpolate(logoSpring, [0, 1], [0.4, 1]);
|
||||
const ringOpacity = interpolate(frame, [0, 22], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
const wordSpring = spring({ frame: frame - 22, fps, config: { damping: 16, stiffness: 80 } });
|
||||
const wordScale = interpolate(wordSpring, [0, 1], [1.18, 1]);
|
||||
const wordOpacity = interpolate(frame, [22, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const wordBlur = interpolate(frame, [22, 46], [16, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
|
||||
const tagOpacity = interpolate(frame, [50, 72], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const tagSpacing = interpolate(frame, [50, 80], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
|
||||
// Light sweep across the wordmark at reveal.
|
||||
const sweepX = interpolate(frame, [44, 74], [-L.vmin(700), L.vmin(700)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
|
||||
const sweepOp = interpolate(frame, [44, 52, 70, 78], [0, 0.8, 0.8, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
const ringR = L.vmin(190);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
|
||||
{/* Brand glow */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 30%, ${backgroundColor} 64%)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${50 + drift / 18}% 72%, ${hexToRgba(secondaryColor, 0.12)} 0%, transparent 45%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Orbiting sparks */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<svg width={width} height={height} style={{ overflow: "visible" }}>
|
||||
{Array.from({ length: 26 }).map((_, i) => {
|
||||
const ang = (i * 137.5 * Math.PI) / 180 + (frame * (0.1 + (i % 4) * 0.04) * Math.PI) / 180;
|
||||
const rr = L.vmin(150) + ((i * 47) % L.vmin(360));
|
||||
const cx = width / 2 + Math.cos(ang) * rr;
|
||||
const cy = height / 2 + Math.sin(ang) * rr;
|
||||
const tw = 0.3 + 0.6 * Math.abs(Math.sin((frame + i * 18) / 10));
|
||||
const appear = interpolate(frame, [16, 44], [0, 1], { extrapolateRight: "clamp" });
|
||||
const c = i % 3 === 0 ? secondaryColor : accentColor;
|
||||
const s = L.vmin(2 + (i % 4));
|
||||
return <circle key={i} cx={cx} cy={cy} r={s} fill={c} opacity={tw * appear} style={{ filter: `drop-shadow(0 0 ${s * 2.5}px ${c})` }} />;
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Concentric brand ring */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", opacity: ringOpacity, transform: `scale(${ringScale})` }}>
|
||||
<svg width={ringR * 3} height={ringR * 3} viewBox={`${-ringR * 1.5} ${-ringR * 1.5} ${ringR * 3} ${ringR * 3}`} style={{ overflow: "visible" }}>
|
||||
<defs>
|
||||
<linearGradient id="lm-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor={accentColor} />
|
||||
<stop offset="100%" stopColor={secondaryColor} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[ringR, ringR * 0.74].map((r, i) => {
|
||||
const circ = 2 * Math.PI * r;
|
||||
const draw = interpolate(frame, [4 + i * 5, 30 + i * 5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
return (
|
||||
<circle key={i} cx={0} cy={0} r={r} fill="none" stroke="url(#lm-grad)" strokeWidth={L.vmin(2.5 - i)} strokeDasharray={`${circ * draw} ${circ}`} strokeLinecap="round" transform={`rotate(${frame * (i ? -0.4 : 0.3)})`} style={{ filter: `drop-shadow(0 0 ${L.vmin(6)}px ${hexToRgba(accentColor, 0.8)})` }} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Wordmark + tagline */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${wordScale})`,
|
||||
opacity: wordOpacity,
|
||||
filter: `blur(${wordBlur}px)`,
|
||||
fontWeight: 900,
|
||||
fontSize: L.vmin(108),
|
||||
color: textColor,
|
||||
textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.9)}, 0 0 ${L.vmin(42)}px ${hexToRgba(accentColor, 0.55)}`,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{brandText}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: L.vmin(22),
|
||||
opacity: tagOpacity,
|
||||
fontWeight: 500,
|
||||
fontSize: L.vmin(28),
|
||||
letterSpacing: tagSpacing,
|
||||
color: hexToRgba(textColor, 0.82),
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Light sweep */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: L.vmin(180),
|
||||
height: L.vmin(420),
|
||||
transform: `translateX(${sweepX}px) rotate(18deg)`,
|
||||
background: `linear-gradient(90deg, transparent, ${mixHex(textColor, accentColor, 0.2)}, transparent)`,
|
||||
filter: `blur(${L.vmin(24)}px)`,
|
||||
opacity: sweepOp,
|
||||
mixBlendMode: "screen",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { Environment, Lightformer, MeshReflectorMaterial, RoundedBox } from "@react-three/drei";
|
||||
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
|
||||
import * as THREE from "three";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, rand } from "../lib/anim";
|
||||
|
||||
export const nowruz3DSchema = z.object({
|
||||
greeting: z.string(),
|
||||
subtitle: z.string(),
|
||||
message: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof nowruz3DSchema>;
|
||||
|
||||
const GOLD = "#f5c542";
|
||||
const RED = "#e23b3b";
|
||||
const SKIN = "#f0b486";
|
||||
const GREEN = "#4fb84f";
|
||||
|
||||
// ── Stylized 3D Haji Firuz (primitive-built, clay-render look) ────────────────
|
||||
const HajiFiruz3D: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = spring({ frame: frame - 20, fps, config: { damping: 14, stiffness: 60 } });
|
||||
const bob = Math.abs(Math.sin(frame / 7)) * 0.12 * enter;
|
||||
const sway = Math.sin(frame / 7) * 0.08 * enter;
|
||||
const armSwing = Math.sin(frame / 3.2) * 0.5;
|
||||
const y = -0.5 + bob;
|
||||
|
||||
return (
|
||||
<group position={[0, y, 0]} rotation={[0, sway, 0]} scale={enter}>
|
||||
{/* tunic (tapered) */}
|
||||
<mesh position={[0, 0.55, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.32, 0.6, 1.1, 32]} />
|
||||
<meshStandardMaterial color={RED} roughness={0.55} metalness={0.05} />
|
||||
</mesh>
|
||||
{/* gold hem + sash */}
|
||||
<mesh position={[0, 0.05, 0]}>
|
||||
<cylinderGeometry args={[0.6, 0.62, 0.12, 32]} />
|
||||
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.55, 0.0]}>
|
||||
<torusGeometry args={[0.42, 0.05, 12, 32]} />
|
||||
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
|
||||
</mesh>
|
||||
{/* buttons */}
|
||||
{[0.75, 0.6, 0.45].map((by, i) => (
|
||||
<mesh key={i} position={[0, by, 0.46 - i * 0.02]}>
|
||||
<sphereGeometry args={[0.04, 16, 16]} />
|
||||
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* head */}
|
||||
<mesh position={[0, 1.32, 0]} castShadow>
|
||||
<sphereGeometry args={[0.33, 32, 32]} />
|
||||
<meshStandardMaterial color={SKIN} roughness={0.6} />
|
||||
</mesh>
|
||||
{/* eyes */}
|
||||
{[-0.12, 0.12].map((ex, i) => (
|
||||
<mesh key={i} position={[ex, 1.36, 0.3]}>
|
||||
<sphereGeometry args={[0.045, 16, 16]} />
|
||||
<meshStandardMaterial color="#2a2030" roughness={0.4} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* smile */}
|
||||
<mesh position={[0, 1.24, 0.3]} rotation={[0, 0, 0]}>
|
||||
<torusGeometry args={[0.1, 0.018, 12, 24, Math.PI]} />
|
||||
<meshStandardMaterial color="#7a3a30" roughness={0.5} />
|
||||
</mesh>
|
||||
{/* hat (cone) + band + tip */}
|
||||
<mesh position={[0, 1.95, 0]} castShadow>
|
||||
<coneGeometry args={[0.34, 0.8, 32]} />
|
||||
<meshStandardMaterial color={RED} roughness={0.5} metalness={0.05} />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.62, 0]}>
|
||||
<cylinderGeometry args={[0.35, 0.35, 0.1, 32]} />
|
||||
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.36, 0]}>
|
||||
<sphereGeometry args={[0.07, 16, 16]} />
|
||||
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
|
||||
</mesh>
|
||||
{/* right arm (down, swings) */}
|
||||
<group position={[0.45, 0.95, 0]} rotation={[0, 0, -0.5 + armSwing * 0.3]}>
|
||||
<mesh position={[0, -0.3, 0]} castShadow>
|
||||
<capsuleGeometry args={[0.1, 0.5, 8, 16]} />
|
||||
<meshStandardMaterial color={RED} roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.62, 0]}>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshStandardMaterial color={SKIN} roughness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
{/* left arm raised with tambourine */}
|
||||
<group position={[-0.45, 1.0, 0.05]} rotation={[0, 0, 0.9 + armSwing * 0.4]}>
|
||||
<mesh position={[0, 0.28, 0]} castShadow>
|
||||
<capsuleGeometry args={[0.1, 0.5, 8, 16]} />
|
||||
<meshStandardMaterial color={RED} roughness={0.55} />
|
||||
</mesh>
|
||||
<group position={[0, 0.6, 0]} rotation={[Math.PI / 2, 0, armSwing]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.26, 0.05, 16, 32]} />
|
||||
<meshStandardMaterial color={GOLD} metalness={0.85} roughness={0.25} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<circleGeometry args={[0.24, 32]} />
|
||||
<meshStandardMaterial color="#fff3d6" roughness={0.4} side={THREE.DoubleSide} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<mesh key={i} position={[Math.cos((i / 8) * Math.PI * 2) * 0.26, Math.sin((i / 8) * Math.PI * 2) * 0.26, 0]}>
|
||||
<sphereGeometry args={[0.035, 12, 12]} />
|
||||
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
{/* legs */}
|
||||
{[-0.16, 0.16].map((lx, i) => (
|
||||
<mesh key={i} position={[lx, -0.2, 0]} castShadow>
|
||||
<capsuleGeometry args={[0.1, 0.3, 8, 16]} />
|
||||
<meshStandardMaterial color="#2a2f45" roughness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Haft-Sin props ───────────────────────────────────────────────────────────
|
||||
const Candle: React.FC<{ x: number; z: number }> = ({ x, z }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const flick = 1 + Math.sin(frame / 4) * 0.12;
|
||||
return (
|
||||
<group position={[x, -0.5, z]}>
|
||||
<mesh castShadow>
|
||||
<cylinderGeometry args={[0.09, 0.1, 0.5, 24]} />
|
||||
<meshStandardMaterial color="#fbf0d8" roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.34, 0]} scale={[1, flick, 1]}>
|
||||
<coneGeometry args={[0.05, 0.18, 16]} />
|
||||
<meshStandardMaterial color="#ffd27a" emissive="#ffae3b" emissiveIntensity={4} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0.4, 0]} intensity={2.2 * flick} color="#ffb14d" distance={3} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Egg: React.FC<{ x: number; z: number; color: string }> = ({ x, z, color }) => (
|
||||
<mesh position={[x, -0.42, z]} scale={[1, 1.3, 1]} castShadow>
|
||||
<sphereGeometry args={[0.14, 24, 24]} />
|
||||
<meshStandardMaterial color={color} roughness={0.35} metalness={0.1} />
|
||||
</mesh>
|
||||
);
|
||||
|
||||
const FishBowl3D: React.FC<{ x: number; z: number }> = ({ x, z }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const fishX = Math.sin(frame / 20) * 0.12;
|
||||
return (
|
||||
<group position={[x, -0.25, z]}>
|
||||
{/* water */}
|
||||
<mesh position={[0, -0.02, 0]}>
|
||||
<sphereGeometry args={[0.27, 32, 32]} />
|
||||
<meshStandardMaterial color="#5bc8f0" roughness={0.1} metalness={0.2} transparent opacity={0.55} />
|
||||
</mesh>
|
||||
{/* fish */}
|
||||
<mesh position={[fishX, -0.02, 0]} scale={[0.13, 0.08, 0.05]}>
|
||||
<sphereGeometry args={[1, 16, 16]} />
|
||||
<meshStandardMaterial color="#ff5a2c" roughness={0.4} emissive="#ff4a1a" emissiveIntensity={0.2} />
|
||||
</mesh>
|
||||
{/* glass */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.32, 32, 32]} />
|
||||
<meshPhysicalMaterial color="#ffffff" roughness={0.05} metalness={0} transmission={0.0} transparent opacity={0.18} ior={1.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Sabzeh3D: React.FC<{ x: number; z: number }> = ({ x, z }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<group position={[x, -0.5, z]}>
|
||||
<mesh position={[0, 0.05, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.26, 0.22, 0.18, 24]} />
|
||||
<meshStandardMaterial color="#caa06a" roughness={0.7} />
|
||||
</mesh>
|
||||
{Array.from({ length: 30 }).map((_, i) => {
|
||||
const a = rand(i) * Math.PI * 2;
|
||||
const r = rand(i + 5) * 0.22;
|
||||
const sway = Math.sin(frame / 18 + i) * 0.08;
|
||||
return (
|
||||
<mesh key={i} position={[Math.cos(a) * r, 0.28, Math.sin(a) * r]} rotation={[sway, 0, sway]}>
|
||||
<coneGeometry args={[0.012, 0.3 + rand(i + 2) * 0.2, 5]} />
|
||||
<meshStandardMaterial color={i % 2 ? GREEN : "#3da53d"} roughness={0.7} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Petals3D: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<group>
|
||||
{Array.from({ length: 30 }).map((_, i) => {
|
||||
const x = (rand(i) - 0.5) * 9;
|
||||
const z = (rand(i + 3) - 0.5) * 4 - 1;
|
||||
const fall = 4 - ((frame * (0.01 + rand(i) * 0.02) + rand(i + 7) * 6) % 7);
|
||||
const rot = frame * 0.03 * (1 + rand(i));
|
||||
return (
|
||||
<mesh key={i} position={[x + Math.sin(frame / 30 + i) * 0.4, fall, z]} rotation={[rot, rot * 0.7, rot * 0.3]}>
|
||||
<circleGeometry args={[0.06 + rand(i + 1) * 0.05, 8]} />
|
||||
<meshStandardMaterial color={["#ffd1e8", "#ffc1dd", "#fff0f6"][i % 3]} side={THREE.DoubleSide} roughness={0.6} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Scene ────────────────────────────────────────────────────────────────────
|
||||
const Scene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const orbit = Math.sin(frame / 110) * 0.22;
|
||||
return (
|
||||
<group rotation={[0, orbit, 0]}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[4, 8, 4]} intensity={2.4} color="#fff3e0" castShadow shadow-mapSize={[1024, 1024]} />
|
||||
<pointLight position={[-4, 2, 3]} intensity={20} color="#ff8a5c" />
|
||||
<pointLight position={[4, 1, -2]} intensity={16} color={GOLD} />
|
||||
|
||||
<Environment resolution={256}>
|
||||
<Lightformer intensity={2} position={[0, 4, -3]} scale={[10, 5, 1]} color="#fff0d8" />
|
||||
<Lightformer intensity={1.2} position={[-4, 2, 2]} scale={[4, 6, 1]} color="#ffb98a" />
|
||||
<Lightformer intensity={1.2} position={[4, 2, 2]} scale={[4, 6, 1]} color="#9ad8e8" />
|
||||
</Environment>
|
||||
|
||||
{/* reflective floor */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.62, 0]} receiveShadow>
|
||||
<planeGeometry args={[40, 40]} />
|
||||
<MeshReflectorMaterial
|
||||
blur={[300, 80]}
|
||||
resolution={1024}
|
||||
mixBlur={1}
|
||||
mixStrength={45}
|
||||
roughness={0.85}
|
||||
depthScale={1}
|
||||
minDepthThreshold={0.4}
|
||||
maxDepthThreshold={1.2}
|
||||
color="#2a2236"
|
||||
metalness={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<HajiFiruz3D />
|
||||
<FishBowl3D x={1.5} z={0.3} />
|
||||
<Sabzeh3D x={-1.5} z={0.2} />
|
||||
<Candle x={0.9} z={0.9} />
|
||||
<Candle x={-0.9} z={0.9} />
|
||||
<Egg x={0.5} z={1.1} color={RED} />
|
||||
<Egg x={-0.4} z={1.15} color="#5bc8f0" />
|
||||
<Egg x={1.0} z={-0.4} color={GOLD} />
|
||||
<Petals3D />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export const Nowruz3D: React.FC<Props> = ({
|
||||
greeting,
|
||||
subtitle,
|
||||
message,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const gSpring = spring({ frame: frame - 120, fps: 30, config: { damping: 13, stiffness: 90 } });
|
||||
const gScale = interpolate(gSpring, [0, 1], [0.6, 1]);
|
||||
const gOp = interpolate(frame, [120, 140], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const subOp = interpolate(frame, [142, 162], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const msgOp = interpolate(frame, [156, 176], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor }}>
|
||||
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 35%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 38%, ${backgroundColor} 72%)` }} />
|
||||
|
||||
<ThreeCanvas
|
||||
width={width}
|
||||
height={height}
|
||||
camera={{ position: [0, 2.0, 5.0], fov: 50 }}
|
||||
shadows
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
|
||||
>
|
||||
<Scene />
|
||||
<EffectComposer>
|
||||
<Bloom intensity={0.75} luminanceThreshold={0.62} luminanceSmoothing={0.3} mipmapBlur />
|
||||
<DepthOfField focusDistance={0.013} focalLength={0.045} bokehScale={3} />
|
||||
<Vignette eskil={false} offset={0.32} darkness={0.55} />
|
||||
</EffectComposer>
|
||||
</ThreeCanvas>
|
||||
|
||||
{/* Greeting overlay */}
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.05 }}>
|
||||
<div style={{ transform: `scale(${gScale})`, opacity: gOp, fontWeight: 900, fontSize: L.vmin(96), color: textColor, textShadow: `0 ${L.vmin(3)}px ${L.vmin(6)}px ${hexToRgba("#1a0a00", 0.7)}, 0 0 ${L.vmin(30)}px ${hexToRgba(accentColor, 0.7)}` }}>
|
||||
{greeting}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(12), opacity: subOp, fontWeight: 700, fontSize: L.vmin(32), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a00", 0.8)}` }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(8), opacity: msgOp, fontWeight: 600, fontSize: L.vmin(26), color: hexToRgba(textColor, 0.9) }}>
|
||||
{message}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
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 nowruzGreetingSchema = z.object({
|
||||
greeting: z.string(),
|
||||
subtitle: z.string(),
|
||||
message: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof nowruzGreetingSchema>;
|
||||
|
||||
// Fixed scene palette (the colour props tint the sky / gold / accent / text).
|
||||
const GREEN = "#5cc85c";
|
||||
const GREEN_D = "#3da53d";
|
||||
const SKIN = "#f0b486";
|
||||
const SKIN_D = "#d99a68";
|
||||
|
||||
// ── Sun with rotating rays ───────────────────────────────────────────────────
|
||||
const Sun: React.FC<{ L: Layout; gold: string; intro: number }> = ({ L, gold, intro }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const cx = L.width * 0.84;
|
||||
const cy = L.height * 0.16;
|
||||
const r = L.vmin(70);
|
||||
return (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${intro})`} opacity={intro}>
|
||||
<g transform={`rotate(${frame * 0.5})`}>
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
<rect key={i} x={-L.vmin(4)} y={-(r + L.vmin(46))} width={L.vmin(8)} height={L.vmin(28)} rx={L.vmin(4)}
|
||||
fill={gold} opacity={0.8} transform={`rotate(${(360 / 14) * i})`} />
|
||||
))}
|
||||
</g>
|
||||
<circle r={r + L.vmin(14)} fill={hexToRgba(gold, 0.25)} />
|
||||
<circle r={r} fill={gold} />
|
||||
<circle r={r} fill={hexToRgba("#ffffff", 0.18)} />
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Drifting blossom petals ──────────────────────────────────────────────────
|
||||
const Petals: React.FC<{ L: Layout }> = ({ L }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const COLORS = ["#ffd1e8", "#ffe3f1", "#ffc1dd", "#fff0f6"];
|
||||
return (
|
||||
<g>
|
||||
{Array.from({ length: 26 }).map((_, i) => {
|
||||
const x = rand(i) * L.width + Math.sin((frame + i * 30) / 30) * L.vmin(40);
|
||||
const fall = ((rand(i + 9) * L.height) + frame * (1 + rand(i) * 2) * L.unit) % (L.height + 40) - 20;
|
||||
const s = L.vmin(7 + rand(i + 3) * 8);
|
||||
const rot = (frame + i * 40) * (i % 2 ? 2 : -2);
|
||||
const appear = interpolate(frame, [0, 25], [0, 1], { extrapolateRight: "clamp" });
|
||||
return (
|
||||
<g key={i} transform={`translate(${x} ${fall}) rotate(${rot})`} opacity={0.85 * appear}>
|
||||
<path d={`M0 ${-s} C ${s * 0.7} ${-s * 0.5} ${s * 0.7} ${s * 0.5} 0 ${s} C ${-s * 0.7} ${s * 0.5} ${-s * 0.7} ${-s * 0.5} 0 ${-s} Z`}
|
||||
fill={COLORS[i % COLORS.length]} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Sabzeh (growing grass) + a tulip ─────────────────────────────────────────
|
||||
const Sabzeh: React.FC<{ L: Layout; x: number; groundY: number; delay: number; scale?: number }> = ({ L, x, groundY, delay, scale = 1 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const grow = spring({ frame: frame - delay, fps: 30, config: { damping: 12, stiffness: 80 } });
|
||||
const h = L.vmin(70) * scale;
|
||||
return (
|
||||
<g transform={`translate(${x} ${groundY})`}>
|
||||
{Array.from({ length: 7 }).map((_, i) => {
|
||||
const lean = (i - 3) * 7 + Math.sin((frame + i * 20) / 22) * 5;
|
||||
const bh = h * (0.7 + (i % 3) * 0.15) * grow;
|
||||
return (
|
||||
<path key={i} d={`M0 0 Q ${lean * 0.6} ${-bh * 0.6} ${lean} ${-bh}`}
|
||||
stroke={i % 2 ? GREEN : GREEN_D} strokeWidth={L.vmin(4) * scale} strokeLinecap="round" fill="none" />
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const Tulip: React.FC<{ L: Layout; x: number; groundY: number; delay: number; color: string; scale?: number }> = ({ L, x, groundY, delay, color, scale = 1 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const grow = spring({ frame: frame - delay, fps: 30, config: { damping: 11, stiffness: 90 } });
|
||||
const stem = L.vmin(90) * scale * grow;
|
||||
const sway = Math.sin((frame + x) / 26) * 4;
|
||||
const bw = L.vmin(34) * scale * grow;
|
||||
return (
|
||||
<g transform={`translate(${x} ${groundY}) rotate(${sway})`}>
|
||||
<path d={`M0 0 Q ${L.vmin(8)} ${-stem * 0.5} 0 ${-stem}`} stroke={GREEN_D} strokeWidth={L.vmin(6) * scale} fill="none" strokeLinecap="round" />
|
||||
<path d={`M0 ${-stem * 0.55} Q ${L.vmin(36) * scale} ${-stem * 0.5} ${L.vmin(20) * scale} ${-stem * 0.8}`} fill={GREEN} />
|
||||
<g transform={`translate(0 ${-stem})`}>
|
||||
<path d={`M${-bw / 2} 0 Q ${-bw / 2} ${-bw * 1.2} 0 ${-bw * 1.3} Q ${bw / 2} ${-bw * 1.2} ${bw / 2} 0 Q 0 ${bw * 0.3} ${-bw / 2} 0 Z`} fill={color} />
|
||||
<path d={`M0 ${-bw * 1.3} Q ${-bw * 0.28} ${-bw * 0.6} 0 0`} fill={mixHex(color, "#ffffff", 0.25)} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Goldfish bowl ────────────────────────────────────────────────────────────
|
||||
const FishBowl: React.FC<{ L: Layout; x: number; groundY: number; intro: number }> = ({ L, x, groundY, intro }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const R = L.vmin(78);
|
||||
const swim = Math.sin(frame / 18) * R * 0.4;
|
||||
const dir = Math.cos(frame / 18) >= 0 ? 1 : -1;
|
||||
const tail = Math.sin(frame / 5) * 14;
|
||||
return (
|
||||
<g transform={`translate(${x} ${groundY - R}) scale(${intro})`} opacity={intro}>
|
||||
{/* water */}
|
||||
<clipPath id="bowlclip"><circle r={R} /></clipPath>
|
||||
<circle r={R} fill={hexToRgba("#9fe3ff", 0.55)} />
|
||||
<g clipPath="url(#bowlclip)">
|
||||
<rect x={-R} y={-R * 0.45} width={R * 2} height={R * 1.5} fill={hexToRgba("#4db8e8", 0.55)} />
|
||||
{/* bubbles */}
|
||||
{[0, 1, 2].map((i) => {
|
||||
const by = (R - ((frame * (1 + i) * L.unit + i * 30) % (R * 1.4)));
|
||||
return <circle key={i} cx={(-R * 0.5) + i * R * 0.4} cy={by} r={L.vmin(4 + i)} fill={hexToRgba("#ffffff", 0.6)} />;
|
||||
})}
|
||||
{/* fish */}
|
||||
<g transform={`translate(${swim} ${L.vmin(6)}) scale(${dir} 1)`}>
|
||||
<g transform={`rotate(${tail})`}>
|
||||
<path d={`M${-L.vmin(20)} 0 L ${-L.vmin(40)} ${-L.vmin(16)} L ${-L.vmin(40)} ${L.vmin(16)} Z`} fill="#ff5a3c" />
|
||||
</g>
|
||||
<ellipse rx={L.vmin(26)} ry={L.vmin(17)} fill="#ff6b4a" />
|
||||
<circle cx={L.vmin(16)} cy={-L.vmin(4)} r={L.vmin(3.5)} fill="#1c2330" />
|
||||
</g>
|
||||
</g>
|
||||
{/* glass */}
|
||||
<circle r={R} fill="none" stroke={hexToRgba("#ffffff", 0.7)} strokeWidth={L.vmin(5)} />
|
||||
<path d={`M ${-R * 0.5} ${-R * 0.6} A ${R} ${R} 0 0 1 ${R * 0.2} ${-R * 0.85}`} stroke={hexToRgba("#ffffff", 0.6)} strokeWidth={L.vmin(4)} fill="none" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Butterfly ────────────────────────────────────────────────────────────────
|
||||
const Butterfly: React.FC<{ L: Layout; i: number; color: string }> = ({ L, i, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = frame + i * 25;
|
||||
const x = interpolate((t * (0.8 + (i % 3) * 0.3)) % (L.width + 200), [0, L.width + 200], [-100, L.width + 100]);
|
||||
const y = L.height * (0.3 + (i % 3) * 0.12) + Math.sin(t / 14) * L.vmin(50);
|
||||
const flap = Math.abs(Math.sin(frame / 3.5));
|
||||
const s = L.vmin(20);
|
||||
const appear = interpolate(frame, [90, 110], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`} opacity={appear}>
|
||||
<g transform={`scale(${0.4 + flap * 0.6} 1)`}>
|
||||
<ellipse cx={-s * 0.5} cy={-s * 0.4} rx={s * 0.5} ry={s * 0.4} fill={color} />
|
||||
<ellipse cx={s * 0.5} cy={-s * 0.4} rx={s * 0.5} ry={s * 0.4} fill={color} />
|
||||
<ellipse cx={-s * 0.4} cy={s * 0.4} rx={s * 0.4} ry={s * 0.35} fill={mixHex(color, "#ffffff", 0.2)} />
|
||||
<ellipse cx={s * 0.4} cy={s * 0.4} rx={s * 0.4} ry={s * 0.35} fill={mixHex(color, "#ffffff", 0.2)} />
|
||||
</g>
|
||||
<rect x={-s * 0.06} y={-s * 0.5} width={s * 0.12} height={s} rx={s * 0.06} fill="#3a2a2a" />
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Haji Firuz (modern stylized) ─────────────────────────────────────────────
|
||||
const HajiFiruz: React.FC<{ L: Layout; x: number; groundY: number; red: string; gold: string }> = ({ L, x, groundY, red, gold }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const inF = frame - 60;
|
||||
// Hop in from the left with a couple of bounces, then dance in place.
|
||||
const entr = spring({ frame: inF, fps: 30, config: { damping: 12, stiffness: 70 } });
|
||||
const startX = -L.width * 0.4;
|
||||
const px = startX + (x - startX) * entr;
|
||||
const danceY = inF > 0 ? -Math.abs(Math.sin(frame / 7)) * L.vmin(22) : 0;
|
||||
const sway = inF > 0 ? Math.sin(frame / 7) * 4 : 0;
|
||||
// Tambourine shake.
|
||||
const tam = Math.sin(frame / 3.2) * 18;
|
||||
const scale = L.vmin(3.2); // unit -> px; character drawn ~ 110 units tall
|
||||
|
||||
if (frame < 60 && entr === 0) return null;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${px} ${groundY + danceY}) scale(${scale}) rotate(${sway})`}>
|
||||
{/* shadow */}
|
||||
<ellipse cx={0} cy={2} rx={26} ry={5} fill={hexToRgba("#000000", 0.12)} transform={`scale(${1 / 1} 1)`} />
|
||||
{/* legs (bouncing) */}
|
||||
<g>
|
||||
<rect x={-12} y={-30} width={9} height={32} rx={4} fill="#2a2f45" transform={`rotate(${Math.sin(frame / 7) * 8} -8 -28)`} />
|
||||
<rect x={3} y={-30} width={9} height={32} rx={4} fill="#2a2f45" transform={`rotate(${-Math.sin(frame / 7) * 8} 8 -28)`} />
|
||||
<ellipse cx={-8} cy={2} rx={8} ry={4} fill="#1c2030" />
|
||||
<ellipse cx={8} cy={2} rx={8} ry={4} fill="#1c2030" />
|
||||
</g>
|
||||
{/* body (red tunic with gold trim) */}
|
||||
<path d="M-20 -78 Q 0 -86 20 -78 L 16 -28 Q 0 -22 -16 -28 Z" fill={red} />
|
||||
<path d="M-18 -34 Q 0 -28 18 -34 L 16 -28 Q 0 -22 -16 -28 Z" fill={gold} />
|
||||
<circle cx={0} cy={-62} r={3.2} fill={gold} />
|
||||
<circle cx={0} cy={-50} r={3.2} fill={gold} />
|
||||
{/* left arm raised holding tambourine */}
|
||||
<g transform={`rotate(${-20 + tam * 0.4} -16 -74)`}>
|
||||
<rect x={-30} y={-78} width={9} height={26} rx={4.5} fill={red} transform="rotate(-35 -16 -74)" />
|
||||
{/* tambourine */}
|
||||
<g transform={`translate(-34 -92) rotate(${tam})`}>
|
||||
<circle r={15} fill="none" stroke={gold} strokeWidth={4} />
|
||||
<circle r={15} fill={hexToRgba("#fff3d6", 0.5)} />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<circle key={i} r={2.4} fill={gold} transform={`rotate(${i * 45}) translate(0 -15)`} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
{/* right arm */}
|
||||
<g transform={`rotate(${25 - tam * 0.3} 16 -74)`}>
|
||||
<rect x={18} y={-78} width={9} height={28} rx={4.5} fill={red} transform="rotate(20 16 -74)" />
|
||||
<circle cx={30} cy={-52} r={5} fill={SKIN} />
|
||||
</g>
|
||||
{/* head */}
|
||||
<circle cx={0} cy={-94} r={15} fill={SKIN} />
|
||||
<path d="M-15 -94 a 15 15 0 0 1 30 0 Z" fill={SKIN_D} opacity={0.25} />
|
||||
{/* face — friendly stylized */}
|
||||
<circle cx={-6} cy={-96} r={2} fill="#2a2030" />
|
||||
<circle cx={6} cy={-96} r={2} fill="#2a2030" />
|
||||
<path d="M-6 -88 Q 0 -83 6 -88" stroke="#7a3a30" strokeWidth={2} fill="none" strokeLinecap="round" />
|
||||
<circle cx={-10} cy={-90} r={2.6} fill={hexToRgba("#ff9a8a", 0.6)} />
|
||||
<circle cx={10} cy={-90} r={2.6} fill={hexToRgba("#ff9a8a", 0.6)} />
|
||||
{/* conical hat */}
|
||||
<path d="M-15 -106 L 0 -150 L 15 -106 Z" fill={red} />
|
||||
<rect x={-16} y={-110} width={32} height={7} rx={3} fill={gold} />
|
||||
<circle cx={0} cy={-150} r={4.5} fill={gold} />
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Composition ──────────────────────────────────────────────────────────────
|
||||
export const NowruzGreeting: React.FC<Props> = ({
|
||||
greeting,
|
||||
subtitle,
|
||||
message,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const gold = accentColor;
|
||||
const red = secondaryColor;
|
||||
const sky = backgroundColor;
|
||||
const groundY = height * 0.84;
|
||||
|
||||
const intro = interpolate(frame, [0, 24], [0, 1], { extrapolateRight: "clamp" });
|
||||
const fishIntro = spring({ frame: frame - 95, fps, config: { damping: 12, stiffness: 90 } });
|
||||
|
||||
// Greeting reveal.
|
||||
const gSpring = spring({ frame: frame - 150, fps, config: { damping: 12, stiffness: 90, mass: 0.8 } });
|
||||
const gScale = interpolate(gSpring, [0, 1], [0.5, 1]);
|
||||
const gOpacity = interpolate(frame, [150, 168], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const subOpacity = interpolate(frame, [172, 192], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const msgOpacity = interpolate(frame, [186, 206], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", backgroundColor: sky }}>
|
||||
{/* Sky gradient */}
|
||||
<AbsoluteFill style={{ background: `linear-gradient(180deg, ${mixHex(sky, "#ffffff", 0.25)} 0%, ${sky} 45%, ${mixHex(sky, "#ffffff", 0.4)} 100%)` }} />
|
||||
|
||||
<svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
|
||||
<Sun L={L} gold={gold} intro={intro} />
|
||||
<Petals L={L} />
|
||||
|
||||
{/* Ground */}
|
||||
<path d={`M0 ${groundY} Q ${width / 2} ${groundY - L.vmin(40)} ${width} ${groundY} L ${width} ${height} L 0 ${height} Z`} fill={GREEN} opacity={intro} />
|
||||
<path d={`M0 ${groundY + L.vmin(20)} Q ${width / 2} ${groundY - L.vmin(15)} ${width} ${groundY + L.vmin(20)} L ${width} ${height} L 0 ${height} Z`} fill={GREEN_D} opacity={intro} />
|
||||
|
||||
{/* Plants along the ground */}
|
||||
<Sabzeh L={L} x={width * 0.12} groundY={groundY} delay={30} />
|
||||
<Tulip L={L} x={width * 0.2} groundY={groundY} delay={42} color={red} />
|
||||
<Tulip L={L} x={width * 0.55} groundY={groundY} delay={48} color="#ff8fab" scale={0.9} />
|
||||
<Sabzeh L={L} x={width * 0.62} groundY={groundY} delay={36} scale={0.85} />
|
||||
<Tulip L={L} x={width * 0.9} groundY={groundY} delay={54} color={gold} scale={0.85} />
|
||||
<Sabzeh L={L} x={width * 0.86} groundY={groundY} delay={40} scale={0.7} />
|
||||
|
||||
<FishBowl L={L} x={width * 0.72} groundY={groundY} intro={Math.max(0, Math.min(1, fishIntro))} />
|
||||
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Butterfly key={i} L={L} i={i} color={[red, gold, "#a855f7"][i]} />
|
||||
))}
|
||||
|
||||
<HajiFiruz L={L} x={width * 0.34} groundY={groundY} red={red} gold={gold} />
|
||||
</svg>
|
||||
|
||||
{/* Greeting */}
|
||||
<AbsoluteFill style={{ alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.16 }}>
|
||||
<div style={{ transform: `scale(${gScale})`, opacity: gOpacity, fontWeight: 900, fontSize: L.vmin(96), color: textColor, textShadow: `0 ${L.vmin(4)}px ${L.vmin(2)}px ${hexToRgba("#7a4a00", 0.25)}, 0 0 ${L.vmin(20)}px ${hexToRgba(gold, 0.6)}` }}>
|
||||
{greeting}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(14), opacity: subOpacity, fontWeight: 700, fontSize: L.vmin(34), color: mixHex(textColor, gold, 0.3), textShadow: `0 ${L.vmin(2)}px ${L.vmin(2)}px ${hexToRgba("#7a4a00", 0.2)}` }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(10), opacity: msgOpacity, fontWeight: 600, fontSize: L.vmin(26), color: hexToRgba(textColor, 0.92) }}>
|
||||
{message}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const openerSchema = z.object({
|
||||
kicker: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof openerSchema>;
|
||||
|
||||
export const Opener: React.FC<Props> = ({
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const L = useLayout();
|
||||
const kick = useReveal(8, { from: 24 });
|
||||
const sub = useReveal(40, { from: 30 });
|
||||
|
||||
// Title wipes up behind a clipping mask.
|
||||
const titleY = interpolate(frame, [18, 44], [L.vmin(140), 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
const lineW = interpolate(frame, [30, 60], [0, L.vmin(260)], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={0} />
|
||||
|
||||
{/* Two framing bars that draw in from the sides */}
|
||||
{[0, 1].map((i) => {
|
||||
const w = interpolate(frame, [4, 26], [0, L.vmin(620)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
top: i === 0 ? `calc(50% - ${L.vmin(150)}px)` : `calc(50% + ${L.vmin(150)}px)`,
|
||||
width: w,
|
||||
height: L.vmin(3),
|
||||
background: `linear-gradient(90deg, transparent, ${accentColor}, ${secondaryColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
||||
<div style={{ opacity: kick.opacity, transform: `translateY(${kick.y}px)`, fontWeight: 700, fontSize: L.vmin(26), letterSpacing: L.vmin(8), color: accentColor, marginBottom: L.vmin(18) }}>
|
||||
{kicker}
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: "hidden", padding: `${L.vmin(6)}px 0` }}>
|
||||
<div style={{ transform: `translateY(${titleY}px)`, fontWeight: 900, fontSize: L.vmin(96), lineHeight: 1.05, color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(8)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: lineW, height: L.vmin(4), borderRadius: 4, margin: `${L.vmin(24)}px 0`, background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` }} />
|
||||
|
||||
<div style={{ opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(28), color: hexToRgba(textColor, 0.8), textAlign: "center", maxWidth: L.vmin(760) }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { RoundedBox } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { hexToRgba, rand } from "../lib/anim";
|
||||
import { StudioEnv, StudioFloor, StudioLights, StudioEffects, Confetti3D } from "../lib/three-kit";
|
||||
|
||||
export const promo3DSchema = z.object({
|
||||
badge: z.string(),
|
||||
headline: z.string(),
|
||||
subtext: z.string(),
|
||||
cta: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof promo3DSchema>;
|
||||
|
||||
const Gift: React.FC<{ size: number; color: string; ribbon: string }> = ({ size, color, ribbon }) => (
|
||||
<group>
|
||||
<RoundedBox args={[size, size, size]} radius={size * 0.07} smoothness={4} castShadow>
|
||||
<meshStandardMaterial color={color} roughness={0.35} metalness={0.15} />
|
||||
</RoundedBox>
|
||||
{/* crossing ribbons */}
|
||||
<mesh>
|
||||
<boxGeometry args={[size * 1.04, size * 1.04, size * 0.2]} />
|
||||
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<boxGeometry args={[size * 0.2, size * 1.04, size * 1.04]} />
|
||||
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
|
||||
</mesh>
|
||||
{/* bow */}
|
||||
<group position={[0, size * 0.5, 0]}>
|
||||
{[-1, 1].map((s) => (
|
||||
<mesh key={s} position={[s * size * 0.16, size * 0.06, 0]} rotation={[0, 0, s * 0.5]} scale={[1, 0.6, 0.5]}>
|
||||
<sphereGeometry args={[size * 0.16, 16, 16]} />
|
||||
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
|
||||
</mesh>
|
||||
))}
|
||||
<mesh position={[0, size * 0.06, 0]}>
|
||||
<sphereGeometry args={[size * 0.08, 16, 16]} />
|
||||
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
|
||||
const FloatingGift: React.FC<{ i: number; accent: string; secondary: string }> = ({ i, accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const ang = rand(i) * Math.PI * 2;
|
||||
const radius = 2.4 + rand(i + 5) * 2.0;
|
||||
const depth = -1 - rand(i + 9) * 3.5;
|
||||
const x = Math.cos(ang + frame * 0.004 * (0.5 + rand(i) * 0.5)) * radius;
|
||||
const y = -0.1 + Math.sin(ang * 1.4 + frame / 40) * (1.0 + rand(i + 3) * 1.0);
|
||||
const size = 0.4 + rand(i + 7) * 0.4;
|
||||
const colA = i % 2 === 0 ? accent : secondary;
|
||||
const colB = i % 2 === 0 ? "#fde047" : "#ffffff";
|
||||
const appear = interpolate(frame, [6 + i * 2, 32 + i * 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<group position={[x, y, depth]} rotation={[frame * 0.01 * (1 + rand(i)), frame * 0.015, 0]} scale={size * appear}>
|
||||
<Gift size={1} color={colA} ribbon={colB} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame: frame - 10, fps, config: { damping: 11, stiffness: 80 } });
|
||||
const heroScale = interpolate(pop, [0, 1], [0, 1.3]);
|
||||
const heroBob = Math.sin(frame / 22) * 0.12;
|
||||
const heroSpin = Math.sin(frame / 60) * 0.5;
|
||||
const orbit = Math.sin(frame / 120) * 0.18;
|
||||
return (
|
||||
<group rotation={[0, orbit, 0]}>
|
||||
<StudioLights accent={accent} secondary={secondary} />
|
||||
<StudioEnv />
|
||||
<StudioFloor color="#1f1a2e" />
|
||||
{/* hero gift */}
|
||||
<group position={[0, -0.15 + heroBob, 0]} rotation={[0, heroSpin, 0]} scale={heroScale}>
|
||||
<Gift size={1.25} color={accent} ribbon={"#fde047"} />
|
||||
</group>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<FloatingGift key={i} i={i} accent={accent} secondary={secondary} />
|
||||
))}
|
||||
<Confetti3D colors={[accent, secondary, "#fde047", "#ffffff"]} count={40} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export const Promo3D: React.FC<Props> = ({
|
||||
badge,
|
||||
headline,
|
||||
subtext,
|
||||
cta,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const badgePop = spring({ frame: frame - 40, fps, config: { damping: 9, stiffness: 130 } });
|
||||
const badgeScale = interpolate(badgePop, [0, 1], [0, 1]);
|
||||
const badgeWobble = Math.sin(frame / 14) * 5;
|
||||
const headOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const headY = interpolate(frame, [70, 92], [L.vmin(40), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const subOp = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const ctaPop = spring({ frame: frame - 116, fps, config: { damping: 11, stiffness: 120 } });
|
||||
const ctaScale = interpolate(ctaPop, [0, 1], [0.6, 1]);
|
||||
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 10);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor }}>
|
||||
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 40%, ${hexToRgba(accentColor, 0.2)} 0%, ${hexToRgba(secondaryColor, 0.07)} 40%, ${backgroundColor} 74%)` }} />
|
||||
<ThreeCanvas
|
||||
width={width}
|
||||
height={height}
|
||||
camera={{ position: [0, 1.7, 6.6], fov: 52 }}
|
||||
shadows
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
|
||||
>
|
||||
<Scene accent={accentColor} secondary={secondaryColor} />
|
||||
<StudioEffects bloom={0.7} focus={0.015} bokeh={3.5} vignette={0.55} />
|
||||
</ThreeCanvas>
|
||||
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.06 }}>
|
||||
{/* discount badge */}
|
||||
<div style={{ width: L.vmin(170), height: L.vmin(170), transform: `scale(${badgeScale}) rotate(${badgeWobble - 8}deg)`, borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.7)}`, fontWeight: 900, fontSize: L.vmin(34), color: "#fff", padding: L.vmin(16) }}>
|
||||
{badge}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(26), transform: `translateY(${headY}px)`, opacity: headOp, fontWeight: 900, fontSize: L.vmin(72), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(8)}px ${hexToRgba("#0a0614", 0.7)}` }}>
|
||||
{headline}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(12), opacity: subOp, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.85), textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#0a0614", 0.7)}` }}>
|
||||
{subtext}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(28), transform: `scale(${ctaScale})`, opacity: ctaPop, padding: `${L.vmin(20)}px ${L.vmin(54)}px`, borderRadius: 999, background: textColor, boxShadow: `0 0 ${L.vmin(20 + ctaGlow * 36)}px ${hexToRgba(accentColor, ctaGlow)}`, fontWeight: 900, fontSize: L.vmin(32), color: backgroundColor }}>
|
||||
{cta}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba, mixHex } from "../lib/anim";
|
||||
|
||||
export const quoteCardSchema = z.object({
|
||||
quote: z.string(),
|
||||
author: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof quoteCardSchema>;
|
||||
|
||||
export const QuoteCard: React.FC<Props> = ({
|
||||
quote,
|
||||
author,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const words = quote.split(/\s+/).filter(Boolean);
|
||||
|
||||
const markOp = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });
|
||||
const tail = 14 + words.length * 3 + 8;
|
||||
const ruleW = interpolate(frame, [tail, tail + 18], [0, L.vmin(120)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const auth = useReveal(tail + 8, { from: 20 });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={10} />
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(90) }}>
|
||||
<div style={{ fontFamily: "Georgia, serif", fontSize: L.vmin(150), lineHeight: 0.5, color: hexToRgba(accentColor, 0.85), opacity: markOp, marginBottom: L.vmin(30) }}>
|
||||
”
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", maxWidth: L.vmin(900), gap: `0 ${L.vmin(14)}px`, fontWeight: 700, fontSize: L.vmin(58), lineHeight: 1.4, color: textColor, textAlign: "center" }}>
|
||||
{words.map((w, i) => {
|
||||
const s = spring({ frame: frame - (12 + i * 3), fps, config: { damping: 18, mass: 0.6, stiffness: 110 } });
|
||||
return (
|
||||
<span key={i} style={{ display: "inline-block", transform: `translateY(${interpolate(s, [0, 1], [L.vmin(22), 0])}px)`, opacity: interpolate(s, [0, 1], [0, 1]), color: i % 5 === 2 ? mixHex(accentColor, secondaryColor, 0.4) : textColor }}>
|
||||
{w}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ width: ruleW, height: L.vmin(3), marginTop: L.vmin(40), borderRadius: 2, background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` }} />
|
||||
<div style={{ marginTop: L.vmin(20), opacity: auth.opacity, transform: `translateY(${auth.y}px)`, fontWeight: 600, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78) }}>
|
||||
{author}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const salePromoSchema = z.object({
|
||||
badge: z.string(),
|
||||
headline: z.string(),
|
||||
subtext: z.string(),
|
||||
cta: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof salePromoSchema>;
|
||||
|
||||
export const SalePromo: React.FC<Props> = ({
|
||||
badge,
|
||||
headline,
|
||||
subtext,
|
||||
cta,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const badgePop = spring({ frame: frame - 6, fps, config: { damping: 9, stiffness: 140, mass: 0.6 } });
|
||||
const badgeScale = interpolate(badgePop, [0, 1], [0, 1]);
|
||||
const badgeWobble = Math.sin(frame / 14) * 6;
|
||||
|
||||
const head = useReveal(22, { from: 50 });
|
||||
const sub = useReveal(40, { from: 28 });
|
||||
const ctaR = useReveal(56, { from: 24, damping: 11 });
|
||||
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 10);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={16} />
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(70) }}>
|
||||
{/* Discount badge */}
|
||||
<div style={{ width: L.vmin(200), height: L.vmin(200), transform: `scale(${badgeScale}) rotate(${badgeWobble - 10}deg)`, borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", boxShadow: `0 0 ${L.vmin(50)}px ${hexToRgba(accentColor, 0.6)}`, fontWeight: 900, fontSize: L.vmin(40), color: "#fff", padding: L.vmin(18) }}>
|
||||
{badge}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(46), opacity: head.opacity, transform: `translateY(${head.y}px)`, fontWeight: 900, fontSize: L.vmin(92), lineHeight: 1.08, color: textColor, textAlign: "center", maxWidth: L.vmin(900), textShadow: `0 ${L.vmin(6)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
||||
{headline}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(20), opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(32), color: hexToRgba(textColor, 0.8), textAlign: "center", maxWidth: L.vmin(760) }}>
|
||||
{subtext}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(52), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(22)}px ${L.vmin(60)}px`, borderRadius: 999, background: textColor, boxShadow: `0 0 ${L.vmin(20 + ctaGlow * 40)}px ${hexToRgba(accentColor, ctaGlow)}`, fontWeight: 900, fontSize: L.vmin(34), color: backgroundColor }}>
|
||||
{cta}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground } from "../lib/kit";
|
||||
import { hexToRgba, mixHex } from "../lib/anim";
|
||||
|
||||
export const slideshowSchema = z.object({
|
||||
title: z.string(),
|
||||
slide1: z.string(),
|
||||
slide2: z.string(),
|
||||
slide3: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof slideshowSchema>;
|
||||
|
||||
export const Slideshow: React.FC<Props> = ({
|
||||
title,
|
||||
slide1,
|
||||
slide2,
|
||||
slide3,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const slides = [slide1, slide2, slide3];
|
||||
const per = durationInFrames / (slides.length + 0.5); // leave a beat for the title
|
||||
const titleEnd = per * 0.5;
|
||||
|
||||
const titleOp = interpolate(frame, [4, 18, titleEnd - 8, titleEnd], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const titleScale = interpolate(frame, [4, titleEnd], [0.9, 1.05], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={20} />
|
||||
|
||||
{/* Title card */}
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", opacity: titleOp }}>
|
||||
<div style={{ transform: `scale(${titleScale})`, fontWeight: 900, fontSize: L.vmin(92), color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(6)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.5)}` }}>
|
||||
{title}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Slides */}
|
||||
{slides.map((s, i) => {
|
||||
const start = titleEnd + i * per;
|
||||
const local = frame - start;
|
||||
if (local < -10 || local > per + 10) return null;
|
||||
const op = interpolate(local, [0, 14, per - 14, per], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
const x = interpolate(local, [0, 18], [L.vmin(60), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<AbsoluteFill key={i} style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", opacity: op, padding: L.vmin(80) }}>
|
||||
<div style={{ fontWeight: 800, fontSize: L.vmin(40), color: mixHex(accentColor, secondaryColor, 0.4), marginBottom: L.vmin(24) }}>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<div style={{ transform: `translateX(${x}px)`, fontWeight: 800, fontSize: L.vmin(64), lineHeight: 1.25, color: textColor, textAlign: "center", maxWidth: L.vmin(880) }}>
|
||||
{s}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Progress dots */}
|
||||
<div style={{ position: "absolute", bottom: L.vmin(70), left: 0, right: 0, display: "flex", justifyContent: "center", gap: L.vmin(14) }}>
|
||||
{slides.map((_, i) => {
|
||||
const start = titleEnd + i * per;
|
||||
const active = frame >= start && frame < start + per;
|
||||
return <div key={i} style={{ width: active ? L.vmin(46) : L.vmin(14), height: L.vmin(14), borderRadius: 999, transition: "all .3s", background: active ? `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` : hexToRgba(textColor, 0.25) }} />;
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const Spinning: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<mesh rotation={[frame * 0.03, frame * 0.04, 0]}>
|
||||
<torusKnotGeometry args={[1, 0.35, 128, 32]} />
|
||||
<meshStandardMaterial color="#3ba7ff" metalness={0.65} roughness={0.2} />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
export const Three3DTest: React.FC = () => {
|
||||
const { width, height } = useVideoConfig();
|
||||
return (
|
||||
<ThreeCanvas
|
||||
width={width}
|
||||
height={height}
|
||||
camera={{ position: [0, 0, 5], fov: 50 }}
|
||||
style={{ backgroundColor: "#05040e" }}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[5, 5, 5]} intensity={60} color="#ffffff" />
|
||||
<pointLight position={[-5, -2, 3]} intensity={30} color="#a855f7" />
|
||||
<Spinning />
|
||||
</ThreeCanvas>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
import { z } from "zod";
|
||||
import { hexToRgba, mixHex, rand } from "../lib/anim";
|
||||
|
||||
export const verticalStorySchema = z.object({
|
||||
kicker: z.string(),
|
||||
line1: z.string(),
|
||||
line2: z.string(),
|
||||
line3: z.string(),
|
||||
ctaText: z.string(),
|
||||
accentColor: zColor(),
|
||||
secondaryColor: zColor(),
|
||||
backgroundColor: zColor(),
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof verticalStorySchema>;
|
||||
|
||||
// ── Diagonal animated gradient + floating dust ───────────────────────────────
|
||||
|
||||
const StoryBackground: React.FC<{
|
||||
bg: string;
|
||||
accent: string;
|
||||
secondary: string;
|
||||
}> = ({ bg, accent, secondary }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const shift = interpolate(frame, [0, 180], [0, 60]);
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(160deg, ${hexToRgba(
|
||||
accent,
|
||||
0.32
|
||||
)} 0%, ${bg} 45%, ${hexToRgba(secondary, 0.3)} 100%)`,
|
||||
transform: `translateY(${-shift}px)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 30%, ${hexToRgba(
|
||||
accent,
|
||||
0.25
|
||||
)} 0%, transparent 45%)`,
|
||||
}}
|
||||
/>
|
||||
{Array.from({ length: 22 }).map((_, i) => {
|
||||
const x = rand(i) * width;
|
||||
const baseY = rand(i + 9) * height;
|
||||
const y = (baseY - frame * (0.6 + rand(i) * 1.2)) % height;
|
||||
const size = 2 + rand(i + 3) * 5;
|
||||
const tw = 0.2 + 0.6 * Math.abs(Math.sin((frame + i * 20) / 16));
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: (y + height) % height,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: i % 2 ? secondary : accent,
|
||||
opacity: tw,
|
||||
filter: `blur(0.5px) drop-shadow(0 0 ${size * 2}px ${
|
||||
i % 2 ? secondary : accent
|
||||
})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<AbsoluteFill
|
||||
style={{ boxShadow: "inset 0 0 400px 120px rgba(0,0,0,0.6)" }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const StoryLine: React.FC<{
|
||||
text: string;
|
||||
delay: number;
|
||||
highlight?: string;
|
||||
}> = ({ text, delay, highlight }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const s = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: { damping: 16, mass: 0.8, stiffness: 100 },
|
||||
});
|
||||
const y = interpolate(s, [0, 1], [70, 0]);
|
||||
const op = interpolate(s, [0, 1], [0, 1]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${y}px)`,
|
||||
opacity: op,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 104,
|
||||
lineHeight: 1.04,
|
||||
letterSpacing: -2,
|
||||
color: highlight ?? "#fff",
|
||||
textShadow: highlight
|
||||
? `0 0 40px ${hexToRgba(highlight, 0.6)}`
|
||||
: "0 4px 24px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VerticalStory: React.FC<Props> = ({
|
||||
kicker,
|
||||
line1,
|
||||
line2,
|
||||
line3,
|
||||
ctaText,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const kickerOp = interpolate(frame, [4, 20], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const ctaSpring = spring({
|
||||
frame: frame - 64,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 120 },
|
||||
});
|
||||
const ctaScale = interpolate(ctaSpring, [0, 1], [0.6, 1]);
|
||||
const ctaOp = interpolate(ctaSpring, [0, 1], [0, 1]);
|
||||
const arrowBounce = Math.sin(frame / 8) * 8;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<StoryBackground
|
||||
bg={backgroundColor}
|
||||
accent={accentColor}
|
||||
secondary={secondaryColor}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: 90,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: kickerOp,
|
||||
display: "inline-block",
|
||||
alignSelf: "flex-start",
|
||||
padding: "10px 24px",
|
||||
marginBottom: 40,
|
||||
borderRadius: 999,
|
||||
border: `2px solid ${hexToRgba(accentColor, 0.7)}`,
|
||||
background: hexToRgba(accentColor, 0.12),
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: 26,
|
||||
letterSpacing: 6,
|
||||
textTransform: "uppercase",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{kicker}
|
||||
</div>
|
||||
|
||||
<StoryLine text={line1} delay={14} />
|
||||
<StoryLine
|
||||
text={line2}
|
||||
delay={26}
|
||||
highlight={mixHex(accentColor, secondaryColor, 0.35)}
|
||||
/>
|
||||
<StoryLine text={line3} delay={38} />
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Swipe-up CTA pinned near the bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 150,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
opacity: ctaOp,
|
||||
transform: `scale(${ctaScale})`,
|
||||
}}
|
||||
>
|
||||
<div style={{ transform: `translateY(${-arrowBounce}px)`, fontSize: 56 }}>
|
||||
⌃
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: -6,
|
||||
padding: "20px 60px",
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`,
|
||||
boxShadow: `0 0 50px ${hexToRgba(accentColor, 0.6)}`,
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 34,
|
||||
letterSpacing: 1,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{ctaText}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { colorSchema } from "../lib/branding";
|
||||
import { FONT } from "../lib/fonts";
|
||||
import { useLayout } from "../lib/aspect";
|
||||
import { BrandBackground, useReveal } from "../lib/kit";
|
||||
import { hexToRgba } from "../lib/anim";
|
||||
|
||||
export const youTubeIntroSchema = z.object({
|
||||
channelName: z.string(),
|
||||
subtitle: z.string(),
|
||||
cta: z.string(),
|
||||
...colorSchema,
|
||||
});
|
||||
|
||||
type Props = z.infer<typeof youTubeIntroSchema>;
|
||||
|
||||
export const YouTubeIntro: React.FC<Props> = ({
|
||||
channelName,
|
||||
subtitle,
|
||||
cta,
|
||||
accentColor,
|
||||
secondaryColor,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
|
||||
const playPop = spring({ frame, fps, config: { damping: 11, stiffness: 130, mass: 0.7 } });
|
||||
const playScale = interpolate(playPop, [0, 1], [0, 1]);
|
||||
const ripple = (frame % 45) / 45;
|
||||
|
||||
const name = useReveal(28, { from: 40 });
|
||||
const sub = useReveal(44, { from: 26 });
|
||||
const bell = useReveal(60, { from: 22, damping: 11 });
|
||||
const bellWiggle = Math.sin(frame / 5) * (frame > 60 && frame < 90 ? 10 : 0);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
|
||||
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={14} />
|
||||
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
||||
{/* Play button with ripple */}
|
||||
<div style={{ position: "relative", width: L.vmin(170), height: L.vmin(170), display: "flex", alignItems: "center", justifyContent: "center", transform: `scale(${playScale})` }}>
|
||||
<div style={{ position: "absolute", width: L.vmin(170) * (1 + ripple), height: L.vmin(170) * (1 + ripple), borderRadius: "50%", border: `${L.vmin(3)}px solid ${hexToRgba(accentColor, 1 - ripple)}` }} />
|
||||
<div style={{ width: L.vmin(150), height: L.vmin(150), borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", boxShadow: `0 0 ${L.vmin(50)}px ${hexToRgba(accentColor, 0.6)}` }}>
|
||||
<div style={{ width: 0, height: 0, marginInlineStart: L.vmin(10), borderTop: `${L.vmin(34)}px solid transparent`, borderBottom: `${L.vmin(34)}px solid transparent`, borderInlineEnd: `${L.vmin(56)}px solid #fff` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: L.vmin(46), opacity: name.opacity, transform: `translateY(${name.y}px)`, fontWeight: 900, fontSize: L.vmin(86), color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
|
||||
{channelName}
|
||||
</div>
|
||||
<div style={{ marginTop: L.vmin(16), opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
|
||||
{/* Subscribe pill */}
|
||||
<div style={{ marginTop: L.vmin(50), opacity: bell.opacity, transform: `scale(${bell.scale})`, display: "flex", alignItems: "center", gap: L.vmin(14), padding: `${L.vmin(18)}px ${L.vmin(46)}px`, borderRadius: 999, background: "#ff0033", boxShadow: `0 0 ${L.vmin(36)}px ${hexToRgba("#ff0033", 0.5)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
|
||||
<span style={{ display: "inline-block", transform: `rotate(${bellWiggle}deg)`, fontSize: L.vmin(34) }}>🔔</span>
|
||||
{cta}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared color + animation helpers for FlatRender code-based templates.
|
||||
|
||||
/** Parse a #rrggbb hex string into [r,g,b] (0..255). */
|
||||
export function hexRgb(hex: string): [number, number, number] {
|
||||
const p = hex.replace("#", "");
|
||||
const i = parseInt(p.length === 3 ? p.replace(/(.)/g, "$1$1") : p, 16);
|
||||
return [(i >> 16) & 255, (i >> 8) & 255, i & 255];
|
||||
}
|
||||
|
||||
/** Mix two hex colors by t (0..1) → rgb() string. Cheap linear blend. */
|
||||
export function mixHex(a: string, b: string, t: number): string {
|
||||
const [ar, ag, ab] = hexRgb(a);
|
||||
const [br, bg, bb] = hexRgb(b);
|
||||
const r = Math.round(ar + (br - ar) * t);
|
||||
const g = Math.round(ag + (bg - ag) * t);
|
||||
const bl = Math.round(ab + (bb - ab) * t);
|
||||
return `rgb(${r}, ${g}, ${bl})`;
|
||||
}
|
||||
|
||||
/** #rrggbb + alpha → rgba() string. */
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
const [r, g, b] = hexRgb(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/** Stable per-index pseudo-random in [0,1) — no Math.random, renders stay deterministic. */
|
||||
export function rand(seed: number): number {
|
||||
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useVideoConfig } from "remotion";
|
||||
|
||||
export type AspectKind = "wide" | "square" | "tall";
|
||||
|
||||
export interface Layout {
|
||||
kind: AspectKind;
|
||||
width: number;
|
||||
height: number;
|
||||
isWide: boolean;
|
||||
isSquare: boolean;
|
||||
isTall: boolean;
|
||||
/** Smaller side / 1000 — a resolution-independent sizing unit. */
|
||||
unit: number;
|
||||
/** Convenience scalers relative to the design baseline (1080 short side). */
|
||||
vmin: (n: number) => number;
|
||||
}
|
||||
|
||||
/** Classify a width×height into one of the three supported aspects. */
|
||||
export function classify(width: number, height: number): AspectKind {
|
||||
const r = width / height;
|
||||
if (r > 1.2) return "wide";
|
||||
if (r < 0.85) return "tall";
|
||||
return "square";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aspect-aware layout tokens. Templates read this to adapt one design to 16:9,
|
||||
* 1:1 and 9:16 — sizing off the shorter side so type and shapes stay balanced
|
||||
* in every frame.
|
||||
*/
|
||||
export function useLayout(): Layout {
|
||||
const { width, height } = useVideoConfig();
|
||||
const kind = classify(width, height);
|
||||
const short = Math.min(width, height);
|
||||
const unit = short / 1000;
|
||||
return {
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
isWide: kind === "wide",
|
||||
isSquare: kind === "square",
|
||||
isTall: kind === "tall",
|
||||
unit,
|
||||
vmin: (n: number) => (n * short) / 1080,
|
||||
};
|
||||
}
|
||||
|
||||
/** The three aspect presets every template is registered in. */
|
||||
export const ASPECTS: { id: string; width: number; height: number; label: string }[] = [
|
||||
{ id: "16x9", width: 1920, height: 1080, label: "16:9" },
|
||||
{ id: "1x1", width: 1080, height: 1080, label: "1:1" },
|
||||
{ id: "9x16", width: 1080, height: 1920, label: "9:16" },
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* FlatRender brand palette + shared template helpers. Every template exposes the
|
||||
* same colour props (accent / secondary / background / text) so the studio can
|
||||
* render one consistent "colour change" control set across all of them.
|
||||
*/
|
||||
export const BRAND = {
|
||||
blue: "#3ba7ff",
|
||||
purple: "#a855f7",
|
||||
cyan: "#22d3ee",
|
||||
pink: "#fb7185",
|
||||
amber: "#f59e0b",
|
||||
green: "#34d399",
|
||||
ink: "#04060f",
|
||||
white: "#ffffff",
|
||||
} as const;
|
||||
|
||||
/** The shared colour schema every template extends. */
|
||||
export const colorSchema = {
|
||||
accentColor: zColor(),
|
||||
secondaryColor: zColor(),
|
||||
backgroundColor: zColor(),
|
||||
textColor: zColor(),
|
||||
};
|
||||
|
||||
/** Default brand colours used by most templates' defaultProps. */
|
||||
export const defaultColors = {
|
||||
accentColor: BRAND.blue,
|
||||
secondaryColor: BRAND.purple,
|
||||
backgroundColor: BRAND.ink,
|
||||
textColor: BRAND.white,
|
||||
};
|
||||
|
||||
export type ColorProps = {
|
||||
accentColor: string;
|
||||
secondaryColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
/** The FlatRender wordmark, in Persian. */
|
||||
export const BRAND_NAME_FA = "فلترندر";
|
||||
|
||||
export const zHex = (hex: string) => hex as z.infer<ReturnType<typeof zColor>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { continueRender, delayRender, staticFile } from "remotion";
|
||||
|
||||
/** The Persian/Latin font used across all FlatRender templates. */
|
||||
export const FONT = "Vazirmatn";
|
||||
|
||||
const WEIGHTS = [400, 600, 700, 800, 900];
|
||||
|
||||
let started = false;
|
||||
|
||||
/**
|
||||
* Loads the local Vazirmatn weights (Persian RTL + Latin) and blocks rendering
|
||||
* until they're ready, so text never renders in a fallback font. Idempotent —
|
||||
* runs once when this module is first imported.
|
||||
*/
|
||||
function loadVazirmatn() {
|
||||
if (started || typeof document === "undefined") return;
|
||||
started = true;
|
||||
for (const w of WEIGHTS) {
|
||||
const handle = delayRender(`vazirmatn-${w}`);
|
||||
const face = new FontFace(
|
||||
FONT,
|
||||
`url(${staticFile(`fonts/vazirmatn-${w}.woff2`)}) format('woff2')`,
|
||||
{ weight: String(w) }
|
||||
);
|
||||
face
|
||||
.load()
|
||||
.then((loaded) => {
|
||||
document.fonts.add(loaded);
|
||||
continueRender(handle);
|
||||
})
|
||||
.catch(() => continueRender(handle));
|
||||
}
|
||||
}
|
||||
|
||||
loadVazirmatn();
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { hexToRgba, rand } from "./anim";
|
||||
import { useLayout } from "./aspect";
|
||||
|
||||
/**
|
||||
* Shared FlatRender template kit: a branded animated background and a couple of
|
||||
* reveal helpers, so each template focuses on its own content + copy.
|
||||
*/
|
||||
|
||||
export const BrandBackground: React.FC<{
|
||||
bg: string;
|
||||
accent: string;
|
||||
secondary: string;
|
||||
/** number of floating particles (0 = none) */
|
||||
particles?: number;
|
||||
/** add a soft moving second glow */
|
||||
nebula?: boolean;
|
||||
}> = ({ bg, accent, secondary, particles = 0, nebula = true }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const drift = Math.sin(frame / 55) * 30;
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: bg }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 42%, ${hexToRgba(accent, 0.2)} 0%, ${hexToRgba(secondary, 0.07)} 32%, ${bg} 66%)`,
|
||||
}}
|
||||
/>
|
||||
{nebula && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${50 + drift / 16}% 74%, ${hexToRgba(secondary, 0.13)} 0%, transparent 46%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{particles > 0 && (
|
||||
<AbsoluteFill>
|
||||
<svg width={width} height={height}>
|
||||
{Array.from({ length: particles }).map((_, i) => {
|
||||
const x = rand(i) * width;
|
||||
const baseY = rand(i + 7) * height;
|
||||
const y = (baseY - frame * (0.5 + rand(i) * 1.1) * L.unit + height) % height;
|
||||
const s = L.vmin(2 + (rand(i + 3) * 4));
|
||||
const tw = 0.25 + 0.6 * Math.abs(Math.sin((frame + i * 17) / 11));
|
||||
const c = i % 3 === 0 ? secondary : accent;
|
||||
return (
|
||||
<circle key={i} cx={x} cy={y} r={s} fill={c} opacity={tw} style={{ filter: `drop-shadow(0 0 ${s * 2}px ${c})` }} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
<AbsoluteFill style={{ boxShadow: `inset 0 0 ${L.vmin(500)}px ${L.vmin(160)}px rgba(0,0,0,0.6)` }} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Spring-based reveal: returns opacity + translateY(px) + scale for a delay. */
|
||||
export function useReveal(delay: number, opts?: { from?: number; damping?: number }) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const s = spring({ frame: frame - delay, fps, config: { damping: opts?.damping ?? 16, stiffness: 90, mass: 0.85 } });
|
||||
return {
|
||||
opacity: interpolate(s, [0, 1], [0, 1]),
|
||||
y: interpolate(s, [0, 1], [L.vmin(opts?.from ?? 40), 0]),
|
||||
scale: interpolate(s, [0, 1], [0.9, 1]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { Environment, Lightformer, MeshReflectorMaterial } from "@react-three/drei";
|
||||
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
|
||||
import { rand } from "./anim";
|
||||
|
||||
/**
|
||||
* Shared max-quality 3D building blocks so every 3D template gets the same
|
||||
* studio look: offline environment reflections (Lightformers — no HDR needed),
|
||||
* a reflective floor, a 3-point + colour-rim light rig, and a post-processing
|
||||
* stack (bloom + depth-of-field + vignette). All verified to render headless
|
||||
* via ANGLE.
|
||||
*/
|
||||
|
||||
export const StudioEnv: React.FC = () => (
|
||||
<Environment resolution={256}>
|
||||
<Lightformer intensity={2} position={[0, 4, -3]} scale={[10, 5, 1]} color="#fff4e6" />
|
||||
<Lightformer intensity={1.2} position={[-4, 2, 2]} scale={[4, 6, 1]} color="#ffd0a8" />
|
||||
<Lightformer intensity={1.2} position={[4, 2, 2]} scale={[4, 6, 1]} color="#a8d8ff" />
|
||||
</Environment>
|
||||
);
|
||||
|
||||
export const StudioLights: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => (
|
||||
<>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[4, 8, 4]} intensity={2.4} color="#fff3e8" castShadow shadow-mapSize={[1024, 1024]} />
|
||||
<pointLight position={[-4, 2, 3]} intensity={24} color={secondary} />
|
||||
<pointLight position={[4, 1, -2]} intensity={18} color={accent} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const StudioFloor: React.FC<{ color?: string; y?: number }> = ({ color = "#241d33", y = -0.62 }) => (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, y, 0]} receiveShadow>
|
||||
<planeGeometry args={[40, 40]} />
|
||||
<MeshReflectorMaterial
|
||||
blur={[300, 80]}
|
||||
resolution={1024}
|
||||
mixBlur={1}
|
||||
mixStrength={40}
|
||||
roughness={0.85}
|
||||
depthScale={1}
|
||||
minDepthThreshold={0.4}
|
||||
maxDepthThreshold={1.2}
|
||||
color={color}
|
||||
metalness={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
|
||||
export const StudioEffects: React.FC<{ bloom?: number; focus?: number; bokeh?: number; vignette?: number }> = ({
|
||||
bloom = 0.75,
|
||||
focus = 0.013,
|
||||
bokeh = 3,
|
||||
vignette = 0.55,
|
||||
}) => (
|
||||
<EffectComposer>
|
||||
<Bloom intensity={bloom} luminanceThreshold={0.62} luminanceSmoothing={0.3} mipmapBlur />
|
||||
<DepthOfField focusDistance={focus} focalLength={0.045} bokehScale={bokeh} />
|
||||
<Vignette eskil={false} offset={0.32} darkness={vignette} />
|
||||
</EffectComposer>
|
||||
);
|
||||
|
||||
/** Falling 3D confetti planes. */
|
||||
export const Confetti3D: React.FC<{ colors: string[]; count?: number; top?: number }> = ({ colors, count = 36, top = 4.5 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<group>
|
||||
{Array.from({ length: count }).map((_, i) => {
|
||||
const x = (rand(i) - 0.5) * 9;
|
||||
const z = (rand(i + 3) - 0.5) * 4 - 0.5;
|
||||
const span = 9;
|
||||
const y = top - ((frame * (0.03 + rand(i) * 0.04) + rand(i + 7) * span) % span);
|
||||
const rot = frame * 0.06 * (1 + rand(i));
|
||||
return (
|
||||
<mesh key={i} position={[x + Math.sin(frame / 28 + i) * 0.5, y, z]} rotation={[rot, rot * 0.7, rot * 0.4]}>
|
||||
<planeGeometry args={[0.12, 0.07]} />
|
||||
<meshStandardMaterial color={colors[i % colors.length]} side={2} roughness={0.5} metalness={0.1} emissiveIntensity={0.1} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Registry of FlatRender branded templates. Each entry is rendered into the
|
||||
* three supported aspects (16:9 / 1:1 / 9:16) by Root.tsx, producing composition
|
||||
* ids like "LogoMotion-16x9". Every template uses Persian text presets + the
|
||||
* shared colour props so the studio can offer one consistent edit experience.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { AnyZodObject } from "zod";
|
||||
import { BRAND } from "./lib/branding";
|
||||
|
||||
import { LogoMotion, logoMotionSchema } from "./compositions/LogoMotion";
|
||||
import { Opener, openerSchema } from "./compositions/Opener";
|
||||
import { InstaPromo, instaPromoSchema } from "./compositions/InstaPromo";
|
||||
import { YouTubeIntro, youTubeIntroSchema } from "./compositions/YouTubeIntro";
|
||||
import { Slideshow, slideshowSchema } from "./compositions/Slideshow";
|
||||
import { HappyBirthday, happyBirthdaySchema } from "./compositions/HappyBirthday";
|
||||
import { SalePromo, salePromoSchema } from "./compositions/SalePromo";
|
||||
import { QuoteCard, quoteCardSchema } from "./compositions/QuoteCard";
|
||||
import { EventInvite, eventInviteSchema } from "./compositions/EventInvite";
|
||||
import { Countdown, countdownSchema } from "./compositions/Countdown";
|
||||
import { GlitterReveal, glitterRevealSchema } from "./compositions/GlitterReveal";
|
||||
import { NowruzGreeting, nowruzGreetingSchema } from "./compositions/NowruzGreeting";
|
||||
import { Hero3D, hero3DSchema } from "./compositions/Hero3D";
|
||||
import { Nowruz3D, nowruz3DSchema } from "./compositions/Nowruz3D";
|
||||
import { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D";
|
||||
import { Promo3D, promo3DSchema } from "./compositions/Promo3D";
|
||||
|
||||
export interface TemplateDef {
|
||||
/** Base id; the registered composition ids are `${id}-${aspect}`. */
|
||||
id: string;
|
||||
/** Persian display name (used when seeding the site catalog). */
|
||||
name: string;
|
||||
/** Short Persian description for the catalog. */
|
||||
description: string;
|
||||
component: React.FC<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
schema: AnyZodObject;
|
||||
durationSec: number;
|
||||
defaultProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const c = (accent: string, secondary: string, bg: string) => ({
|
||||
accentColor: accent,
|
||||
secondaryColor: secondary,
|
||||
backgroundColor: bg,
|
||||
textColor: BRAND.white,
|
||||
});
|
||||
|
||||
export const TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
id: "LogoMotion",
|
||||
name: "موشن لوگو",
|
||||
description: "نمایش حرفهای لوگو و نام برند با درخشش و حرکت",
|
||||
component: LogoMotion,
|
||||
schema: logoMotionSchema,
|
||||
durationSec: 5,
|
||||
defaultProps: { brandText: "فلترندر", tagline: "موشن، ساده و حرفهای", ...c(BRAND.blue, BRAND.purple, "#04060f") },
|
||||
},
|
||||
{
|
||||
id: "Opener",
|
||||
name: "تیتراژ آغازین",
|
||||
description: "شروع سینمایی برای ویدیو با عنوان و زیرعنوان",
|
||||
component: Opener,
|
||||
schema: openerSchema,
|
||||
durationSec: 5,
|
||||
defaultProps: { kicker: "تقدیم میکند", title: "یک شروع تازه", subtitle: "داستان شما از همینجا آغاز میشود", ...c(BRAND.cyan, "#6366f1", "#0a0a12") },
|
||||
},
|
||||
{
|
||||
id: "InstaPromo",
|
||||
name: "تبلیغ پیج اینستاگرام",
|
||||
description: "معرفی و تبلیغ صفحهٔ اینستاگرام با دعوت به فالو",
|
||||
component: InstaPromo,
|
||||
schema: instaPromoSchema,
|
||||
durationSec: 5,
|
||||
defaultProps: { handle: "@flatrender", headline: "پیج ما را دنبال کنید", subtext: "هر روز محتوای تازه و الهامبخش", cta: "فالو کنید", ...c(BRAND.pink, BRAND.amber, "#140a12") },
|
||||
},
|
||||
{
|
||||
id: "YouTubeIntro",
|
||||
name: "اینترو کانال یوتیوب",
|
||||
description: "اینترو حرفهای کانال یوتیوب با دکمهٔ سابسکرایب",
|
||||
component: YouTubeIntro,
|
||||
schema: youTubeIntroSchema,
|
||||
durationSec: 5,
|
||||
defaultProps: { channelName: "کانال فلترندر", subtitle: "آموزش، ترفند و انگیزه", cta: "سابسکرایب کنید", ...c("#ff4d4d", BRAND.purple, "#0c0810") },
|
||||
},
|
||||
{
|
||||
id: "Slideshow",
|
||||
name: "اسلایدشو",
|
||||
description: "نمایش پشتسرهم چند پیام یا ویژگی بهصورت اسلاید",
|
||||
component: Slideshow,
|
||||
schema: slideshowSchema,
|
||||
durationSec: 9,
|
||||
defaultProps: { title: "چرا فلترندر؟", slide1: "ساخت ویدیو در چند دقیقه", slide2: "بدون نیاز به دانش فنی", slide3: "خروجی با کیفیت حرفهای", ...c(BRAND.green, "#3b82f6", "#060b0a") },
|
||||
},
|
||||
{
|
||||
id: "HappyBirthday",
|
||||
name: "تولدت مبارک",
|
||||
description: "کارت تبریک تولد با کاغذرنگی و نام شخص",
|
||||
component: HappyBirthday,
|
||||
schema: happyBirthdaySchema,
|
||||
durationSec: 6,
|
||||
defaultProps: { greeting: "تولدت مبارک", name: "سارا", message: "بهترینها را برایت آرزومندیم 🎉", ...c(BRAND.pink, "#fde047", "#140a18") },
|
||||
},
|
||||
{
|
||||
id: "SalePromo",
|
||||
name: "فروش ویژه",
|
||||
description: "بنر تبلیغاتی فروش و تخفیف با دعوت به خرید",
|
||||
component: SalePromo,
|
||||
schema: salePromoSchema,
|
||||
durationSec: 5,
|
||||
defaultProps: { badge: "۵۰٪ تخفیف", headline: "فروش ویژهٔ پایان فصل", subtext: "فقط تا پایان همین هفته", cta: "همین حالا خرید کنید", ...c(BRAND.amber, BRAND.pink, "#120a08") },
|
||||
},
|
||||
{
|
||||
id: "QuoteCard",
|
||||
name: "کارت نقلقول",
|
||||
description: "نمایش جملهٔ انگیزشی یا نقلقول با نام گوینده",
|
||||
component: QuoteCard,
|
||||
schema: quoteCardSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: { quote: "موفقیت، مجموع تلاشهای کوچکِ هر روز است.", author: "فلترندر", ...c(BRAND.cyan, "#6366f1", "#0a0a12") },
|
||||
},
|
||||
{
|
||||
id: "EventInvite",
|
||||
name: "دعوتنامهٔ رویداد",
|
||||
description: "دعوتنامهٔ شیک برای رویداد با تاریخ و مکان",
|
||||
component: EventInvite,
|
||||
schema: eventInviteSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: { kicker: "دعوتنامه", eventTitle: "همایش سالانهٔ نوآوری", date: "۱۵ مهر ۱۴۰۳", location: "تهران، سالن همایشها", cta: "ثبتنام کنید", ...c(BRAND.purple, BRAND.blue, "#0a0814") },
|
||||
},
|
||||
{
|
||||
id: "Countdown",
|
||||
name: "شمارش معکوس",
|
||||
description: "شمارش معکوس هیجانانگیز برای شروع یک رویداد",
|
||||
component: Countdown,
|
||||
schema: countdownSchema,
|
||||
durationSec: 8,
|
||||
defaultProps: { title: "شروع رویداد تا", startNumber: 5, goText: "شروع!", subtitle: "آمادهاید؟", ...c(BRAND.blue, BRAND.cyan, "#04060f") },
|
||||
},
|
||||
{
|
||||
id: "GlitterReveal",
|
||||
name: "نمایش لوگو با غبار درخشان",
|
||||
description: "نمایش جادویی لوگو با ذرات درخشان؛ لوگو و متن قابل ویرایش",
|
||||
component: GlitterReveal,
|
||||
schema: glitterRevealSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: { brandText: "فلترندر", tagline: "موشن، ساده و حرفهای", logoUrl: "", ...c(BRAND.blue, BRAND.purple, "#05040e") },
|
||||
},
|
||||
{
|
||||
id: "NowruzGreeting",
|
||||
name: "تبریک نوروز",
|
||||
description: "صحنهٔ بهاری نوروز با شخصیتهای متحرک؛ حاجیفیروز، ماهی قرمز و سبزه",
|
||||
component: NowruzGreeting,
|
||||
schema: nowruzGreetingSchema,
|
||||
durationSec: 7.5,
|
||||
defaultProps: {
|
||||
greeting: "نوروز مبارک",
|
||||
subtitle: "سال نو پیروز و شادمان",
|
||||
message: "۱۴۰۶",
|
||||
accentColor: "#f5b942",
|
||||
secondaryColor: "#e23b3b",
|
||||
backgroundColor: "#1fb6b0",
|
||||
textColor: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Hero3D",
|
||||
name: "نمایش سهبعدی برند",
|
||||
description: "نمایش حرفهای و سهبعدی لوگو و برند با نورپردازی و جلوههای واقعی",
|
||||
component: Hero3D,
|
||||
schema: hero3DSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: { brandText: "فلترندر", tagline: "موشن، ساده و حرفهای", ...c(BRAND.blue, BRAND.purple, "#04060f") },
|
||||
},
|
||||
{
|
||||
id: "Nowruz3D",
|
||||
name: "تبریک نوروز سهبعدی",
|
||||
description: "صحنهٔ سهبعدی نوروز با حاجیفیروز، سفرهٔ هفتسین و نورپردازی سینمایی",
|
||||
component: Nowruz3D,
|
||||
schema: nowruz3DSchema,
|
||||
durationSec: 7,
|
||||
defaultProps: {
|
||||
greeting: "نوروز مبارک",
|
||||
subtitle: "سال نو پیروز و شادمان",
|
||||
message: "۱۴۰۶",
|
||||
accentColor: "#f5c542",
|
||||
secondaryColor: "#e23b3b",
|
||||
backgroundColor: "#1a1228",
|
||||
textColor: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Birthday3D",
|
||||
name: "تولد سهبعدی",
|
||||
description: "صحنهٔ سهبعدی تولد با کیک و شمعهای روشن، بادکنک و کاغذرنگی",
|
||||
component: Birthday3D,
|
||||
schema: birthday3DSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: {
|
||||
greeting: "تولدت مبارک",
|
||||
name: "سارا",
|
||||
message: "بهترینها را برایت آرزومندیم 🎉",
|
||||
accentColor: "#fb7185",
|
||||
secondaryColor: "#a855f7",
|
||||
backgroundColor: "#1a1226",
|
||||
textColor: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Promo3D",
|
||||
name: "فروش ویژه سهبعدی",
|
||||
description: "تبلیغ سهبعدی فروش و تخفیف با جعبههای هدیه و نورپردازی سینمایی",
|
||||
component: Promo3D,
|
||||
schema: promo3DSchema,
|
||||
durationSec: 6,
|
||||
defaultProps: {
|
||||
badge: "۵۰٪ تخفیف",
|
||||
headline: "فروش ویژهٔ پایان فصل",
|
||||
subtext: "فقط تا پایان همین هفته",
|
||||
cta: "همین حالا خرید کنید",
|
||||
accentColor: "#f59e0b",
|
||||
secondaryColor: "#fb7185",
|
||||
backgroundColor: "#140e1f",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -666,6 +666,42 @@ func (s *Store) GetTemplateCompName(ctx context.Context, originalProjectID uuid.
|
||||
return *comp, nil
|
||||
}
|
||||
|
||||
// TemplateRenderConfig describes how a template should be rendered.
|
||||
type TemplateRenderConfig struct {
|
||||
// Engine is "AfterEffects" or "Remotion".
|
||||
Engine string
|
||||
// CompName is the composition to render: render_aep_comp for AE, or
|
||||
// render_remotion_comp for Remotion.
|
||||
CompName string
|
||||
}
|
||||
|
||||
// GetTemplateRenderConfig resolves the render engine + composition for a template.
|
||||
// For Remotion templates the composition id comes from render_remotion_comp; for
|
||||
// After Effects it comes from render_aep_comp. Defaults to AfterEffects when the
|
||||
// render_engine column is missing/empty (older rows pre-migration).
|
||||
func (s *Store) GetTemplateRenderConfig(ctx context.Context, originalProjectID uuid.UUID) (TemplateRenderConfig, error) {
|
||||
var engine *string
|
||||
var aepComp, remotionComp *string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT render_engine, render_aep_comp, render_remotion_comp
|
||||
FROM content.projects WHERE id = $1`, originalProjectID).Scan(&engine, &aepComp, &remotionComp)
|
||||
if err != nil {
|
||||
return TemplateRenderConfig{}, err
|
||||
}
|
||||
cfg := TemplateRenderConfig{Engine: "AfterEffects"}
|
||||
if engine != nil && *engine != "" {
|
||||
cfg.Engine = *engine
|
||||
}
|
||||
if cfg.Engine == "Remotion" {
|
||||
if remotionComp != nil {
|
||||
cfg.CompName = *remotionComp
|
||||
}
|
||||
} else if aepComp != nil {
|
||||
cfg.CompName = *aepComp
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetRenderBindings returns the user's edited input values for a saved project so the
|
||||
// node can write them into the AE project before rendering (the render binder). Only
|
||||
// inputs with a non-empty value are returned (defaults are already in the template).
|
||||
|
||||
@@ -282,10 +282,14 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
// and reused by every render of that template. A .zip is a full AE project
|
||||
// bundle (.aep + footage/fonts) the node must extract before rendering.
|
||||
// Errors are non-fatal — the node agent falls back to mock render when URL is empty.
|
||||
// Resolve the render engine + composition for this template. Remotion
|
||||
// templates are code-based and need no .aep download.
|
||||
rcfg, _ := h.store.GetTemplateRenderConfig(c.Request.Context(), job.OriginalProjectID)
|
||||
|
||||
aepURL := ""
|
||||
isBundle := false
|
||||
bundleMD5 := ""
|
||||
if h.minio != nil {
|
||||
if rcfg.Engine != "Remotion" && h.minio != nil {
|
||||
candidates := []struct {
|
||||
name string
|
||||
bundle bool
|
||||
@@ -311,11 +315,12 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Composition to render (-comp). Non-fatal: empty → node uses the render queue.
|
||||
compName, _ := h.store.GetTemplateCompName(c.Request.Context(), job.OriginalProjectID)
|
||||
// Composition to render: AE comp (-comp) or the Remotion composition id.
|
||||
// Non-fatal: empty → AE node uses the render queue.
|
||||
compName := rcfg.CompName
|
||||
|
||||
// User's edited input values → the node writes them into the AE project before
|
||||
// rendering (render binder). Non-fatal: empty → renders template defaults.
|
||||
// rendering, or passes them as Remotion --props. Non-fatal: empty → template defaults.
|
||||
bindings, _ := h.store.GetRenderBindings(c.Request.Context(), job.SavedProjectID)
|
||||
|
||||
c.JSON(http.StatusOK, models.ClaimedJob{
|
||||
@@ -326,6 +331,7 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
FrameRate: job.FrameRate,
|
||||
HasMusic: job.HasMusic,
|
||||
HasVoiceover: job.HasVoiceover,
|
||||
Engine: rcfg.Engine,
|
||||
AEPDownloadURL: aepURL,
|
||||
IsBundle: isBundle,
|
||||
BundleMD5: bundleMD5,
|
||||
|
||||
@@ -422,6 +422,9 @@ type ClaimedJob struct {
|
||||
FrameRate int `json:"frame_rate"`
|
||||
HasMusic bool `json:"has_music"`
|
||||
HasVoiceover bool `json:"has_voiceover"`
|
||||
// Engine selects the render engine: "AfterEffects" (default) or "Remotion".
|
||||
// For Remotion jobs CompName is the composition id and AEPDownloadURL is empty.
|
||||
Engine string `json:"engine,omitempty"`
|
||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file
|
||||
// (or .zip bundle). Valid for 2 hours. Empty when the template is not yet uploaded.
|
||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user