Add original footage to shots function
Deploy / deploy (push) Has been cancelled

This commit is contained in:
twotalesanimation
2026-05-20 11:02:33 +02:00
parent 5c1fd9f288
commit bcea123112
12 changed files with 880 additions and 326 deletions
+4
View File
@@ -23,3 +23,7 @@ Dockerfile*
.github
migrations
README.md
uploads
.git
*.tar
*.sql
+23
View File
@@ -0,0 +1,23 @@
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: docker
steps:
- name: Deploy
run: |
cd /opt/vfxreview
git pull
docker-compose up -d --build
docker-compose exec -T vfxreview npx prisma migrate deploy
docker image prune -f
+2
View File
@@ -103,6 +103,8 @@ const updateShotSchema = z.object({
dueDate: z.string().optional().nullable(),
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
thumbnailUrl: z.string().optional().nullable(),
originalFootageUrl: z.string().optional().nullable(),
originalFootageKey: z.string().optional().nullable(),
});
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
@@ -19,9 +19,11 @@ import {
CheckCircle2,
Settings,
ListTodo,
Video,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
const STATUS_CONFIG: Record<
string,
@@ -51,7 +53,7 @@ export default function ShotDetailPage() {
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
const fetchShot = async () => {
try {
@@ -190,6 +192,18 @@ export default function ShotDetailPage() {
<ListTodo className="h-4 w-4" />
Tasks
</button>
<button
onClick={() => setActiveTab("footage")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "footage"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Video className="h-4 w-4" />
Footage
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
@@ -217,6 +231,14 @@ export default function ShotDetailPage() {
/>
)}
{activeTab === "footage" && (
<FootageViewer
shot={shot}
canManage={canManage}
onSaved={fetchShot}
/>
)}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
-260
View File
@@ -1,260 +0,0 @@
root@ubuntu-4gb-nbg1-3:/opt/vfxreview# docker inspect vfxreview
[
{
"Id": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
"Created": "2026-05-15T11:13:46.919887274Z",
"Path": "docker-entrypoint.sh",
"Args": [
"node",
"server.js"
],
"State": {
"Status": "restarting",
"Running": true,
"Paused": false,
"Restarting": true,
"OOMKilled": false,
"Dead": false,
"Pid": 0,
"ExitCode": 255,
"Error": "",
"StartedAt": "2026-05-15T11:17:31.349008071Z",
"FinishedAt": "2026-05-15T11:17:31.553910724Z"
},
"Image": "sha256:69368bb47b96170569bd23be2343dded31696127c8d1e0c1ddff6fbc6b9d4fb2",
"ResolvConfPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hostname",
"HostsPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hosts",
"LogPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320-json.log",
"Name": "/vfxreview",
"RestartCount": 13,
"Driver": "overlay2",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "docker-default",
"ExecIDs": null,
"HostConfig": {
"Binds": [
"/opt/vfxreview/uploads:/uploads:rw"
],
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {}
},
"NetworkMode": "vfxreview_app-net",
"PortBindings": {},
"RestartPolicy": {
"Name": "unless-stopped",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": [],
"ConsoleSize": [
0,
0
],
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "private",
"Dns": null,
"DnsOptions": null,
"DnsSearch": null,
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": null,
"BlkioDeviceReadBps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteIOps": null,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": null,
"DeviceCgroupRules": null,
"DeviceRequests": null,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": null,
"PidsLimit": null,
"Ulimits": null,
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"MaskedPaths": [
"/proc/acpi",
"/proc/asound",
"/proc/interrupts",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/sched_debug",
"/proc/scsi",
"/proc/timer_list",
"/proc/timer_stats",
"/sys/devices/virtual/powercap",
"/sys/firmware"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": {
"ID": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
"LowerDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c-init/diff:/var/lib/docker/overlay2/99da5f835f83c7550d6a8f82f03fc4f7147c86a02f934cc8688fc5444110352c/diff:/var/lib/docker/overlay2/a824d9563ca0af7794e667c9b4799f57c6a76dcff84b0f6375018dc90c4f50ce/diff:/var/lib/docker/overlay2/6c9a5c50a54b3111494f6780dbb640559b6db9310b92826e875fd4d3048bc590/diff:/var/lib/docker/overlay2/ba53a31b5ba6e15b84be1424c9fb2053a16dbba42a2056b39cc0c0ca68a23ea3/diff:/var/lib/docker/overlay2/5a6252acf39dbbb2daa9a95050adbd5d61f7079c3ca281544a72d7942bdb86fa/diff:/var/lib/docker/overlay2/2fec287c58bf77e6f80c042a360d9814b77a747b1c6436ba68b4dcee309b8bb4/diff:/var/lib/docker/overlay2/100154f217c53a8e26927fa82f3fe56074e192506607c283a0cc3dfd7d4261a7/diff:/var/lib/docker/overlay2/c8d0b59c20926f8c6337ab778303dd37701740dcab6494a8ef7547eccfd266ed/diff:/var/lib/docker/overlay2/adb4f687409d280798957b268c1eef0fcc93ab6ecc24f291ba2ff4afaf5ef9c1/diff:/var/lib/docker/overlay2/0567d27adf4684c0b9d2be40dd32e2be2a81e43d2b118c8ac10697f64ce2b41c/diff:/var/lib/docker/overlay2/509a8569764702e18a334b6c7dd49e7dfdf237b821bcd34fc9102c11378e6c2b/diff:/var/lib/docker/overlay2/66cb5fe679daa03d24693ef170278322206904e36cad21c55a582175c7ea2298/diff",
"MergedDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/merged",
"UpperDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/diff",
"WorkDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/work"
},
"Name": "overlay2"
},
"Mounts": [
{
"Type": "bind",
"Source": "/opt/vfxreview/uploads",
"Destination": "/uploads",
"Mode": "rw",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "c657a3a390f6",
"Domainname": "",
"User": "nextjs",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"3000/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"DATABASE_URL=postgresql://postgres:postgres@postgres:5432/feedback",
"NEXTAUTH_SECRET=your-secret-here-change-in-production",
"NEXTAUTH_URL=http://localhost:3000",
"UPLOADTHING_SECRET=eyJhcGlLZXkiOiJza19saXZlXzM0YmMzMTQ0NmVkZTJlMDQ3NmUwYmMzY2IyYzJkNTAyOTM1ODk0ZmM0YWRiNTQ1ODIxODhhM2VjNmU5OTE2NGMiLCJhcHBJZCI6ImVrN20xbWg2cXUiLCJyZWdpb25zIjpbInNlYTEiXX0=",
"UPLOADTHING_APP_ID=",
"STORAGE_PROVIDER=local",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_REGION=us-east-1",
"AWS_BUCKET_NAME=",
"R2_ACCESS_KEY_ID=",
"R2_SECRET_ACCESS_KEY=",
"R2_ACCOUNT_ID=",
"R2_BUCKET_NAME=",
"R2_PUBLIC_URL=",
"B2_APPLICATION_KEY_ID=",
"B2_APPLICATION_KEY=",
"B2_BUCKET_NAME=",
"B2_ENDPOINT=",
"MINIO_ENDPOINT=http://localhost:9000",
"MINIO_ACCESS_KEY=",
"MINIO_SECRET_KEY=",
"MINIO_BUCKET_NAME=vfx-review",
"LOCAL_UPLOAD_DIR=./uploads",
"EMAIL_FROM=noreply@yourcompany.com",
"EMAIL_SERVER_HOST=smtp.gmail.com",
"EMAIL_SERVER_PORT=587",
"EMAIL_SERVER_USER=",
"EMAIL_SERVER_PASSWORD=",
"SLACK_DEFAULT_WEBHOOK=",
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
"NEXT_PUBLIC_APP_NAME=VFX Review",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_VERSION=20.20.2",
"YARN_VERSION=1.22.22",
"NODE_ENV=production",
"NEXT_TELEMETRY_DISABLED=1",
"PORT=3000",
"HOSTNAME=0.0.0.0"
],
"Cmd": [
"node",
"server.js"
],
"Image": "vfxreview:1.0.0",
"Volumes": {
"/uploads": {}
},
"WorkingDir": "/app",
"Entrypoint": [
"docker-entrypoint.sh"
],
"Labels": {
"com.docker.compose.config-hash": "be61c44b05bc31b228b3b1ab25372b6e4e6b8358b1bc7c96a2b131e642a81358",
"com.docker.compose.container-number": "1",
"com.docker.compose.oneoff": "False",
"com.docker.compose.project": "vfxreview",
"com.docker.compose.project.config_files": "docker-compose.yml",
"com.docker.compose.project.working_dir": "/opt/vfxreview",
"com.docker.compose.service": "vfxreview",
"com.docker.compose.version": "1.29.2"
}
},
"NetworkSettings": {
"SandboxID": "",
"SandboxKey": "",
"Ports": {},
"Networks": {
"vfxreview_app-net": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"vfxreview",
"c657a3a390f6"
],
"DriverOpts": null,
"GwPriority": 0,
"NetworkID": "e3f79c0c30cca1c12a74793b70818cc7573e9ec6ffa5ee393d3a81b26bae33ee",
"EndpointID": "",
"Gateway": "",
"IPAddress": "",
"MacAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": [
"vfxreview",
"c657a3a390f6"
]
}
}
}
}
]
+325
View File
@@ -0,0 +1,325 @@
"use client";
import { useRef, useState } from "react";
import { Video, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { updateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
interface FootageViewerProps {
shot: {
id: string;
shotCode: string;
originalFootageUrl: string | null;
originalFootageKey: string | null;
};
canManage: boolean;
onSaved?: () => void;
}
function uploadViaXhr(
file: File,
onProgress: (fraction: number) => void
): Promise<{ url: string; key: string }> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload/local");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
else reject(new Error(json.error ?? "Upload failed"));
} catch {
reject(new Error("Invalid server response"));
}
} else {
try {
const json = JSON.parse(xhr.responseText);
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
xhr.send(formData);
});
}
export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps) {
const { toast } = useToast();
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [footageFile, setFootageFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [currentUrl, setCurrentUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFootageFile(file);
e.target.value = "";
};
const handleUpload = async () => {
if (!footageFile) return;
setUploading(true);
setProgress(0);
try {
const { url, key } = await uploadViaXhr(footageFile, setProgress);
await updateShot({
shotId: shot.id,
originalFootageUrl: url,
originalFootageKey: key || undefined,
});
setCurrentUrl(url);
setFootageFile(null);
toast({ title: "Footage uploaded" });
onSaved?.();
} catch (e) {
toast({
title: "Upload failed",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
} finally {
setUploading(false);
setProgress(0);
}
};
const handleRemove = async () => {
try {
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
setCurrentUrl(null);
setFootageFile(null);
toast({ title: "Footage removed" });
onSaved?.();
} catch (e) {
toast({
title: "Failed to remove footage",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
}
};
return (
<div className="space-y-4 max-w-4xl">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300 flex items-center gap-2">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
<span className="text-xs font-mono text-zinc-500">{shot.shotCode}</span>
</h3>
{canManage && currentUrl && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Replace
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-zinc-500 hover:text-red-400"
onClick={handleRemove}
disabled={uploading}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
</div>
)}
</div>
{/* Video player */}
{currentUrl && (
<div className="relative w-full rounded-xl overflow-hidden bg-black border border-zinc-800 aspect-video">
<video
ref={videoRef}
src={currentUrl}
controls
className="w-full h-full"
preload="metadata"
>
Your browser does not support the video tag.
</video>
</div>
)}
{/* Empty state */}
{!currentUrl && !footageFile && (
<div
className={`flex flex-col items-center justify-center py-20 gap-4 rounded-xl border-2 border-dashed border-zinc-800 text-zinc-500 ${
canManage ? "hover:border-amber-500/40 cursor-pointer transition-colors" : ""
}`}
onClick={() => canManage && fileInputRef.current?.click()}
>
<Video className="h-12 w-12 opacity-30" />
<p className="text-sm">No original footage uploaded yet.</p>
{canManage && <p className="text-xs text-zinc-600">Click to select a video file</p>}
</div>
)}
{/* Pending file — ready to upload */}
{footageFile && !uploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">{footageFile.name}</p>
<p className="text-xs text-zinc-500">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</p>
</div>
<Button size="sm" onClick={handleUpload}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Upload
</Button>
<button
type="button"
onClick={() => setFootageFile(null)}
className="text-zinc-500 hover:text-zinc-300"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Upload progress */}
{uploading && (
<div className="space-y-2 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>Uploading footage</span>
<span>{Math.round(progress * 100)}%</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 transition-all duration-200"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}
interface FootageViewerProps {
shot: {
id: string;
shotCode: string;
description: string | null;
status: string;
priority: string;
fps: number;
frameStart?: number | null;
frameEnd?: number | null;
dueDate: Date | string | null;
artistId: string | null;
thumbnailUrl: string | null;
originalFootageUrl: string | null;
originalFootageKey: string | null;
};
canManage: boolean;
artists: { id: string; name: string | null; email: string }[];
onSaved?: () => void;
}
export function FootageViewer({ shot, canManage, artists, onSaved }: FootageViewerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [showUpload, setShowUpload] = useState(false);
if (!shot.originalFootageUrl) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-4 text-zinc-500">
<Video className="h-12 w-12 opacity-30" />
<p className="text-sm">No original footage uploaded yet.</p>
{canManage && !showUpload && (
<Button variant="outline" size="sm" onClick={() => setShowUpload(true)}>
Upload Footage
</Button>
)}
{canManage && showUpload && (
<div className="w-full max-w-2xl mt-4">
<ShotSettingsTab
shot={shot}
artists={artists}
onSaved={() => { setShowUpload(false); onSaved?.(); }}
/>
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300 flex items-center gap-2">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
<span className="text-xs font-mono text-zinc-500">{shot.shotCode}</span>
</h3>
{canManage && (
<Button
variant="ghost"
size="sm"
className="text-xs text-zinc-500 hover:text-zinc-300"
onClick={() => setShowUpload((v) => !v)}
>
<Settings className="h-3.5 w-3.5 mr-1.5" />
{showUpload ? "Hide" : "Replace"}
</Button>
)}
</div>
{/* Video player */}
<div className="relative w-full rounded-xl overflow-hidden bg-black border border-zinc-800 aspect-video">
<video
ref={videoRef}
src={shot.originalFootageUrl}
controls
className="w-full h-full"
preload="metadata"
>
Your browser does not support the video tag.
</video>
</div>
{canManage && showUpload && (
<div className="mt-6 max-w-2xl">
<ShotSettingsTab
shot={shot}
artists={artists}
onSaved={() => { setShowUpload(false); onSaved?.(); }}
/>
</div>
)}
</div>
);
}
+449 -1
View File
@@ -19,7 +19,455 @@ import {
} from "@/components/ui/select";
import { updateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon } from "lucide-react";
import { Upload, X, Film, ImageIcon, Video } from "lucide-react";
const settingsSchema = z.object({
description: z.string().optional(),
status: z.enum(["WAITING", "IN_PROGRESS", "IN_REVIEW", "REVISIONS", "COMPLETE"]),
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
fps: z.coerce.number().min(1).max(240),
frameStart: z.coerce.number().int().optional().or(z.literal("")),
frameEnd: z.coerce.number().int().optional().or(z.literal("")),
dueDate: z.string().optional(),
artistId: z.string().optional(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
interface Artist {
id: string;
name: string | null;
email: string;
}
interface ShotSettingsTabProps {
shot: {
id: string;
shotCode: string;
description: string | null;
status: string;
priority: string;
fps: number;
frameStart?: number | null;
frameEnd?: number | null;
dueDate: Date | string | null;
artistId: string | null;
thumbnailUrl: string | null;
originalFootageUrl: string | null;
originalFootageKey: string | null;
};
artists: Artist[];
onSaved?: () => void;
}
function uploadViaXhr(
file: File,
onProgress: (fraction: number) => void
): Promise<{ url: string; key: string }> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload/local");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
else reject(new Error(json.error ?? "Upload failed"));
} catch {
reject(new Error("Invalid server response"));
}
} else {
try {
const json = JSON.parse(xhr.responseText);
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
xhr.send(formData);
});
}
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
const { toast } = useToast();
const [isSaving, setIsSaving] = useState(false);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(shot.thumbnailUrl ?? null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Footage upload state
const [footageFile, setFootageFile] = useState<File | null>(null);
const [footageUploading, setFootageUploading] = useState(false);
const [footageProgress, setFootageProgress] = useState(0);
const [currentFootageUrl, setCurrentFootageUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
const footageInputRef = useRef<HTMLInputElement>(null);
const formatDate = (d: Date | string | null) => {
if (!d) return "";
return new Date(d).toISOString().split("T")[0];
};
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
description: shot.description ?? "",
status: shot.status as SettingsFormValues["status"],
priority: shot.priority as SettingsFormValues["priority"],
fps: shot.fps,
frameStart: shot.frameStart ?? "",
frameEnd: shot.frameEnd ?? "",
dueDate: formatDate(shot.dueDate),
artistId: shot.artistId ?? "__none__",
},
});
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setThumbnailFile(file);
setClearThumbnail(false);
const reader = new FileReader();
reader.onload = (ev) => setThumbnailPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleFootageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFootageFile(file);
};
const handleFootageUpload = async () => {
if (!footageFile) return;
setFootageUploading(true);
setFootageProgress(0);
try {
const { url, key } = await uploadViaXhr(footageFile, setFootageProgress);
await updateShot({
shotId: shot.id,
originalFootageUrl: url,
originalFootageKey: key || undefined,
});
setCurrentFootageUrl(url);
setFootageFile(null);
toast({ title: "Footage uploaded" });
onSaved?.();
} catch (e) {
toast({ title: "Upload failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setFootageUploading(false);
setFootageProgress(0);
}
};
const handleFootageRemove = async () => {
try {
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
setCurrentFootageUrl(null);
setFootageFile(null);
toast({ title: "Footage removed" });
onSaved?.();
} catch (e) {
toast({ title: "Failed to remove footage", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
};
const onSubmit = async (values: SettingsFormValues) => {
setIsSaving(true);
try {
let thumbnailUrl: string | null | undefined = undefined;
if (clearThumbnail) {
thumbnailUrl = null;
} else if (thumbnailFile) {
const fd = new FormData();
fd.append("file", thumbnailFile);
fd.append("type", "image");
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("Thumbnail upload failed");
const data = await res.json();
thumbnailUrl = data.url;
}
await updateShot({
shotId: shot.id,
description: values.description || undefined,
status: values.status,
priority: values.priority,
fps: values.fps,
frameStart: values.frameStart !== "" && values.frameStart != null ? Number(values.frameStart) : null,
frameEnd: values.frameEnd !== "" && values.frameEnd != null ? Number(values.frameEnd) : null,
dueDate: values.dueDate || null,
artistId: values.artistId === "__none__" ? null : values.artistId,
thumbnailUrl,
});
toast({ title: "Shot updated" });
onSaved?.();
} catch (e) {
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-10 max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Details */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<Film className="h-4 w-4 text-amber-500" />
Details
</div>
<Separator />
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea {...register("description")} rows={3} placeholder="Shot description…" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Status</Label>
<Select
defaultValue={shot.status}
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"])}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="WAITING">Waiting</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="IN_REVIEW">In Review</SelectItem>
<SelectItem value="REVISIONS">Revisions</SelectItem>
<SelectItem value="COMPLETE">Complete</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
defaultValue={shot.priority}
onValueChange={(v) => setValue("priority", v as SettingsFormValues["priority"])}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Low</SelectItem>
<SelectItem value="NORMAL">Normal</SelectItem>
<SelectItem value="HIGH">High</SelectItem>
<SelectItem value="CRITICAL">Critical</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Timing */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<span className="text-amber-500 font-mono text-xs">FPS</span>
Timing
</div>
<Separator />
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>FPS</Label>
<Input type="number" step="any" {...register("fps")} />
{errors.fps && <p className="text-xs text-destructive">{errors.fps.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Frame Start</Label>
<Input type="number" {...register("frameStart")} placeholder="1001" />
</div>
<div className="space-y-1.5">
<Label>Frame End</Label>
<Input type="number" {...register("frameEnd")} placeholder="1100" />
</div>
</div>
<div className="space-y-1.5 max-w-xs">
<Label>Due Date</Label>
<Input type="date" {...register("dueDate")} />
</div>
</div>
{/* Assignment */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<span className="text-amber-500">👤</span>
Assignment
</div>
<Separator />
<div className="space-y-1.5 max-w-xs">
<Label>Artist</Label>
<Select
defaultValue={shot.artistId ?? "__none__"}
onValueChange={(v) => setValue("artistId", v)}
>
<SelectTrigger><SelectValue placeholder="Unassigned" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Thumbnail */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<ImageIcon className="h-4 w-4 text-amber-500" />
Thumbnail
</div>
<Separator />
{thumbnailPreview && !clearThumbnail ? (
<div className="relative w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border group">
<Image src={thumbnailPreview} alt={shot.shotCode} fill className="object-cover" />
<button
type="button"
onClick={() => { setClearThumbnail(true); setThumbnailPreview(null); setThumbnailFile(null); }}
className="absolute top-1.5 right-1.5 bg-black/70 hover:bg-black text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="w-72 aspect-[2.39] rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex items-center justify-center gap-2 text-sm text-muted-foreground cursor-pointer transition-colors"
>
<Upload className="h-4 w-4" />
Upload thumbnail
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleThumbnailChange}
/>
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving…" : "Save Changes"}
</Button>
</form>
{/* Original Footage — separate section with its own XHR upload */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
</div>
<Separator />
<p className="text-xs text-zinc-500">Upload the original (pre-VFX) footage for reference. Supported formats: MP4, MOV, AVI, MKV, WebM.</p>
{currentFootageUrl ? (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-amber-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">Footage uploaded</span>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => footageInputRef.current?.click()}
>
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-zinc-500 hover:text-red-400"
onClick={handleFootageRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div
onClick={() => !footageUploading && footageInputRef.current?.click()}
className="w-full rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground cursor-pointer transition-colors"
>
<Video className="h-6 w-6" />
<span>Click to select original footage</span>
</div>
)}
{footageFile && !footageUploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">{footageFile.name}</span>
<span className="text-xs text-zinc-500 shrink-0">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</span>
<Button type="button" size="sm" onClick={handleFootageUpload}>
Upload
</Button>
<button
type="button"
onClick={() => setFootageFile(null)}
className="text-zinc-500 hover:text-zinc-300"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{footageUploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>Uploading</span>
<span>{Math.round(footageProgress * 100)}%</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 transition-all duration-200"
style={{ width: `${footageProgress * 100}%` }}
/>
</div>
</div>
)}
<input
ref={footageInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
className="hidden"
onChange={handleFootageChange}
/>
</div>
</div>
);
}
const settingsSchema = z.object({
description: z.string().optional(),
+46 -64
View File
@@ -1,72 +1,54 @@
version: "3.8"
services:
# ── PostgreSQL ──────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: vfxreview_db
restart: unless-stopped
environment:
POSTGRES_DB: vfxreview
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
vfxreview:
image: vfxreview:1.0.1
container_name: vfxreview
# ── MinIO (S3-compatible local storage) ────────────────
minio:
image: minio/minio:latest
container_name: vfxreview_storage
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# ── App (production build) ─────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/arm64
- linux/amd64
container_name: vfxreview_app
restart: unless-stopped
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/vfxreview
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
STORAGE_PROVIDER: minio
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET_NAME: vfx-review
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
env_file:
- .env
volumes:
- ./uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
networks:
- app-net
- npm_default
postgres:
image: postgres:15
container_name: vfxreview-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: feedback
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
restart: always
networks:
- app-net
volumes:
postgres_data:
minio_data:
pgdata:
networks:
app-net:
npm_default:
external: true
@@ -0,0 +1 @@
-- This is an empty migration.
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "shots" ADD COLUMN "originalFootageKey" TEXT,
ADD COLUMN "originalFootageUrl" TEXT;
+2
View File
@@ -267,6 +267,8 @@ model Shot {
fps Float @default(24)
dueDate DateTime?
thumbnailUrl String?
originalFootageUrl String?
originalFootageKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+2
View File
@@ -162,6 +162,8 @@ export interface ShotWithDetails {
frameEnd: number | null;
dueDate: Date | null;
thumbnailUrl: string | null;
originalFootageUrl: string | null;
originalFootageKey: string | null;
createdAt: Date;
updatedAt: Date;
artist: {