Skip to content

Commit c23b1a8

Browse files
committed
mobile updates and processing now find a better thumbnail
1 parent 8e5d11b commit c23b1a8

File tree

5 files changed

+125
-58
lines changed

5 files changed

+125
-58
lines changed

app/components/storage-used-text.tsx

Lines changed: 0 additions & 26 deletions
This file was deleted.

app/components/upload-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function UploadButton() {
1717
onMouseEnter={() => setIsHovering(true)}
1818
onMouseLeave={() => setIsHovering(false)}
1919
variant="ghost"
20-
className="relative inline-flex h-12 overflow-hidden rounded-lg p-[1px] focus:outline-none focus:ring-2 hover:ring-red-400 hover:ring-1 focus:ring-offset-2"
20+
className="gap-2 md:order-2 relative inline-flex h-12 overflow-hidden rounded-lg p-[1px] focus:outline-none focus:ring-2 hover:ring-red-400 hover:ring-1 focus:ring-offset-2"
2121
>
2222
<span className="absolute inset-[-1000%] animate-[spin_5s_linear_infinite] bg-[conic-gradient(from_90deg_at_50%_50%,#ffcbcb_0%,#b23939_50%,#ffcbcb_100%)]" />
2323
<span className="text-lg inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-background px-3 py-1 font-medium text-primary backdrop-blur-3xl">

app/lib/query-utils.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { createServerFn } from "@tanstack/start";
33
import { z } from "zod";
44
import { authGuardMiddleware } from "../middleware/auth-guard";
55
import { db } from "./db";
6-
import { desc, inArray, not } from "drizzle-orm";
6+
import { and, desc, eq, inArray, not, sum } from "drizzle-orm";
77
import { videos, type VideoStoryboard } from "./schema";
88
import { MAX_FILE_SIZE_FREE_TIER, PLAN_STORAGE_SIZES } from "./constants";
99
import { clerkClient, getAuth } from "@clerk/tanstack-start/server";
10-
import { getIpFromHeaders, safeParseAccountTier } from "./utils";
10+
import {
11+
getIpFromHeaders,
12+
notNanOrDefault,
13+
safeParseAccountTier,
14+
} from "./utils";
1115
import { getWebRequest } from "@tanstack/start/server";
1216
import dayjs from "dayjs";
1317
import { createSigner } from "fast-jwt";
@@ -215,18 +219,30 @@ export const videoQueryOptions = (videoId: string) =>
215219
const fetchUsageDataServerFn = createServerFn({ method: "GET" })
216220
.middleware([authGuardMiddleware])
217221
.handler(async ({ context }) => {
218-
const userData = await db.query.users.findFirst({
219-
where: (table, { eq }) => eq(table.id, context.userId),
220-
columns: {
221-
totalStorageUsed: true,
222-
accountTier: true,
223-
},
224-
});
222+
const [userData, [{ totalVideoStorageUsed }]] = await db.batch([
223+
db.query.users.findFirst({
224+
where: (table, { eq }) => eq(table.id, context.userId),
225+
columns: {
226+
accountTier: true,
227+
},
228+
}),
229+
db
230+
.select({
231+
totalVideoStorageUsed: sum(videos.fileSizeBytes),
232+
})
233+
.from(videos)
234+
.where(
235+
and(
236+
eq(videos.authorId, context.userId),
237+
inArray(videos.status, ["ready", "processing"])
238+
)
239+
),
240+
]);
225241

226242
const maxStorage = PLAN_STORAGE_SIZES[userData?.accountTier ?? "free"];
227243

228244
return {
229-
totalStorageUsed: userData?.totalStorageUsed ?? 0,
245+
totalStorageUsed: notNanOrDefault(totalVideoStorageUsed),
230246
maxStorage,
231247
maxFileUpload:
232248
userData?.accountTier === "free" ? MAX_FILE_SIZE_FREE_TIER : undefined,

app/routes/videos.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { HeroHighlight } from "@/components/ui/hero-highlight";
2222
import { Separator } from "@/components/ui/seperator";
2323
import { Footer } from "@/components/footer";
2424
import { useEffect } from "react";
25+
import { HumanFileSizeMotion } from "@/components/human-file-size-motion";
26+
import { humanFileSize } from "@/lib/utils";
2527

2628
export const Route = createFileRoute("/videos")({
2729
component: RouteComponent,
@@ -85,15 +87,21 @@ function RouteComponent() {
8587
<TrimVideoDialog />
8688
<FullPageDropzone />
8789
<main className="grow container space-y-8 mx-auto px-4 py-8">
88-
<div className="flex gap-2 items-center justify-between">
89-
<h1 className="text-2xl w-64 font-bold">Your Catalog</h1>
90-
<div className="flex flex-col-reverse md:flex-row items-center md:gap-8">
91-
{usageData && (
92-
<StorageUsedText
93-
maxStorage={usageData.maxStorage}
94-
totalStorageUsed={usageData.totalStorageUsed}
95-
/>
96-
)}
90+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
91+
<h1 className="text-2xl font-bold">Your Videos</h1>
92+
93+
<div className="flex flex-col gap-4 md:gap-2 md:flex-row md:items-center">
94+
<div className="text-sm md:order-1 md:p-2">
95+
{usageData && (
96+
<div className="bg-zinc-900/50 h-12 p-2 rounded-lg flex items-center justify-center border backdrop-blur-sm">
97+
Storage used:
98+
<span className="mx-1">
99+
<HumanFileSizeMotion size={usageData.totalStorageUsed} />
100+
</span>{" "}
101+
/ {humanFileSize(usageData.maxStorage)}
102+
</div>
103+
)}
104+
</div>
97105
<UploadButton />
98106
</div>
99107
</div>

app/trigger/video-processing.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,26 +200,95 @@ export const videoProcessingTask = schemaTask({
200200

201201
if (steps.includes("thumbnails")) {
202202
await logger.trace("Step thumbnails", async (span) => {
203-
const frameFilePath = `${nativeFilePath}.webp`;
203+
const framesDir = path.join(workingDir, "frames");
204+
await mkdir(framesDir, { recursive: true });
205+
206+
// First get video duration and fps
207+
const { stdout: videoInfo } =
208+
await execa`ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate,duration -of json ${nativeFilePath}`;
209+
const info = JSON.parse(videoInfo);
210+
211+
// Parse frame rate (comes as ratio like "24000/1001")
212+
const [num, den] = info.streams[0].r_frame_rate.split("/");
213+
const fps = Number(num) / (Number(den) || 1);
214+
215+
// Get duration in seconds
216+
const duration = Math.min(Number(info.streams[0].duration) || 60, 60); // Cap at 60 seconds
217+
218+
// Calculate sampling interval to get ~10 frames from first minute
219+
// but ensure we don't sample faster than 1 frame per second
220+
const targetFrames = 10;
221+
const interval = Math.max(duration / targetFrames, 1);
222+
223+
logger.info("Video analysis", {
224+
fps,
225+
duration,
226+
samplingInterval: interval,
227+
expectedFrames: Math.floor(duration / interval),
228+
});
204229

205-
await logger.trace("FFMPEG thumbnail", async (span) => {
206-
span.setAttributes({
207-
input: nativeFilePath,
208-
output: frameFilePath,
209-
command: "ffmpeg -frames:v 1 -q:v 75 -f image2",
210-
});
230+
// Extract frames using calculated interval
231+
await logger.trace("FFMPEG multiple thumbnails", async (span) => {
232+
try {
233+
await execa`ffmpeg -i ${nativeFilePath} -vf fps=1/${interval} -t ${duration} -q:v 75 -f image2 ${framesDir}/frame_%03d.webp`;
234+
} catch (error) {
235+
logger.error("Failed to extract frames", { error });
236+
// Fallback to single frame if multiple frames fail
237+
await execa`ffmpeg -i ${nativeFilePath} -frames:v 1 -q:v 75 -f image2 ${framesDir}/frame_001.webp`;
238+
}
239+
});
211240

212-
await execa`ffmpeg -i ${nativeFilePath} -frames:v 1 -q:v 75 -f image2 ${frameFilePath}`;
241+
// Find the brightest frame
242+
const files = readdirSync(framesDir).sort(); // Ensure consistent order
243+
let brightestFrame = "";
244+
let maxBrightness = -1;
213245

214-
span.end();
215-
});
246+
if (files.length === 0) {
247+
throw new Error("No frames were extracted");
248+
}
249+
250+
// Default to first frame in case brightness calculation fails
251+
brightestFrame = path.join(framesDir, files[0]);
252+
253+
try {
254+
await Promise.all(
255+
files.map(async (file) => {
256+
const framePath = path.join(framesDir, file);
257+
try {
258+
const stats = await sharp(framePath).stats();
259+
260+
// Calculate perceived brightness using the luminance formula
261+
const brightness =
262+
stats.channels[0].mean * 0.299 + // Red
263+
stats.channels[1].mean * 0.587 + // Green
264+
stats.channels[2].mean * 0.114; // Blue
265+
266+
if (brightness > maxBrightness) {
267+
maxBrightness = brightness;
268+
brightestFrame = framePath;
269+
}
270+
} catch (error) {
271+
logger.error("Failed to process frame", { file, error });
272+
}
273+
})
274+
);
275+
276+
logger.info("Selected brightest frame", {
277+
brightness: maxBrightness,
278+
frame: brightestFrame,
279+
totalFrames: files.length,
280+
});
281+
} catch (error) {
282+
logger.error("Failed to process frames for brightness", { error });
283+
// We'll use the default first frame that was set earlier
284+
}
216285

217286
const uploadPromises: Promise<unknown>[] = [];
218287

219288
await Promise.all([
220289
logger
221290
.trace("Sharp large thumbnail", async (span) => {
222-
const buffer = await sharp(frameFilePath)
291+
const buffer = await sharp(brightestFrame)
223292
.webp({ quality: 90, effort: 6, alphaQuality: 90 })
224293
.toBuffer();
225294

@@ -243,7 +312,7 @@ export const videoProcessingTask = schemaTask({
243312
}),
244313
logger
245314
.trace("Sharp small thumbnail", async (span) => {
246-
const buffer = await sharp(frameFilePath)
315+
const buffer = await sharp(brightestFrame)
247316
.resize(1280, 720, { fit: "cover" })
248317
.webp({
249318
quality: 70,

0 commit comments

Comments
 (0)