From bcea12311269c86cfc1da2780f181bee0a07316a Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 20 May 2026 11:02:33 +0200 Subject: [PATCH] Add original footage to shots function --- .dockerignore | 4 + .gitea/workflows/deploy.yml | 23 + actions/shots.ts | 2 + .../projects/[id]/shots/[shotId]/page.tsx | 24 +- check.md | 260 ---------- components/shots/FootageViewer.tsx | 325 +++++++++++++ components/shots/ShotSettingsTab.tsx | 450 +++++++++++++++++- docker-compose.yml | 110 ++--- .../migration.sql | 1 + .../migration.sql | 3 + prisma/schema.prisma | 2 + types/index.ts | 2 + 12 files changed, 880 insertions(+), 326 deletions(-) create mode 100644 .gitea/workflows/deploy.yml delete mode 100644 check.md create mode 100644 components/shots/FootageViewer.tsx create mode 100644 prisma/migrations/20260520062517_add_shot_original_footage/migration.sql create mode 100644 prisma/migrations/20260520062603_add_shot_original_footage/migration.sql diff --git a/.dockerignore b/.dockerignore index 91da83a..629ce33 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,3 +23,7 @@ Dockerfile* .github migrations README.md +uploads +.git +*.tar +*.sql \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..94e93dc --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/actions/shots.ts b/actions/shots.ts index 802c62f..a3d9e91 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -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) { diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 913a17a..7bbfccb 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -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([]); const [artists, setArtists] = useState([]); 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() { Tasks + {canManage && ( + + + )} + + + {/* Video player */} + {currentUrl && ( +
+ +
+ )} + + {/* Empty state */} + {!currentUrl && !footageFile && ( +
canManage && fileInputRef.current?.click()} + > +
+ )} + + {/* Pending file β€” ready to upload */} + {footageFile && !uploading && ( +
+
+ )} + + {/* Upload progress */} + {uploading && ( +
+
+ Uploading footage… + {Math.round(progress * 100)}% +
+
+
+
+
+ )} + + +
+ ); +} + + +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(null); + const [showUpload, setShowUpload] = useState(false); + + if (!shot.originalFootageUrl) { + return ( +
+
+ ); + } + + return ( +
+
+

+

+ {canManage && ( + + )} +
+ + {/* Video player */} +
+ +
+ + {canManage && showUpload && ( +
+ { setShowUpload(false); onSaved?.(); }} + /> +
+ )} +
+ ); +} diff --git a/components/shots/ShotSettingsTab.tsx b/components/shots/ShotSettingsTab.tsx index 1c48ccf..e45f928 100644 --- a/components/shots/ShotSettingsTab.tsx +++ b/components/shots/ShotSettingsTab.tsx @@ -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; + +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(null); + const [thumbnailPreview, setThumbnailPreview] = useState(shot.thumbnailUrl ?? null); + const [clearThumbnail, setClearThumbnail] = useState(false); + const fileInputRef = useRef(null); + + // Footage upload state + const [footageFile, setFootageFile] = useState(null); + const [footageUploading, setFootageUploading] = useState(false); + const [footageProgress, setFootageProgress] = useState(0); + const [currentFootageUrl, setCurrentFootageUrl] = useState(shot.originalFootageUrl ?? null); + const footageInputRef = useRef(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({ + 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) => { + 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) => { + 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 ( +
+
+ {/* Details */} +
+
+ + Details +
+ + +
+ +