Initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Frame-accurate utilities for VFX review.
|
||||
* All frame calculations use 0-based frame numbers
|
||||
* unless documented otherwise.
|
||||
*/
|
||||
|
||||
/** Convert frame number to video currentTime in seconds */
|
||||
export function frameToTime(frame: number, fps: number): number {
|
||||
return frame / fps;
|
||||
}
|
||||
|
||||
/** Convert video currentTime to frame number (0-based) */
|
||||
export function timeToFrame(time: number, fps: number): number {
|
||||
return Math.floor(time * fps);
|
||||
}
|
||||
|
||||
/** Format a frame number as a timecode string: MM:SS:FF */
|
||||
export function frameToTimecode(frame: number, fps: number): string {
|
||||
const totalSeconds = frame / fps;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const frames = frame % Math.round(fps);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Format a raw time (seconds) as MM:SS.mmm */
|
||||
export function timeToDisplay(time: number): string {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
const ms = Math.floor((time % 1) * 1000);
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** Given duration and fps, compute total frame count */
|
||||
export function durationToFrameCount(duration: number, fps: number): number {
|
||||
return Math.floor(duration * fps);
|
||||
}
|
||||
|
||||
/** Clamp a frame number to [0, totalFrames - 1] */
|
||||
export function clampFrame(frame: number, totalFrames: number): number {
|
||||
return Math.max(0, Math.min(totalFrames - 1, frame));
|
||||
}
|
||||
|
||||
/** Step a single frame forward or backward */
|
||||
export function stepFrame(
|
||||
currentFrame: number,
|
||||
delta: number,
|
||||
totalFrames: number
|
||||
): number {
|
||||
return clampFrame(currentFrame + delta, totalFrames);
|
||||
}
|
||||
|
||||
/** Convert a timeline position (0-1) to frame number */
|
||||
export function positionToFrame(position: number, totalFrames: number): number {
|
||||
return clampFrame(Math.floor(position * totalFrames), totalFrames);
|
||||
}
|
||||
|
||||
/** Convert a frame number to timeline position (0-1) */
|
||||
export function frameToPosition(frame: number, totalFrames: number): number {
|
||||
if (totalFrames === 0) return 0;
|
||||
return frame / totalFrames;
|
||||
}
|
||||
|
||||
/** Common VFX frame rates */
|
||||
export const COMMON_FPS = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60] as const;
|
||||
|
||||
export type CommonFPS = (typeof COMMON_FPS)[number];
|
||||
|
||||
/** Human-readable fps label */
|
||||
export function fpsLabel(fps: number): string {
|
||||
if (fps === 23.976) return "23.976";
|
||||
return String(fps);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { NotificationType } from "@prisma/client";
|
||||
|
||||
export interface CreateNotificationPayload {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single in-app notification.
|
||||
*/
|
||||
export async function createNotification(
|
||||
payload: CreateNotificationPayload
|
||||
) {
|
||||
return db.notification.create({
|
||||
data: {
|
||||
userId: payload.userId,
|
||||
type: payload.type,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
data: (payload.data ?? {}) as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for multiple users at once.
|
||||
*/
|
||||
export async function createNotifications(
|
||||
payloads: CreateNotificationPayload[]
|
||||
) {
|
||||
return db.notification.createMany({
|
||||
data: payloads.map((p) => ({
|
||||
userId: p.userId,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
message: p.message,
|
||||
data: (p.data ?? {}) as any,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all ADMIN, PRODUCER, and SUPERVISOR users about a new version upload.
|
||||
*/
|
||||
export async function notifyNewVersionUploaded(params: {
|
||||
shotCode: string;
|
||||
versionNumber: number;
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
artistName: string;
|
||||
}) {
|
||||
const staff = await db.user.findMany({
|
||||
where: {
|
||||
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const label = `v${String(params.versionNumber).padStart(3, "0")}`;
|
||||
|
||||
await createNotifications(
|
||||
staff.map((u) => ({
|
||||
userId: u.id,
|
||||
type: NotificationType.VERSION_UPLOADED,
|
||||
title: "New version uploaded",
|
||||
message: `${params.shotCode} ${label} uploaded by ${params.artistName}`,
|
||||
data: {
|
||||
versionId: params.versionId,
|
||||
shotCode: params.shotCode,
|
||||
versionNumber: params.versionNumber,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the shot's artist about new feedback.
|
||||
*/
|
||||
export async function notifyFeedbackAdded(params: {
|
||||
artistId: string;
|
||||
shotCode: string;
|
||||
frameNumber: number;
|
||||
versionId: string;
|
||||
authorName: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: NotificationType.FEEDBACK_ADDED,
|
||||
title: "New feedback on your shot",
|
||||
message: `${params.authorName} commented on ${params.shotCode} at frame ${params.frameNumber}`,
|
||||
data: {
|
||||
versionId: params.versionId,
|
||||
shotCode: params.shotCode,
|
||||
frameNumber: params.frameNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify relevant users of an approval status change.
|
||||
*/
|
||||
export async function notifyApprovalChange(params: {
|
||||
artistId: string | null;
|
||||
shotCode: string;
|
||||
versionId: string;
|
||||
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES";
|
||||
reviewerName: string;
|
||||
}) {
|
||||
if (!params.artistId) return;
|
||||
|
||||
const typeMap = {
|
||||
APPROVED: NotificationType.SHOT_APPROVED,
|
||||
REJECTED: NotificationType.SHOT_REJECTED,
|
||||
NEEDS_CHANGES: NotificationType.REVISION_REQUESTED,
|
||||
} as const;
|
||||
|
||||
const msgMap = {
|
||||
APPROVED: `${params.shotCode} was approved by ${params.reviewerName}`,
|
||||
REJECTED: `${params.shotCode} was rejected by ${params.reviewerName}`,
|
||||
NEEDS_CHANGES: `${params.shotCode} needs changes — ${params.reviewerName}`,
|
||||
} as const;
|
||||
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: typeMap[params.status],
|
||||
title: msgMap[params.status],
|
||||
message: msgMap[params.status],
|
||||
data: { versionId: params.versionId, shotCode: params.shotCode },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify an artist that a task has been assigned to them.
|
||||
*/
|
||||
export async function notifyTaskAssigned(params: {
|
||||
artistId: string;
|
||||
taskTitle: string;
|
||||
taskId: string;
|
||||
contextCode: string | null;
|
||||
assignedByName: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: NotificationType.TASK_ASSIGNED,
|
||||
title: "New task assigned to you",
|
||||
message: `${params.assignedByName} assigned you: ${params.taskTitle}${
|
||||
params.contextCode ? ` (${params.contextCode})` : ""
|
||||
}`,
|
||||
data: { taskId: params.taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify supervisors/producers that a task is ready for internal review.
|
||||
*/
|
||||
export async function notifyTaskReadyForReview(params: {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
contextCode: string | null;
|
||||
artistName: string;
|
||||
projectId: string;
|
||||
taskUrl: string;
|
||||
}) {
|
||||
const staff = await db.user.findMany({
|
||||
where: {
|
||||
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const label = params.contextCode ? `${params.contextCode} — ` : "";
|
||||
await createNotifications(
|
||||
staff.map((u) => ({
|
||||
userId: u.id,
|
||||
type: NotificationType.TASK_READY_FOR_REVIEW,
|
||||
title: "Task ready for review",
|
||||
message: `${label}${params.taskTitle} marked ready by ${params.artistName}`,
|
||||
data: { taskId: params.taskId, taskUrl: params.taskUrl },
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify an artist that a task has been approved.
|
||||
*/
|
||||
export async function notifyTaskApproved(params: {
|
||||
artistId: string;
|
||||
taskTitle: string;
|
||||
taskId: string;
|
||||
contextCode: string | null;
|
||||
reviewerName: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: NotificationType.TASK_APPROVED,
|
||||
title: "Task approved",
|
||||
message: `${params.reviewerName} approved: ${params.taskTitle}${
|
||||
params.contextCode ? ` (${params.contextCode})` : ""
|
||||
}`,
|
||||
data: { taskId: params.taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify an artist that changes were requested on their task.
|
||||
*/
|
||||
export async function notifyTaskChangesRequested(params: {
|
||||
artistId: string;
|
||||
taskTitle: string;
|
||||
taskId: string;
|
||||
contextCode: string | null;
|
||||
reviewerName: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: NotificationType.TASK_CHANGES_REQUESTED,
|
||||
title: "Changes requested on task",
|
||||
message: `${params.reviewerName} requested changes: ${params.taskTitle}${
|
||||
params.contextCode ? ` (${params.contextCode})` : ""
|
||||
}`,
|
||||
data: { taskId: params.taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a comment author about a new reply.
|
||||
*/
|
||||
export async function notifyCommentReply(params: {
|
||||
commentAuthorId: string;
|
||||
replierName: string;
|
||||
shotCode: string;
|
||||
frameNumber: number;
|
||||
versionId: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.commentAuthorId,
|
||||
type: NotificationType.COMMENT_REPLY,
|
||||
title: "New reply to your comment",
|
||||
message: `${params.replierName} replied on ${params.shotCode} frame ${params.frameNumber}`,
|
||||
data: {
|
||||
versionId: params.versionId,
|
||||
shotCode: params.shotCode,
|
||||
frameNumber: params.frameNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ShotStatus, TaskStatus } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
type TxClient = Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Derive the shot status from its tasks.
|
||||
* Priority order (highest → lowest):
|
||||
* CHANGES on any task → REVISIONS
|
||||
* INTERNAL_REVIEW / CLIENT_REVIEW on any task → IN_REVIEW
|
||||
* TODO / IN_PROGRESS on any task → IN_PROGRESS
|
||||
* All tasks DONE → COMPLETE
|
||||
* No tasks → WAITING
|
||||
*/
|
||||
export function deriveShotStatus(
|
||||
tasks: { status: TaskStatus }[]
|
||||
): ShotStatus {
|
||||
if (tasks.length === 0) return "WAITING";
|
||||
if (tasks.some((t) => t.status === "CHANGES")) return "REVISIONS";
|
||||
if (tasks.some((t) => t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"))
|
||||
return "IN_REVIEW";
|
||||
if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS"))
|
||||
return "IN_PROGRESS";
|
||||
if (tasks.every((t) => t.status === "DONE")) return "COMPLETE";
|
||||
return "WAITING";
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all tasks for a shot, derive the correct ShotStatus, and persist it.
|
||||
* Accepts an optional Prisma transaction client (tx) for use inside transactions.
|
||||
*/
|
||||
export async function recalcShotStatus(
|
||||
shotId: string,
|
||||
tx?: TxClient
|
||||
): Promise<void> {
|
||||
const client = tx ?? db;
|
||||
|
||||
const tasks = await client.task.findMany({
|
||||
where: { shotId },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
const newStatus = deriveShotStatus(tasks);
|
||||
|
||||
await client.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { status: newStatus },
|
||||
});
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
export interface SlackMessage {
|
||||
text?: string;
|
||||
blocks?: SlackBlock[];
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
interface SlackBlock {
|
||||
type: string;
|
||||
text?: { type: string; text: string };
|
||||
elements?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Slack webhook URL.
|
||||
*/
|
||||
export async function sendSlackMessage(
|
||||
webhookUrl: string,
|
||||
message: SlackMessage
|
||||
): Promise<void> {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error("[Slack] Webhook failed:", res.status, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a plain text Slack notification.
|
||||
*/
|
||||
export async function sendSlackText(
|
||||
webhookUrl: string,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
return sendSlackMessage(webhookUrl, { text });
|
||||
}
|
||||
|
||||
// ── Pre-built notification templates ─────────────────────────────────────────
|
||||
|
||||
export async function slackNotifyVersionUploaded(
|
||||
webhookUrl: string,
|
||||
params: {
|
||||
shotCode: string;
|
||||
versionLabel: string;
|
||||
artistName: string;
|
||||
projectName: string;
|
||||
reviewUrl: string;
|
||||
}
|
||||
) {
|
||||
await sendSlackMessage(webhookUrl, {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `🎬 *New version uploaded*\n*${params.projectName}* · ${params.shotCode} ${params.versionLabel} by ${params.artistName}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "Review Now" },
|
||||
url: params.reviewUrl,
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function slackNotifyApproval(
|
||||
webhookUrl: string,
|
||||
params: {
|
||||
shotCode: string;
|
||||
versionLabel: string;
|
||||
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES";
|
||||
reviewerName: string;
|
||||
projectName: string;
|
||||
reviewUrl: string;
|
||||
}
|
||||
) {
|
||||
const icons = {
|
||||
APPROVED: "✅",
|
||||
REJECTED: "❌",
|
||||
NEEDS_CHANGES: "⚠️",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
APPROVED: "approved",
|
||||
REJECTED: "rejected",
|
||||
NEEDS_CHANGES: "needs changes",
|
||||
};
|
||||
|
||||
const icon = icons[params.status];
|
||||
const label = labels[params.status];
|
||||
|
||||
await sendSlackMessage(webhookUrl, {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `${icon} *${params.shotCode} ${params.versionLabel} ${label}*\nBy ${params.reviewerName} · ${params.projectName}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "View Version" },
|
||||
url: params.reviewUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function slackNotifyTaskReadyForReview(
|
||||
webhookUrl: string,
|
||||
params: {
|
||||
taskTitle: string;
|
||||
contextCode: string | null;
|
||||
artistName: string;
|
||||
projectName: string;
|
||||
taskUrl: string;
|
||||
}
|
||||
) {
|
||||
const label = params.contextCode ? `${params.contextCode} — ` : "";
|
||||
await sendSlackMessage(webhookUrl, {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `👁 *Task ready for review*\n*${params.projectName}* · ${label}${params.taskTitle}\nSubmitted by ${params.artistName}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "Review Task" },
|
||||
url: params.taskUrl,
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function slackNotifyTaskAssigned(
|
||||
webhookUrl: string,
|
||||
params: {
|
||||
taskTitle: string;
|
||||
contextCode: string | null;
|
||||
artistName: string;
|
||||
assignedByName: string;
|
||||
projectName: string;
|
||||
taskUrl: string;
|
||||
}
|
||||
) {
|
||||
const label = params.contextCode ? `${params.contextCode} — ` : "";
|
||||
await sendSlackMessage(webhookUrl, {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `📋 *Task assigned*\n*${params.projectName}* · ${label}${params.taskTitle}\nAssigned to ${params.artistName} by ${params.assignedByName}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "View Task" },
|
||||
url: params.taskUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function slackNotifyNewFeedback(
|
||||
webhookUrl: string,
|
||||
params: {
|
||||
shotCode: string;
|
||||
frameNumber: number;
|
||||
authorName: string;
|
||||
commentText: string;
|
||||
reviewUrl: string;
|
||||
}
|
||||
) {
|
||||
const truncated =
|
||||
params.commentText.length > 120
|
||||
? params.commentText.slice(0, 117) + "..."
|
||||
: params.commentText;
|
||||
|
||||
await sendSlackMessage(webhookUrl, {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `💬 *New feedback* on ${params.shotCode} frame ${params.frameNumber}\n*${params.authorName}:* ${truncated}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "Jump to Frame" },
|
||||
url: params.reviewUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* S3-compatible storage abstraction.
|
||||
*
|
||||
* STORAGE_PROVIDER env controls which backend is used:
|
||||
* local – local filesystem (dev only, no CDN)
|
||||
* uploadthing – UploadThing managed (default for quick start)
|
||||
* s3 – AWS S3
|
||||
* r2 – Cloudflare R2
|
||||
* b2 – Backblaze B2 (S3-compatible endpoint)
|
||||
* minio – Self-hosted MinIO
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type StorageProvider = "local" | "uploadthing" | "s3" | "r2" | "b2" | "minio";
|
||||
|
||||
function getProvider(): StorageProvider {
|
||||
return (process.env.STORAGE_PROVIDER as StorageProvider) ?? "uploadthing";
|
||||
}
|
||||
|
||||
// ── S3 Client factory ────────────────────────────────────────────────────────
|
||||
|
||||
function buildS3Client(): S3Client {
|
||||
const provider = getProvider();
|
||||
|
||||
if (provider === "r2") {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${process.env.R2_ACCOUNT_ID!}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "b2") {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.B2_ENDPOINT!,
|
||||
credentials: {
|
||||
accessKeyId: process.env.B2_APPLICATION_KEY_ID!,
|
||||
secretAccessKey: process.env.B2_APPLICATION_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "minio") {
|
||||
return new S3Client({
|
||||
region: "us-east-1",
|
||||
endpoint: process.env.MINIO_ENDPOINT ?? "http://localhost:9000",
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Default: AWS S3
|
||||
return new S3Client({
|
||||
region: process.env.AWS_REGION ?? "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getBucketName(): string {
|
||||
const provider = getProvider();
|
||||
const map: Record<string, string | undefined> = {
|
||||
s3: process.env.AWS_BUCKET_NAME,
|
||||
r2: process.env.R2_BUCKET_NAME,
|
||||
b2: process.env.B2_BUCKET_NAME,
|
||||
minio: process.env.MINIO_BUCKET_NAME,
|
||||
};
|
||||
return map[provider] ?? "vfx-review";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
key: string;
|
||||
provider: StorageProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file buffer to the configured storage backend.
|
||||
* Returns the public URL and storage key.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
folder: string = "uploads"
|
||||
): Promise<UploadResult> {
|
||||
const provider = getProvider();
|
||||
const key = `${folder}/${randomUUID()}-${fileName}`;
|
||||
|
||||
if (provider === "local") {
|
||||
return uploadLocal(buffer, key);
|
||||
}
|
||||
|
||||
// All S3-compatible providers share the same logic
|
||||
const client = buildS3Client();
|
||||
const bucket = getBucketName();
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
const url = getPublicUrl(key, provider);
|
||||
return { url, key, provider };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for direct browser-to-storage upload.
|
||||
* Expires in 1 hour by default.
|
||||
*/
|
||||
export async function generatePresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<string> {
|
||||
const provider = getProvider();
|
||||
if (provider === "local" || provider === "uploadthing") {
|
||||
// Not applicable for local / uploadthing (use their own flow)
|
||||
throw new Error(`Presigned URLs not supported for provider: ${provider}`);
|
||||
}
|
||||
|
||||
const client = buildS3Client();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: getBucketName(),
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned download URL for a private object.
|
||||
*/
|
||||
export async function generatePresignedDownloadUrl(
|
||||
key: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<string> {
|
||||
const provider = getProvider();
|
||||
if (provider === "local") {
|
||||
return `/api/files/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
const client = buildS3Client();
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: getBucketName(),
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage by key.
|
||||
*/
|
||||
export async function deleteFile(key: string): Promise<void> {
|
||||
const provider = getProvider();
|
||||
|
||||
if (provider === "local") {
|
||||
const filePath = path.join(process.env.LOCAL_UPLOAD_DIR ?? "./uploads", key);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = buildS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: getBucketName(), Key: key })
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function uploadLocal(buffer: Buffer, key: string): UploadResult {
|
||||
const uploadDir = process.env.LOCAL_UPLOAD_DIR ?? "./uploads";
|
||||
const filePath = path.join(uploadDir, key);
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
const url = `/api/files/${encodeURIComponent(key)}`;
|
||||
return { url, key, provider: "local" };
|
||||
}
|
||||
|
||||
function getPublicUrl(key: string, provider: StorageProvider): string {
|
||||
if (provider === "r2" && process.env.R2_PUBLIC_URL) {
|
||||
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
||||
}
|
||||
if (provider === "s3") {
|
||||
return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION ?? "us-east-1"}.amazonaws.com/${key}`;
|
||||
}
|
||||
if (provider === "minio") {
|
||||
return `${process.env.MINIO_ENDPOINT}/${getBucketName()}/${key}`;
|
||||
}
|
||||
// Fallback
|
||||
return `/${key}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { generateReactHelpers } from "@uploadthing/react";
|
||||
import type { OurFileRouter } from "@/lib/uploadthing";
|
||||
|
||||
export const { useUploadThing, uploadFiles } = generateReactHelpers<OurFileRouter>();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createUploadthing, type FileRouter } from "uploadthing/next";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const f = createUploadthing();
|
||||
|
||||
export const uploadRouter = {
|
||||
/** Full-resolution video upload (mp4, mov) */
|
||||
versionVideo: f({
|
||||
video: {
|
||||
maxFileSize: "2GB",
|
||||
maxFileCount: 1,
|
||||
},
|
||||
})
|
||||
.middleware(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
return { userId: session.user.id };
|
||||
})
|
||||
.onUploadComplete(async ({ metadata, file }) => {
|
||||
return { uploadedBy: metadata.userId, url: file.url };
|
||||
}),
|
||||
|
||||
/** Thumbnail / poster image upload */
|
||||
versionThumbnail: f({
|
||||
image: {
|
||||
maxFileSize: "4MB",
|
||||
maxFileCount: 1,
|
||||
},
|
||||
})
|
||||
.middleware(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
return { userId: session.user.id };
|
||||
})
|
||||
.onUploadComplete(async ({ metadata, file }) => {
|
||||
return { uploadedBy: metadata.userId, url: file.url };
|
||||
}),
|
||||
} satisfies FileRouter;
|
||||
|
||||
export type OurFileRouter = typeof uploadRouter;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const frames = Math.floor((seconds % 1) * 24); // rough
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatTimecode(seconds: number, fps: number = 24): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const f = Math.floor((seconds % 1) * fps);
|
||||
if (h > 0) {
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(f).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(f).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function frameToTimecode(frame: number, fps: number): string {
|
||||
return formatTimecode(frame / fps, fps);
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number | bigint): string {
|
||||
const b = typeof bytes === "bigint" ? Number(bytes) : bytes;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function formatRelativeDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function getInitials(name: string | null | undefined): string {
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function versionLabel(n: number): string {
|
||||
return `v${String(n).padStart(3, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user