feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
+53
View File
@@ -0,0 +1,53 @@
export interface MusicGenreCard {
id: string;
name: string;
gradientFrom: string;
gradientTo: string;
}
export const MUSIC_GENRE_CARDS: MusicGenreCard[] = [
{ id: "all", name: "All", gradientFrom: "#6366f1", gradientTo: "#8b5cf6" },
{ id: "newest", name: "Newest", gradientFrom: "#ec4899", gradientTo: "#f43f5e" },
{
id: "corporate",
name: "Corporate",
gradientFrom: "#3b82f6",
gradientTo: "#1d4ed8",
},
{
id: "inspiring",
name: "Inspiring",
gradientFrom: "#f59e0b",
gradientTo: "#f97316",
},
{
id: "love-sessions",
name: "Love sessions",
gradientFrom: "#ec4899",
gradientTo: "#be185d",
},
{ id: "playful", name: "Playful", gradientFrom: "#a855f7", gradientTo: "#7c3aed" },
{
id: "cinematic",
name: "Cinematic",
gradientFrom: "#1f2937",
gradientTo: "#111827",
},
{
id: "electronic",
name: "Electronic",
gradientFrom: "#06b6d4",
gradientTo: "#0891b2",
},
{ id: "pop", name: "Pop", gradientFrom: "#f472b6", gradientTo: "#ec4899" },
{ id: "rock", name: "Rock", gradientFrom: "#374151", gradientTo: "#111827" },
{ id: "folk", name: "Folk", gradientFrom: "#84cc16", gradientTo: "#65a30d" },
{ id: "funk", name: "Funk", gradientFrom: "#f97316", gradientTo: "#ea580c" },
{ id: "upbeat", name: "Upbeat", gradientFrom: "#fbbf24", gradientTo: "#f59e0b" },
{
id: "classical",
name: "Classical",
gradientFrom: "#64748b",
gradientTo: "#475569",
},
];
+21
View File
@@ -0,0 +1,21 @@
import type Konva from "konva";
import type { Layer } from "@/lib/studio-types";
export function nodeTransformToLayer(node: Konva.Node): Partial<Layer> {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
return {
x: node.x(),
y: node.y(),
width: Math.max(8, node.width() * scaleX),
height: Math.max(8, node.height() * scaleY),
rotation: node.rotation(),
};
}
export function resetNodeScale(node: Konva.Node): void {
node.scaleX(1);
node.scaleY(1);
}
+65
View File
@@ -0,0 +1,65 @@
import { createDefaultSceneData } from "@/lib/project-defaults";
import type { ProjectType } from "@/lib/projects";
import type { TemplateCatalogCategory } from "@/lib/templates-catalog";
export function catalogCategoryToProjectType(
category: TemplateCatalogCategory
): ProjectType {
if (category === "Image") return "image";
return "video";
}
export function studioPathForProject(
id: string,
type: ProjectType
): string {
if (type === "video") return `/studio/video/${id}`;
if (type === "image") return `/studio/image/${id}`;
return "/studio/trimmer";
}
export type CreateProjectFromTemplateResult =
| { ok: true; project: { id: string; type: ProjectType } }
| { ok: false; error: string; status: number };
export async function createProjectFromTemplate(input: {
id: string;
name: string;
category: TemplateCatalogCategory;
}): Promise<CreateProjectFromTemplateResult> {
const type = catalogCategoryToProjectType(input.category);
const scene_data = {
...createDefaultSceneData(type),
templateId: input.id,
};
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.name,
type,
scene_data,
}),
});
const data = (await response.json()) as {
project?: { id: string; type: ProjectType };
error?: string;
code?: string;
};
if (!response.ok || !data.project) {
const message =
data.code === "SUPABASE_NOT_CONFIGURED"
? "Supabase is not configured. Copy .env.example to .env.local and add your project URL and anon key."
: (data.error ?? "Could not create project");
return {
ok: false,
error: message,
status: response.status,
};
}
return { ok: true, project: data.project };
}
+51
View File
@@ -0,0 +1,51 @@
import { createDefaultSceneData } from "@/lib/project-defaults";
import type { ProjectType } from "@/lib/projects";
export type CreateVideoProjectResult =
| { ok: true; projectId: string }
| { ok: false; error: string };
export async function createVideoProject(input?: {
name?: string;
presetId?: string;
initialSceneName?: string;
}): Promise<CreateVideoProjectResult> {
const scene_data: Record<string, unknown> = {
...createDefaultSceneData("video"),
...(input?.presetId ? { templateId: input.presetId } : {}),
};
if (input?.initialSceneName) {
const scenes = scene_data.scenes;
if (Array.isArray(scenes) && scenes[0] && typeof scenes[0] === "object") {
const first = scenes[0] as Record<string, unknown>;
first.name = input.initialSceneName;
}
}
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "video" satisfies ProjectType,
name: input?.name ?? undefined,
scene_data,
}),
});
const data = (await response.json()) as {
project?: { id: string };
error?: string;
code?: string;
};
if (!response.ok || !data.project) {
const message =
data.code === "SUPABASE_NOT_CONFIGURED"
? "Supabase is not configured. Add your keys to .env.local."
: (data.error ?? "Could not create project");
return { ok: false, error: message };
}
return { ok: true, projectId: data.project.id };
}
+20
View File
@@ -0,0 +1,20 @@
import type { ProjectRow, ProjectType } from "@/lib/projects";
export function buildMockProjectRow(input: {
name: string;
type: ProjectType;
scene_data: Record<string, unknown>;
}): ProjectRow {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
user_id: "dev-local-user",
name: input.name,
type: input.type,
scene_data: input.scene_data,
render_url: null,
status: "draft",
created_at: now,
updated_at: now,
};
}
+49
View File
@@ -0,0 +1,49 @@
const STORAGE_PREFIX = "flatrender-project-";
export interface LocalProjectSnapshot {
scene_data: Record<string, unknown>;
name?: string;
savedAt: string;
}
export function localProjectStorageKey(projectId: string): string {
return `${STORAGE_PREFIX}${projectId}`;
}
export function saveLocalProject(
projectId: string,
snapshot: Omit<LocalProjectSnapshot, "savedAt">
): void {
if (typeof window === "undefined") return;
const payload: LocalProjectSnapshot = {
...snapshot,
savedAt: new Date().toISOString(),
};
window.localStorage.setItem(
localProjectStorageKey(projectId),
JSON.stringify(payload)
);
}
export function loadLocalProject(projectId: string): LocalProjectSnapshot | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(localProjectStorageKey(projectId));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as LocalProjectSnapshot;
if (!parsed.scene_data || typeof parsed.scene_data !== "object") {
return null;
}
return parsed;
} catch {
return null;
}
}
export function isDevelopmentEnv(): boolean {
return process.env.NODE_ENV === "development";
}
+128
View File
@@ -0,0 +1,128 @@
import type { CropBox, ExportFormat, VideoDimensions } from "@/lib/trimmer-types";
export interface ProcessVideoInWorkerOptions {
file: File;
trimStart: number;
trimEnd: number;
cropBox: CropBox;
displaySize: VideoDimensions;
videoSize: VideoDimensions;
exportFormat: ExportFormat;
onProgress: (percent: number) => void;
onLog?: (message: string) => void;
}
let worker: Worker | null = null;
let initPromise: Promise<void> | null = null;
function getWorker(): Worker {
if (!worker) {
worker = new Worker(
new URL("../workers/ffmpeg-trim.worker.ts", import.meta.url)
);
}
return worker;
}
export function preloadFfmpegWorker(): Promise<void> {
if (initPromise) return initPromise;
initPromise = new Promise((resolve, reject) => {
const w = getWorker();
const handleMessage = (event: MessageEvent) => {
const { type } = event.data as { type: string };
if (type === "ready") {
w.removeEventListener("message", handleMessage);
resolve();
}
if (type === "error") {
w.removeEventListener("message", handleMessage);
reject(new Error("FFmpeg worker failed to initialize"));
}
};
w.addEventListener("message", handleMessage);
w.postMessage({ type: "init" });
});
return initPromise;
}
export function isFfmpegWorkerReady(): boolean {
return initPromise !== null;
}
export async function processTrimmedVideoInWorker(
options: ProcessVideoInWorkerOptions
): Promise<Blob> {
await preloadFfmpegWorker();
const {
file,
trimStart,
trimEnd,
cropBox,
displaySize,
videoSize,
exportFormat,
onProgress,
onLog,
} = options;
const fileBuffer = await file.arrayBuffer();
return new Promise((resolve, reject) => {
const w = getWorker();
const handleMessage = (event: MessageEvent) => {
const data = event.data as {
type: string;
percent?: number;
message?: string;
buffer?: ArrayBuffer;
mime?: string;
};
if (data.type === "progress" && typeof data.percent === "number") {
onProgress(data.percent);
}
if (data.type === "log" && data.message) {
onLog?.(data.message);
}
if (data.type === "complete" && data.buffer && data.mime) {
w.removeEventListener("message", handleMessage);
resolve(new Blob([data.buffer], { type: data.mime }));
}
if (data.type === "error") {
w.removeEventListener("message", handleMessage);
reject(new Error(data.message ?? "Processing failed"));
}
};
w.addEventListener("message", handleMessage);
w.postMessage(
{
type: "process",
fileBuffer,
fileName: file.name,
trimStart,
trimEnd,
cropBox,
displaySize,
videoSize,
exportFormat,
},
[fileBuffer]
);
});
}
export function terminateFfmpegWorker(): void {
worker?.terminate();
worker = null;
initPromise = null;
}
+114
View File
@@ -0,0 +1,114 @@
import type { CropRect, ImageCropAspectRatio, ImageLayer } from "@/lib/image-editor-types";
export function getCropAspectRatioValue(
ratio: ImageCropAspectRatio
): number | undefined {
switch (ratio) {
case "1:1":
return 1;
case "16:9":
return 16 / 9;
case "4:3":
return 4 / 3;
case "9:16":
return 9 / 16;
default:
return undefined;
}
}
export function fitCropRectToAspect(
rect: CropRect,
aspect: ImageCropAspectRatio,
canvasWidth: number,
canvasHeight: number
): CropRect {
const ratio = getCropAspectRatioValue(aspect);
if (ratio === undefined) return rect;
let w = Math.min(rect.w, canvasWidth);
let h = w / ratio;
if (h > canvasHeight) {
h = canvasHeight;
w = h * ratio;
}
if (w > canvasWidth) {
w = canvasWidth;
h = w / ratio;
}
const x = Math.min(Math.max(0, rect.x), Math.max(0, canvasWidth - w));
const y = Math.min(Math.max(0, rect.y), Math.max(0, canvasHeight - h));
return { x, y, w, h };
}
export function cropImageDataUrl(
src: string,
region: CropRect,
displayWidth: number,
displayHeight: number
): Promise<{ dataUrl: string; width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new window.Image();
image.crossOrigin = "anonymous";
image.onload = () => {
const scaleX =
displayWidth > 0 ? image.naturalWidth / displayWidth : 1;
const scaleY =
displayHeight > 0 ? image.naturalHeight / displayHeight : 1;
const sx = Math.max(0, Math.round(region.x * scaleX));
const sy = Math.max(0, Math.round(region.y * scaleY));
const sw = Math.min(
image.naturalWidth - sx,
Math.max(1, Math.round(region.w * scaleX))
);
const sh = Math.min(
image.naturalHeight - sy,
Math.max(1, Math.round(region.h * scaleY))
);
const canvas = document.createElement("canvas");
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas not supported"));
return;
}
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh);
resolve({
dataUrl: canvas.toDataURL("image/png"),
width: region.w,
height: region.h,
});
};
image.onerror = () => reject(new Error("Failed to load image for crop"));
image.src = src;
});
}
/** Layer-local display pixels to extract from the base image for a canvas-space crop */
export function getBaseLayerSourceCrop(
base: ImageLayer,
crop: CropRect
): CropRect | null {
const layerRight = base.x + base.width;
const layerBottom = base.y + base.height;
const cropRight = crop.x + crop.w;
const cropBottom = crop.y + crop.h;
const x1 = Math.max(base.x, crop.x);
const y1 = Math.max(base.y, crop.y);
const x2 = Math.min(layerRight, cropRight);
const y2 = Math.min(layerBottom, cropBottom);
if (x2 <= x1 || y2 <= y1) return null;
return {
x: x1 - base.x,
y: y1 - base.y,
w: x2 - x1,
h: y2 - y1,
};
}
+27
View File
@@ -0,0 +1,27 @@
import type Konva from "konva";
import type { ExportImageFormat } from "@/lib/image-editor-types";
export function downloadStageImage(
stage: Konva.Stage,
format: ExportImageFormat,
quality: number
): void {
const mimeType =
format === "png"
? "image/png"
: format === "jpg"
? "image/jpeg"
: "image/webp";
const dataUrl = stage.toDataURL({
pixelRatio: 2,
mimeType,
quality: format === "png" ? 1 : quality / 100,
});
const link = document.createElement("a");
link.download = `design-${Date.now()}.${format}`;
link.href = dataUrl;
link.click();
}
+110
View File
@@ -0,0 +1,110 @@
import type { FilterPresetId, ImageAdjustments } from "@/lib/image-editor-types";
import { DEFAULT_ADJUSTMENTS } from "@/lib/image-editor-types";
export interface FilterPreset {
id: FilterPresetId;
label: string;
adjustments: ImageAdjustments;
}
export const FILTER_PRESETS: FilterPreset[] = [
{ id: "original", label: "Original", adjustments: { ...DEFAULT_ADJUSTMENTS } },
{
id: "vivid",
label: "Vivid",
adjustments: { ...DEFAULT_ADJUSTMENTS, contrast: 28, saturation: 45 },
},
{
id: "warm",
label: "Warm",
adjustments: { ...DEFAULT_ADJUSTMENTS, hue: 18, saturation: 22, brightness: 6 },
},
{
id: "cool",
label: "Cool",
adjustments: { ...DEFAULT_ADJUSTMENTS, hue: -22, saturation: 12, brightness: 4 },
},
{
id: "bw",
label: "Black & White",
adjustments: { ...DEFAULT_ADJUSTMENTS, saturation: -100, contrast: 12 },
},
{
id: "vintage",
label: "Vintage",
adjustments: {
...DEFAULT_ADJUSTMENTS,
contrast: -12,
saturation: -35,
brightness: 12,
vignette: 35,
},
},
{
id: "fade",
label: "Fade",
adjustments: {
...DEFAULT_ADJUSTMENTS,
contrast: -18,
brightness: 18,
saturation: -25,
},
},
{
id: "chrome",
label: "Chrome",
adjustments: {
...DEFAULT_ADJUSTMENTS,
contrast: 38,
saturation: 28,
sharpen: 4,
},
},
{
id: "moody",
label: "Moody",
adjustments: {
...DEFAULT_ADJUSTMENTS,
brightness: -18,
contrast: 22,
saturation: -28,
vignette: 45,
},
},
{
id: "sunset",
label: "Sunset",
adjustments: {
...DEFAULT_ADJUSTMENTS,
hue: 24,
saturation: 38,
brightness: 8,
},
},
{
id: "forest",
label: "Forest",
adjustments: {
...DEFAULT_ADJUSTMENTS,
hue: -28,
saturation: 24,
contrast: 10,
},
},
{
id: "dramatic",
label: "Dramatic",
adjustments: {
...DEFAULT_ADJUSTMENTS,
contrast: 42,
brightness: -12,
saturation: 18,
vignette: 55,
sharpen: 2,
},
},
];
export function getPresetById(id: FilterPresetId): FilterPreset {
return FILTER_PRESETS.find((preset) => preset.id === id) ?? FILTER_PRESETS[0];
}
+42
View File
@@ -0,0 +1,42 @@
import Konva from "konva";
import type { ImageAdjustments } from "@/lib/image-editor-types";
export function buildKonvaFilterList(adjustments: ImageAdjustments): Array<typeof Konva.Filters.Brighten> {
const filters: Array<typeof Konva.Filters.Brighten> = [];
if (adjustments.brightness !== 0) filters.push(Konva.Filters.Brighten);
if (adjustments.contrast !== 0) filters.push(Konva.Filters.Contrast);
if (
adjustments.saturation !== 0 ||
adjustments.hue !== 0
) {
filters.push(Konva.Filters.HSL);
}
if (adjustments.blur > 0) filters.push(Konva.Filters.Blur);
if (adjustments.sharpen > 0) filters.push(Konva.Filters.Enhance);
return filters;
}
export function applyAdjustmentsToNode(
node: Konva.Image,
adjustments: ImageAdjustments,
filters: Array<typeof Konva.Filters.Brighten>
): void {
if (filters.length === 0) {
node.filters([]);
node.clearCache();
return;
}
node.filters(filters);
node.brightness(adjustments.brightness / 100);
node.contrast(adjustments.contrast / 100);
node.hue(adjustments.hue);
node.saturation(adjustments.saturation / 100);
node.luminance(0);
node.blurRadius(adjustments.blur);
node.enhance(adjustments.sharpen / 10);
node.cache();
}
+11
View File
@@ -0,0 +1,11 @@
import type Konva from "konva";
let stage: Konva.Stage | null = null;
export function registerImageEditorStage(instance: Konva.Stage | null): void {
stage = instance;
}
export function getImageEditorStage(): Konva.Stage | null {
return stage;
}
+372
View File
@@ -0,0 +1,372 @@
import { create } from "zustand";
import {
cropImageDataUrl,
fitCropRectToAspect,
getBaseLayerSourceCrop,
} from "@/lib/image-editor-crop";
import type {
CropRect,
ExportImageFormat,
FilterPresetId,
ImageAdjustments,
ImageCropAspectRatio,
ImageLayer,
ImageLayerType,
ImagePanelTab,
ImageShapeKind,
ImageTool,
} from "@/lib/image-editor-types";
import {
DEFAULT_ADJUSTMENTS,
DEFAULT_CANVAS_SIZE,
} from "@/lib/image-editor-types";
import { getPresetById } from "@/lib/image-editor-filters";
import {
buildImageSceneDataPayload,
parseImageSceneData,
} from "@/lib/image-scene-data";
function createId(): string {
return crypto.randomUUID();
}
function createLayer(
type: ImageLayerType,
zIndex: number,
partial?: Partial<ImageLayer>
): ImageLayer {
return {
id: createId(),
type,
name: partial?.name ?? type,
visible: true,
x: partial?.x ?? 100,
y: partial?.y ?? 100,
width: partial?.width ?? 240,
height: partial?.height ?? 120,
rotation: partial?.rotation ?? 0,
opacity: partial?.opacity ?? 1,
zIndex,
props: partial?.props ?? {},
};
}
export interface ImageEditorState {
canvasWidth: number;
canvasHeight: number;
layers: ImageLayer[];
selectedLayerId: string | null;
activeTool: ImageTool;
activePanelTab: ImagePanelTab;
adjustments: ImageAdjustments;
activeFilterPreset: FilterPresetId;
cropRect: CropRect | null;
cropAspectRatio: ImageCropAspectRatio;
exportFormat: ExportImageFormat;
exportQuality: number;
isAiModalOpen: boolean;
pendingShape: ImageShapeKind;
}
export interface ImageEditorActions {
setActiveTool: (tool: ImageTool) => void;
setActivePanelTab: (tab: ImagePanelTab) => void;
setSelectedLayer: (layerId: string | null) => void;
setAdjustments: (updates: Partial<ImageAdjustments>) => void;
applyFilterPreset: (presetId: FilterPresetId) => void;
setCropRect: (rect: CropRect | null) => void;
setCropAspectRatio: (ratio: ImageCropAspectRatio) => void;
applyCrop: () => Promise<void>;
cancelCrop: () => void;
setCanvasSize: (width: number, height: number) => void;
loadBaseImage: (src: string, width: number, height: number) => void;
replaceBaseImage: (src: string) => void;
addLayer: (partial: Partial<ImageLayer> & { type: ImageLayerType }) => void;
updateLayer: (layerId: string, updates: Partial<ImageLayer>) => void;
updateLayerProps: (layerId: string, props: Record<string, unknown>) => void;
deleteLayer: (layerId: string) => void;
toggleLayerVisibility: (layerId: string) => void;
reorderLayers: (orderedIds: string[]) => void;
setExportFormat: (format: ExportImageFormat) => void;
setExportQuality: (quality: number) => void;
setAiModalOpen: (open: boolean) => void;
setPendingShape: (shape: ImageShapeKind) => void;
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean;
getSceneDataForSave: () => Record<string, unknown>;
}
export type ImageEditorStore = ImageEditorState & ImageEditorActions;
function getBaseLayer(layers: ImageLayer[]): ImageLayer | undefined {
return layers.find((layer) => layer.type === "image");
}
export const useImageEditorStore = create<ImageEditorStore>((set, get) => ({
canvasWidth: DEFAULT_CANVAS_SIZE.width,
canvasHeight: DEFAULT_CANVAS_SIZE.height,
layers: [],
selectedLayerId: null,
activeTool: "select",
activePanelTab: "adjust",
adjustments: { ...DEFAULT_ADJUSTMENTS },
activeFilterPreset: "original",
cropRect: null,
cropAspectRatio: "free",
exportFormat: "png",
exportQuality: 90,
isAiModalOpen: false,
pendingShape: "rect",
setActiveTool: (tool) => {
if (tool === "crop") {
const { canvasWidth, canvasHeight } = get();
set({
activeTool: "crop",
cropRect: { x: 0, y: 0, w: canvasWidth, h: canvasHeight },
cropAspectRatio: "free",
selectedLayerId: null,
isAiModalOpen: false,
});
return;
}
set({
activeTool: tool,
cropRect: null,
cropAspectRatio: "free",
isAiModalOpen: tool === "ai",
});
},
setActivePanelTab: (tab) => set({ activePanelTab: tab }),
setSelectedLayer: (layerId) => set({ selectedLayerId: layerId }),
setAdjustments: (updates) => {
set({
adjustments: { ...get().adjustments, ...updates },
activeFilterPreset: "original",
});
},
applyFilterPreset: (presetId) => {
const preset = getPresetById(presetId);
set({
activeFilterPreset: presetId,
adjustments: { ...preset.adjustments },
});
},
setCropRect: (rect) => set({ cropRect: rect }),
setCropAspectRatio: (ratio) => {
const { cropRect, canvasWidth, canvasHeight } = get();
if (!cropRect) return;
set({
cropAspectRatio: ratio,
cropRect: fitCropRectToAspect(
cropRect,
ratio,
canvasWidth,
canvasHeight
),
});
},
cancelCrop: () =>
set({
activeTool: "select",
cropRect: null,
cropAspectRatio: "free",
}),
applyCrop: async () => {
const { cropRect, layers } = get();
if (!cropRect) return;
const base = getBaseLayer(layers);
let nextLayers = layers.map((layer) => ({
...layer,
x: layer.x - cropRect.x,
y: layer.y - cropRect.y,
}));
if (base && typeof base.props.src === "string") {
const sourceCrop = getBaseLayerSourceCrop(base, cropRect);
if (sourceCrop) {
try {
const cropped = await cropImageDataUrl(
base.props.src,
sourceCrop,
base.width,
base.height
);
nextLayers = nextLayers.map((layer) =>
layer.id === base.id
? {
...layer,
x: Math.max(0, base.x - cropRect.x),
y: Math.max(0, base.y - cropRect.y),
width: cropped.width,
height: cropped.height,
props: { ...layer.props, src: cropped.dataUrl },
}
: layer
);
} catch {
const intersect = getBaseLayerSourceCrop(base, cropRect);
if (intersect) {
nextLayers = nextLayers.map((layer) =>
layer.id === base.id
? {
...layer,
x: Math.max(0, base.x - cropRect.x),
y: Math.max(0, base.y - cropRect.y),
width: intersect.w,
height: intersect.h,
}
: layer
);
}
}
}
}
set({
canvasWidth: Math.round(cropRect.w),
canvasHeight: Math.round(cropRect.h),
layers: nextLayers,
cropRect: null,
cropAspectRatio: "free",
activeTool: "select",
selectedLayerId: null,
});
},
setCanvasSize: (width, height) => set({ canvasWidth: width, canvasHeight: height }),
loadBaseImage: (src, width, height) => {
const layer = createLayer("image", 0, {
name: "Background",
x: 0,
y: 0,
width,
height,
props: { src },
});
set({
canvasWidth: width,
canvasHeight: height,
layers: [layer],
selectedLayerId: layer.id,
adjustments: { ...DEFAULT_ADJUSTMENTS },
activeFilterPreset: "original",
});
},
replaceBaseImage: (src) => {
const base = getBaseLayer(get().layers);
if (!base) return;
get().updateLayerProps(base.id, { src });
},
addLayer: (partial) => {
const maxZ = get().layers.reduce((m, l) => Math.max(m, l.zIndex), 0);
const layer = createLayer(partial.type, maxZ + 1, partial);
set({
layers: [...get().layers, layer],
selectedLayerId: layer.id,
activeTool: "select",
});
},
updateLayer: (layerId, updates) => {
set({
layers: get().layers.map((layer) =>
layer.id === layerId ? { ...layer, ...updates } : layer
),
});
},
updateLayerProps: (layerId, props) => {
set({
layers: get().layers.map((layer) =>
layer.id === layerId
? { ...layer, props: { ...layer.props, ...props } }
: layer
),
});
},
deleteLayer: (layerId) => {
const target = get().layers.find((l) => l.id === layerId);
if (!target || target.type === "image") return;
set({
layers: get().layers.filter((layer) => layer.id !== layerId),
selectedLayerId:
get().selectedLayerId === layerId ? null : get().selectedLayerId,
});
},
toggleLayerVisibility: (layerId) => {
set({
layers: get().layers.map((layer) =>
layer.id === layerId ? { ...layer, visible: !layer.visible } : layer
),
});
},
reorderLayers: (orderedIds) => {
const map = new Map(get().layers.map((l) => [l.id, l]));
const reordered = orderedIds
.map((id, index) => {
const layer = map.get(id);
return layer ? { ...layer, zIndex: index } : null;
})
.filter((layer): layer is ImageLayer => layer !== null);
set({ layers: reordered });
},
setExportFormat: (format) => set({ exportFormat: format }),
setExportQuality: (quality) => set({ exportQuality: quality }),
setAiModalOpen: (open) =>
set({ isAiModalOpen: open, activeTool: open ? "ai" : "select" }),
setPendingShape: (shape) => set({ pendingShape: shape }),
hydrateFromSceneData: (sceneData) => {
const parsed = parseImageSceneData(sceneData);
if (!parsed) return false;
set({
canvasWidth: parsed.canvasWidth,
canvasHeight: parsed.canvasHeight,
layers: parsed.layers,
adjustments: parsed.adjustments,
activeFilterPreset: parsed.activeFilterPreset,
selectedLayerId: null,
activeTool: "select",
cropRect: null,
cropAspectRatio: "free",
isAiModalOpen: false,
});
return true;
},
getSceneDataForSave: () => {
const state = get();
return buildImageSceneDataPayload({
canvasWidth: state.canvasWidth,
canvasHeight: state.canvasHeight,
layers: state.layers,
adjustments: state.adjustments,
activeFilterPreset: state.activeFilterPreset,
});
},
}));
export function getBaseImageLayer(
state: Pick<ImageEditorState, "layers">
): ImageLayer | undefined {
return getBaseLayer(state.layers);
}
+21
View File
@@ -0,0 +1,21 @@
import type Konva from "konva";
import type { ImageLayer } from "@/lib/image-editor-types";
export function nodeToImageLayer(node: Konva.Node): Partial<ImageLayer> {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
return {
x: node.x(),
y: node.y(),
width: Math.max(8, node.width() * scaleX),
height: Math.max(8, node.height() * scaleY),
rotation: node.rotation(),
};
}
export function resetNodeScale(node: Konva.Node): void {
node.scaleX(1);
node.scaleY(1);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Layer, LayerType } from "@/lib/studio-types";
export type ImageTool = "select" | "crop" | "text" | "shape" | "draw" | "ai";
/** Shared with video studio — see `Layer` in studio-types.ts */
export type ImageLayer = Layer;
export type ImageLayerType = Extract<
LayerType,
"image" | "text" | "shape" | "draw"
>;
export type ImageShapeKind = "rect" | "circle" | "line" | "arrow";
export type ImagePanelTab = "adjust" | "filters" | "layers";
export type ExportImageFormat = "png" | "jpg" | "webp";
export type FilterPresetId =
| "original"
| "vivid"
| "warm"
| "cool"
| "bw"
| "vintage"
| "fade"
| "chrome"
| "moody"
| "sunset"
| "forest"
| "dramatic";
export interface ImageAdjustments {
brightness: number;
contrast: number;
saturation: number;
hue: number;
blur: number;
sharpen: number;
vignette: number;
}
export interface CropRect {
x: number;
y: number;
w: number;
h: number;
}
export type ImageCropAspectRatio = "free" | "1:1" | "16:9" | "4:3" | "9:16";
export const DEFAULT_CANVAS_SIZE = { width: 1080, height: 1080 } as const;
export const DEFAULT_ADJUSTMENTS: ImageAdjustments = {
brightness: 0,
contrast: 0,
saturation: 0,
hue: 0,
blur: 0,
sharpen: 0,
vignette: 0,
};
+3
View File
@@ -0,0 +1,3 @@
/** Tiny neutral JPEG used as next/image blur placeholder for remote images. */
export const IMAGE_BLUR_DATA_URL =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=";
+109
View File
@@ -0,0 +1,109 @@
import type {
FilterPresetId,
ImageAdjustments,
ImageLayer,
} from "@/lib/image-editor-types";
import { DEFAULT_ADJUSTMENTS } from "@/lib/image-editor-types";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseImageLayer(value: unknown): ImageLayer | null {
if (!isRecord(value)) return null;
if (typeof value.id !== "string" || typeof value.type !== "string") return null;
return {
id: value.id,
type: value.type as ImageLayer["type"],
name: typeof value.name === "string" ? value.name : undefined,
visible: typeof value.visible === "boolean" ? value.visible : true,
x: typeof value.x === "number" ? value.x : 0,
y: typeof value.y === "number" ? value.y : 0,
width: typeof value.width === "number" ? value.width : 240,
height: typeof value.height === "number" ? value.height : 120,
rotation: typeof value.rotation === "number" ? value.rotation : 0,
opacity: typeof value.opacity === "number" ? value.opacity : 1,
zIndex: typeof value.zIndex === "number" ? value.zIndex : 0,
props: isRecord(value.props) ? value.props : {},
};
}
function parseAdjustments(value: unknown): ImageAdjustments {
if (!isRecord(value)) return { ...DEFAULT_ADJUSTMENTS };
return {
brightness:
typeof value.brightness === "number"
? value.brightness
: DEFAULT_ADJUSTMENTS.brightness,
contrast:
typeof value.contrast === "number"
? value.contrast
: DEFAULT_ADJUSTMENTS.contrast,
saturation:
typeof value.saturation === "number"
? value.saturation
: DEFAULT_ADJUSTMENTS.saturation,
hue: typeof value.hue === "number" ? value.hue : DEFAULT_ADJUSTMENTS.hue,
blur: typeof value.blur === "number" ? value.blur : DEFAULT_ADJUSTMENTS.blur,
sharpen:
typeof value.sharpen === "number"
? value.sharpen
: DEFAULT_ADJUSTMENTS.sharpen,
vignette:
typeof value.vignette === "number"
? value.vignette
: DEFAULT_ADJUSTMENTS.vignette,
};
}
export function isImageSceneDataEmpty(sceneData: Record<string, unknown>): boolean {
if (!Array.isArray(sceneData.layers) || sceneData.layers.length === 0) {
return true;
}
return false;
}
export interface ImagePersistedSceneData {
canvasWidth: number;
canvasHeight: number;
layers: ImageLayer[];
adjustments: ImageAdjustments;
activeFilterPreset: FilterPresetId;
}
export function buildImageSceneDataPayload(
input: ImagePersistedSceneData
): Record<string, unknown> {
return {
canvasWidth: input.canvasWidth,
canvasHeight: input.canvasHeight,
layers: input.layers,
adjustments: input.adjustments,
activeFilterPreset: input.activeFilterPreset,
};
}
export function parseImageSceneData(
sceneData: Record<string, unknown>
): ImagePersistedSceneData | null {
const layers = Array.isArray(sceneData.layers)
? sceneData.layers
.map(parseImageLayer)
.filter((layer): layer is ImageLayer => layer !== null)
: [];
if (layers.length === 0) return null;
return {
canvasWidth:
typeof sceneData.canvasWidth === "number" ? sceneData.canvasWidth : 1080,
canvasHeight:
typeof sceneData.canvasHeight === "number" ? sceneData.canvasHeight : 1080,
layers,
adjustments: parseAdjustments(sceneData.adjustments),
activeFilterPreset:
typeof sceneData.activeFilterPreset === "string"
? (sceneData.activeFilterPreset as FilterPresetId)
: "original",
};
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export const SITE_NAME = "FlatRender";
export const DEFAULT_OG_IMAGE = "/opengraph-image";
export const defaultMetadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: `${SITE_NAME} — Create Videos & Images`,
template: `%s — ${SITE_NAME}`,
},
description:
"Create pro videos and images with AI-powered templates, editors, and one-click export for creators and brands.",
openGraph: {
type: "website",
locale: "en_US",
siteName: SITE_NAME,
images: [
{
url: DEFAULT_OG_IMAGE,
width: 1200,
height: 630,
alt: "FlatRender — AI Video & Image Maker",
},
],
},
twitter: {
card: "summary_large_image",
images: [DEFAULT_OG_IMAGE],
},
};
interface PageMetadataOptions {
title: string;
description: string;
path?: string;
}
export function createPageMetadata({
title,
description,
path = "",
}: PageMetadataOptions): Metadata {
const url = new URL(path, siteUrl).toString();
return {
title,
description,
openGraph: {
title,
description,
url,
images: [
{
url: DEFAULT_OG_IMAGE,
width: 1200,
height: 630,
alt: `${title}${SITE_NAME}`,
},
],
},
twitter: {
title,
description,
images: [DEFAULT_OG_IMAGE],
},
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface NavbarMenuLink {
label: string;
href: string;
}
export const VIDEO_MAKER_NAV = {
browseLabel: "Browse Templates",
browseHref: "/templates",
items: [
{ label: "Animation Videos", href: "/templates?category=animation" },
{ label: "Intros & Logos", href: "/templates?category=intros" },
{ label: "Social Media", href: "/templates?category=social" },
{ label: "Slideshow", href: "/templates?category=slideshow" },
{ label: "Video Ad Templates", href: "/templates?category=ads" },
{ label: "Music Visualisation", href: "/templates?category=music" },
{ label: "Featured Animations", href: "/templates?category=featured" },
] satisfies NavbarMenuLink[],
};
export const IMAGE_MAKER_NAV = {
browseLabel: "Browse Image Templates",
browseHref: "/image-maker",
items: [
{ label: "Social Media Graphics", href: "/image-maker?category=social" },
{ label: "Banners & Ads", href: "/image-maker?category=banners" },
{ label: "Presentations", href: "/image-maker?category=presentations" },
{ label: "Posters & Flyers", href: "/image-maker?category=posters" },
{ label: "Logo Maker", href: "/image-maker?category=logos" },
] satisfies NavbarMenuLink[],
};
export const LEARN_NAV_ITEMS: NavbarMenuLink[] = [
{ label: "Blog", href: "/blog" },
{ label: "Tutorials", href: "/tutorials" },
{ label: "Help Center", href: "/help" },
];
+49
View File
@@ -0,0 +1,49 @@
import type { BillingPeriod } from "@/components/sections/pricing-data";
export type PlanId = "free" | "pro" | "business";
export type PaidPlanId = Extract<PlanId, "pro" | "business">;
export const PAID_PLAN_IDS: PaidPlanId[] = ["pro", "business"];
export function isPaidPlanId(value: string): value is PaidPlanId {
return PAID_PLAN_IDS.includes(value as PaidPlanId);
}
export function getPlanLabel(plan: PlanId): string {
switch (plan) {
case "pro":
return "Pro";
case "business":
return "Business";
default:
return "Free";
}
}
export function getStripePriceId(
plan: PaidPlanId,
billing: BillingPeriod
): string {
const priceIds: Record<PaidPlanId, Record<BillingPeriod, string | undefined>> =
{
pro: {
monthly: process.env.STRIPE_PRICE_PRO_MONTHLY,
annual: process.env.STRIPE_PRICE_PRO_ANNUAL,
},
business: {
monthly: process.env.STRIPE_PRICE_BUSINESS_MONTHLY,
annual: process.env.STRIPE_PRICE_BUSINESS_ANNUAL,
},
};
const priceId = priceIds[plan][billing];
if (!priceId) {
throw new Error(
`Missing Stripe price ID for plan "${plan}" (${billing} billing).`
);
}
return priceId;
}
+54
View File
@@ -0,0 +1,54 @@
import type { PlanId } from "@/lib/plans";
import { createClient } from "@/lib/supabase/server";
export interface UserProfile {
id: string;
email: string | null;
plan: PlanId;
billing_period: string | null;
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
}
export async function getUserProfile(userId: string): Promise<UserProfile> {
const supabase = await createClient();
const { data, error } = await supabase
.from("profiles")
.select("id, email, plan, billing_period, stripe_customer_id, stripe_subscription_id")
.eq("id", userId)
.maybeSingle();
if (error) {
return {
id: userId,
email: null,
plan: "free",
billing_period: null,
stripe_customer_id: null,
stripe_subscription_id: null,
};
}
if (!data) {
return {
id: userId,
email: null,
plan: "free",
billing_period: null,
stripe_customer_id: null,
stripe_subscription_id: null,
};
}
const plan = data.plan as PlanId;
return {
id: data.id,
email: data.email,
plan: plan === "pro" || plan === "business" ? plan : "free",
billing_period: data.billing_period,
stripe_customer_id: data.stripe_customer_id,
stripe_subscription_id: data.stripe_subscription_id,
};
}
+74
View File
@@ -0,0 +1,74 @@
import type { ProjectStatus, ProjectType } from "@/lib/projects";
export interface ProjectDetail {
id: string;
name: string;
type: ProjectType;
scene_data: Record<string, unknown>;
status: ProjectStatus;
updated_at: string;
}
export interface ProjectApiResponse {
project: ProjectDetail;
}
export class ProjectApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ProjectApiError";
this.status = status;
}
}
export function isProjectApiError(error: unknown): error is ProjectApiError {
return error instanceof ProjectApiError;
}
export function isProjectNotFoundError(error: unknown): boolean {
return isProjectApiError(error) && error.status === 404;
}
async function parseProjectResponse(
response: Response
): Promise<ProjectApiResponse & { error?: string }> {
return (await response.json()) as ProjectApiResponse & { error?: string };
}
export async function fetchProject(
projectId: string
): Promise<ProjectApiResponse> {
const response = await fetch(`/api/projects/${projectId}`, {
method: "GET",
cache: "no-store",
});
const data = await parseProjectResponse(response);
if (!response.ok) {
throw new ProjectApiError(data.error ?? "Failed to load project", response.status);
}
return data;
}
export async function patchProjectSceneData(
projectId: string,
scene_data: Record<string, unknown>
): Promise<ProjectApiResponse> {
const response = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scene_data }),
});
const data = await parseProjectResponse(response);
if (!response.ok) {
throw new ProjectApiError(data.error ?? "Failed to save project", response.status);
}
return data;
}
+48
View File
@@ -0,0 +1,48 @@
import type { ProjectType } from "@/lib/projects";
function createId(): string {
return crypto.randomUUID();
}
export function createDefaultSceneData(type: ProjectType): Record<string, unknown> {
switch (type) {
case "video": {
const sceneId = createId();
return {
scenes: [
{
id: sceneId,
name: "Scene 1",
duration: 5,
layers: [],
transitionType: "none",
},
],
activeSceneId: sceneId,
};
}
case "image":
return {
canvasWidth: 1080,
canvasHeight: 1080,
layers: [],
};
case "trimmer":
return {};
default:
return {};
}
}
export function defaultProjectName(type: ProjectType): string {
switch (type) {
case "video":
return "Untitled video";
case "image":
return "Untitled image";
case "trimmer":
return "Untitled trim";
default:
return "Untitled project";
}
}
+5
View File
@@ -0,0 +1,5 @@
export const DEV_PROJECT_ID = "dev-project";
export function isDevProjectId(projectId: string): boolean {
return projectId === DEV_PROJECT_ID;
}
+10
View File
@@ -0,0 +1,10 @@
export type ProjectSaveStatus =
| "idle"
| "pending"
| "saving"
| "saved"
| "local"
| "error";
export const PROJECT_SAVE_DEBOUNCE_MS = 3000;
export const PROJECT_SAVED_DISPLAY_MS = 2000;
+86
View File
@@ -0,0 +1,86 @@
export type ProjectType = "video" | "image" | "trimmer";
export type ProjectStatus = "draft" | "rendering" | "ready";
export interface DashboardProject {
id: string;
name: string;
type: ProjectType;
status: ProjectStatus;
renderUrl: string | null;
thumbnailSeed: string;
lastEditedAt: string;
}
export interface ProjectRow {
id: string;
user_id: string;
name: string;
type: ProjectType;
scene_data: Record<string, unknown>;
render_url: string | null;
status: ProjectStatus;
created_at: string;
updated_at: string;
}
export function mapProjectRow(row: ProjectRow): DashboardProject {
return {
id: row.id,
name: row.name,
type: row.type,
status: row.status,
renderUrl: row.render_url,
thumbnailSeed: row.id.replace(/-/g, "").slice(0, 12),
lastEditedAt: row.updated_at,
};
}
export function getProjectStudioPath(project: Pick<DashboardProject, "id" | "type">): string {
switch (project.type) {
case "video":
return `/studio/video/${project.id}`;
case "image":
return `/studio/image/${project.id}`;
case "trimmer":
return "/studio/trimmer";
default:
return "/dashboard";
}
}
export function getProjectTypeLabel(type: ProjectType): string {
switch (type) {
case "video":
return "VIDEO";
case "image":
return "IMAGE";
case "trimmer":
return "TRIMMER";
default: {
const _exhaustive: never = type;
return _exhaustive;
}
}
}
export function formatLastEdited(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Edited today";
if (diffDays === 1) return "Edited yesterday";
if (diffDays < 7) return `Edited ${diffDays} days ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function getProjectThumbnailSrc(seed: string): string {
return `https://picsum.photos/seed/${seed}/480/270`;
}
+76
View File
@@ -0,0 +1,76 @@
import { createAdminClient } from "@/lib/supabase/admin";
import type { RenderRequest, RenderJobStatus } from "@/lib/render-schemas";
export interface RenderJobRow {
id: string;
project_id: string;
status: RenderJobStatus;
progress: number;
progress_message: string | null;
output_url: string | null;
scenes: RenderRequest["scenes"];
settings: RenderRequest["settings"];
error_message: string | null;
created_at: string;
updated_at: string;
}
export async function createRenderJob(
payload: RenderRequest
): Promise<{ jobId: string } | { error: string }> {
const supabase = createAdminClient();
const { data, error } = await supabase
.from("render_jobs")
.insert({
project_id: payload.projectId,
status: "queued",
progress: 0,
progress_message: "Queued for rendering",
scenes: payload.scenes,
settings: payload.settings,
})
.select("id")
.single();
if (error || !data) {
return { error: error?.message ?? "Failed to create render job" };
}
return { jobId: data.id };
}
export async function getRenderJob(
jobId: string
): Promise<RenderJobRow | null> {
const supabase = createAdminClient();
const { data, error } = await supabase
.from("render_jobs")
.select("*")
.eq("id", jobId)
.maybeSingle();
if (error || !data) return null;
return data as RenderJobRow;
}
export async function triggerRenderWorker(jobId: string): Promise<void> {
const workerUrl = process.env.RENDER_WORKER_URL;
if (!workerUrl) return;
const secret = process.env.RENDER_WORKER_SECRET;
try {
await fetch(`${workerUrl.replace(/\/$/, "")}/process`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(secret ? { Authorization: `Bearer ${secret}` } : {}),
},
body: JSON.stringify({ jobId }),
});
} catch {
// Worker may be offline; job stays queued for retry/poll
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { RenderSettings } from "@/lib/render-schemas";
export type RenderExportPreset = "full" | "preview" | "gif";
export const RENDER_EXPORT_PRESETS: Record<
RenderExportPreset,
{ label: string; settings: RenderSettings; description: string }
> = {
full: {
label: "Render Video (Full Quality)",
description: "1080p MP4 at 30 fps",
settings: { resolution: "1080p", format: "mp4", fps: 30 },
},
preview: {
label: "Quick Preview (720p)",
description: "720p MP4 at 24 fps",
settings: { resolution: "720p", format: "mp4", fps: 24 },
},
gif: {
label: "Export as GIF",
description: "720p MP4 preview (GIF pipeline coming soon)",
settings: { resolution: "720p", format: "mp4", fps: 24 },
},
};
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
export const layerSchema = z.object({
id: z.string(),
type: z.enum(["text", "image", "video", "shape"]),
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
rotation: z.number(),
opacity: z.number(),
zIndex: z.number(),
props: z.record(z.string(), z.unknown()),
});
export const sceneSchema = z.object({
id: z.string(),
name: z.string(),
duration: z.number().positive(),
layers: z.array(layerSchema),
thumbnailUrl: z.string().optional(),
});
export const renderSettingsSchema = z.object({
resolution: z.enum(["720p", "1080p", "4K"]),
format: z.literal("mp4").default("mp4"),
fps: z.union([z.literal(24), z.literal(30), z.literal(60)]),
});
export const renderRequestSchema = z.object({
projectId: z.string().min(1),
scenes: z.array(sceneSchema).min(1),
settings: renderSettingsSchema,
});
export type RenderRequest = z.infer<typeof renderRequestSchema>;
export type RenderSettings = z.infer<typeof renderSettingsSchema>;
export type RenderScene = z.infer<typeof sceneSchema>;
export const renderStatusSchema = z.enum([
"queued",
"processing",
"completed",
"failed",
]);
export type RenderJobStatus = z.infer<typeof renderStatusSchema>;
export const RESOLUTION_DIMENSIONS: Record<
RenderSettings["resolution"],
{ width: number; height: number }
> = {
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
"4K": { width: 3840, height: 2160 },
};
+623
View File
@@ -0,0 +1,623 @@
/** A layer definition without an `id` — fresh ids are assigned when the template is applied. */
export interface SceneTemplateLayer {
type: "text" | "image" | "video" | "shape" | "draw";
name?: string;
visible?: boolean;
x: number;
y: number;
width: number;
height: number;
rotation: number;
opacity: number;
zIndex: number;
props: Record<string, unknown>;
}
export const SCENE_BROWSER_CATEGORIES = [
{ id: "all", label: "All Scenes" },
{ id: "characters", label: "Characters" },
{ id: "business", label: "Business" },
{ id: "technology", label: "Technology" },
{ id: "nature", label: "Nature" },
{ id: "abstract", label: "Abstract" },
{ id: "sports", label: "Sports" },
{ id: "food", label: "Food" },
] as const;
export type SceneBrowserCategoryId =
(typeof SCENE_BROWSER_CATEGORIES)[number]["id"];
export type SceneBrowserContentCategory = Exclude<
SceneBrowserCategoryId,
"all"
>;
export type SceneBrowserMediaFilter = "all" | "video" | "photo";
export interface BrowserSceneItem {
id: string;
name: string;
category: SceneBrowserContentCategory;
mediaType: "video" | "photo";
characterCount: number;
durationLabel: string;
gradientFrom: string;
gradientTo: string;
/** Pre-built layers that populate the scene when selected. */
templateLayers: SceneTemplateLayer[];
}
// ---------------------------------------------------------------------------
// Layout helpers — canvas is 1280 × 720
// ---------------------------------------------------------------------------
/** Two-column layout: solid colour left, image placeholder right. */
function splitLayout(
bg: string,
titleColor = "#FFFFFF",
subtitleColor = "#94a3b8"
): SceneTemplateLayer[] {
return [
{
type: "shape",
x: 0,
y: 0,
width: 1280,
height: 720,
rotation: 0,
opacity: 1,
zIndex: 0,
props: { shape: "rect", fill: bg, stroke: bg, strokeWidth: 0, cornerRadius: 0 },
},
{
type: "image",
x: 660,
y: 60,
width: 540,
height: 600,
rotation: 0,
opacity: 1,
zIndex: 1,
props: { src: "", cornerRadius: 12 },
},
{
type: "text",
x: 80,
y: 230,
width: 530,
height: 120,
rotation: 0,
opacity: 1,
zIndex: 2,
props: {
text: "Your Main Title",
fontSize: 60,
fill: titleColor,
fontFamily: "Inter, sans-serif",
align: "left",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
{
type: "text",
x: 80,
y: 380,
width: 530,
height: 80,
rotation: 0,
opacity: 1,
zIndex: 3,
props: {
text: "Your Subtitle Here",
fontSize: 36,
fill: subtitleColor,
fontFamily: "Inter, sans-serif",
align: "left",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
];
}
/** Centred title + subtitle, no image placeholder. */
function centeredLayout(
bg: string,
titleColor = "#FFFFFF",
subtitleColor = "#94a3b8"
): SceneTemplateLayer[] {
return [
{
type: "shape",
x: 0,
y: 0,
width: 1280,
height: 720,
rotation: 0,
opacity: 1,
zIndex: 0,
props: { shape: "rect", fill: bg, stroke: bg, strokeWidth: 0, cornerRadius: 0 },
},
{
type: "text",
x: 80,
y: 265,
width: 1120,
height: 135,
rotation: 0,
opacity: 1,
zIndex: 1,
props: {
text: "Your Main Title",
fontSize: 72,
fill: titleColor,
fontFamily: "Inter, sans-serif",
align: "center",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
{
type: "text",
x: 80,
y: 430,
width: 1120,
height: 80,
rotation: 0,
opacity: 1,
zIndex: 2,
props: {
text: "Your Subtitle Here",
fontSize: 40,
fill: subtitleColor,
fontFamily: "Inter, sans-serif",
align: "center",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
];
}
/** Full-bleed image background with a dark overlay + centred text on top. */
function overlayLayout(
bg: string,
titleColor = "#FFFFFF",
subtitleColor = "#e2e8f0"
): SceneTemplateLayer[] {
return [
{
type: "shape",
x: 0,
y: 0,
width: 1280,
height: 720,
rotation: 0,
opacity: 1,
zIndex: 0,
props: { shape: "rect", fill: bg, stroke: bg, strokeWidth: 0, cornerRadius: 0 },
},
{
type: "image",
x: 0,
y: 0,
width: 1280,
height: 720,
rotation: 0,
opacity: 1,
zIndex: 1,
props: { src: "", cornerRadius: 0 },
},
{
type: "shape",
x: 0,
y: 0,
width: 1280,
height: 720,
rotation: 0,
opacity: 0.55,
zIndex: 2,
props: { shape: "rect", fill: "#000000", stroke: "#000000", strokeWidth: 0, cornerRadius: 0 },
},
{
type: "text",
x: 80,
y: 265,
width: 1120,
height: 135,
rotation: 0,
opacity: 1,
zIndex: 3,
props: {
text: "Your Main Title",
fontSize: 72,
fill: titleColor,
fontFamily: "Inter, sans-serif",
align: "center",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
{
type: "text",
x: 80,
y: 430,
width: 1120,
height: 80,
rotation: 0,
opacity: 1,
zIndex: 4,
props: {
text: "Your Subtitle Here",
fontSize: 40,
fill: subtitleColor,
fontFamily: "Inter, sans-serif",
align: "center",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
];
}
// ---------------------------------------------------------------------------
// Scene catalog
// ---------------------------------------------------------------------------
export const BROWSER_SCENES: BrowserSceneItem[] = [
// ── Characters ────────────────────────────────────────────────────────────
{
id: "man-waving",
name: "Man waving hello",
category: "characters",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-sky-200",
gradientTo: "to-blue-300",
templateLayers: splitLayout("#0c1a3d"),
},
{
id: "woman-presenting",
name: "Woman presenting",
category: "characters",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-violet-200",
gradientTo: "to-purple-300",
templateLayers: splitLayout("#1a0a2e"),
},
{
id: "friendly-greeting",
name: "Friendly office greeting",
category: "characters",
mediaType: "photo",
characterCount: 2,
durationLabel: "3-10 sec.",
gradientFrom: "from-rose-200",
gradientTo: "to-pink-300",
templateLayers: splitLayout("#2d0a14"),
},
{
id: "customer-support",
name: "Customer support agent",
category: "characters",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-cyan-200",
gradientTo: "to-teal-300",
templateLayers: splitLayout("#071a1a"),
},
// ── Business ──────────────────────────────────────────────────────────────
{
id: "team-meeting",
name: "Team meeting",
category: "business",
mediaType: "video",
characterCount: 4,
durationLabel: "3-10 sec.",
gradientFrom: "from-blue-200",
gradientTo: "to-indigo-300",
templateLayers: splitLayout("#0a1a3d"),
},
{
id: "handshake-deal",
name: "Handshake closing deal",
category: "business",
mediaType: "photo",
characterCount: 2,
durationLabel: "3-10 sec.",
gradientFrom: "from-slate-200",
gradientTo: "to-gray-300",
templateLayers: splitLayout("#0f172a"),
},
{
id: "startup-pitch",
name: "Startup pitch deck",
category: "business",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-indigo-200",
gradientTo: "to-violet-300",
templateLayers: centeredLayout("#0f0f2e"),
},
{
id: "office-collaboration",
name: "Office collaboration",
category: "business",
mediaType: "video",
characterCount: 3,
durationLabel: "3-10 sec.",
gradientFrom: "from-blue-200",
gradientTo: "to-sky-300",
templateLayers: splitLayout("#0a1428"),
},
// ── Technology ────────────────────────────────────────────────────────────
{
id: "city-skyline",
name: "City skyline",
category: "technology",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-indigo-200",
gradientTo: "to-blue-400",
templateLayers: overlayLayout("#0a0f2e"),
},
{
id: "tech-network",
name: "Tech network",
category: "technology",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-cyan-200",
gradientTo: "to-indigo-300",
templateLayers: centeredLayout("#071a1a"),
},
{
id: "coding-desk",
name: "Developer at desk",
category: "technology",
mediaType: "photo",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-emerald-200",
gradientTo: "to-teal-300",
templateLayers: splitLayout("#071c14"),
},
{
id: "data-visualization",
name: "Data visualization",
category: "technology",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-violet-200",
gradientTo: "to-fuchsia-300",
templateLayers: centeredLayout("#1a0a2e"),
},
// ── Nature ────────────────────────────────────────────────────────────────
{
id: "forest-path",
name: "Forest morning path",
category: "nature",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-green-200",
gradientTo: "to-emerald-300",
templateLayers: overlayLayout("#071c0f"),
},
{
id: "ocean-sunset",
name: "Ocean sunset",
category: "nature",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-amber-200",
gradientTo: "to-orange-300",
templateLayers: overlayLayout("#1c0f07"),
},
{
id: "mountain-aerial",
name: "Mountain aerial",
category: "nature",
mediaType: "photo",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-sky-200",
gradientTo: "to-blue-300",
templateLayers: overlayLayout("#0c1a2e"),
},
{
id: "wildlife-meadow",
name: "Wildlife meadow",
category: "nature",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-lime-200",
gradientTo: "to-green-300",
templateLayers: overlayLayout("#0a1c07"),
},
// ── Abstract ──────────────────────────────────────────────────────────────
{
id: "abstract-waves",
name: "Abstract waves",
category: "abstract",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-fuchsia-200",
gradientTo: "to-purple-300",
templateLayers: centeredLayout("#1a0a2e"),
},
{
id: "gradient-flow",
name: "Gradient flow",
category: "abstract",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-pink-200",
gradientTo: "to-rose-300",
templateLayers: centeredLayout("#1c0a14"),
},
{
id: "geometric-shapes",
name: "Geometric shapes",
category: "abstract",
mediaType: "photo",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-indigo-200",
gradientTo: "to-violet-300",
templateLayers: centeredLayout("#0f0a2e"),
},
{
id: "particle-burst",
name: "Particle burst",
category: "abstract",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-blue-200",
gradientTo: "to-cyan-300",
templateLayers: centeredLayout("#071628"),
},
// ── Sports ────────────────────────────────────────────────────────────────
{
id: "sports-celebration",
name: "Sports celebration",
category: "sports",
mediaType: "video",
characterCount: 3,
durationLabel: "3-10 sec.",
gradientFrom: "from-orange-200",
gradientTo: "to-red-300",
templateLayers: splitLayout("#1c0f07"),
},
{
id: "soccer-goal",
name: "Soccer goal moment",
category: "sports",
mediaType: "video",
characterCount: 2,
durationLabel: "3-10 sec.",
gradientFrom: "from-green-200",
gradientTo: "to-lime-300",
templateLayers: splitLayout("#0a1c07"),
},
{
id: "gym-workout",
name: "Gym workout",
category: "sports",
mediaType: "photo",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-amber-200",
gradientTo: "to-yellow-300",
templateLayers: splitLayout("#1c1007"),
},
{
id: "running-track",
name: "Running on track",
category: "sports",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-sky-200",
gradientTo: "to-indigo-300",
templateLayers: splitLayout("#0a1428"),
},
// ── Food ──────────────────────────────────────────────────────────────────
{
id: "food-preparation",
name: "Food preparation",
category: "food",
mediaType: "video",
characterCount: 1,
durationLabel: "3-10 sec.",
gradientFrom: "from-amber-200",
gradientTo: "to-orange-300",
templateLayers: splitLayout("#1c0f07"),
},
{
id: "restaurant-plating",
name: "Restaurant plating",
category: "food",
mediaType: "photo",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-rose-200",
gradientTo: "to-red-300",
templateLayers: overlayLayout("#1c0a0f"),
},
{
id: "coffee-pour",
name: "Coffee pour slow-mo",
category: "food",
mediaType: "video",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-yellow-200",
gradientTo: "to-amber-300",
templateLayers: overlayLayout("#0f0a07"),
},
{
id: "fresh-ingredients",
name: "Fresh ingredients",
category: "food",
mediaType: "photo",
characterCount: 0,
durationLabel: "3-10 sec.",
gradientFrom: "from-lime-200",
gradientTo: "to-green-300",
templateLayers: overlayLayout("#0a1c07"),
},
];
export function filterBrowserScenes(
scenes: BrowserSceneItem[],
options: {
categoryId: SceneBrowserCategoryId;
mediaFilter: SceneBrowserMediaFilter;
search: string;
}
): BrowserSceneItem[] {
const query = options.search.trim().toLowerCase();
return scenes.filter((scene) => {
if (options.categoryId !== "all" && scene.category !== options.categoryId) {
return false;
}
if (
options.mediaFilter !== "all" &&
scene.mediaType !== options.mediaFilter
) {
return false;
}
if (query && !scene.name.toLowerCase().includes(query)) {
return false;
}
return true;
});
}
+20
View File
@@ -0,0 +1,20 @@
import {
ArrowLeftRight,
Ban,
Layers,
ZoomIn,
type LucideIcon,
} from "lucide-react";
import type { SceneTransition } from "@/lib/studio-types";
export const SCENE_TRANSITION_OPTIONS: {
id: SceneTransition;
label: string;
icon: LucideIcon;
}[] = [
{ id: "none", label: "None", icon: Ban },
{ id: "fade", label: "Fade", icon: Layers },
{ id: "slide-left", label: "Slide", icon: ArrowLeftRight },
{ id: "zoom", label: "Zoom", icon: ZoomIn },
];
+47
View File
@@ -0,0 +1,47 @@
import { animate } from "framer-motion";
import type { SceneTransition } from "@/lib/studio-types";
export const SCENE_TRANSITION_DURATION_MS = 300;
const HALF_DURATION_SEC = SCENE_TRANSITION_DURATION_MS / 2000;
export async function playSceneTransition(
element: HTMLElement,
type: SceneTransition,
onMidpoint?: () => void
): Promise<void> {
if (type === "none") {
onMidpoint?.();
return;
}
const ease = "easeInOut" as const;
switch (type) {
case "fade": {
await animate(element, { opacity: 0 }, { duration: HALF_DURATION_SEC, ease });
onMidpoint?.();
await animate(element, { opacity: 1 }, { duration: HALF_DURATION_SEC, ease });
break;
}
case "slide-left": {
await animate(element, { x: "-100%" }, { duration: HALF_DURATION_SEC, ease });
onMidpoint?.();
await animate(element, { x: "100%" }, { duration: 0 });
await animate(element, { x: "0%" }, { duration: HALF_DURATION_SEC, ease });
break;
}
case "zoom": {
await animate(element, { scale: 0.8 }, { duration: HALF_DURATION_SEC, ease });
onMidpoint?.();
await animate(element, { scale: 1 }, { duration: HALF_DURATION_SEC, ease });
break;
}
default:
onMidpoint?.();
}
element.style.opacity = "";
element.style.transform = "";
}
+20
View File
@@ -0,0 +1,20 @@
import Stripe from "stripe";
let stripeClient: Stripe | null = null;
export function getStripe(): Stripe {
if (!stripeClient) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error("Missing STRIPE_SECRET_KEY environment variable.");
}
stripeClient = new Stripe(secretKey, {
apiVersion: "2026-04-22.dahlia",
typescript: true,
});
}
return stripeClient;
}
+11
View File
@@ -0,0 +1,11 @@
import type Konva from "konva";
let studioStage: Konva.Stage | null = null;
export function registerStudioStage(stage: Konva.Stage | null): void {
studioStage = stage;
}
export function getStudioStage(): Konva.Stage | null {
return studioStage;
}
+96
View File
@@ -0,0 +1,96 @@
export const DEFAULT_SCENE_BACKGROUND_COLOR = "#ffffff";
export const DEFAULT_SCENE_ACCENT_COLOR = "#94a3b8";
/**
* Each palette: [mainBg, accent, text, dark, darkest]
* Inspired by Renderforest's palette library.
*/
export const COLOR_PALETTES: string[][] = [
// 1 — Steel blue
["#b0c8e0", "#7898b0", "#2a3a50", "#141a28", "#080c14"],
// 2 — Yellow-lime
["#d8d040", "#90b838", "#3a5020", "#1a2810", "#28281a"],
// 3 — Warm sunset
["#d8b08a", "#c07060", "#881820", "#4a0808", "#1a0408"],
// 4 — Mint teal
["#90d8b8", "#50c8a0", "#107860", "#064030", "#021a14"],
// 5 — Royal blue
["#a0b8f0", "#5878e8", "#2028b0", "#0a1060", "#080830"],
// 6 — Rose pink
["#f0a8c0", "#e04898", "#981060", "#4a0830", "#200818"],
// 7 — Warm amber
["#e8b880", "#d07838", "#9a3010", "#4a1808", "#1a0804"],
// 8 — Deep violet
["#d8a8f0", "#b068e8", "#701898", "#380850", "#18041e"],
// 9 — Periwinkle
["#d0d8f0", "#a8b8e8", "#6070d0", "#2030a0", "#101870"],
// 10 — Gold amber
["#f0e040", "#e0b830", "#987010", "#485010", "#202810"],
// 11 — Silver neutral
["#f0f2f4", "#c0d0d8", "#8098a8", "#304050", "#90c0e0"],
// 12 — Forest green
["#a0e0a8", "#60c870", "#207820", "#103810", "#081808"],
// 13 — Warm gray + orange
["#e0e0e0", "#9098a0", "#404858", "#d07030", "#c05020"],
// 14 — Coral red
["#f09090", "#e83030", "#e0e0e0", "#3a3a3a", "#f0c030"],
// 15 — Midnight teal
["#1a3a4a", "#0d5c6e", "#1a9aae", "#80d0df", "#e0f4f8"],
// 16 — Neon purple
["#2d0060", "#5c00c0", "#9b30ff", "#d090ff", "#f0d8ff"],
// 17 — Earthy brown
["#3d2010", "#7a4020", "#c07840", "#e0b880", "#f5e0c0"],
// 18 — Ocean depth
["#001030", "#003060", "#0060c0", "#40a0e0", "#a0d8f8"],
// 19 — Cherry blossom
["#f8e0e8", "#f0a0c0", "#e04880", "#980840", "#400018"],
// 20 — Electric teal
["#003030", "#006060", "#00a8a8", "#40e0d0", "#a0f4f0"],
];
/** Returns a human-readable name for display purposes */
export const PALETTE_NAMES: string[] = [
"Steel Blue",
"Yellow Lime",
"Warm Sunset",
"Mint Teal",
"Royal Blue",
"Rose Pink",
"Warm Amber",
"Deep Violet",
"Periwinkle",
"Gold Amber",
"Silver",
"Forest Green",
"Urban Gray",
"Coral Red",
"Midnight Teal",
"Neon Purple",
"Earthy Brown",
"Ocean Depth",
"Cherry Blossom",
"Electric Teal",
];
/** @deprecated Use COLOR_PALETTES */
export const STUDIO_COLOR_PALETTES = COLOR_PALETTES.map((colors, index) => ({
id: `palette-${index}`,
name: PALETTE_NAMES[index] ?? `Palette ${index + 1}`,
colors: colors.slice(0, 4) as [string, string, string, string],
}));
/** Returns true if the hex color is perceptually dark (good for white text on top) */
export function isColorDark(hex: string): boolean {
const clean = hex.replace("#", "");
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
// WCAG luminance approximation
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
}
/** Pick a readable text colour for a given background */
export function contrastTextColor(bgHex: string): string {
return isColorDark(bgHex) ? "#ffffff" : "#111827";
}
+31
View File
@@ -0,0 +1,31 @@
import type { Scene } from "@/lib/studio-types";
export const STUDIO_HISTORY_LIMIT = 50;
export interface StudioHistorySnapshot {
scenes: Scene[];
activeSceneId: string;
selectedLayerId: string | null;
}
export function cloneScenes(scenes: Scene[]): Scene[] {
return scenes.map((scene) => ({
...scene,
layers: scene.layers.map((layer) => ({
...layer,
props: { ...layer.props },
})),
}));
}
export function captureHistorySnapshot(state: {
scenes: Scene[];
activeSceneId: string;
selectedLayerId: string | null;
}): StudioHistorySnapshot {
return {
scenes: cloneScenes(state.scenes),
activeSceneId: state.activeSceneId,
selectedLayerId: state.selectedLayerId,
};
}
+120
View File
@@ -0,0 +1,120 @@
import type { LayerProps } from "@/lib/studio-types";
export type ShapeKind = "rect" | "circle" | "line" | "arrow";
export type TextAlign = "left" | "center" | "right";
export type TextAnimation =
| "none"
| "fadeIn"
| "slideUp"
| "zoomIn"
| "typewriter";
export const FONT_FAMILY_OPTIONS = [
{ label: "Inter", value: "Inter, sans-serif" },
{ label: "Roboto", value: "Roboto, sans-serif" },
{ label: "Playfair", value: "Playfair Display, serif" },
{ label: "Montserrat", value: "Montserrat, sans-serif" },
{ label: "Oswald", value: "Oswald, sans-serif" },
] as const;
export const TEXT_ANIMATION_OPTIONS: { label: string; value: TextAnimation }[] =
[
{ label: "None", value: "none" },
{ label: "Fade In", value: "fadeIn" },
{ label: "Slide Up", value: "slideUp" },
{ label: "Zoom In", value: "zoomIn" },
{ label: "Typewriter", value: "typewriter" },
];
function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
}
function asString(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function asBoolean(value: unknown): boolean {
return value === true;
}
export function buildKonvaFontStyle(bold: boolean, italic: boolean): string {
if (bold && italic) return "bold italic";
if (bold) return "bold";
if (italic) return "italic";
return "normal";
}
export function getTextProps(props: LayerProps) {
const bold = asBoolean(props.bold);
const italic = asBoolean(props.italic);
const alignRaw = props.align;
const align: TextAlign =
alignRaw === "center" || alignRaw === "right" ? alignRaw : "left";
const animationRaw = props.animation;
const animation: TextAnimation =
animationRaw === "fadeIn" ||
animationRaw === "slideUp" ||
animationRaw === "zoomIn" ||
animationRaw === "typewriter"
? animationRaw
: "none";
return {
text: asString(props.text, "Text"),
fontSize: asNumber(props.fontSize, 48),
fill: asString(props.fill, "#111827"),
fontFamily: asString(props.fontFamily, "Inter, sans-serif"),
bold,
italic,
underline: asBoolean(props.underline),
align,
letterSpacing: asNumber(props.letterSpacing, 0),
lineHeight: asNumber(props.lineHeight, 1.2),
animation,
fontStyle: buildKonvaFontStyle(bold, italic),
};
}
export function getImageProps(props: LayerProps) {
return {
src:
typeof props.src === "string" && props.src.length > 0
? props.src
: undefined,
flipHorizontal: asBoolean(props.flipHorizontal),
flipVertical: asBoolean(props.flipVertical),
cornerRadius: asNumber(props.cornerRadius, 0),
};
}
export function getImageSrc(props: LayerProps): string | undefined {
return getImageProps(props).src;
}
export function getShapeProps(props: LayerProps) {
const shapeRaw = props.shape;
const shape: ShapeKind =
shapeRaw === "circle" ||
shapeRaw === "line" ||
shapeRaw === "arrow"
? shapeRaw
: "rect";
return {
shape,
fill: asString(props.fill, "#2563EB"),
stroke: asString(props.stroke, "#1E3A8A"),
strokeWidth: asNumber(props.strokeWidth, 0),
cornerRadius: asNumber(props.cornerRadius, 0),
};
}
export function mergeLayerProps(
current: LayerProps,
updates: LayerProps
): LayerProps {
return { ...current, ...updates };
}
+151
View File
@@ -0,0 +1,151 @@
import {
DEFAULT_SCENE_ACCENT_COLOR,
DEFAULT_SCENE_BACKGROUND_COLOR,
} from "@/lib/studio-color-palettes";
import type { Layer, Scene, SceneTransition } from "@/lib/studio-types";
import { DEFAULT_SCENE_DURATION } from "@/lib/studio-types";
const SCENE_TRANSITIONS: SceneTransition[] = [
"none",
"fade",
"slide-left",
"zoom",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseLayer(value: unknown): Layer | null {
if (!isRecord(value)) return null;
if (typeof value.id !== "string" || typeof value.type !== "string") return null;
if (
typeof value.x !== "number" ||
typeof value.y !== "number" ||
typeof value.width !== "number" ||
typeof value.height !== "number"
) {
return null;
}
return {
id: value.id,
type: value.type as Layer["type"],
name: typeof value.name === "string" ? value.name : undefined,
visible: typeof value.visible === "boolean" ? value.visible : undefined,
x: value.x,
y: value.y,
width: value.width,
height: value.height,
rotation: typeof value.rotation === "number" ? value.rotation : 0,
opacity: typeof value.opacity === "number" ? value.opacity : 1,
zIndex: typeof value.zIndex === "number" ? value.zIndex : 0,
props: isRecord(value.props) ? value.props : {},
};
}
function parseScene(value: unknown): Scene | null {
if (!isRecord(value)) return null;
if (typeof value.id !== "string" || typeof value.name !== "string") return null;
if (!Array.isArray(value.layers)) return null;
const layers = value.layers
.map(parseLayer)
.filter((layer): layer is Layer => layer !== null);
const transitionType =
typeof value.transitionType === "string" &&
SCENE_TRANSITIONS.includes(value.transitionType as SceneTransition)
? (value.transitionType as SceneTransition)
: "none";
return {
id: value.id,
name: value.name,
duration:
typeof value.duration === "number" ? value.duration : DEFAULT_SCENE_DURATION,
layers,
transitionType,
};
}
export function parseVideoScenes(value: unknown): Scene[] {
if (!Array.isArray(value)) return [];
return value.map(parseScene).filter((scene): scene is Scene => scene !== null);
}
export function isVideoSceneDataEmpty(sceneData: Record<string, unknown>): boolean {
const scenes = parseVideoScenes(sceneData.scenes);
return scenes.length === 0;
}
/** Omit generated thumbnails — they are re-created in the editor */
export function scenesForPersistence(scenes: Scene[]): Scene[] {
return scenes.map((scene) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- strip before save
const { thumbnailUrl, ...rest } = scene;
return rest;
});
}
export interface VideoPersistedSceneData {
scenes: Scene[];
activeSceneId: string;
currentTime?: number;
pxPerSecond?: number;
audioFileName?: string | null;
audioSrc?: string | null;
audioVolume?: number;
sceneBackgroundColor?: string;
sceneAccentColor?: string;
}
export function buildVideoSceneDataPayload(
input: VideoPersistedSceneData
): Record<string, unknown> {
return {
scenes: scenesForPersistence(input.scenes),
activeSceneId: input.activeSceneId,
currentTime: input.currentTime,
pxPerSecond: input.pxPerSecond,
audioFileName: input.audioFileName,
audioSrc: input.audioSrc,
audioVolume: input.audioVolume,
sceneBackgroundColor: input.sceneBackgroundColor,
sceneAccentColor: input.sceneAccentColor,
};
}
export function parseVideoSceneData(
sceneData: Record<string, unknown>
): VideoPersistedSceneData | null {
const scenes = parseVideoScenes(sceneData.scenes);
if (scenes.length === 0) return null;
const activeSceneId =
typeof sceneData.activeSceneId === "string" &&
scenes.some((scene) => scene.id === sceneData.activeSceneId)
? sceneData.activeSceneId
: scenes[0].id;
return {
scenes,
activeSceneId,
currentTime:
typeof sceneData.currentTime === "number" ? sceneData.currentTime : 0,
pxPerSecond:
typeof sceneData.pxPerSecond === "number" ? sceneData.pxPerSecond : undefined,
audioFileName:
typeof sceneData.audioFileName === "string" ? sceneData.audioFileName : null,
audioSrc: typeof sceneData.audioSrc === "string" ? sceneData.audioSrc : null,
audioVolume:
typeof sceneData.audioVolume === "number" ? sceneData.audioVolume : 100,
sceneBackgroundColor:
typeof sceneData.sceneBackgroundColor === "string"
? sceneData.sceneBackgroundColor
: DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor:
typeof sceneData.sceneAccentColor === "string"
? sceneData.sceneAccentColor
: DEFAULT_SCENE_ACCENT_COLOR,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { getStudioStage } from "@/lib/studio-canvas-stage";
/** Wait for Konva to paint after store-driven layer changes */
export function scheduleSceneThumbnailCapture(
capture: () => void
): void {
requestAnimationFrame(() => {
requestAnimationFrame(capture);
});
}
export function captureSceneThumbnailFromStage(): string | null {
const stage = getStudioStage();
if (!stage) return null;
try {
return stage.toDataURL({ pixelRatio: 0.2 });
} catch {
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { getStudioStage } from "@/lib/studio-canvas-stage";
export function downloadCanvasSnapshot(): boolean {
const stage = getStudioStage();
if (!stage) return false;
const dataUrl = stage.toDataURL({ pixelRatio: 2 });
const link = document.createElement("a");
link.download = `snapshot-${Date.now()}.png`;
link.href = dataUrl;
link.click();
return true;
}
+794
View File
@@ -0,0 +1,794 @@
import { create } from "zustand";
import type {
AddLayerInput,
Layer,
LayerProps,
LayerType,
Scene,
} from "@/lib/studio-types";
import {
DEFAULT_LAYER_SIZE,
DEFAULT_SCENE_DURATION,
} from "@/lib/studio-types";
import type { BrowserSceneItem } from "@/lib/scene-browser-data";
import {
DEFAULT_PX_PER_SECOND,
getNextZoomLevel,
getProjectDuration,
getSceneAtTime,
getSceneStartTime,
} from "@/lib/studio-timeline";
import {
captureHistorySnapshot,
cloneScenes,
STUDIO_HISTORY_LIMIT,
type StudioHistorySnapshot,
} from "@/lib/studio-history";
import {
contrastTextColor,
DEFAULT_SCENE_ACCENT_COLOR,
DEFAULT_SCENE_BACKGROUND_COLOR,
} from "@/lib/studio-color-palettes";
import {
buildVideoSceneDataPayload,
parseVideoSceneData,
} from "@/lib/studio-scene-data";
import type { SceneTransition } from "@/lib/studio-types";
import {
captureSceneThumbnailFromStage,
scheduleSceneThumbnailCapture,
} from "@/lib/studio-scene-thumbnail";
function createId(): string {
return crypto.randomUUID();
}
function defaultPropsForType(type: LayerType): LayerProps {
switch (type) {
case "text":
return {
text: "Text",
fontSize: 48,
fill: "#111827",
fontFamily: "Inter, sans-serif",
align: "left",
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
};
case "image":
return { src: "", cornerRadius: 0 };
case "video":
return { src: "", fileName: "" };
case "shape":
return {
shape: "rect",
fill: "#2563EB",
stroke: "#1E3A8A",
strokeWidth: 0,
cornerRadius: 0,
};
default:
return {};
}
}
function createDefaultScene(name: string): Scene {
return {
id: createId(),
name,
duration: DEFAULT_SCENE_DURATION,
transitionType: "none",
layers: [
{
id: createId(),
type: "text",
x: 240,
y: 270,
width: 800,
height: 100,
rotation: 0,
opacity: 1,
zIndex: 1,
props: {
text: "Your Title Here",
fontSize: 72,
fill: "#111827",
fontFamily: "Inter, sans-serif",
align: "center",
bold: true,
letterSpacing: 0,
lineHeight: 1.2,
animation: "none",
},
},
{
id: createId(),
type: "text",
x: 290,
y: 390,
width: 700,
height: 60,
rotation: 0,
opacity: 1,
zIndex: 2,
props: {
text: "Add your subtitle here",
fontSize: 32,
fill: "#6b7280",
fontFamily: "Inter, sans-serif",
align: "center",
bold: false,
letterSpacing: 0,
lineHeight: 1.4,
animation: "none",
},
},
],
};
}
function createDefaultLayer(type: LayerType, zIndex: number): Layer {
return {
id: createId(),
type,
x: 100,
y: 100,
width: DEFAULT_LAYER_SIZE.width,
height: DEFAULT_LAYER_SIZE.height,
rotation: 0,
opacity: 1,
zIndex,
props: defaultPropsForType(type),
};
}
const initialScene = createDefaultScene("Scene 1");
export interface StudioState {
scenes: Scene[];
activeSceneId: string;
selectedLayerId: string | null;
isPlaying: boolean;
currentTime: number;
pxPerSecond: number;
audioFileName: string | null;
audioSrc: string | null;
audioVolume: number;
sceneBackgroundColor: string;
sceneAccentColor: string;
past: StudioHistorySnapshot[];
future: StudioHistorySnapshot[];
layerClipboard: Layer | null;
}
export interface StudioActions {
addScene: (name?: string) => void;
addSceneFromTemplate: (template: BrowserSceneItem) => void;
deleteScene: (sceneId: string) => void;
duplicateScene: (sceneId: string) => void;
reorderScenes: (fromIndex: number, toIndex: number) => void;
updateScene: (sceneId: string, updates: Partial<Scene>) => void;
updateSceneThumbnail: (sceneId: string) => void;
setActiveScene: (sceneId: string) => void;
addLayer: (input: LayerType | AddLayerInput) => void;
updateLayer: (layerId: string, updates: Partial<Layer>) => void;
moveLayerToFront: (layerId: string) => void;
moveLayerToBack: (layerId: string) => void;
deleteLayer: (layerId: string) => void;
copyLayer: (layerId: string) => void;
pasteLayer: () => void;
setSelectedLayer: (layerId: string | null) => void;
undo: () => void;
redo: () => void;
togglePlay: () => void;
startPlayback: () => void;
stopPlayback: () => void;
setCurrentTime: (time: number) => void;
setPxPerSecond: (pxPerSecond: number) => void;
timelineZoomIn: () => void;
timelineZoomOut: () => void;
setAudioTrack: (fileName: string, src: string) => void;
clearAudioTrack: () => void;
setAudioVolume: (volume: number) => void;
setSceneBackgroundColor: (color: string) => void;
setSceneAccentColor: (color: string) => void;
applyPaletteToAllScenes: (mainColor: string, accentColor: string) => void;
applyTransitionToAllScenes: (transitionType: SceneTransition) => void;
applyFontFamilyToAllTextLayers: (fontFamily: string) => void;
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean;
getSceneDataForSave: () => Record<string, unknown>;
}
export type StudioStore = StudioState & StudioActions;
function pushHistory(
state: StudioState,
next: Partial<StudioState>
): Partial<StudioState> {
return {
past: [...state.past, captureHistorySnapshot(state)].slice(
-STUDIO_HISTORY_LIMIT
),
future: [],
...next,
};
}
function updateActiveSceneLayers(
scenes: Scene[],
activeSceneId: string,
updater: (layers: Layer[]) => Layer[]
): Scene[] {
return scenes.map((scene) =>
scene.id !== activeSceneId
? scene
: { ...scene, layers: updater(scene.layers) }
);
}
export const useStudioStore = create<StudioStore>((set, get) => {
const scheduleActiveSceneThumbnailUpdate = (): void => {
const sceneId = get().activeSceneId;
scheduleSceneThumbnailCapture(() => {
get().updateSceneThumbnail(sceneId);
});
};
return {
scenes: [initialScene],
activeSceneId: initialScene.id,
selectedLayerId: null,
isPlaying: false,
currentTime: 0,
pxPerSecond: DEFAULT_PX_PER_SECOND,
audioFileName: null,
audioSrc: null,
audioVolume: 100,
sceneBackgroundColor: DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor: DEFAULT_SCENE_ACCENT_COLOR,
past: [],
future: [],
layerClipboard: null,
addScene: (name) => {
const scene = createDefaultScene(
name ?? `Scene ${get().scenes.length + 1}`
);
set({
scenes: [...get().scenes, scene],
activeSceneId: scene.id,
selectedLayerId: null,
currentTime: 0,
isPlaying: false,
});
},
addSceneFromTemplate: (template) => {
const layers: Layer[] = template.templateLayers.map((tl) => ({
...tl,
id: createId(),
}));
const scene: Scene = {
id: createId(),
name: template.name,
duration: DEFAULT_SCENE_DURATION,
layers,
transitionType: "none",
};
set({
scenes: [...get().scenes, scene],
activeSceneId: scene.id,
selectedLayerId: null,
currentTime: 0,
isPlaying: false,
});
},
deleteScene: (sceneId) => {
const { scenes, activeSceneId } = get();
if (scenes.length <= 1) return;
const nextScenes = scenes.filter((scene) => scene.id !== sceneId);
const nextActiveId =
activeSceneId === sceneId
? (nextScenes[0]?.id ?? activeSceneId)
: activeSceneId;
set({
scenes: nextScenes,
activeSceneId: nextActiveId,
selectedLayerId: null,
currentTime: 0,
isPlaying: false,
});
},
duplicateScene: (sceneId) => {
const { scenes } = get();
const source = scenes.find((scene) => scene.id === sceneId);
if (!source) return;
const copy: Scene = {
...source,
id: createId(),
name: `${source.name} (copy)`,
thumbnailUrl: source.thumbnailUrl,
layers: source.layers.map((layer) => ({
...layer,
id: createId(),
props: { ...layer.props },
})),
};
const index = scenes.findIndex((scene) => scene.id === sceneId);
const nextScenes = [...scenes];
nextScenes.splice(index + 1, 0, copy);
set({
scenes: nextScenes,
activeSceneId: copy.id,
selectedLayerId: null,
currentTime: 0,
isPlaying: false,
});
},
reorderScenes: (fromIndex, toIndex) => {
const scenes = [...get().scenes];
const [removed] = scenes.splice(fromIndex, 1);
if (!removed) return;
scenes.splice(toIndex, 0, removed);
set({ scenes });
},
updateScene: (sceneId, updates) => {
set({
scenes: get().scenes.map((scene) =>
scene.id === sceneId ? { ...scene, ...updates } : scene
),
});
},
updateSceneThumbnail: (sceneId) => {
const state = get();
if (state.activeSceneId !== sceneId) return;
const thumbnailUrl = captureSceneThumbnailFromStage();
if (!thumbnailUrl) return;
set({
scenes: state.scenes.map((scene) =>
scene.id === sceneId ? { ...scene, thumbnailUrl } : scene
),
});
},
setActiveScene: (sceneId) => {
set({
activeSceneId: sceneId,
selectedLayerId: null,
currentTime: getSceneStartTime(get().scenes, sceneId),
isPlaying: false,
});
},
addLayer: (input) => {
const state = get();
const sceneIndex = state.scenes.findIndex(
(scene) => scene.id === state.activeSceneId
);
if (sceneIndex === -1) return;
const config: AddLayerInput =
typeof input === "string" ? { type: input } : input;
const scene = state.scenes[sceneIndex];
const maxZIndex = scene.layers.reduce(
(max, layer) => Math.max(max, layer.zIndex),
0
);
const base = createDefaultLayer(config.type, maxZIndex + 1);
const layer: Layer = {
...base,
x: config.x ?? base.x,
y: config.y ?? base.y,
width: config.width ?? base.width,
height: config.height ?? base.height,
rotation: config.rotation ?? base.rotation,
opacity: config.opacity ?? base.opacity,
props: { ...base.props, ...config.props },
};
const nextScenes = [...state.scenes];
nextScenes[sceneIndex] = {
...scene,
layers: [...scene.layers, layer],
};
set(
pushHistory(state, {
scenes: nextScenes,
selectedLayerId: layer.id,
})
);
scheduleActiveSceneThumbnailUpdate();
},
updateLayer: (layerId, updates) => {
const state = get();
set(
pushHistory(state, {
scenes: updateActiveSceneLayers(
state.scenes,
state.activeSceneId,
(layers) =>
layers.map((layer) =>
layer.id === layerId ? { ...layer, ...updates } : layer
)
),
})
);
scheduleActiveSceneThumbnailUpdate();
},
moveLayerToFront: (layerId) => {
const state = get();
const scene = getActiveScene(state);
if (!scene) return;
const maxZIndex = scene.layers.reduce(
(max, layer) => Math.max(max, layer.zIndex),
0
);
const target = scene.layers.find((layer) => layer.id === layerId);
if (!target || target.zIndex >= maxZIndex) return;
set(
pushHistory(state, {
scenes: updateActiveSceneLayers(
state.scenes,
state.activeSceneId,
(layers) =>
layers.map((layer) =>
layer.id === layerId
? { ...layer, zIndex: maxZIndex + 1 }
: layer
)
),
})
);
},
moveLayerToBack: (layerId) => {
const state = get();
const scene = getActiveScene(state);
if (!scene) return;
const minZIndex = scene.layers.reduce(
(min, layer) => Math.min(min, layer.zIndex),
0
);
const target = scene.layers.find((layer) => layer.id === layerId);
if (!target || target.zIndex <= minZIndex) return;
set(
pushHistory(state, {
scenes: updateActiveSceneLayers(
state.scenes,
state.activeSceneId,
(layers) =>
layers.map((layer) =>
layer.id === layerId
? { ...layer, zIndex: minZIndex - 1 }
: layer
)
),
})
);
},
deleteLayer: (layerId) => {
const state = get();
set(
pushHistory(state, {
scenes: updateActiveSceneLayers(
state.scenes,
state.activeSceneId,
(layers) => layers.filter((layer) => layer.id !== layerId)
),
selectedLayerId:
state.selectedLayerId === layerId ? null : state.selectedLayerId,
})
);
scheduleActiveSceneThumbnailUpdate();
},
copyLayer: (layerId) => {
const scene = getActiveScene(get());
const layer = scene?.layers.find((item) => item.id === layerId);
if (!layer) return;
set({
layerClipboard: {
...layer,
props: { ...layer.props },
},
});
},
pasteLayer: () => {
const state = get();
const clip = state.layerClipboard;
if (!clip) return;
const sceneIndex = state.scenes.findIndex(
(scene) => scene.id === state.activeSceneId
);
if (sceneIndex === -1) return;
const scene = state.scenes[sceneIndex];
const maxZIndex = scene.layers.reduce(
(max, layer) => Math.max(max, layer.zIndex),
0
);
const layer: Layer = {
...clip,
id: createId(),
x: clip.x + 24,
y: clip.y + 24,
zIndex: maxZIndex + 1,
props: { ...clip.props },
};
const nextScenes = [...state.scenes];
nextScenes[sceneIndex] = {
...scene,
layers: [...scene.layers, layer],
};
set(
pushHistory(state, {
scenes: nextScenes,
selectedLayerId: layer.id,
})
);
},
setSelectedLayer: (layerId) => set({ selectedLayerId: layerId }),
undo: () => {
const state = get();
if (state.past.length === 0) return;
const previous = state.past[state.past.length - 1];
const current = captureHistorySnapshot(state);
set({
...previous,
scenes: cloneScenes(previous.scenes),
past: state.past.slice(0, -1),
future: [current, ...state.future].slice(0, STUDIO_HISTORY_LIMIT),
});
},
redo: () => {
const state = get();
if (state.future.length === 0) return;
const next = state.future[0];
const current = captureHistorySnapshot(state);
set({
...next,
scenes: cloneScenes(next.scenes),
past: [...state.past, current].slice(-STUDIO_HISTORY_LIMIT),
future: state.future.slice(1),
});
},
togglePlay: () => set((state) => ({ isPlaying: !state.isPlaying })),
startPlayback: () => {
const state = get();
const max = getProjectDuration(state.scenes);
const time = state.currentTime >= max ? 0 : state.currentTime;
const scene = getSceneAtTime(state.scenes, time);
set({
isPlaying: true,
currentTime: time,
activeSceneId: scene?.id ?? state.activeSceneId,
selectedLayerId: null,
});
},
stopPlayback: () => set({ isPlaying: false }),
setCurrentTime: (time) => {
const max = getProjectDuration(get().scenes);
set({ currentTime: Math.min(Math.max(0, time), max) });
},
setPxPerSecond: (pxPerSecond) => set({ pxPerSecond }),
timelineZoomIn: () => {
set({ pxPerSecond: getNextZoomLevel(get().pxPerSecond, "in") });
},
timelineZoomOut: () => {
set({ pxPerSecond: getNextZoomLevel(get().pxPerSecond, "out") });
},
setAudioTrack: (fileName, src) =>
set({ audioFileName: fileName, audioSrc: src }),
clearAudioTrack: () =>
set({ audioFileName: null, audioSrc: null, audioVolume: 100 }),
setAudioVolume: (volume) =>
set({ audioVolume: Math.min(100, Math.max(0, volume)) }),
setSceneBackgroundColor: (color) => set({ sceneBackgroundColor: color }),
setSceneAccentColor: (color) => set({ sceneAccentColor: color }),
applyPaletteToAllScenes: (mainColor, accentColor) => {
const state = get();
// Derive a readable text colour from the main background
const textColor = contrastTextColor(mainColor);
// A shape is the "full-canvas background" when it fills almost the entire stage
const isCanvasBg = (layer: Layer) =>
layer.type === "shape" &&
layer.zIndex === 0 &&
layer.x <= 10 &&
layer.y <= 10 &&
layer.width >= 1200 &&
layer.height >= 600;
// A shape is a dark overlay when it has low opacity and sits above the bg
const isDarkOverlay = (layer: Layer) =>
layer.type === "shape" &&
layer.zIndex >= 2 &&
layer.opacity <= 0.65;
const nextScenes = state.scenes.map((scene) => ({
...scene,
layers: scene.layers.map((layer) => {
if (isCanvasBg(layer)) {
// Main background → use mainColor
return {
...layer,
props: {
...layer.props,
fill: mainColor,
stroke: mainColor,
},
};
}
if (isDarkOverlay(layer)) {
// Keep the overlay as-is — just ensure it stays dark
return layer;
}
if (layer.type === "shape") {
// Accent shapes → use accentColor
return {
...layer,
props: {
...layer.props,
fill: accentColor,
stroke: accentColor,
},
};
}
if (layer.type === "text") {
// Text → auto-contrast against the main background
return {
...layer,
props: {
...layer.props,
fill: textColor,
},
};
}
return layer;
}),
}));
set(
pushHistory(state, {
scenes: nextScenes,
sceneBackgroundColor: mainColor,
sceneAccentColor: accentColor,
})
);
scheduleActiveSceneThumbnailUpdate();
},
applyTransitionToAllScenes: (transitionType) => {
set({
scenes: get().scenes.map((scene) => ({ ...scene, transitionType })),
});
},
applyFontFamilyToAllTextLayers: (fontFamily) => {
const state = get();
const nextScenes = state.scenes.map((scene) => ({
...scene,
layers: scene.layers.map((layer) =>
layer.type !== "text"
? layer
: {
...layer,
props: { ...layer.props, fontFamily },
}
),
}));
set(
pushHistory(state, {
scenes: nextScenes,
})
);
scheduleActiveSceneThumbnailUpdate();
},
hydrateFromSceneData: (sceneData) => {
const parsed = parseVideoSceneData(sceneData);
if (!parsed) return false;
set({
scenes: parsed.scenes,
activeSceneId: parsed.activeSceneId,
selectedLayerId: null,
isPlaying: false,
currentTime: parsed.currentTime ?? 0,
...(parsed.pxPerSecond !== undefined
? { pxPerSecond: parsed.pxPerSecond }
: {}),
audioFileName: parsed.audioFileName ?? null,
audioSrc: parsed.audioSrc ?? null,
audioVolume: parsed.audioVolume ?? 100,
sceneBackgroundColor:
parsed.sceneBackgroundColor ?? DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor:
parsed.sceneAccentColor ?? DEFAULT_SCENE_ACCENT_COLOR,
past: [],
future: [],
});
return true;
},
getSceneDataForSave: () => {
const state = get();
return buildVideoSceneDataPayload({
scenes: state.scenes,
activeSceneId: state.activeSceneId,
currentTime: state.currentTime,
pxPerSecond: state.pxPerSecond,
audioFileName: state.audioFileName,
audioSrc: state.audioSrc,
audioVolume: state.audioVolume,
sceneBackgroundColor: state.sceneBackgroundColor,
sceneAccentColor: state.sceneAccentColor,
});
},
};
});
export function getActiveScene(
state: Pick<StudioState, "scenes" | "activeSceneId">
): Scene | undefined {
return state.scenes.find((scene) => scene.id === state.activeSceneId);
}
export function getSelectedLayer(
state: Pick<StudioState, "scenes" | "activeSceneId" | "selectedLayerId">
): Layer | undefined {
const scene = getActiveScene(state);
if (!scene || !state.selectedLayerId) return undefined;
return scene.layers.find((layer) => layer.id === state.selectedLayerId);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Scene } from "@/lib/studio-types";
export const TIMELINE_ZOOM_LEVELS = [30, 60, 90, 120] as const;
export type TimelineZoomLevel = (typeof TIMELINE_ZOOM_LEVELS)[number];
export const DEFAULT_PX_PER_SECOND: TimelineZoomLevel = 60;
/** Compact scale for scene thumbnail strip (Renderforest-style) */
export const STRIP_PX_PER_SECOND = 24;
export const MIN_SCENE_DURATION = 1;
export const MAX_SCENE_DURATION = 30;
export const SCENE_BLOCK_COLORS = [
{ base: "bg-blue-600", active: "bg-blue-500" },
{ base: "bg-purple-600", active: "bg-purple-500" },
{ base: "bg-green-600", active: "bg-green-500" },
{ base: "bg-orange-600", active: "bg-orange-500" },
] as const;
/** Inline gradient styles for scene thumbnails — avoids Tailwind purging dynamic class names */
export const SCENE_THUMB_GRADIENTS = [
{ backgroundImage: "linear-gradient(135deg,#60a5fa,#8b5cf6)" },
{ backgroundImage: "linear-gradient(135deg,#a78bfa,#ec4899)" },
{ backgroundImage: "linear-gradient(135deg,#22d3ee,#3b82f6)" },
{ backgroundImage: "linear-gradient(135deg,#34d399,#14b8a6)" },
{ backgroundImage: "linear-gradient(135deg,#fbbf24,#f97316)" },
{ backgroundImage: "linear-gradient(135deg,#fb7185,#ef4444)" },
] as const;
export function formatTimelineTime(seconds: number): string {
const safe = Math.max(0, seconds);
const mins = Math.floor(safe / 60);
const secs = Math.floor(safe % 60);
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
export function getProjectDuration(scenes: Scene[]): number {
return scenes.reduce((total, scene) => total + scene.duration, 0);
}
export interface SceneTimelineSegment {
scene: Scene;
startTime: number;
index: number;
}
export function getSceneTimelineSegments(
scenes: Scene[]
): SceneTimelineSegment[] {
let startTime = 0;
return scenes.map((scene, index) => {
const segment = { scene, startTime, index };
startTime += scene.duration;
return segment;
});
}
export function getSceneAtTime(scenes: Scene[], time: number): Scene | undefined {
const segments = getSceneTimelineSegments(scenes);
if (segments.length === 0) return undefined;
const total = getProjectDuration(scenes);
if (time >= total) {
return segments[segments.length - 1]?.scene;
}
return segments.find(
(segment) =>
time >= segment.startTime &&
time < segment.startTime + segment.scene.duration
)?.scene;
}
export function getSceneStartTime(scenes: Scene[], sceneId: string): number {
let start = 0;
for (const scene of scenes) {
if (scene.id === sceneId) return start;
start += scene.duration;
}
return 0;
}
export function clampSceneDuration(duration: number): number {
return Math.min(
MAX_SCENE_DURATION,
Math.max(MIN_SCENE_DURATION, Math.round(duration * 10) / 10)
);
}
export function getNextZoomLevel(
current: number,
direction: "in" | "out"
): TimelineZoomLevel {
const index = TIMELINE_ZOOM_LEVELS.findIndex((level) => level === current);
const resolvedIndex = index === -1 ? 1 : index;
if (direction === "in") {
return TIMELINE_ZOOM_LEVELS[
Math.min(resolvedIndex + 1, TIMELINE_ZOOM_LEVELS.length - 1)
];
}
return TIMELINE_ZOOM_LEVELS[Math.max(resolvedIndex - 1, 0)];
}
export function snapZoomLevel(value: number): TimelineZoomLevel {
const snapped = TIMELINE_ZOOM_LEVELS.reduce((prev, level) =>
Math.abs(level - value) < Math.abs(prev - value) ? level : prev
);
return snapped;
}
+51
View File
@@ -0,0 +1,51 @@
export type LayerType = "text" | "image" | "video" | "shape" | "draw";
export type LayerProps = Record<string, unknown>;
export interface Layer {
id: string;
type: LayerType;
/** Image editor display name; optional in video studio */
name?: string;
/** Image editor visibility toggle; optional in video studio */
visible?: boolean;
x: number;
y: number;
width: number;
height: number;
rotation: number;
opacity: number;
zIndex: number;
props: LayerProps;
}
export type SceneTransition = "none" | "fade" | "slide-left" | "zoom";
export interface Scene {
id: string;
name: string;
duration: number;
layers: Layer[];
/** Transition when advancing to the next scene during playback */
transitionType?: SceneTransition;
/** Konva canvas preview (data URL), generated for the active scene */
thumbnailUrl?: string;
}
export const DEFAULT_SCENE_DURATION = 5;
export const DEFAULT_LAYER_SIZE = {
width: 320,
height: 180,
} as const;
export interface AddLayerInput {
type: LayerType;
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
opacity?: number;
props?: LayerProps;
}
+15
View File
@@ -0,0 +1,15 @@
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
import { isSupabaseConfigured } from "@/lib/supabase/config";
export function createClient(): SupabaseClient | null {
if (!isSupabaseConfigured()) {
return null;
}
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
+19
View File
@@ -0,0 +1,19 @@
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceRoleKey) {
throw new Error(
"Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables."
);
}
return createClient(url, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
+6
View File
@@ -0,0 +1,6 @@
export function isSupabaseConfigured(): boolean {
return Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
}
+38
View File
@@ -0,0 +1,38 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
return supabaseResponse;
}
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value);
});
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => {
supabaseResponse.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.getUser();
return supabaseResponse;
}
+32
View File
@@ -0,0 +1,32 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
throw new Error(
"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables."
);
}
return createServerClient(url, anonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// setAll is a no-op when called from a Server Component.
}
},
},
});
}
+71
View File
@@ -0,0 +1,71 @@
/** Short, muted-friendly preview clips for template card hover states */
const MIXKIT = {
sunsetPlateaus:
"https://assets.mixkit.co/videos/preview/mixkit-set-of-plateaus-seen-from-the-heights-in-a-sunset-26070-large.mp4",
cloudsRunner:
"https://assets.mixkit.co/videos/preview/mixkit-woman-running-above-the-clouds-34096-large.mp4",
yellowFlowers:
"https://assets.mixkit.co/videos/preview/mixkit-tree-with-yellow-flowers-1173-large.mp4",
cityTraffic:
"https://assets.mixkit.co/videos/preview/mixkit-aerial-view-of-city-traffic-at-night-11-large.mp4",
skyscrapers:
"https://assets.mixkit.co/videos/preview/mixkit-young-woman-walking-among-the-skyscrapers-42300-large.mp4",
meadow:
"https://assets.mixkit.co/videos/preview/mixkit-countryside-meadow-4075-large.mp4",
inkWater:
"https://assets.mixkit.co/videos/preview/mixkit-ink-swirling-in-water-186-large.mp4",
} as const;
const TEMPLATE_PREVIEW_VIDEOS = [
MIXKIT.sunsetPlateaus,
MIXKIT.cloudsRunner,
MIXKIT.yellowFlowers,
MIXKIT.cityTraffic,
MIXKIT.skyscrapers,
MIXKIT.meadow,
] as const;
/** Hero preview row */
const HERO_PREVIEW_VIDEOS = [
MIXKIT.meadow,
MIXKIT.cloudsRunner,
MIXKIT.inkWater,
MIXKIT.sunsetPlateaus,
] as const;
/** Category → video mapping for scene browser cards */
const SCENE_CATEGORY_VIDEOS: Record<string, string> = {
characters: MIXKIT.skyscrapers,
business: MIXKIT.cityTraffic,
technology: MIXKIT.cityTraffic,
nature: MIXKIT.meadow,
abstract: MIXKIT.inkWater,
sports: MIXKIT.cloudsRunner,
food: MIXKIT.yellowFlowers,
};
function hashSeed(seed: string): number {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash << 5) - hash + seed.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
export function getTemplatePreviewVideoSrc(seed: string): string {
const index = hashSeed(seed) % TEMPLATE_PREVIEW_VIDEOS.length;
return TEMPLATE_PREVIEW_VIDEOS[index];
}
export function getHeroPreviewVideoSrc(index: number): string {
return HERO_PREVIEW_VIDEOS[index % HERO_PREVIEW_VIDEOS.length];
}
/** Returns a muted preview clip appropriate for the scene's category */
export function getScenePreviewVideoSrc(
category: string,
fallbackSeed: string
): string {
return SCENE_CATEGORY_VIDEOS[category] ?? getTemplatePreviewVideoSrc(fallbackSeed);
}
+144
View File
@@ -0,0 +1,144 @@
export const TEMPLATE_CATEGORIES = [
"All",
"Video",
"Image",
"Social",
"Business",
] as const;
export const TEMPLATE_STYLES = ["Minimal", "Bold", "Elegant"] as const;
export const TEMPLATE_COLORS = [
{ id: "blue", label: "Blue", className: "bg-primary-600" },
{ id: "violet", label: "Violet", className: "bg-violet-600" },
{ id: "rose", label: "Rose", className: "bg-rose-500" },
{ id: "emerald", label: "Emerald", className: "bg-emerald-500" },
{ id: "amber", label: "Amber", className: "bg-amber-500" },
{ id: "slate", label: "Slate", className: "bg-slate-600" },
] as const;
export type TemplateCategoryFilter = (typeof TEMPLATE_CATEGORIES)[number];
export type TemplateStyle = (typeof TEMPLATE_STYLES)[number];
export type TemplateColorId = (typeof TEMPLATE_COLORS)[number]["id"];
export type TemplateCatalogCategory = Exclude<TemplateCategoryFilter, "All">;
export interface CatalogTemplate {
id: string;
name: string;
category: TemplateCatalogCategory;
style: TemplateStyle;
color: TemplateColorId;
}
const namesByCategory: Record<TemplateCatalogCategory, string[]> = {
Video: [
"Promo Reel",
"Product Launch",
"Brand Story",
"Tutorial Intro",
"Event Recap",
"Testimonial",
],
Image: [
"Hero Banner",
"Catalog Spread",
"Poster",
"Flyer",
"Lookbook",
"Quote Card",
],
Social: [
"Instagram Carousel",
"TikTok Hook",
"Story Highlight",
"LinkedIn Post",
"Pinterest Pin",
"Thread Cover",
],
Business: [
"Pitch Deck",
"One Pager",
"Report Cover",
"Newsletter",
"Invoice",
"Meeting Slide",
],
};
function buildCatalog(): CatalogTemplate[] {
const categories: TemplateCatalogCategory[] = [
"Video",
"Image",
"Social",
"Business",
];
const styles: TemplateStyle[] = ["Minimal", "Bold", "Elegant"];
const colors: TemplateColorId[] = [
"blue",
"violet",
"rose",
"emerald",
"amber",
"slate",
];
const items: CatalogTemplate[] = [];
let index = 0;
for (const category of categories) {
for (const baseName of namesByCategory[category]) {
for (let variant = 0; variant < 2; variant += 1) {
const style = styles[index % styles.length];
const color = colors[index % colors.length];
items.push({
id: `tpl-${category.toLowerCase()}-${index}`,
name: `${baseName}${variant > 0 ? ` ${variant + 1}` : ""}`.trim(),
category,
style,
color,
});
index += 1;
}
}
}
return items;
}
export const TEMPLATES_CATALOG = buildCatalog();
export const TEMPLATES_PAGE_SIZE = 24;
export function getTemplateImageSrc(id: string): string {
return `https://picsum.photos/seed/${id}/480/360`;
}
export interface TemplateFilters {
search: string;
category: TemplateCategoryFilter;
style: TemplateStyle | null;
color: TemplateColorId | null;
}
export function filterCatalog(
templates: CatalogTemplate[],
filters: TemplateFilters
): CatalogTemplate[] {
const query = filters.search.trim().toLowerCase();
return templates.filter((template) => {
if (filters.category !== "All" && template.category !== filters.category) {
return false;
}
if (filters.style && template.style !== filters.style) {
return false;
}
if (filters.color && template.color !== filters.color) {
return false;
}
if (query && !template.name.toLowerCase().includes(query)) {
return false;
}
return true;
});
}
+15
View File
@@ -0,0 +1,15 @@
export type ExportFormat = "mp4" | "webm";
export type AspectRatioPreset = "free" | "16:9" | "9:16" | "1:1" | "4:3";
export interface CropBox {
x: number;
y: number;
w: number;
h: number;
}
export interface VideoDimensions {
width: number;
height: number;
}
+66
View File
@@ -0,0 +1,66 @@
import type { AspectRatioPreset } from "@/lib/trimmer-types";
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatTime(seconds: number): string {
const safe = Math.max(0, seconds);
const mins = Math.floor(safe / 60);
const secs = Math.floor(safe % 60);
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
export function getAspectRatioValue(
preset: AspectRatioPreset
): number | undefined {
switch (preset) {
case "16:9":
return 16 / 9;
case "9:16":
return 9 / 16;
case "1:1":
return 1;
case "4:3":
return 4 / 3;
default:
return undefined;
}
}
export function scaleCropToVideo(
crop: { x: number; y: number; w: number; h: number },
display: { width: number; height: number },
video: { width: number; height: number }
) {
const scaleX = video.width / display.width;
const scaleY = video.height / display.height;
const x = Math.round(crop.x * scaleX);
const y = Math.round(crop.y * scaleY);
let w = Math.round(crop.w * scaleX);
let h = Math.round(crop.h * scaleY);
w = Math.min(w, video.width - x);
h = Math.min(h, video.height - y);
w = w % 2 === 0 ? w : w - 1;
h = h % 2 === 0 ? h : h - 1;
return {
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.max(2, w),
h: Math.max(2, h),
};
}
export function parseFfmpegProgress(message: string): number | null {
const match = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
if (!match) return null;
const hours = Number(match[1]);
const mins = Number(match[2]);
const secs = Number(match[3]);
return hours * 3600 + mins * 60 + secs;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+432
View File
@@ -0,0 +1,432 @@
export type VideoSidebarCategoryId =
| "all"
| "animation"
| "intros"
| "editing"
| "invitation"
| "holiday"
| "slideshow"
| "presentations"
| "social"
| "ads"
| "sales"
| "music";
export type AspectRatioFilter =
| "all"
| "widescreen"
| "portrait"
| "square"
| "fourFive";
export type DurationFilter = "all" | "flexible" | "fixed";
export type RefineType = "templates" | "packs";
export interface VideoSidebarCategory {
id: VideoSidebarCategoryId;
label: string;
}
export const VIDEO_SIDEBAR_CATEGORIES: VideoSidebarCategory[] = [
{ id: "all", label: "All Templates" },
{ id: "animation", label: "Animation Videos" },
{ id: "intros", label: "Intros and Logos" },
{ id: "editing", label: "Video Editing" },
{ id: "invitation", label: "Invitation Videos" },
{ id: "holiday", label: "Holiday Videos" },
{ id: "slideshow", label: "Slideshow" },
{ id: "presentations", label: "Presentations" },
{ id: "social", label: "Social Media Videos" },
{ id: "ads", label: "Video Ad Templates" },
{ id: "sales", label: "Sales Videos" },
{ id: "music", label: "Music Visualization" },
];
export const ASPECT_RATIO_OPTIONS: {
id: AspectRatioFilter;
label: string;
}[] = [
{ id: "all", label: "All Sizes" },
{ id: "widescreen", label: "16:9" },
{ id: "portrait", label: "9:16" },
{ id: "square", label: "1:1" },
{ id: "fourFive", label: "4:5" },
];
export type TemplateDetailAspectRatio = "16:9" | "9:16";
export const TEMPLATE_STYLE_COUNT = 4;
export interface VideoCatalogTemplate {
id: string;
name: string;
videoCategory: Exclude<VideoSidebarCategoryId, "all">;
aspectRatio: Exclude<AspectRatioFilter, "all">;
aspectRatios?: readonly TemplateDetailAspectRatio[];
durationType: "flexible" | "fixed";
premium: boolean;
sceneCount: number;
supports4k: boolean;
colorChange: boolean;
scriptToVideo: boolean;
description?: string;
isNew?: boolean;
}
export function getVideoTemplateCategoryLabel(
category: Exclude<VideoSidebarCategoryId, "all">
): string {
const match = VIDEO_SIDEBAR_CATEGORIES.find((item) => item.id === category);
return match?.label ?? category;
}
export function getTemplateDetailAspectRatios(
template: VideoCatalogTemplate
): TemplateDetailAspectRatio[] {
if (template.aspectRatios && template.aspectRatios.length > 0) {
return [...template.aspectRatios];
}
return ["16:9", "9:16"];
}
export function getVideoTemplateStyleImageSrc(
templateId: string,
styleIndex: number
): string {
return `https://picsum.photos/seed/${templateId}-style${styleIndex}/240/135`;
}
export function getVideoTemplateExampleImageSrc(
templateId: string,
exampleIndex: number
): string {
return `https://picsum.photos/seed/${templateId}-example${exampleIndex}/520/325`;
}
const templatesByCategory: Record<
Exclude<VideoSidebarCategoryId, "all">,
string[]
> = {
animation: [
"Whiteboard Animation Toolkit",
"3D Explainer Video Toolkit",
"Trendy Explainer Toolkit",
"Factory of 3D Animations",
"Anime Stories Pack",
"Healthcare Explainer Toolkit",
],
intros: [
"Abstract Distortion Intro",
"Glossy Bubbles Intro",
"Neon Soundwaves Visualizer",
"Minimal Logo Reveal",
"Glitch Intro Pack",
],
editing: [
"Cinematic Color Grade",
"Quick Cut Montage",
"Documentary Style Opener",
],
invitation: [
"Wedding Invitation Slideshow",
"Birthday Party Invite",
"Corporate Event Opening",
],
holiday: [
"Christmas Greeting Card",
"New Year Countdown",
"Seasonal Sale Promo",
],
slideshow: [
"Polaroid Frames Slideshow",
"Flipping Slideshow",
"Fragmented Transitions Slideshow",
"Parallax Circles",
"Bokeh Effects Slideshow",
],
presentations: [
"Business Presentation Pack",
"Startup Pitch Deck",
"Quarterly Report Intro",
],
social: [
"Instagram Carousel",
"TikTok Hook Pack",
"Story Highlight Reel",
"LinkedIn Promo",
],
ads: [
"Product Launch Ad",
"App Promo Vertical",
"Flash Sale Countdown",
],
sales: [
"SaaS Explainer",
"Real Estate Walkthrough",
"Restaurant Promo",
],
music: [
"Audio Spectrum Visualizer",
"Vinyl Record Spin",
"Beat Sync Typography",
],
};
const aspectRatios: Exclude<AspectRatioFilter, "all">[] = [
"widescreen",
"portrait",
"square",
"fourFive",
];
function buildVideoCatalog(): VideoCatalogTemplate[] {
const items: VideoCatalogTemplate[] = [];
let index = 0;
for (const [category, names] of Object.entries(templatesByCategory)) {
const videoCategory = category as Exclude<VideoSidebarCategoryId, "all">;
for (const baseName of names) {
for (let variant = 0; variant < 2; variant += 1) {
const name = variant > 0 ? `${baseName} ${variant + 1}` : baseName;
const detailAspectRatios: TemplateDetailAspectRatio[] =
index % 3 === 0 ? ["16:9"] : ["16:9", "9:16"];
items.push({
id: `vtpl-${category}-${index}`,
name,
videoCategory,
aspectRatio: aspectRatios[index % aspectRatios.length],
aspectRatios: detailAspectRatios,
durationType: index % 3 === 0 ? "fixed" : "flexible",
premium: index % 4 === 0,
sceneCount: 5 + (index % 12) * 10,
supports4k: index % 2 === 0,
colorChange: index % 3 !== 0,
scriptToVideo: index % 5 === 0,
isNew: index < 8,
});
index += 1;
}
}
}
return items;
}
/** Featured presets on /studio/video/new — ids match TEMPLATE_GALLERY_ITEMS */
const ONBOARDING_PRESET_TEMPLATES: VideoCatalogTemplate[] = [
{
id: "promo-reel",
name: "Animated Inspirational Video",
videoCategory: "animation",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 12,
supports4k: true,
colorChange: true,
scriptToVideo: false,
isNew: true,
},
{
id: "product-launch",
name: "Cybersecurity Company Promo",
videoCategory: "ads",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: true,
sceneCount: 8,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "brand-story",
name: "Get to Know Your Customers Day",
videoCategory: "social",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 10,
supports4k: false,
colorChange: true,
scriptToVideo: true,
},
{
id: "instagram-carousel",
name: "SEO Agency Introduction",
videoCategory: "social",
aspectRatio: "square",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 6,
supports4k: false,
colorChange: true,
scriptToVideo: false,
},
{
id: "tiktok-hook",
name: "Tech Startup Promo",
videoCategory: "social",
aspectRatio: "portrait",
aspectRatios: ["9:16"],
durationType: "flexible",
premium: false,
sceneCount: 5,
supports4k: false,
colorChange: true,
scriptToVideo: false,
isNew: true,
},
{
id: "pitch-deck",
name: "Corporate Explainer",
videoCategory: "presentations",
aspectRatio: "widescreen",
aspectRatios: ["16:9"],
durationType: "fixed",
premium: false,
sceneCount: 15,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "hero-promo",
name: "Hero Product Launch",
videoCategory: "ads",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: true,
sceneCount: 9,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "event-recap",
name: "Event Recap Highlight",
videoCategory: "slideshow",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 11,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
];
export const VIDEO_TEMPLATES_CATALOG = [
...ONBOARDING_PRESET_TEMPLATES,
...buildVideoCatalog(),
];
export interface VideoTemplateFilters {
search: string;
sidebarCategory: VideoSidebarCategoryId;
aspectRatio: AspectRatioFilter;
duration: DurationFilter;
premiumOnly: boolean;
supports4k: boolean;
colorChange: boolean;
scriptToVideo: boolean;
}
export function filterVideoCatalog(
templates: VideoCatalogTemplate[],
filters: VideoTemplateFilters
): VideoCatalogTemplate[] {
const query = filters.search.trim().toLowerCase();
return templates.filter((template) => {
if (
filters.sidebarCategory !== "all" &&
template.videoCategory !== filters.sidebarCategory
) {
return false;
}
if (
filters.aspectRatio !== "all" &&
template.aspectRatio !== filters.aspectRatio
) {
return false;
}
if (
filters.duration !== "all" &&
template.durationType !== filters.duration
) {
return false;
}
if (filters.premiumOnly && !template.premium) return false;
if (filters.supports4k && !template.supports4k) return false;
if (filters.colorChange && !template.colorChange) return false;
if (filters.scriptToVideo && !template.scriptToVideo) return false;
if (query && !template.name.toLowerCase().includes(query)) return false;
return true;
});
}
export function getVideoTemplateImageSrc(id: string): string {
return `https://picsum.photos/seed/${id}/640/360`;
}
export interface VideoTemplateSection {
id: string;
title: string;
count: number;
templates: VideoCatalogTemplate[];
}
export function buildVideoTemplateSections(
filtered: VideoCatalogTemplate[],
sidebarCategory: VideoSidebarCategoryId
): VideoTemplateSection[] {
const newlyReleased = filtered.filter((t) => t.isNew).slice(0, 8);
const sections: VideoTemplateSection[] = [];
if (newlyReleased.length > 0 && sidebarCategory === "all") {
sections.push({
id: "newly-released",
title: "Newly released",
count: newlyReleased.length,
templates: newlyReleased,
});
}
const categories =
sidebarCategory === "all"
? VIDEO_SIDEBAR_CATEGORIES.filter((c) => c.id !== "all")
: VIDEO_SIDEBAR_CATEGORIES.filter((c) => c.id === sidebarCategory);
for (const category of categories) {
const templates = filtered
.filter((t) => t.videoCategory === category.id)
.slice(0, 12);
if (templates.length === 0) continue;
sections.push({
id: category.id,
title: category.label,
count: filtered.filter((t) => t.videoCategory === category.id).length,
templates,
});
}
return sections;
}
export function toProjectTemplate(
template: VideoCatalogTemplate
): { id: string; name: string; category: "Video" } {
return {
id: template.id,
name: template.name,
category: "Video",
};
}