Merge pull request #3 from packman86/feature/video-review
Feature/video review
This commit is contained in:
commit
35c19f0cfd
39 changed files with 2082 additions and 70 deletions
|
|
@ -24,6 +24,9 @@ ANTHROPIC_API_KEY=""
|
|||
# Cron / Scheduler
|
||||
CRON_SECRET="" # Secret for /api/cron/* endpoints. Generate with: openssl rand -hex 32
|
||||
|
||||
# Video uploads — override storage directory (default: /data/uploads in prod, ./data/uploads in dev)
|
||||
# VIDEO_UPLOADS_DIR="/data/uploads"
|
||||
|
||||
# Ollama (AI — embeddings, search, chat fallback)
|
||||
# Local Ollama instance for embeddings, LLM summarization, and chat fallback.
|
||||
# No data leaves the network. Zero ongoing AI costs.
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -46,4 +46,5 @@ next-env.d.ts
|
|||
|
||||
# uploaded assets (runtime-generated, not needed in repo)
|
||||
/public/uploads/
|
||||
/data/uploads/
|
||||
/assets/review-images/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
FROM node:22-alpine AS base
|
||||
|
||||
# FFmpeg for video transcoding (HLS), thumbnail extraction, and metadata
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
|
@ -38,6 +41,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/src/generated ./src/generated
|
||||
|
||||
# Create uploads directory for video/media storage (mounted as volume)
|
||||
RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ services:
|
|||
NODE_ENV: production
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
|
||||
volumes:
|
||||
- uploads_data:/data/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -70,3 +72,4 @@ services:
|
|||
volumes:
|
||||
pgdata:
|
||||
ollama_data:
|
||||
uploads_data:
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
|
|
@ -9202,6 +9203,12 @@
|
|||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.4",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ model DeliverableStage {
|
|||
feedbackItems FeedbackItem[]
|
||||
reviewSessionItems ReviewSessionItem[]
|
||||
|
||||
@@unique([deliverableId, templateId])
|
||||
@@unique([deliverableId, stageDefinitionId])
|
||||
@@index([deliverableId])
|
||||
@@index([stageDefinitionId])
|
||||
@@index([organizationId])
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ export default function DashboardPage() {
|
|||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--status-approved)]" />
|
||||
<span className="font-medium">{item.template.name}</span>
|
||||
<span className="font-medium">{item.stageDefinition?.name ?? item.template.name}</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
on {item.deliverable.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface Assignment {
|
|||
id: string;
|
||||
status: string;
|
||||
template: { name: string; order: number };
|
||||
stageDefinition?: { name: string; order: number } | null;
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -83,8 +84,8 @@ export default function MyWorkPage() {
|
|||
{items
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.deliverableStage.template.order -
|
||||
b.deliverableStage.template.order
|
||||
(a.deliverableStage.stageDefinition?.order ?? a.deliverableStage.template.order) -
|
||||
(b.deliverableStage.stageDefinition?.order ?? b.deliverableStage.template.order)
|
||||
)
|
||||
.map((assignment) => (
|
||||
<div
|
||||
|
|
@ -97,7 +98,7 @@ export default function MyWorkPage() {
|
|||
{assignment.deliverableStage.deliverable.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{assignment.deliverableStage.template.name}
|
||||
{assignment.deliverableStage.stageDefinition?.name ?? assignment.deliverableStage.template.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export default function DeliverableDetailPage() {
|
|||
}
|
||||
|
||||
const stages = (deliverable.stages ?? []).sort(
|
||||
(a: any, b: any) => a.template.order - b.template.order
|
||||
(a: any, b: any) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -236,8 +236,8 @@ export default function DeliverableDetailPage() {
|
|||
{stages.map((stage: any, idx: number) => {
|
||||
const available = TRANSITIONS[stage.status] ?? [];
|
||||
const assignments = stage.assignments ?? [];
|
||||
const isGate = stage.template.isCriticalGate;
|
||||
const isOptional = stage.template.isOptional;
|
||||
const isGate = stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate;
|
||||
const isOptional = stage.stageDefinition?.isOptional ?? stage.template.isOptional;
|
||||
return (
|
||||
<Card
|
||||
key={stage.id}
|
||||
|
|
@ -270,10 +270,10 @@ export default function DeliverableDetailPage() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[var(--muted-foreground)]">
|
||||
{stage.template.order}.
|
||||
{stage.stageDefinition?.order ?? stage.template.order}.
|
||||
</span>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</CardTitle>
|
||||
{isGate && (
|
||||
<Badge
|
||||
|
|
@ -311,7 +311,7 @@ export default function DeliverableDetailPage() {
|
|||
{/* Dates — clickable popover for overrides */}
|
||||
<StageDatePopover
|
||||
stageId={stage.id}
|
||||
stageName={stage.template.name}
|
||||
stageName={stage.stageDefinition?.name ?? stage.template.name}
|
||||
startDate={stage.startDate}
|
||||
dueDate={stage.dueDate}
|
||||
completedDate={stage.completedDate}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
Columns2,
|
||||
Loader2,
|
||||
Images,
|
||||
Film,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -33,8 +35,10 @@ import {
|
|||
type ComparisonMode,
|
||||
} from "@/components/review/comparison-toolbar";
|
||||
import { ImageUploadZone } from "@/components/review/image-upload-zone";
|
||||
import { VideoUploadZone } from "@/components/review/video-upload-zone";
|
||||
import { ImageGallery } from "@/components/review/image-gallery";
|
||||
import { AnnotationLayer } from "@/components/review/annotation-layer";
|
||||
import { VideoPlayer } from "@/components/review/video-player";
|
||||
import { ReviewSidebar } from "@/components/review/review-sidebar";
|
||||
import { useDeliverable } from "@/hooks/use-deliverables";
|
||||
import { useRevisions, useCreateRevision } from "@/hooks/use-revisions";
|
||||
|
|
@ -52,9 +56,24 @@ interface AttachedImage {
|
|||
originalUrl?: string;
|
||||
}
|
||||
|
||||
interface AttachedVideo {
|
||||
url: string;
|
||||
hlsUrl: string | null;
|
||||
status: "processing" | "ready" | "failed";
|
||||
thumbnailUrl: string | null;
|
||||
filename: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RevisionAttachments {
|
||||
referenceImage?: AttachedImage;
|
||||
currentImage?: AttachedImage;
|
||||
video?: AttachedVideo;
|
||||
referenceVideo?: AttachedVideo;
|
||||
}
|
||||
|
||||
interface RevisionImage {
|
||||
|
|
@ -84,6 +103,9 @@ export default function ReviewPage() {
|
|||
const [uploadPanelOpen, setUploadPanelOpen] = useState(false);
|
||||
const [activeImageUrl, setActiveImageUrl] = useState<string | null>(null);
|
||||
|
||||
// ── Viewer mode: image or video ─────────────────────────────────────
|
||||
const [viewerMode, setViewerMode] = useState<"image" | "video">("image");
|
||||
|
||||
// ── Gallery strip state ──────────────────────────────────────────────
|
||||
const [galleryOpen, setGalleryOpen] = useState(true);
|
||||
|
||||
|
|
@ -105,7 +127,7 @@ export default function ReviewPage() {
|
|||
const stages = useMemo(() => {
|
||||
if (!deliverable?.stages) return [];
|
||||
return [...deliverable.stages].sort(
|
||||
(a: any, b: any) => a.template.order - b.template.order
|
||||
(a: any, b: any) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
|
||||
);
|
||||
}, [deliverable]);
|
||||
|
||||
|
|
@ -232,6 +254,39 @@ export default function ReviewPage() {
|
|||
return match?.revisionId ?? null;
|
||||
}, [galleryImages, activeImageUrl]);
|
||||
|
||||
// ── Active video attachment (for video mode) ──────────────────────
|
||||
const activeVideo = useMemo(() => {
|
||||
if (!activeRevisionId) return null;
|
||||
const rev = revisions.find((r: any) => r.id === activeRevisionId);
|
||||
if (!rev) return null;
|
||||
const att = rev.attachments as RevisionAttachments | null;
|
||||
return att?.video ?? null;
|
||||
}, [activeRevisionId, revisions]);
|
||||
|
||||
// Auto-switch to video mode when a video exists but no image
|
||||
useEffect(() => {
|
||||
if (!activeRevisionId) return;
|
||||
const rev = revisions.find((r: any) => r.id === activeRevisionId);
|
||||
const att = rev?.attachments as RevisionAttachments | null;
|
||||
const hasImage = !!(att?.currentImage || att?.referenceImage);
|
||||
const hasVideo = !!att?.video;
|
||||
if (hasVideo && !hasImage) {
|
||||
setViewerMode("video");
|
||||
} else if (hasImage && !hasVideo) {
|
||||
setViewerMode("image");
|
||||
}
|
||||
// When both exist: keep the user's current selection
|
||||
}, [activeRevisionId, revisions]);
|
||||
|
||||
const hasImageAttachment = useMemo(() => {
|
||||
if (!activeRevisionId) return false;
|
||||
const rev = revisions.find((r: any) => r.id === activeRevisionId);
|
||||
const att = rev?.attachments as RevisionAttachments | null;
|
||||
return !!(att?.currentImage || att?.referenceImage);
|
||||
}, [activeRevisionId, revisions]);
|
||||
|
||||
const hasVideoAttachment = !!activeVideo;
|
||||
|
||||
// ── Image URLs for CMF probe sampling ──────────────────────────────
|
||||
const { workingImageUrl, referenceImageUrl } = useMemo(() => {
|
||||
if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
|
||||
|
|
@ -423,10 +478,10 @@ export default function ReviewPage() {
|
|||
</Button>
|
||||
<div className="flex items-center gap-1.5 rounded-md border bg-[var(--background)] px-2.5 py-1">
|
||||
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
|
||||
{selectedStage.template.order}
|
||||
{selectedStage.stageDefinition?.order ?? selectedStage.template.order}
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{selectedStage.template.name}
|
||||
{selectedStage.stageDefinition?.name ?? selectedStage.template.name}
|
||||
</span>
|
||||
<StageStatusBadge status={selectedStage.status} />
|
||||
</div>
|
||||
|
|
@ -444,8 +499,32 @@ export default function ReviewPage() {
|
|||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Image/Video toggle */}
|
||||
{hasImageAttachment && hasVideoAttachment && (
|
||||
<div className="flex items-center rounded-md border bg-[var(--background)]">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewerMode === "image" ? "default" : "ghost"}
|
||||
className="h-7 rounded-r-none px-2 text-xs"
|
||||
onClick={() => setViewerMode("image")}
|
||||
>
|
||||
<ImageIcon className="mr-1 h-3 w-3" />
|
||||
Image
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewerMode === "video" ? "default" : "ghost"}
|
||||
className="h-7 rounded-l-none px-2 text-xs"
|
||||
onClick={() => setViewerMode("video")}
|
||||
>
|
||||
<Film className="mr-1 h-3 w-3" />
|
||||
Video
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compare toggle */}
|
||||
{!comparisonActive && galleryImages.length >= 2 && (
|
||||
{!comparisonActive && galleryImages.length >= 2 && viewerMode === "image" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -467,9 +546,9 @@ export default function ReviewPage() {
|
|||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-sm">Upload Images</SheetTitle>
|
||||
<SheetTitle className="text-sm">Upload Media</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Upload reference and current render images for review
|
||||
Upload images and video for review
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Separator className="my-3" />
|
||||
|
|
@ -503,6 +582,31 @@ export default function ReviewPage() {
|
|||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Video
|
||||
</p>
|
||||
<VideoUploadZone
|
||||
stageId={selectedStageId!}
|
||||
revisionId={latestRevision.id}
|
||||
videoType="video"
|
||||
existingVideo={latestAttachments.video}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Reference Video
|
||||
</p>
|
||||
<VideoUploadZone
|
||||
stageId={selectedStageId!}
|
||||
revisionId={latestRevision.id}
|
||||
videoType="referenceVideo"
|
||||
existingVideo={latestAttachments.referenceVideo}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
|
|
@ -567,6 +671,15 @@ export default function ReviewPage() {
|
|||
flipB={flipB}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
) : viewerMode === "video" && activeVideo ? (
|
||||
<VideoPlayer
|
||||
hlsUrl={activeVideo.hlsUrl}
|
||||
mp4Url={activeVideo.url}
|
||||
posterUrl={activeVideo.thumbnailUrl}
|
||||
fps={activeVideo.fps || 24}
|
||||
status={activeVideo.status}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<ImageViewer
|
||||
src={imageOverride ?? activeImageUrl}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,27 @@ import { prisma } from "@/lib/prisma";
|
|||
import {
|
||||
processAndStoreImage,
|
||||
deleteRevisionImage,
|
||||
processAndStoreVideo,
|
||||
deleteRevisionVideo,
|
||||
} from "@/lib/services/upload-service";
|
||||
import type { UploadedImage } from "@/lib/services/upload-service";
|
||||
import type { UploadedImage, UploadedVideo, VideoType } from "@/lib/services/upload-service";
|
||||
|
||||
type Params = { params: Promise<{ stageId: string; revisionId: string }> };
|
||||
|
||||
type UploadType = "reference" | "current" | "screenshot" | "video" | "referenceVideo";
|
||||
|
||||
const IMAGE_TYPES = ["reference", "current", "screenshot"] as const;
|
||||
const VIDEO_TYPES = ["video", "referenceVideo"] as const;
|
||||
const ALL_TYPES = [...IMAGE_TYPES, ...VIDEO_TYPES] as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Prisma JSON field
|
||||
type JsonValue = any;
|
||||
|
||||
interface Attachments {
|
||||
referenceImage?: UploadedImage;
|
||||
currentImage?: UploadedImage;
|
||||
video?: UploadedVideo;
|
||||
referenceVideo?: UploadedVideo;
|
||||
}
|
||||
|
||||
// POST /api/stages/:stageId/revisions/:revisionId/upload
|
||||
|
|
@ -30,14 +43,37 @@ export async function POST(request: Request, { params }: Params) {
|
|||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const imageType = formData.get("type") as "reference" | "current" | "screenshot" | null;
|
||||
const uploadType = formData.get("type") as UploadType | null;
|
||||
|
||||
if (!file) return badRequest("No file provided");
|
||||
if (!imageType || !["reference", "current", "screenshot"].includes(imageType)) {
|
||||
return badRequest('Image type must be "reference", "current", or "screenshot"');
|
||||
if (!uploadType || !ALL_TYPES.includes(uploadType as (typeof ALL_TYPES)[number])) {
|
||||
return badRequest(
|
||||
`Type must be one of: ${ALL_TYPES.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Process and store the image
|
||||
// ─── Video upload path ─────────────────────────────────
|
||||
if ((VIDEO_TYPES as readonly string[]).includes(uploadType)) {
|
||||
const videoType = uploadType as VideoType;
|
||||
const uploaded = await processAndStoreVideo(revisionId, file, videoType);
|
||||
|
||||
// Update revision attachments with video metadata
|
||||
const existing = (revision.attachments as Attachments) ?? {};
|
||||
const updated: Attachments = {
|
||||
...existing,
|
||||
[videoType]: uploaded,
|
||||
};
|
||||
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments: updated as JsonValue },
|
||||
});
|
||||
|
||||
return NextResponse.json(uploaded, { status: 201 });
|
||||
}
|
||||
|
||||
// ─── Image upload path (existing) ──────────────────────
|
||||
const imageType = uploadType as "reference" | "current" | "screenshot";
|
||||
const uploaded = await processAndStoreImage(revisionId, file, imageType);
|
||||
|
||||
// Screenshots are stored as annotation data, not in revision attachments
|
||||
|
|
@ -54,7 +90,7 @@ export async function POST(request: Request, { params }: Params) {
|
|||
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments: updated },
|
||||
data: { attachments: updated as JsonValue },
|
||||
});
|
||||
|
||||
return NextResponse.json(uploaded, { status: 201 });
|
||||
|
|
@ -69,7 +105,7 @@ export async function POST(request: Request, { params }: Params) {
|
|||
}
|
||||
}
|
||||
|
||||
// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current
|
||||
// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current|video|referenceVideo
|
||||
export async function DELETE(request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
|
@ -77,10 +113,13 @@ export async function DELETE(request: Request, { params }: Params) {
|
|||
try {
|
||||
const { stageId, revisionId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const imageType = url.searchParams.get("type") as "reference" | "current" | null;
|
||||
const deleteType = url.searchParams.get("type") as string | null;
|
||||
|
||||
if (!imageType || !["reference", "current"].includes(imageType)) {
|
||||
return badRequest('Query param "type" must be "reference" or "current"');
|
||||
const validDeleteTypes = ["reference", "current", "video", "referenceVideo"];
|
||||
if (!deleteType || !validDeleteTypes.includes(deleteType)) {
|
||||
return badRequest(
|
||||
`Query param "type" must be one of: ${validDeleteTypes.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const revision = await prisma.revision.findFirst({
|
||||
|
|
@ -88,17 +127,32 @@ export async function DELETE(request: Request, { params }: Params) {
|
|||
});
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
// Delete files from disk
|
||||
const existing = (revision.attachments as Attachments) ?? {};
|
||||
|
||||
// ─── Video delete path ─────────────────────────────────
|
||||
if ((VIDEO_TYPES as readonly string[]).includes(deleteType)) {
|
||||
const videoType = deleteType as VideoType;
|
||||
await deleteRevisionVideo(revisionId, videoType);
|
||||
|
||||
const { [videoType]: _removed, ...rest } = existing;
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments: Object.keys(rest).length > 0 ? (rest as JsonValue) : null },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// ─── Image delete path (existing) ──────────────────────
|
||||
const imageType = deleteType as "reference" | "current";
|
||||
await deleteRevisionImage(revisionId, imageType);
|
||||
|
||||
// Remove from attachments JSON
|
||||
const existing = (revision.attachments as Attachments) ?? {};
|
||||
const key = imageType === "reference" ? "referenceImage" : "currentImage";
|
||||
const { [key]: _removed, ...rest } = existing;
|
||||
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments: Object.keys(rest).length > 0 ? rest : null },
|
||||
data: { attachments: Object.keys(rest).length > 0 ? (rest as JsonValue) : null },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
|
|
|||
135
src/app/api/uploads/[...path]/route.ts
Normal file
135
src/app/api/uploads/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Streaming file server for uploaded media (videos, HLS segments, thumbnails).
|
||||
*
|
||||
* Serves files from /data/uploads (or local dev equivalent) with proper
|
||||
* MIME types, Range header support for seeking, and caching headers.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stat, open } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const VIDEO_UPLOADS_DIR =
|
||||
process.env.VIDEO_UPLOADS_DIR ||
|
||||
(process.env.NODE_ENV === "production"
|
||||
? "/data/uploads"
|
||||
: path.join(process.cwd(), "data", "uploads"));
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".ts": "video/mp2t",
|
||||
".mp4": "video/mp4",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
type Params = { params: Promise<{ path: string[] }> };
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const segments = (await params).path;
|
||||
const relativePath = segments.join("/");
|
||||
|
||||
// Prevent directory traversal
|
||||
if (relativePath.includes("..")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const filePath = path.join(VIDEO_UPLOADS_DIR, relativePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await stat(filePath);
|
||||
} catch {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const fileSize = fileStat.size;
|
||||
const rangeHeader = request.headers.get("range");
|
||||
|
||||
// HLS playlists and segments get aggressive caching
|
||||
const cacheControl =
|
||||
ext === ".m3u8"
|
||||
? "public, max-age=2" // playlist: short cache, allows near-live updates
|
||||
: ext === ".ts"
|
||||
? "public, max-age=31536000, immutable" // segments never change
|
||||
: "public, max-age=3600"; // thumbnails, MP4s: 1 hour
|
||||
|
||||
// Range request support (for MP4 seeking)
|
||||
if (rangeHeader) {
|
||||
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||
if (!match) {
|
||||
return new NextResponse("Invalid range", { status: 416 });
|
||||
}
|
||||
|
||||
const start = parseInt(match[1], 10);
|
||||
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
||||
|
||||
if (start >= fileSize || end >= fileSize) {
|
||||
return new NextResponse("Range not satisfiable", {
|
||||
status: 416,
|
||||
headers: { "Content-Range": `bytes */${fileSize}` },
|
||||
});
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1;
|
||||
const fileHandle = await open(filePath, "r");
|
||||
const stream = fileHandle.createReadStream({ start, end });
|
||||
|
||||
// Convert Node stream to Web ReadableStream
|
||||
const webStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on("data", (chunk) => controller.enqueue(chunk));
|
||||
stream.on("end", () => controller.close());
|
||||
stream.on("error", (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
fileHandle.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Full file response
|
||||
const fileHandle = await open(filePath, "r");
|
||||
const stream = fileHandle.createReadStream();
|
||||
|
||||
const webStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on("data", (chunk) => controller.enqueue(chunk));
|
||||
stream.on("end", () => controller.close());
|
||||
stream.on("error", (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
fileHandle.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(fileSize),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ export function CalendarEventPill({
|
|||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Stage:</span>{" "}
|
||||
<span className="font-medium">{event.template.name}</span>
|
||||
<span className="font-medium">{event.stageDefinition?.name ?? event.template.name}</span>
|
||||
</div>
|
||||
{event.startDate && event.dueDate && (
|
||||
<div className="text-xs">
|
||||
|
|
@ -142,7 +142,7 @@ export function CalendarEventPill({
|
|||
{event.deliverable.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
{event.template.name}
|
||||
{event.stageDefinition?.name ?? event.template.name}
|
||||
{event.assignments.length > 0 &&
|
||||
` \u00b7 ${event.assignments.map((a) => a.user.name).join(", ")}`}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -552,7 +552,7 @@ export function CalendarGrid({
|
|||
<span className="truncate">
|
||||
{seg.event.deliverable.project.projectCode}
|
||||
{" \u00b7 "}
|
||||
{seg.event.template.name}
|
||||
{seg.event.stageDefinition?.name ?? seg.event.template.name}
|
||||
</span>
|
||||
{seg.continuesRight && (
|
||||
<span className="ml-auto shrink-0 opacity-60">
|
||||
|
|
@ -599,7 +599,7 @@ export function CalendarGrid({
|
|||
Stage:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{seg.event.template.name}
|
||||
{seg.event.stageDefinition?.name ?? seg.event.template.name}
|
||||
</span>
|
||||
</div>
|
||||
{seg.event.startDate &&
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export function DeliverableFormDialog({
|
|||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
{...register("assetCount", { valueAsNumber: true })}
|
||||
{...register("assetCount", { setValueAs: (v: string) => v === "" ? undefined : parseInt(v, 10) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface Stage {
|
|||
id: string;
|
||||
status: string;
|
||||
template: { name: string; order: number; isCriticalGate: boolean; isOptional: boolean };
|
||||
stageDefinition?: { name: string; order: number } | null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
|
@ -21,7 +22,9 @@ const STATUS_COLORS: Record<string, string> = {
|
|||
};
|
||||
|
||||
export function PipelineProgress({ stages }: { stages: Stage[] }) {
|
||||
const sorted = [...stages].sort((a, b) => a.template.order - b.template.order);
|
||||
const sorted = [...stages].sort(
|
||||
(a, b) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
|
||||
);
|
||||
const completed = sorted.filter(
|
||||
(s) =>
|
||||
s.status === "APPROVED" ||
|
||||
|
|
@ -50,7 +53,7 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) {
|
|||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<span className="font-medium">{stage.template.name}</span>
|
||||
<span className="font-medium">{stage.stageDefinition?.name ?? stage.template.name}</span>
|
||||
<span className="ml-1.5 opacity-70">
|
||||
{stage.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ export function ProjectFormDialog({
|
|||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
{...register("estimatedCost", { valueAsNumber: true })}
|
||||
{...register("estimatedCost", { setValueAs: (v: string) => v === "" ? undefined : parseFloat(v) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -304,7 +304,7 @@ export function ProjectFormDialog({
|
|||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
{...register("actualCost", { valueAsNumber: true })}
|
||||
{...register("actualCost", { setValueAs: (v: string) => v === "" ? undefined : parseFloat(v) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ interface SessionItem {
|
|||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; slug: string; order: number };
|
||||
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -299,7 +300,7 @@ export function SessionBuilder({
|
|||
{deliverable.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
— {stage.template.name}
|
||||
— {stage.stageDefinition?.name ?? stage.template.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface SessionItem {
|
|||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; slug: string; order: number };
|
||||
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -377,7 +378,7 @@ export function SessionPresenter({
|
|||
</h2>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</span>
|
||||
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
|
||||
</div>
|
||||
|
|
@ -558,7 +559,7 @@ export function SessionPresenter({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{item.deliverableStage.deliverable.name} —{" "}
|
||||
{item.deliverableStage.template.name}
|
||||
{item.deliverableStage.stageDefinition?.name ?? item.deliverableStage.template.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface SessionItem {
|
|||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; order: number };
|
||||
stageDefinition?: { id: string; name: string; order: number } | null;
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -147,7 +148,7 @@ export function SessionSummary({ items, onItemClick }: SessionSummaryProps) {
|
|||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
272
src/components/review/video-controls.tsx
Normal file
272
src/components/review/video-controls.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Repeat,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { VideoFrameDisplay } from "./video-frame-display";
|
||||
import type { PlaybackSpeed } from "@/hooks/use-video-player";
|
||||
|
||||
interface VideoControlsProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
isPlaying: boolean;
|
||||
playbackSpeed: PlaybackSpeed;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
isLooping: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSeekRelative: (delta: number) => void;
|
||||
onStepFrame: (direction: 1 | -1) => void;
|
||||
onSetPlaybackSpeed: (speed: PlaybackSpeed) => void;
|
||||
onSetVolume: (vol: number) => void;
|
||||
onToggleMute: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onToggleLoop: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS: PlaybackSpeed[] = [0.25, 0.5, 1, 1.5, 2];
|
||||
|
||||
export function VideoControls({
|
||||
currentTime,
|
||||
duration,
|
||||
fps,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
isLooping,
|
||||
onTogglePlay,
|
||||
onSeekRelative,
|
||||
onStepFrame,
|
||||
onSetPlaybackSpeed,
|
||||
onSetVolume,
|
||||
onToggleMute,
|
||||
onToggleFullscreen,
|
||||
onToggleLoop,
|
||||
className,
|
||||
}: VideoControlsProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 ${className ?? ""}`}
|
||||
>
|
||||
{/* Left group: playback controls + timecode */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Frame back */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onStepFrame(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Previous frame (←)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Skip back 5s */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onSeekRelative(-5)}
|
||||
>
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
-5 seconds (J)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Skip forward 5s */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onSeekRelative(5)}
|
||||
>
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
+5 seconds (L)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Frame forward */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onStepFrame(1)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Next frame (→)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Timecode */}
|
||||
<div className="ml-1.5">
|
||||
<VideoFrameDisplay
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
fps={fps}
|
||||
className="text-[var(--muted-foreground)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right group: speed, loop, volume, fullscreen */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Playback speed */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-1.5 text-[10px] font-mono tabular-nums"
|
||||
onClick={() => {
|
||||
const idx = SPEED_OPTIONS.indexOf(playbackSpeed);
|
||||
const next = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
|
||||
onSetPlaybackSpeed(next);
|
||||
}}
|
||||
>
|
||||
{playbackSpeed}x
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Speed ([ / ])
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Loop */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`h-7 w-7 p-0 ${isLooping ? "text-[var(--primary)]" : ""}`}
|
||||
onClick={onToggleLoop}
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Loop
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onToggleMute}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Mute (M)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => onSetVolume(parseFloat(e.target.value))}
|
||||
className="h-1 w-14 cursor-pointer appearance-none rounded-full bg-[var(--muted)] accent-[var(--primary)] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Fullscreen (F)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/review/video-frame-display.tsx
Normal file
44
src/components/review/video-frame-display.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Timecode display in HH:MM:SS:FF format (frames based on fps).
|
||||
*/
|
||||
|
||||
interface VideoFrameDisplayProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function toTimecode(seconds: number, fps: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return "00:00:00:00";
|
||||
|
||||
const totalFrames = Math.floor(seconds * fps);
|
||||
const ff = totalFrames % fps;
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const ss = totalSeconds % 60;
|
||||
const mm = Math.floor(totalSeconds / 60) % 60;
|
||||
const hh = Math.floor(totalSeconds / 3600);
|
||||
|
||||
return `${pad(hh)}:${pad(mm)}:${pad(ss)}:${pad(ff)}`;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function VideoFrameDisplay({
|
||||
currentTime,
|
||||
duration,
|
||||
fps,
|
||||
className,
|
||||
}: VideoFrameDisplayProps) {
|
||||
return (
|
||||
<span className={`font-mono text-[10px] tabular-nums ${className ?? ""}`}>
|
||||
{toTimecode(currentTime, fps)}
|
||||
<span className="text-[var(--muted-foreground)] opacity-50"> / </span>
|
||||
{toTimecode(duration, fps)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
174
src/components/review/video-player.tsx
Normal file
174
src/components/review/video-player.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Film, Loader2 } from "lucide-react";
|
||||
import { useVideoPlayer } from "@/hooks/use-video-player";
|
||||
import { VideoTimeline } from "./video-timeline";
|
||||
import { VideoControls } from "./video-controls";
|
||||
|
||||
export interface VideoPlayerState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
fps: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
/** HLS playlist URL (.m3u8) — preferred */
|
||||
hlsUrl?: string | null;
|
||||
/** Raw MP4 URL — fallback if HLS unavailable */
|
||||
mp4Url?: string | null;
|
||||
/** Poster/thumbnail image */
|
||||
posterUrl?: string | null;
|
||||
/** Video frame rate (for timecode + frame stepping) */
|
||||
fps?: number;
|
||||
/** Processing status from upload pipeline */
|
||||
status?: "processing" | "ready" | "failed";
|
||||
className?: string;
|
||||
/** Render prop for overlays (annotations in A7.3) */
|
||||
renderOverlay?: (state: VideoPlayerState) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
hlsUrl,
|
||||
mp4Url,
|
||||
posterUrl,
|
||||
fps = 24,
|
||||
status,
|
||||
className,
|
||||
renderOverlay,
|
||||
}: VideoPlayerProps) {
|
||||
const player = useVideoPlayer(fps);
|
||||
const [videoDimensions, setVideoDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
// Load source when URLs change
|
||||
useEffect(() => {
|
||||
if (status === "ready" || !hlsUrl) {
|
||||
player.loadSource(hlsUrl ?? null, mp4Url ?? null);
|
||||
} else if (mp4Url) {
|
||||
// HLS not ready yet — use MP4 fallback
|
||||
player.loadSource(null, mp4Url);
|
||||
}
|
||||
}, [hlsUrl, mp4Url, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Track video dimensions
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
const video = player.videoRef.current;
|
||||
if (video) {
|
||||
setVideoDimensions({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
});
|
||||
}
|
||||
}, [player.videoRef]);
|
||||
|
||||
// Compute buffered progress (0-1)
|
||||
const buffered = useMemo(() => {
|
||||
const video = player.videoRef.current;
|
||||
if (!video || !video.buffered.length || !video.duration) return 0;
|
||||
return video.buffered.end(video.buffered.length - 1) / video.duration;
|
||||
}, [player.videoRef, player.currentTime]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const videoState: VideoPlayerState = {
|
||||
currentTime: player.currentTime,
|
||||
duration: player.duration,
|
||||
isPlaying: player.isPlaying,
|
||||
fps,
|
||||
videoWidth: videoDimensions.width,
|
||||
videoHeight: videoDimensions.height,
|
||||
};
|
||||
|
||||
const hasSource = !!(hlsUrl || mp4Url);
|
||||
const isProcessing = status === "processing";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={player.containerRef}
|
||||
className={`relative flex flex-col bg-[#1a1a1a] ${className ?? ""}`}
|
||||
>
|
||||
{/* Video viewport */}
|
||||
<div
|
||||
className="relative flex-1 flex items-center justify-center overflow-hidden"
|
||||
onClick={player.togglePlay}
|
||||
>
|
||||
{!hasSource && !isProcessing && (
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Film className="h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">No video loaded</p>
|
||||
<p className="text-xs opacity-60">
|
||||
Upload a video to start reviewing
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="text-sm">Processing video...</p>
|
||||
<p className="text-xs opacity-60">
|
||||
HLS transcoding in progress — playback will start automatically
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{player.isBuffering && hasSource && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={player.videoRef}
|
||||
className="max-h-full max-w-full"
|
||||
poster={posterUrl ?? undefined}
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
player.toggleFullscreen();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlay slot for annotations (A7.3) */}
|
||||
{renderOverlay?.(videoState)}
|
||||
</div>
|
||||
|
||||
{/* Timeline + Controls bar */}
|
||||
{hasSource && (
|
||||
<div
|
||||
className="shrink-0 border-t border-white/10 bg-[var(--card)] px-3 py-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoTimeline
|
||||
currentTime={player.currentTime}
|
||||
duration={player.duration}
|
||||
buffered={buffered}
|
||||
onSeek={player.seek}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<VideoControls
|
||||
currentTime={player.currentTime}
|
||||
duration={player.duration}
|
||||
fps={fps}
|
||||
isPlaying={player.isPlaying}
|
||||
playbackSpeed={player.playbackSpeed}
|
||||
volume={player.volume}
|
||||
isMuted={player.isMuted}
|
||||
isFullscreen={player.isFullscreen}
|
||||
isLooping={player.isLooping}
|
||||
onTogglePlay={player.togglePlay}
|
||||
onSeekRelative={player.seekRelative}
|
||||
onStepFrame={player.stepFrame}
|
||||
onSetPlaybackSpeed={player.setPlaybackSpeed}
|
||||
onSetVolume={player.setVolume}
|
||||
onToggleMute={player.toggleMute}
|
||||
onToggleFullscreen={player.toggleFullscreen}
|
||||
onToggleLoop={player.toggleLoop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/review/video-timeline.tsx
Normal file
138
src/components/review/video-timeline.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface VideoTimelineProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
buffered: number; // 0-1, how much is buffered
|
||||
onSeek: (time: number) => void;
|
||||
/** Optional annotation markers (for A7.3) */
|
||||
markers?: { time: number; color: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function VideoTimeline({
|
||||
currentTime,
|
||||
duration,
|
||||
buffered,
|
||||
onSeek,
|
||||
markers,
|
||||
className,
|
||||
}: VideoTimelineProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [hoverTime, setHoverTime] = useState<number | null>(null);
|
||||
const [hoverX, setHoverX] = useState(0);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const bufferedPct = duration > 0 ? buffered * 100 : 0;
|
||||
|
||||
const getTimeFromX = useCallback(
|
||||
(clientX: number): number => {
|
||||
const track = trackRef.current;
|
||||
if (!track || duration <= 0) return 0;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return pct * duration;
|
||||
},
|
||||
[duration]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
setIsScrubbing(true);
|
||||
const time = getTimeFromX(e.clientX);
|
||||
onSeek(time);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[getTimeFromX, onSeek]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
setHoverX(x);
|
||||
setHoverTime(getTimeFromX(e.clientX));
|
||||
|
||||
if (isScrubbing) {
|
||||
onSeek(getTimeFromX(e.clientX));
|
||||
}
|
||||
},
|
||||
[getTimeFromX, isScrubbing, onSeek]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsScrubbing(false);
|
||||
}, []);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
setHoverTime(null);
|
||||
setIsScrubbing(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`group relative ${className ?? ""}`}>
|
||||
{/* Hover time tooltip */}
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className="pointer-events-none absolute -top-7 z-10 -translate-x-1/2 rounded bg-[var(--popover)] px-1.5 py-0.5 text-[10px] font-mono text-[var(--popover-foreground)] shadow-md"
|
||||
style={{ left: hoverX }}
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-1.5 cursor-pointer rounded-full bg-[var(--muted)] transition-all group-hover:h-2.5"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{/* Buffered range */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--foreground)]/10"
|
||||
style={{ width: `${bufferedPct}%` }}
|
||||
/>
|
||||
|
||||
{/* Progress */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--primary)]"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--primary)] opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
|
||||
{/* Annotation markers (A7.3 prep) */}
|
||||
{markers?.map((marker, i) => {
|
||||
const pos = duration > 0 ? (marker.time / duration) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{ left: `${pos}%`, backgroundColor: marker.color }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/components/review/video-upload-zone.tsx
Normal file
259
src/components/review/video-upload-zone.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Upload, Film, X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { VideoType } from "@/lib/services/upload-service";
|
||||
|
||||
interface ExistingVideo {
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
filename: string;
|
||||
duration: number;
|
||||
status: "processing" | "ready" | "failed";
|
||||
}
|
||||
|
||||
interface VideoUploadZoneProps {
|
||||
stageId: string;
|
||||
revisionId: string;
|
||||
videoType: VideoType;
|
||||
existingVideo?: ExistingVideo | null;
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!isFinite(seconds)) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function VideoUploadZone({
|
||||
stageId,
|
||||
revisionId,
|
||||
videoType,
|
||||
existingVideo,
|
||||
onUploadComplete,
|
||||
}: VideoUploadZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", videoType);
|
||||
|
||||
// Use XMLHttpRequest for progress tracking on large files
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(
|
||||
"POST",
|
||||
`/api/stages/${stageId}/revisions/${revisionId}/upload`
|
||||
);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setUploadProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.error || "Upload failed"));
|
||||
} catch {
|
||||
reject(new Error(`Upload failed (${xhr.status})`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
const label = videoType === "video" ? "Video" : "Reference video";
|
||||
toast.success(`${label} uploaded — transcoding in progress`);
|
||||
onUploadComplete();
|
||||
} catch (e) {
|
||||
toast.error("Upload failed", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
},
|
||||
[stageId, revisionId, videoType, onUploadComplete]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) uploadFile(file);
|
||||
},
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
e.target.value = "";
|
||||
},
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/stages/${stageId}/revisions/${revisionId}/upload?type=${videoType}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
toast.success("Video removed");
|
||||
onUploadComplete();
|
||||
} catch {
|
||||
toast.error("Failed to remove video");
|
||||
}
|
||||
}, [stageId, revisionId, videoType, onUploadComplete]);
|
||||
|
||||
// Existing video: show thumbnail with status + actions
|
||||
if (existingVideo) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="relative overflow-hidden rounded border">
|
||||
{existingVideo.thumbnailUrl ? (
|
||||
<img
|
||||
src={existingVideo.thumbnailUrl}
|
||||
alt={existingVideo.filename}
|
||||
className="h-24 w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-full items-center justify-center bg-[var(--muted)]">
|
||||
<Film className="h-8 w-8 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-1">
|
||||
{existingVideo.status === "processing" && (
|
||||
<span className="flex items-center gap-1 rounded bg-yellow-500/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Processing
|
||||
</span>
|
||||
)}
|
||||
{existingVideo.status === "ready" && (
|
||||
<span className="rounded bg-green-600/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
{existingVideo.status === "failed" && (
|
||||
<span className="rounded bg-red-600/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 font-mono text-[9px] text-white">
|
||||
{formatDuration(existingVideo.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay with actions */}
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 text-[10px] text-white hover:bg-white/20 hover:text-white"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 text-[10px] text-white hover:bg-red-500/50 hover:text-white"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state: drop zone
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors",
|
||||
isDragging
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/5"
|
||||
: "border-[var(--border)] hover:border-[var(--muted-foreground)]",
|
||||
isUploading && "pointer-events-none"
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !isUploading && fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--primary)]" />
|
||||
<p className="text-xs font-medium">Uploading… {uploadProgress}%</p>
|
||||
<div className="h-1 w-32 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--primary)] transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mb-2 h-6 w-6 text-[var(--muted-foreground)]" />
|
||||
<p className="text-xs font-medium">
|
||||
{videoType === "video" ? "Video" : "Reference Video"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
|
||||
Drop file or click to browse
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
|
||||
MP4 — up to 500MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,12 +37,12 @@ export function StageDetailSheet({
|
|||
<SheetHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[var(--muted-foreground)]">
|
||||
{stage.template.order}.
|
||||
{stage.stageDefinition?.order ?? stage.template.order}.
|
||||
</span>
|
||||
<SheetTitle className="text-base">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</SheetTitle>
|
||||
{stage.template.isCriticalGate && (
|
||||
{(stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-0.5 border-[var(--accent)] text-[10px] text-[var(--accent)]"
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ interface Stage {
|
|||
name: string;
|
||||
order: number;
|
||||
};
|
||||
stageDefinition?: {
|
||||
name: string;
|
||||
order: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Deliverable {
|
||||
|
|
@ -148,7 +152,7 @@ export function GanttTimeline({
|
|||
const rows = useMemo(() => {
|
||||
return deliverables.map((deliv) => {
|
||||
const sortedStages = [...deliv.stages].sort(
|
||||
(a, b) => a.template.order - b.template.order
|
||||
(a, b) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
|
||||
);
|
||||
|
||||
const bars = sortedStages
|
||||
|
|
@ -379,7 +383,7 @@ export function GanttTimeline({
|
|||
{/* Stage name on bar (if wide enough) */}
|
||||
{barPixelWidth > 60 && (
|
||||
<span className="text-[8px] font-semibold text-white/90 pl-1.5 truncate">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -405,7 +409,7 @@ export function GanttTimeline({
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-semibold">{stage.template.name}</p>
|
||||
<p className="font-semibold">{stage.stageDefinition?.name ?? stage.template.name}</p>
|
||||
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
|
||||
{stage.startDate && (
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ interface Stage {
|
|||
completedDate: string | null;
|
||||
dueDate: string | null;
|
||||
template: { name: string; slug: string; order: number };
|
||||
stageDefinition?: { name: string; slug: string; order: number } | null;
|
||||
assignments: { user: { id: string; name: string | null; image: string | null } }[];
|
||||
}
|
||||
|
||||
|
|
@ -686,7 +687,7 @@ export function ProductionTimeline({
|
|||
{/* Stage name */}
|
||||
{barPx > 55 && (
|
||||
<span className="text-[7px] font-semibold text-white/90 pl-1 truncate">
|
||||
{stage.template.name}
|
||||
{stage.stageDefinition?.name ?? stage.template.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -719,7 +720,7 @@ export function ProductionTimeline({
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-semibold">{stage.template.name}</p>
|
||||
<p className="font-semibold">{stage.stageDefinition?.name ?? stage.template.name}</p>
|
||||
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
|
||||
{stage.assignments.length > 0 && (
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ export interface CalendarEvent {
|
|||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
stageDefinition?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
368
src/hooks/use-video-player.ts
Normal file
368
src/hooks/use-video-player.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 1 | 1.5 | 2;
|
||||
|
||||
const SPEEDS: PlaybackSpeed[] = [0.25, 0.5, 1, 1.5, 2];
|
||||
|
||||
export interface UseVideoPlayerReturn {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
// State
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
playbackSpeed: PlaybackSpeed;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
isReady: boolean;
|
||||
isBuffering: boolean;
|
||||
fps: number;
|
||||
// Actions
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seek: (time: number) => void;
|
||||
seekRelative: (delta: number) => void;
|
||||
stepFrame: (direction: 1 | -1) => void;
|
||||
setPlaybackSpeed: (speed: PlaybackSpeed) => void;
|
||||
cycleSpeedUp: () => void;
|
||||
cycleSpeedDown: () => void;
|
||||
setVolume: (vol: number) => void;
|
||||
toggleMute: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
toggleLoop: () => void;
|
||||
isLooping: boolean;
|
||||
// HLS
|
||||
loadSource: (hlsUrl: string | null, mp4Url: string | null) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer(fps: number = 24): UseVideoPlayerReturn {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeedState] = useState<PlaybackSpeed>(1);
|
||||
const [volume, setVolumeState] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [isLooping, setIsLooping] = useState(false);
|
||||
|
||||
// ── Source loading (HLS + MP4 fallback) ──────────────────────────
|
||||
const loadSource = useCallback(
|
||||
(hlsUrl: string | null, mp4Url: string | null) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Cleanup previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
setIsReady(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
|
||||
if (hlsUrl && Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
startLevel: -1, // auto quality
|
||||
});
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsReady(true);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal && mp4Url) {
|
||||
// Fallback to raw MP4
|
||||
console.warn("[VideoPlayer] HLS fatal error, falling back to MP4");
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
video.src = mp4Url;
|
||||
}
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
} else if (hlsUrl && video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Native HLS (Safari)
|
||||
video.src = hlsUrl;
|
||||
} else if (mp4Url) {
|
||||
// Direct MP4
|
||||
video.src = mp4Url;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ── Video event listeners ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(video.currentTime);
|
||||
const onDurationChange = () => setDuration(video.duration || 0);
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onCanPlay = () => {
|
||||
setIsReady(true);
|
||||
setIsBuffering(false);
|
||||
};
|
||||
const onWaiting = () => setIsBuffering(true);
|
||||
const onPlaying = () => setIsBuffering(false);
|
||||
|
||||
video.addEventListener("timeupdate", onTimeUpdate);
|
||||
video.addEventListener("durationchange", onDurationChange);
|
||||
video.addEventListener("play", onPlay);
|
||||
video.addEventListener("pause", onPause);
|
||||
video.addEventListener("canplay", onCanPlay);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
||||
video.removeEventListener("durationchange", onDurationChange);
|
||||
video.removeEventListener("play", onPlay);
|
||||
video.removeEventListener("pause", onPause);
|
||||
video.removeEventListener("canplay", onCanPlay);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Fullscreen listener ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onFsChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||
}, []);
|
||||
|
||||
// ── Playback actions ─────────────────────────────────────────────
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.paused ? video.play() : video.pause();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
|
||||
}, []);
|
||||
|
||||
const seekRelative = useCallback(
|
||||
(delta: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
seek(video.currentTime + delta);
|
||||
},
|
||||
[seek]
|
||||
);
|
||||
|
||||
const stepFrame = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !video.paused) return;
|
||||
const frameDuration = 1 / fps;
|
||||
video.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(video.currentTime + direction * frameDuration, video.duration)
|
||||
);
|
||||
},
|
||||
[fps]
|
||||
);
|
||||
|
||||
const setPlaybackSpeed = useCallback((speed: PlaybackSpeed) => {
|
||||
const video = videoRef.current;
|
||||
if (video) video.playbackRate = speed;
|
||||
setPlaybackSpeedState(speed);
|
||||
}, []);
|
||||
|
||||
const cycleSpeedUp = useCallback(() => {
|
||||
const idx = SPEEDS.indexOf(playbackSpeed);
|
||||
if (idx < SPEEDS.length - 1) setPlaybackSpeed(SPEEDS[idx + 1]);
|
||||
}, [playbackSpeed, setPlaybackSpeed]);
|
||||
|
||||
const cycleSpeedDown = useCallback(() => {
|
||||
const idx = SPEEDS.indexOf(playbackSpeed);
|
||||
if (idx > 0) setPlaybackSpeed(SPEEDS[idx - 1]);
|
||||
}, [playbackSpeed, setPlaybackSpeed]);
|
||||
|
||||
const setVolume = useCallback((vol: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
video.volume = clamped;
|
||||
setVolumeState(clamped);
|
||||
if (clamped > 0 && video.muted) {
|
||||
video.muted = false;
|
||||
setIsMuted(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(video.muted);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
container.requestFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLoop = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.loop = !video.loop;
|
||||
setIsLooping(video.loop);
|
||||
}, []);
|
||||
|
||||
// ── Keyboard shortcuts ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
case "k":
|
||||
case "K":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "j":
|
||||
case "J":
|
||||
e.preventDefault();
|
||||
seekRelative(-5);
|
||||
break;
|
||||
case "l":
|
||||
case "L":
|
||||
e.preventDefault();
|
||||
seekRelative(5);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (videoRef.current?.paused) {
|
||||
stepFrame(-1);
|
||||
} else {
|
||||
seekRelative(-5);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (videoRef.current?.paused) {
|
||||
stepFrame(1);
|
||||
} else {
|
||||
seekRelative(5);
|
||||
}
|
||||
break;
|
||||
case ",":
|
||||
e.preventDefault();
|
||||
stepFrame(-1);
|
||||
break;
|
||||
case ".":
|
||||
e.preventDefault();
|
||||
stepFrame(1);
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
break;
|
||||
case "[":
|
||||
e.preventDefault();
|
||||
cycleSpeedDown();
|
||||
break;
|
||||
case "]":
|
||||
e.preventDefault();
|
||||
cycleSpeedUp();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
togglePlay,
|
||||
seekRelative,
|
||||
stepFrame,
|
||||
toggleFullscreen,
|
||||
toggleMute,
|
||||
cycleSpeedUp,
|
||||
cycleSpeedDown,
|
||||
]);
|
||||
|
||||
// ── Cleanup HLS on unmount ───────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
containerRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
isReady,
|
||||
isBuffering,
|
||||
fps,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
seekRelative,
|
||||
stepFrame,
|
||||
setPlaybackSpeed,
|
||||
cycleSpeedUp,
|
||||
cycleSpeedDown,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
toggleLoop,
|
||||
isLooping,
|
||||
loadSource,
|
||||
};
|
||||
}
|
||||
|
|
@ -172,6 +172,7 @@ export async function executeTool(
|
|||
where: { deliverableId: { in: relevantDeliverableIds } },
|
||||
include: {
|
||||
template: { select: { name: true, slug: true, order: true } },
|
||||
stageDefinition: { select: { name: true, slug: true, order: true } },
|
||||
},
|
||||
orderBy: { template: { order: "asc" } },
|
||||
});
|
||||
|
|
@ -181,9 +182,9 @@ export async function executeTool(
|
|||
}
|
||||
stagesMap.get(s.deliverableId)!.push({
|
||||
stageId: s.id,
|
||||
stageName: s.template.name,
|
||||
stageSlug: s.template.slug,
|
||||
order: s.template.order,
|
||||
stageName: s.stageDefinition?.name ?? s.template.name,
|
||||
stageSlug: s.stageDefinition?.slug ?? s.template.slug,
|
||||
order: s.stageDefinition?.order ?? s.template.order,
|
||||
status: s.status,
|
||||
subStatus: s.subStatus,
|
||||
});
|
||||
|
|
@ -269,6 +270,7 @@ export async function executeTool(
|
|||
stages: {
|
||||
include: {
|
||||
template: { select: { name: true, slug: true, order: true } },
|
||||
stageDefinition: { select: { name: true, slug: true, order: true } },
|
||||
},
|
||||
orderBy: { template: { order: "asc" } },
|
||||
},
|
||||
|
|
@ -289,9 +291,9 @@ export async function executeTool(
|
|||
projectCode: d.project.projectCode,
|
||||
stages: d.stages.map((s) => ({
|
||||
stageId: s.id,
|
||||
stageName: s.template.name,
|
||||
stageSlug: s.template.slug,
|
||||
order: s.template.order,
|
||||
stageName: s.stageDefinition?.name ?? s.template.name,
|
||||
stageSlug: s.stageDefinition?.slug ?? s.template.slug,
|
||||
order: s.stageDefinition?.order ?? s.template.order,
|
||||
status: s.status,
|
||||
subStatus: s.subStatus,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export async function getMyWork(userId: string) {
|
|||
deliverableStage: {
|
||||
include: {
|
||||
template: true,
|
||||
stageDefinition: true,
|
||||
deliverable: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, projectCode: true } },
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export async function getCalendarEvents(filters: CalendarFilters) {
|
|||
where,
|
||||
include: {
|
||||
template: true,
|
||||
stageDefinition: true,
|
||||
deliverable: {
|
||||
include: {
|
||||
project: true,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export async function listDeliverables(projectId: string) {
|
|||
stages: {
|
||||
include: {
|
||||
template: true,
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
assignments: { include: { user: true } },
|
||||
},
|
||||
orderBy: { template: { order: "asc" } },
|
||||
|
|
@ -154,6 +155,7 @@ export async function getDeliverable(id: string) {
|
|||
stages: {
|
||||
include: {
|
||||
template: true,
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
assignments: { include: { user: true } },
|
||||
},
|
||||
orderBy: { template: { order: "asc" } },
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function buildDeliverableText(deliverable: {
|
|||
notes?: string | null;
|
||||
cmfSku?: string | null;
|
||||
project?: { name: string; projectCode: string } | null;
|
||||
stages?: { template: { name: string }; status: string }[];
|
||||
stages?: { template: { name: string }; stageDefinition?: { name: string } | null; status: string }[];
|
||||
}): string {
|
||||
const parts = [
|
||||
`Deliverable: ${deliverable.name}`,
|
||||
|
|
@ -85,7 +85,7 @@ export function buildDeliverableText(deliverable: {
|
|||
|
||||
if (deliverable.stages?.length) {
|
||||
const stageInfo = deliverable.stages
|
||||
.map((s) => `${s.template.name}: ${s.status}`)
|
||||
.map((s) => `${s.stageDefinition?.name ?? s.template.name}: ${s.status}`)
|
||||
.join("; ");
|
||||
parts.push(`Pipeline stages: ${stageInfo}`);
|
||||
}
|
||||
|
|
@ -176,8 +176,11 @@ export async function updateDeliverableEmbedding(
|
|||
include: {
|
||||
project: { select: { name: true, projectCode: true } },
|
||||
stages: {
|
||||
include: { template: { select: { name: true } } },
|
||||
select: { status: true, template: true },
|
||||
include: {
|
||||
template: { select: { name: true } },
|
||||
stageDefinition: { select: { name: true } },
|
||||
},
|
||||
select: { status: true, template: true, stageDefinition: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function getProject(id: string, organizationId: string) {
|
|||
deliverables: {
|
||||
include: {
|
||||
stages: {
|
||||
include: { template: true },
|
||||
include: { template: true, stageDefinition: true },
|
||||
orderBy: { template: { order: "asc" } },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,18 +1,37 @@
|
|||
import { writeFile, mkdir, unlink } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { writeFile, mkdir, unlink, readdir, rm } from "fs/promises";
|
||||
import { createWriteStream, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
extractMetadata,
|
||||
extractThumbnail,
|
||||
transcodeToHLS,
|
||||
} from "@/lib/services/video-service";
|
||||
|
||||
/** Images stay in /public for static serving (backward compat) */
|
||||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "revisions");
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
/** Videos go to a mounted volume, served via /api/uploads streaming route */
|
||||
const VIDEO_UPLOADS_DIR =
|
||||
process.env.VIDEO_UPLOADS_DIR ||
|
||||
(process.env.NODE_ENV === "production"
|
||||
? "/data/uploads/revisions"
|
||||
: path.join(process.cwd(), "data", "uploads", "revisions"));
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const ALLOWED_VIDEO_TYPES = ["video/mp4"];
|
||||
|
||||
const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
export interface UploadedImage {
|
||||
url: string;
|
||||
|
|
@ -38,14 +57,14 @@ export async function processAndStoreImage(
|
|||
imageType: "reference" | "current" | "screenshot"
|
||||
): Promise<UploadedImage> {
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
throw new Error(
|
||||
`Unsupported file type: ${file.type}. Allowed: PNG, JPEG, WebP, TIFF.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
throw new Error(
|
||||
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 50MB.`
|
||||
);
|
||||
|
|
@ -134,7 +153,6 @@ export async function deleteRevisionImage(
|
|||
const revisionDir = path.join(UPLOADS_DIR, revisionId);
|
||||
if (!existsSync(revisionDir)) return;
|
||||
|
||||
const { readdir } = await import("fs/promises");
|
||||
const files = await readdir(revisionDir);
|
||||
|
||||
for (const file of files) {
|
||||
|
|
@ -143,3 +161,173 @@ export async function deleteRevisionImage(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Video Upload ──────────────────────────────────────────────
|
||||
|
||||
export type VideoType = "video" | "referenceVideo";
|
||||
|
||||
export interface UploadedVideo {
|
||||
url: string;
|
||||
hlsUrl: string | null;
|
||||
status: "processing" | "ready" | "failed";
|
||||
thumbnailUrl: string | null;
|
||||
filename: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and store an uploaded video for a revision.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate type & size
|
||||
* 2. Stream-write raw MP4 to disk (avoids buffering 500MB in memory)
|
||||
* 3. Extract metadata via ffprobe (instant — headers only)
|
||||
* 4. Extract thumbnail JPEG
|
||||
* 5. Return immediately with status: "processing"
|
||||
* 6. Kick off async HLS transcoding, update revision on completion
|
||||
*/
|
||||
export async function processAndStoreVideo(
|
||||
revisionId: string,
|
||||
file: File,
|
||||
videoType: VideoType
|
||||
): Promise<UploadedVideo> {
|
||||
// Validate MIME type
|
||||
if (!ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||
throw new Error(
|
||||
`Unsupported file type: ${file.type}. Allowed: MP4.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_VIDEO_SIZE) {
|
||||
throw new Error(
|
||||
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 500MB.`
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure upload directory
|
||||
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
|
||||
if (!existsSync(revisionDir)) {
|
||||
await mkdir(revisionDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const videoFilename = `${videoType}_${timestamp}.mp4`;
|
||||
const videoPath = path.join(revisionDir, videoFilename);
|
||||
const videoUrl = `/api/uploads/revisions/${revisionId}/${videoFilename}`;
|
||||
|
||||
// Stream-write the file to disk
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const nodeStream = Readable.from(Buffer.from(arrayBuffer));
|
||||
await pipeline(nodeStream, createWriteStream(videoPath));
|
||||
|
||||
// Extract metadata (reads headers — fast; returns defaults if no FFmpeg)
|
||||
const metadata = await extractMetadata(videoPath);
|
||||
|
||||
// Extract thumbnail (skipped if no FFmpeg)
|
||||
const thumbFilename = `${videoType}_${timestamp}_thumb.jpg`;
|
||||
const thumbPath = path.join(revisionDir, thumbFilename);
|
||||
const thumbTime = Math.min(1, metadata.duration * 0.1 || 1);
|
||||
const thumbCreated = await extractThumbnail(videoPath, thumbPath, thumbTime);
|
||||
const thumbnailUrl = thumbCreated
|
||||
? `/api/uploads/revisions/${revisionId}/${thumbFilename}`
|
||||
: null;
|
||||
|
||||
// HLS output paths
|
||||
const hlsDir = path.join(revisionDir, `${videoType}_${timestamp}_hls`);
|
||||
const hlsUrl = `/api/uploads/revisions/${revisionId}/${videoType}_${timestamp}_hls/index.m3u8`;
|
||||
|
||||
const uploaded: UploadedVideo = {
|
||||
url: videoUrl,
|
||||
hlsUrl: null,
|
||||
status: "processing",
|
||||
thumbnailUrl,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
fps: metadata.fps,
|
||||
codec: metadata.codec,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Kick off async HLS transcoding — don't await
|
||||
transcodeToHLS(videoPath, hlsDir)
|
||||
.then(async (result) => {
|
||||
if (!result) {
|
||||
// FFmpeg not available — mark as ready with raw MP4 only (no HLS)
|
||||
console.log(`[Video] FFmpeg unavailable — serving raw MP4 for ${revisionId}/${videoType}`);
|
||||
}
|
||||
|
||||
// Update the revision attachments with HLS URL (if available) + ready status
|
||||
const revision = await prisma.revision.findUnique({
|
||||
where: { id: revisionId },
|
||||
select: { attachments: true },
|
||||
});
|
||||
const attachments = (revision?.attachments as Record<string, unknown>) ?? {};
|
||||
const videoKey = videoType === "video" ? "video" : "referenceVideo";
|
||||
const existing = attachments[videoKey] as Record<string, unknown> | undefined;
|
||||
|
||||
if (existing) {
|
||||
attachments[videoKey] = {
|
||||
...existing,
|
||||
hlsUrl: result ? hlsUrl : null,
|
||||
status: "ready",
|
||||
};
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments },
|
||||
});
|
||||
}
|
||||
console.log(`[Video] Processing complete for ${revisionId}/${videoType}${result ? " (HLS)" : " (MP4 only)"}`);
|
||||
})
|
||||
.catch(async (err) => {
|
||||
console.error(`[Video] HLS transcoding failed for ${revisionId}:`, err);
|
||||
// Mark as failed but keep raw MP4 as fallback
|
||||
const revision = await prisma.revision.findUnique({
|
||||
where: { id: revisionId },
|
||||
select: { attachments: true },
|
||||
});
|
||||
const attachments = (revision?.attachments as Record<string, unknown>) ?? {};
|
||||
const videoKey = videoType === "video" ? "video" : "referenceVideo";
|
||||
const existing = attachments[videoKey] as Record<string, unknown> | undefined;
|
||||
|
||||
if (existing) {
|
||||
attachments[videoKey] = { ...existing, status: "failed" };
|
||||
await prisma.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: { attachments },
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete uploaded video files and HLS segments for a revision.
|
||||
*/
|
||||
export async function deleteRevisionVideo(
|
||||
revisionId: string,
|
||||
videoType: VideoType
|
||||
): Promise<void> {
|
||||
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
|
||||
if (!existsSync(revisionDir)) return;
|
||||
|
||||
const files = await readdir(revisionDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(`${videoType}_`)) {
|
||||
const fullPath = path.join(revisionDir, file);
|
||||
// Could be a directory (HLS segments) or a file
|
||||
await rm(fullPath, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
220
src/lib/services/video-service.ts
Normal file
220
src/lib/services/video-service.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Video Service — FFmpeg/ffprobe wrapper for HLS transcoding,
|
||||
* thumbnail extraction, and metadata parsing.
|
||||
*
|
||||
* Graceful degradation: when FFmpeg is not installed (e.g., local dev
|
||||
* on Windows without FFmpeg), functions return sensible defaults instead
|
||||
* of crashing. Videos still upload and play as raw MP4 — only HLS
|
||||
* transcoding and thumbnail extraction are skipped.
|
||||
*/
|
||||
|
||||
import { execFile } from "child_process";
|
||||
import { mkdir } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Cached result of FFmpeg availability check */
|
||||
let _ffmpegAvailable: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check whether ffprobe/ffmpeg are available on PATH.
|
||||
* Result is cached for the process lifetime.
|
||||
*/
|
||||
export async function isFFmpegAvailable(): Promise<boolean> {
|
||||
if (_ffmpegAvailable !== null) return _ffmpegAvailable;
|
||||
try {
|
||||
await execFileAsync("ffprobe", ["-version"]);
|
||||
_ffmpegAvailable = true;
|
||||
} catch {
|
||||
console.warn(
|
||||
"[Video] FFmpeg/ffprobe not found on PATH — video processing will be skipped. " +
|
||||
"Install FFmpeg locally or run in Docker for full functionality."
|
||||
);
|
||||
_ffmpegAvailable = false;
|
||||
}
|
||||
return _ffmpegAvailable;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
duration: number; // seconds
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract video metadata using ffprobe.
|
||||
* Reads headers only — fast even on large files.
|
||||
* Returns defaults if FFmpeg is not available.
|
||||
*/
|
||||
export async function extractMetadata(
|
||||
filePath: string
|
||||
): Promise<VideoMetadata> {
|
||||
if (!(await isFFmpegAvailable())) {
|
||||
return { duration: 0, width: 0, height: 0, fps: 24, codec: "unknown" };
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("ffprobe", [
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const probe = JSON.parse(stdout);
|
||||
const stream = probe.streams?.[0];
|
||||
const format = probe.format;
|
||||
|
||||
if (!stream) {
|
||||
throw new Error("No video stream found in file");
|
||||
}
|
||||
|
||||
// Parse frame rate from r_frame_rate (e.g., "24/1" or "24000/1001")
|
||||
let fps = 24;
|
||||
if (stream.r_frame_rate) {
|
||||
const [num, den] = stream.r_frame_rate.split("/").map(Number);
|
||||
if (den && den > 0) fps = Math.round((num / den) * 100) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
duration: parseFloat(format?.duration ?? stream.duration ?? "0"),
|
||||
width: stream.width ?? 0,
|
||||
height: stream.height ?? 0,
|
||||
fps,
|
||||
codec: stream.codec_name ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a thumbnail JPEG from the video at a given time.
|
||||
* Returns false if FFmpeg is not available (no thumbnail generated).
|
||||
*/
|
||||
export async function extractThumbnail(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
timeSeconds: number = 1
|
||||
): Promise<boolean> {
|
||||
if (!(await isFFmpegAvailable())) return false;
|
||||
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await execFileAsync("ffmpeg", [
|
||||
"-y",
|
||||
"-ss",
|
||||
String(timeSeconds),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale='min(400,iw)':-2",
|
||||
"-q:v",
|
||||
"3",
|
||||
outputPath,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcode an MP4 to HLS (.m3u8 + .ts segments).
|
||||
*
|
||||
* Single quality tier matching source resolution, 6-second segments.
|
||||
* Uses -preset fast for reasonable speed on server hardware.
|
||||
* Returns null if FFmpeg is not available.
|
||||
*/
|
||||
export async function transcodeToHLS(
|
||||
inputPath: string,
|
||||
outputDir: string
|
||||
): Promise<{ playlistPath: string } | null> {
|
||||
if (!(await isFFmpegAvailable())) return null;
|
||||
if (!existsSync(outputDir)) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const playlistPath = path.join(outputDir, "index.m3u8");
|
||||
const segmentPattern = path.join(outputDir, "segment_%03d.ts");
|
||||
|
||||
await execFileAsync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-y",
|
||||
"-i",
|
||||
inputPath,
|
||||
// Video: re-encode to H.264 for maximum browser compatibility
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
// Ensure keyframes align with segment boundaries for clean seeking
|
||||
"-g",
|
||||
"150", // keyframe every ~6s at typical frame rates
|
||||
"-keyint_min",
|
||||
"150",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
// Audio: AAC passthrough or transcode
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
// HLS output
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"6",
|
||||
"-hls_list_size",
|
||||
"0", // keep all segments in playlist
|
||||
"-hls_segment_filename",
|
||||
segmentPattern,
|
||||
playlistPath,
|
||||
],
|
||||
{ timeout: 600_000 } // 10 minute timeout for large files
|
||||
);
|
||||
|
||||
return { playlistPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single frame at a specific timestamp as JPEG.
|
||||
* Used for server-side frame extraction fallback (A7.3).
|
||||
*/
|
||||
export async function extractFrame(
|
||||
inputPath: string,
|
||||
timeSeconds: number
|
||||
): Promise<Buffer> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-ss",
|
||||
String(timeSeconds),
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-f",
|
||||
"image2",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-q:v",
|
||||
"2",
|
||||
"pipe:1",
|
||||
],
|
||||
{ encoding: "buffer", maxBuffer: 10 * 1024 * 1024 }
|
||||
);
|
||||
|
||||
return stdout as unknown as Buffer;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue