feat(05): Phase 5 — short-form video via Mux

Schema:
- Add VideoPost model (muxUploadId, muxAssetId, muxPlaybackId,
  status, durationSecs, aspectRatio) linked 1-to-1 with Post
- Add VideoStatus enum (PROCESSING/READY/ERROR)
- Add PostType.VIDEO

Backend:
- src/lib/mux.ts — singleton Mux client + webhook secret
- videos router: createUpload (Mux direct upload URL + PROCESSING post),
  getByPostId (status polling), updateCaption
- POST /api/webhooks/mux: verifies signature; handles
  video.upload.asset_created (store muxAssetId),
  video.asset.ready (set READY + playbackId + fan-out to feed),
  video.asset.errored (set ERROR)
- feed.ts + posts.byPetId: include videoPost in all post queries

UI:
- VideoUploadForm: drop zone → XHR PUT to Mux upload URL with
  progress bar; caption field; transitions to "Processing…" on success
- VideoCard: PROCESSING shows spinner; READY renders MuxPlayer
  (HLS, orange accent); polls every 5s until status changes
- PostTypeSheet: "Video Post" option added (between Photo and Milestone)
- PostCard: VIDEO type renders VideoCard instead of image carousel

Env vars: MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET,
NEXT_PUBLIC_APP_URL — placeholders added to .env.local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 13:27:43 +02:00
1 parent 08fd0dfb71
commit 60366691e8
13 files changed
+758 -7

No files matched your search

+146 -3
View File
@@ -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": {
+2
View File
@@ -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",
+27
View File
@@ -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 {
+108
View File
@@ -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<string, unknown> };
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 });
}
+16 -2
View File
@@ -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) {
</DropdownMenu>
</div>
{/* Image area */}
{images.length > 0 && (
{/* Video area — VIDEO posts */}
{post.type === "VIDEO" && post.videoPost && (
<VideoCard
postId={post.id}
initialStatus={post.videoPost.status}
initialPlaybackId={post.videoPost.muxPlaybackId}
/>
)}
{/* Image area — PHOTO posts */}
{post.type !== "VIDEO" && images.length > 0 && (
<>
{isCarousel ? (
/* Carousel: 2–10 images — Embla via shadcn (PHOTO-02) */
+26 -2
View File
@@ -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<PostType>(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) {
<span>Story</span>
</Button>
{/* Video Post option */}
<Button
variant="outline"
className="w-full min-h-[52px] justify-start gap-3 text-sm font-medium"
onClick={() => handleSelectType("video")}
>
<Video className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Video Post</span>
</Button>
{/* Milestone option — seam for 02-06 */}
<Button
variant="outline"
@@ -152,6 +166,16 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<MilestoneForm onClose={handleMilestoneClose} />
</DialogContent>
</Dialog>
{/* Video Dialog */}
<Dialog open={videoDialogOpen} onOpenChange={setVideoDialogOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Video</DialogTitle>
</DialogHeader>
<VideoUploadForm onClose={() => { setVideoDialogOpen(false); setSelectedType(null); }} />
</DialogContent>
</Dialog>
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useEffect } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import MuxPlayer from "@mux/mux-player-react";
interface VideoCardProps {
postId: string;
/** Initial status from the feed/profile query — avoids a redundant fetch when already READY */
initialStatus: "PROCESSING" | "READY" | "ERROR";
initialPlaybackId?: string | null;
}
export function VideoCard({ postId, initialStatus, initialPlaybackId }: VideoCardProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
// Poll while PROCESSING — stop once READY or ERROR
const { data } = useQuery({
...trpc.videos.getByPostId.queryOptions({ postId }),
initialData: initialPlaybackId
? { id: "", postId, muxUploadId: null, muxAssetId: null, muxPlaybackId: initialPlaybackId, status: initialStatus as "PROCESSING" | "READY" | "ERROR", durationSecs: null, aspectRatio: null }
: undefined,
enabled: initialStatus === "PROCESSING",
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "PROCESSING" ? 5_000 : false;
},
});
const status = data?.status ?? initialStatus;
const playbackId = data?.muxPlaybackId ?? initialPlaybackId;
// Invalidate the parent feed/post query once the video becomes ready
useEffect(() => {
if (status === "READY" && initialStatus === "PROCESSING") {
void queryClient.invalidateQueries();
}
}, [status, initialStatus, queryClient]);
if (status === "PROCESSING") {
return (
<div className="aspect-video bg-muted flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
<p className="text-sm">Processing video…</p>
<p className="text-xs">This usually takes less than a minute</p>
</div>
);
}
if (status === "ERROR") {
return (
<div className="aspect-video bg-muted flex flex-col items-center justify-center gap-2 text-destructive">
<AlertCircle className="h-8 w-8" />
<p className="text-sm">Video processing failed</p>
</div>
);
}
if (!playbackId) return null;
return (
<MuxPlayer
playbackId={playbackId}
streamType="on-demand"
style={{ aspectRatio: "16/9", width: "100%" }}
accentColor="#f97316"
/>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Video, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
const ACCEPTED_TYPES = ["video/mp4", "video/quicktime", "video/webm"];
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
type UploadState = "idle" | "uploading" | "processing" | "error";
interface VideoUploadFormProps {
onClose: () => void;
}
export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState("");
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [uploadProgress, setUploadProgress] = useState(0);
const createUploadMutation = useMutation(
trpc.videos.createUpload.mutationOptions()
);
const handleFileSelect = useCallback((file: File) => {
if (!ACCEPTED_TYPES.includes(file.type)) {
toast.error("Upload an MP4, MOV, or WebM video.");
return;
}
if (file.size > MAX_FILE_SIZE) {
toast.error("Video must be under 500 MB.");
return;
}
setFile(file);
setPreviewUrl(URL.createObjectURL(file));
setUploadState("idle");
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) handleFileSelect(f);
e.target.value = "";
},
[handleFileSelect]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (f) handleFileSelect(f);
},
[handleFileSelect]
);
const handleSubmit = useCallback(async () => {
if (!file || !activePetId) return;
setUploadState("uploading");
setUploadProgress(0);
try {
const { uploadUrl } = await createUploadMutation.mutateAsync({
petId: activePetId,
caption: caption.trim() || null,
});
// PUT file directly to Mux upload URL
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setUploadProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.open("PUT", uploadUrl ?? "");
// Mux direct upload accepts the video without a specific Content-Type header
xhr.send(file);
});
setUploadState("processing");
toast.success("Video uploaded — processing in the background");
onClose();
} catch (err) {
console.error(err);
setUploadState("error");
toast.error("Upload failed. Please try again.");
}
}, [file, activePetId, caption, createUploadMutation, onClose]);
return (
<div className="p-4 space-y-4">
{/* Drop zone */}
{!file ? (
<div
className="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer hover:border-muted-foreground/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
aria-label="Select video"
>
<Video className="h-10 w-10 text-muted-foreground mx-auto mb-3" aria-hidden="true" />
<p className="text-sm font-medium">Select a video</p>
<p className="text-xs text-muted-foreground mt-1">
MP4, MOV or WebM · up to 500 MB · max 3 minutes
</p>
</div>
) : (
/* Video preview */
<div className="relative rounded-lg overflow-hidden bg-black aspect-video">
{/* eslint-disable-next-line @next/next/no-img-element */}
<video
src={previewUrl ?? undefined}
controls
className="w-full h-full object-contain"
preload="metadata"
/>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="video/mp4,video/quicktime,video/webm"
className="hidden"
onChange={handleFileChange}
/>
{/* Change video button */}
{file && uploadState === "idle" && (
<button
type="button"
onClick={() => { setFile(null); setPreviewUrl(null); }}
className="text-xs text-muted-foreground hover:text-foreground"
>
Choose a different video
</button>
)}
{/* Caption */}
{file && (
<div className="space-y-1.5">
<Label htmlFor="video-caption">Caption</Label>
<Textarea
id="video-caption"
placeholder="Write a caption…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
maxLength={500}
rows={3}
className="text-sm resize-none"
disabled={uploadState !== "idle"}
/>
{caption.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} left
</p>
)}
</div>
)}
{/* Upload progress */}
{uploadState === "uploading" && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Uploading…</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{uploadState === "processing" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
Uploaded — video is being processed
</div>
)}
{uploadState === "error" && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
Upload failed — please try again
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={onClose}
disabled={uploadState === "uploading"}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!file || uploadState !== "idle" || createUploadMutation.isPending}
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
>
{uploadState === "uploading" ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Uploading…</>
) : (
"Post video"
)}
</Button>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import "server-only";
import Mux from "@mux/mux-node";
// Singleton Mux client — MUX_TOKEN_ID and MUX_TOKEN_SECRET must be set in .env.local
// Get credentials at dashboard.mux.com → Settings → Access Tokens
export const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID ?? "",
tokenSecret: process.env.MUX_TOKEN_SECRET ?? "",
});
export const MUX_WEBHOOK_SECRET = process.env.MUX_WEBHOOK_SECRET ?? "";
+2
View File
@@ -14,6 +14,7 @@ import { repostsRouter } from "@/trpc/routers/reposts";
import { exploreRouter } from "@/trpc/routers/explore";
import { searchRouter } from "@/trpc/routers/search";
import { notificationsRouter } from "@/trpc/routers/notifications";
import { videosRouter } from "@/trpc/routers/videos";
export const appRouter = router({
pets: petsRouter,
@@ -31,6 +32,7 @@ export const appRouter = router({
explore: exploreRouter,
search: searchRouter,
notifications: notificationsRouter,
videos: videosRouter,
});
// Export the type for use in the client
+1
View File
@@ -58,6 +58,7 @@ export const feedRouter = router({
images: { orderBy: { position: "asc" } as const },
milestone: true,
_count: { select: { reactions: true, comments: true } },
videoPost: { select: { status: true, muxPlaybackId: true } },
repost: {
include: {
originalPost: {
+1
View File
@@ -216,6 +216,7 @@ export const postsRouter = router({
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
_count: { select: { reactions: true, comments: true } },
videoPost: { select: { status: true, muxPlaybackId: true } },
repost: {
include: {
originalPost: {
+109
View File
@@ -0,0 +1,109 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { mux } from "@/lib/mux";
import type { PrismaClient } from "@prisma/client";
const MAX_VIDEO_DURATION_SECS = 180; // 3 min hard cap — Mux rejects longer via passthrough check
async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
const pet = await prisma.pet.findUnique({ where: { id: petId } });
if (!pet || pet.ownerId !== userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return pet;
}
export const videosRouter = router({
/**
* Create a Mux direct upload URL + a PROCESSING Post/VideoPost row.
* The client PUTs the video file directly to Mux — never through our server.
* Returns { uploadUrl, postId } — postId used for polling + caption update.
*/
createUpload: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
caption: z.string().max(500).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
if (!process.env.MUX_TOKEN_ID || !process.env.MUX_TOKEN_SECRET) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Video upload is not configured. Add MUX_TOKEN_ID and MUX_TOKEN_SECRET to .env.local.",
});
}
// Create Mux direct upload — client PUTs directly to the returned URL
const upload = await mux.video.uploads.create({
cors_origin: process.env.NEXT_PUBLIC_APP_URL ?? "*",
new_asset_settings: {
playback_policy: ["public"],
// max_resolution_tier tells Mux to cap at 1080p (cost control)
max_resolution_tier: "1080p",
passthrough: "", // will be set after post creation
},
});
// Create Post + VideoPost in one transaction
const post = await ctx.prisma.post.create({
data: {
petId: input.petId,
type: "VIDEO",
caption: input.caption ?? null,
videoPost: {
create: {
muxUploadId: upload.id,
status: "PROCESSING",
},
},
},
include: { videoPost: true },
});
// Webhook correlation: we look up VideoPost by muxUploadId — no passthrough update needed
return {
uploadUrl: upload.url,
postId: post.id,
muxUploadId: upload.id,
};
}),
/**
* Get the video post status — used to poll while video is PROCESSING.
* Returns the VideoPost record so the UI can transition to the player when READY.
*/
getByPostId: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const videoPost = await ctx.prisma.videoPost.findUnique({
where: { postId: input.postId },
});
if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });
return videoPost;
}),
/**
* Update caption after upload (post was created with optional caption at upload time).
*/
updateCaption: protectedProcedure
.input(z.object({ postId: z.string().min(1), caption: z.string().max(500).nullable() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { pet: { select: { ownerId: true } } },
});
if (!post || post.pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
await ctx.prisma.post.update({
where: { id: input.postId },
data: { caption: input.caption },
});
}),
});