feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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=";
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DEV_PROJECT_ID = "dev-project";
|
||||
|
||||
export function isDevProjectId(projectId: string): boolean {
|
||||
return projectId === DEV_PROJECT_ID;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function isSupabaseConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user