diff --git a/package-lock.json b/package-lock.json index f0a42f7..96e02cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@base-ui/react": "^1.5.0", "@clerk/nextjs": "^7.4.3", "@hookform/resolvers": "^5.4.0", + "@mux/mux-node": "^14.1.1", + "@mux/mux-player-react": "^3.13.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/extension-accelerate": "^3.0.1", @@ -2975,6 +2977,83 @@ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", "license": "MIT" }, + "node_modules/@mux/mux-data-google-ima": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@mux/mux-data-google-ima/-/mux-data-google-ima-0.3.17.tgz", + "integrity": "sha512-4wpH6dYybyZhqLn9qGn/+67Z8MZnQRAdqTFEEZw2bx61M9q01uPYYHxd8qwOnYtUGEeafsdTwVHVxKHGD3oc1A==", + "license": "MIT", + "dependencies": { + "mux-embed": "5.18.1" + } + }, + "node_modules/@mux/mux-node": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@mux/mux-node/-/mux-node-14.1.1.tgz", + "integrity": "sha512-Am79fhXU6A15zef1y+2UKfbIQ/UMGiVOjKKV3fOAqR8IS0A4HbL6WLDs0rNlMRHrefvASAftM2NWwLLBU3Peaw==", + "license": "Apache-2.0", + "bin": { + "mux-mux-node": "bin/cli" + } + }, + "node_modules/@mux/mux-player": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@mux/mux-player/-/mux-player-3.13.0.tgz", + "integrity": "sha512-vh4CIMahUa29gys+mlfsOFKYKAKXxE07jSWk9WZxkdpGBW1fKfCQXlNxBoAIQlPa9Uk4MZuM3HVgqdW6aTuaZg==", + "license": "MIT", + "dependencies": { + "@mux/mux-video": "0.31.0", + "@mux/playback-core": "0.35.0", + "media-chrome": "~4.19.0", + "player.style": "^0.3.0" + } + }, + "node_modules/@mux/mux-player-react": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@mux/mux-player-react/-/mux-player-react-3.13.0.tgz", + "integrity": "sha512-7IkImo1H3rUYeuWHI/L0L7sGUqBvZCvptx3+4igc+P/V3WgqNFjOyQlcyxzxgXNtNNhGmkn5NaF3TU9DPbOmAQ==", + "license": "MIT", + "dependencies": { + "@mux/mux-player": "3.13.0", + "@mux/playback-core": "0.35.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0", + "react": "^17.0.2 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0", + "react-dom": "^17.0.2 || ^17.0.2-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mux/mux-video": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@mux/mux-video/-/mux-video-0.31.0.tgz", + "integrity": "sha512-DvO2GynIJhPDc0LMuWvC144lCF+E07NI+chrg/vcRQlCf2fPdNNqCSMoaRdWu2S2v/Orsc85giT9K6GRbjbzgA==", + "license": "MIT", + "dependencies": { + "@mux/mux-data-google-ima": "^0.3.4", + "@mux/playback-core": "0.35.0", + "castable-video": "~1.1.13", + "custom-media-element": "~1.4.6", + "media-tracks": "~0.3.5" + } + }, + "node_modules/@mux/playback-core": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@mux/playback-core/-/playback-core-0.35.0.tgz", + "integrity": "sha512-7Zi1EJ9sQNIUlQVBjJCXV0CB+rUVEsU3vNRElRV4xnD7dbpoioIAhe1SjZNBifjnK5aBKLDwQHypbnL3Cw3a5A==", + "license": "MIT", + "dependencies": { + "hls.js": "~1.6.15", + "mux-embed": "^5.16.1" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -6145,6 +6224,24 @@ ], "license": "CC-BY-4.0" }, + "node_modules/castable-video": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/castable-video/-/castable-video-1.1.16.tgz", + "integrity": "sha512-wBhe2dZu2afhewL3EaGgVYTyDsa9HvNhY98clMZkNzDrLelOValSrTaoMos9YX7PPBCrgpd1j6YmNyyI2Vbq3w==", + "license": "MIT", + "dependencies": { + "custom-media-element": "~1.4.6" + } + }, + "node_modules/ce-la-react": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ce-la-react/-/ce-la-react-0.3.2.tgz", + "integrity": "sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==", + "license": "BSD-3-Clause", + "peerDependencies": { + "react": ">=17.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6512,6 +6609,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-media-element": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/custom-media-element/-/custom-media-element-1.4.6.tgz", + "integrity": "sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==", + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8588,6 +8691,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.12.23", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", @@ -10006,7 +10115,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10115,6 +10223,21 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-chrome": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz", + "integrity": "sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==", + "license": "MIT", + "dependencies": { + "ce-la-react": "^0.3.2" + } + }, + "node_modules/media-tracks": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.5.tgz", + "integrity": "sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -10304,6 +10427,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/mux-embed": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.18.1.tgz", + "integrity": "sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==", + "license": "MIT" + }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", @@ -11178,6 +11307,22 @@ "pathe": "^2.0.3" } }, + "node_modules/player.style": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/player.style/-/player.style-0.3.4.tgz", + "integrity": "sha512-5O9bkbq0APQIkhptyZwp0gUgheCeImGPFo4hvPF2M5xBmgsUYzsUPHCfMye2xyeRE1zvQBKtziaC3jPgnHE1tw==", + "license": "MIT", + "workspaces": [ + ".", + "site", + "examples/*", + "scripts/*", + "themes/*" + ], + "dependencies": { + "media-chrome": "~4.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11377,7 +11522,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -11566,7 +11710,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/readdirp": { diff --git a/package.json b/package.json index 02c71f1..bd909f1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@base-ui/react": "^1.5.0", "@clerk/nextjs": "^7.4.3", "@hookform/resolvers": "^5.4.0", + "@mux/mux-node": "^14.1.1", + "@mux/mux-player-react": "^3.13.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/extension-accelerate": "^3.0.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef7ee7c..60ea129 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,13 @@ enum PostType { PHOTO MILESTONE REPOST + VIDEO +} + +enum VideoStatus { + PROCESSING + READY + ERROR } enum MilestoneType { @@ -124,6 +131,9 @@ model Post { // Phase 4 back-relations notifications Notification[] + // Phase 5 back-relation + videoPost VideoPost? + @@index([petId, createdAt(sort: Desc)]) } @@ -269,6 +279,23 @@ model PostHashtag { @@index([hashtagId]) } +// ─── Phase 5 Models ────────────────────────────────────────────────────────── + +model VideoPost { + id String @id @default(cuid()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String @unique + muxUploadId String? // Mux direct upload ID — set on creation + muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook + muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook + status VideoStatus @default(PROCESSING) + durationSecs Float? // populated by webhook + aspectRatio String? // e.g. "16:9" from Mux + + @@index([muxUploadId]) + @@index([muxAssetId]) +} + // ─── Phase 4 Enums ─────────────────────────────────────────────────────────── enum NotificationType { diff --git a/src/app/api/webhooks/mux/route.ts b/src/app/api/webhooks/mux/route.ts new file mode 100644 index 0000000..e892369 --- /dev/null +++ b/src/app/api/webhooks/mux/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { mux, MUX_WEBHOOK_SECRET } from "@/lib/mux"; +import { redis } from "@/lib/redis"; +import { fanOutPost } from "@/lib/feed-helpers"; + +/** + * POST /api/webhooks/mux + * + * Handles Mux webhook events for video lifecycle: + * - video.upload.asset_created → store muxAssetId on VideoPost + * - video.asset.ready → mark READY + store playbackId + fan-out to feed + * - video.asset.errored → mark ERROR + * + * Signature verification uses Mux.Webhooks.unwrap() — returns 400 on mismatch. + * Configure in Mux dashboard: Webhooks → Add endpoint → https://yourdomain/api/webhooks/mux + * Subscribe to: video.upload.asset_created, video.asset.ready, video.asset.errored + */ +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get("mux-signature") ?? ""; + + // Verify signature — skip verification in dev if secret not set + if (MUX_WEBHOOK_SECRET) { + try { + mux.webhooks.verifySignature(body, req.headers as never, MUX_WEBHOOK_SECRET); + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + } + + let event: { type: string; data: Record }; + try { + event = JSON.parse(body) as typeof event; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { type, data } = event; + + switch (type) { + case "video.upload.asset_created": { + const uploadId = data.id as string; + const assetId = (data.asset_id ?? (data.asset as { id?: string } | undefined)?.id) as string | undefined; + if (!uploadId || !assetId) break; + + await prisma.videoPost.updateMany({ + where: { muxUploadId: uploadId }, + data: { muxAssetId: assetId }, + }); + break; + } + + case "video.asset.ready": { + const assetId = data.id as string; + const playbackIds = data.playback_ids as Array<{ id: string; policy: string }> | undefined; + const publicPlayback = playbackIds?.find((p) => p.policy === "public"); + const playbackId = publicPlayback?.id; + const durationSecs = data.duration as number | undefined; + const aspectRatio = data.aspect_ratio as string | undefined; + + if (!assetId || !playbackId) break; + + const videoPost = await prisma.videoPost.findFirst({ + where: { muxAssetId: assetId }, + include: { post: { select: { id: true, petId: true, createdAt: true } } }, + }); + if (!videoPost) break; + + await prisma.videoPost.update({ + where: { id: videoPost.id }, + data: { + muxPlaybackId: playbackId, + status: "READY", + durationSecs: durationSecs ?? null, + aspectRatio: aspectRatio ?? null, + }, + }); + + // Fan-out to followers now that the video is playable + await fanOutPost( + redis, + videoPost.post.id, + videoPost.post.petId, + videoPost.post.createdAt, + prisma + ).catch(() => {}); + break; + } + + case "video.asset.errored": { + const assetId = data.id as string; + if (!assetId) break; + + await prisma.videoPost.updateMany({ + where: { muxAssetId: assetId }, + data: { status: "ERROR" }, + }); + break; + } + + default: + // Unhandled event type — acknowledge without error + break; + } + + return NextResponse.json({ received: true }); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index 502ef4a..eee36ce 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -40,6 +40,7 @@ import { getMediaUrl } from "@/lib/media-url"; import { parseCaption } from "@/lib/parse-caption"; import { PawRing } from "@/components/stories/PawRing"; import { MilestoneCard } from "@/components/feed/MilestoneCard"; +import { VideoCard } from "@/components/video/VideoCard"; import { PawButton } from "@/components/feed/PawButton"; import { PawBackButton } from "@/components/feed/PawBackButton"; import { CommentSheet } from "@/components/feed/CommentSheet"; @@ -99,6 +100,10 @@ export type PostCardPost = { reposterPetId: string; originalPost: OriginalPostContent; } | null; + videoPost?: { + status: "PROCESSING" | "READY" | "ERROR"; + muxPlaybackId: string | null; + } | null; }; export type PostCardProps = { @@ -297,8 +302,17 @@ export function PostCard({ post, onDeleted }: PostCardProps) { - {/* Image area */} - {images.length > 0 && ( + {/* Video area — VIDEO posts */} + {post.type === "VIDEO" && post.videoPost && ( + + )} + + {/* Image area — PHOTO posts */} + {post.type !== "VIDEO" && images.length > 0 && ( <> {isCarousel ? ( /* Carousel: 2–10 images — Embla via shadcn (PHOTO-02) */ diff --git a/src/components/post-creation/PostTypeSheet.tsx b/src/components/post-creation/PostTypeSheet.tsx index fd3a2cc..098e754 100644 --- a/src/components/post-creation/PostTypeSheet.tsx +++ b/src/components/post-creation/PostTypeSheet.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Camera, BookOpen, Trophy } from "lucide-react"; +import { Camera, BookOpen, Trophy, Video } from "lucide-react"; import { Sheet, SheetContent, @@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"; import { PhotoPostForm } from "@/components/post-creation/PhotoPostForm"; import { StoryForm } from "@/components/stories/StoryForm"; import { MilestoneForm } from "@/components/post-creation/MilestoneForm"; +import { VideoUploadForm } from "@/components/video/VideoUploadForm"; /** * PostTypeSheet — bottom Sheet with 3 post type choices (D-01). @@ -40,7 +41,7 @@ interface PostTypeSheetProps { onOpenChange: (open: boolean) => void; } -type PostType = "photo" | "story" | "milestone" | null; +type PostType = "photo" | "story" | "milestone" | "video" | null; export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) { const [selectedType, setSelectedType] = useState(null); @@ -51,6 +52,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) { // TODO(02-06): MilestoneForm — open milestone dialog const [milestoneDialogOpen, setMilestoneDialogOpen] = useState(false); + const [videoDialogOpen, setVideoDialogOpen] = useState(false); function handleStoryClose() { setStoryDialogOpen(false); @@ -72,6 +74,8 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) { setStoryDialogOpen(true); } else if (type === "milestone") { setMilestoneDialogOpen(true); + } else if (type === "video") { + setVideoDialogOpen(true); } } @@ -110,6 +114,16 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) { Story + {/* Video Post option */} + + {/* Milestone option — seam for 02-06 */} + )} + + {/* Caption */} + {file && ( +
+ +