diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index a6621463a0..2170bacdf8 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -93,25 +93,14 @@ pub async fn get_export_estimates( resolution: XY, fps: u32, ) -> Result { - let screen_metadata = get_video_metadata(path.clone()).await?; - let camera_metadata = get_video_metadata(path.clone()).await.ok(); - - let raw_duration = screen_metadata.duration.max( - camera_metadata - .map(|m| m.duration) - .unwrap_or(screen_metadata.duration), - ); + let metadata = get_video_metadata(path.clone()).await?; let meta = RecordingMeta::load_for_project(&path).unwrap(); let project_config = meta.project_config(); let duration_seconds = if let Some(timeline) = &project_config.timeline { - timeline - .segments - .iter() - .map(|s| (s.end - s.start) / s.timescale) - .sum() + timeline.segments.iter().map(|s| s.duration()).sum() } else { - raw_duration + metadata.duration }; let (width, height) = (resolution.x, resolution.y); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 46dca64d57..bd1e570651 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -88,6 +88,8 @@ use windows::EditorWindowIds; use windows::set_window_transparent; use windows::{CapWindowId, ShowCapWindow}; +use crate::upload::build_video_meta; + #[allow(clippy::large_enum_variant)] pub enum RecordingState { None, @@ -1123,28 +1125,6 @@ async fn upload_exported_video( return Ok(UploadResult::NotAuthenticated); }; - let screen_metadata = get_video_metadata(path.clone()).await.map_err(|e| { - sentry::capture_message( - &format!("Failed to get video metadata: {e}"), - sentry::Level::Error, - ); - - "Failed to read video metadata. The recording may be from an incompatible version." - .to_string() - })?; - - let camera_metadata = get_video_metadata(path.clone()).await.ok(); - - let duration = screen_metadata.duration.max( - camera_metadata - .map(|m| m.duration) - .unwrap_or(screen_metadata.duration), - ); - - if !auth.is_upgraded() && duration > 300.0 { - return Ok(UploadResult::UpgradeRequired); - } - let mut meta = RecordingMeta::load_for_project(&path).map_err(|v| v.to_string())?; let output_path = meta.output_path(); @@ -1153,6 +1133,13 @@ async fn upload_exported_video( return Err("Failed to upload video: Rendered video not found".to_string()); } + let metadata = build_video_meta(&output_path) + .map_err(|err| format!("Error getting output video meta: {}", err.to_string()))?; + + if !auth.is_upgraded() && metadata.duration_in_secs > 300.0 { + return Ok(UploadResult::UpgradeRequired); + } + UploadProgress { progress: 0.0 }.emit(&app).ok(); let s3_config = async { @@ -1177,7 +1164,7 @@ async fn upload_exported_video( false, video_id, Some(meta.pretty_name.clone()), - Some(duration.to_string()), + Some(metadata.clone()), ) .await } @@ -1191,7 +1178,7 @@ async fn upload_exported_video( output_path, Some(s3_config), Some(meta.project_path.join("screenshots/display.jpg")), - Some(duration.to_string()), + Some(metadata), ) .await { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 5dcfb1b97f..01e90208b8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -770,7 +770,7 @@ async fn handle_recording_finish( output_path, Some(video_upload_info.config.clone()), Some(display_screenshot.clone()), - meta.map(|v| v.duration), + meta, ) .await { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 303461d615..f306140b9d 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -3,6 +3,7 @@ use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; use cap_utils::spawn_actor; +use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; use futures::StreamExt; use image::ImageReader; @@ -10,7 +11,6 @@ use image::codecs::jpeg::JpegEncoder; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; -use serde_json::json; use specta::Type; use std::path::PathBuf; use std::time::Duration; @@ -76,50 +76,17 @@ impl S3UploadMeta { // } } -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct S3UploadBody { - video_id: String, - subpath: String, -} - -#[derive(serde::Serialize)] +#[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct S3VideoMeta { - pub duration: String, - pub bandwidth: String, - pub resolution: String, - pub video_codec: String, - pub audio_codec: String, - pub framerate: String, + #[serde(rename = "durationInSecs")] + pub duration_in_secs: f64, + pub width: u32, + pub height: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub fps: Option, } -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct S3VideoUploadBody { - #[serde(flatten)] - base: S3UploadBody, - #[serde(flatten)] - meta: S3VideoMeta, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct S3ImageUploadBody { - #[serde(flatten)] - base: S3UploadBody, -} - -// #[derive(serde::Serialize)] -// #[serde(rename_all = "camelCase")] -// struct S3AudioUploadBody { -// #[serde(flatten)] -// base: S3UploadBody, -// duration: String, -// audio_codec: String, -// is_mp3: bool, -// } - pub struct UploadedVideo { pub link: String, pub id: String, @@ -144,25 +111,26 @@ pub async fn upload_video( file_path: PathBuf, existing_config: Option, screenshot_path: Option, - duration: Option, + meta: Option, ) -> Result { println!("Uploading video {video_id}..."); let client = reqwest::Client::new(); let s3_config = match existing_config { Some(config) => config, - None => create_or_get_video(app, false, Some(video_id.clone()), None, duration).await?, + None => create_or_get_video(app, false, Some(video_id.clone()), None, meta).await?, }; - let body = S3VideoUploadBody { - base: S3UploadBody { + let presigned_put = presigned_s3_put( + app, + PresignedS3PutRequest { video_id: video_id.clone(), subpath: "result.mp4".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: Some(build_video_meta(&file_path)?), }, - meta: build_video_meta(&file_path)?, - }; - - let presigned_put = presigned_s3_put(app, body).await?; + ) + .await?; let file = tokio::fs::File::open(&file_path) .await @@ -265,14 +233,16 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result, name: Option, - duration: Option, + meta: Option, ) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") @@ -328,8 +298,13 @@ pub async fn create_or_get_video( s3_config_url.push_str(&format!("&name={name}")); } - if let Some(duration) = duration { - s3_config_url.push_str(&format!("&duration={duration}")); + if let Some(meta) = meta { + s3_config_url.push_str(&format!("&durationInSecs={}", meta.duration_in_secs)); + s3_config_url.push_str(&format!("&width={}", meta.width)); + s3_config_url.push_str(&format!("&height={}", meta.height)); + if let Some(fps) = meta.fps { + s3_config_url.push_str(&format!("&fps={}", fps)); + } } let response = app @@ -353,7 +328,25 @@ pub async fn create_or_get_video( Ok(config) } -async fn presigned_s3_put(app: &AppHandle, body: impl Serialize) -> Result { +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PresignedS3PutRequest { + video_id: String, + subpath: String, + method: PresignedS3PutRequestMethod, + #[serde(flatten)] + meta: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PresignedS3PutRequestMethod { + #[allow(unused)] + Post, + Put, +} + +async fn presigned_s3_put(app: &AppHandle, body: PresignedS3PutRequest) -> Result { #[derive(Deserialize, Debug)] struct Data { url: String, @@ -367,10 +360,7 @@ async fn presigned_s3_put(app: &AppHandle, body: impl Serialize) -> Result Result { .best(ffmpeg::media::Type::Video) .ok_or_else(|| "Failed to find appropriate video stream in file".to_string())?; - let duration_millis = input.duration() as f64 / 1000.; - let video_codec = ffmpeg::codec::context::Context::from_parameters(video_stream.parameters()) .map_err(|e| format!("Unable to read video codec information: {e}"))?; - let video_codec_name = video_codec.id(); - let video = video_codec.decoder().video().unwrap(); - let width = video.width(); - let height = video.height(); - let frame_rate = video - .frame_rate() - .map(|fps| fps.to_string()) - .unwrap_or("-".into()); - let bit_rate = video.bit_rate(); - - let audio_codec_name = input - .streams() - .best(ffmpeg::media::Type::Audio) - .map(|audio_stream| { - ffmpeg::codec::context::Context::from_parameters(audio_stream.parameters()) - .map(|c| c.id()) - .map_err(|e| format!("Unable to read audio codec information: {e}")) - }) - .transpose()?; + let video = video_codec + .decoder() + .video() + .map_err(|e| format!("Unable to get video decoder: {e}"))?; Ok(S3VideoMeta { - duration: duration_millis.to_string(), - resolution: format!("{width}x{height}"), - framerate: frame_rate, - bandwidth: bit_rate.to_string(), - video_codec: format!("{video_codec_name:?}") - .replace("Id::", "") - .to_lowercase(), - audio_codec: audio_codec_name - .map(|name| format!("{name:?}").replace("Id::", "").to_lowercase()) - .unwrap_or_default(), + duration_in_secs: input.duration() as f64 / AV_TIME_BASE as f64, + width: video.width(), + height: video.height(), + fps: video + .frame_rate() + .map(|v| (v.numerator() as f32 / v.denominator() as f32)), }) } @@ -465,14 +434,16 @@ pub async fn prepare_screenshot_upload( s3_config: &S3UploadMeta, screenshot_path: PathBuf, ) -> Result { - let body = S3ImageUploadBody { - base: S3UploadBody { + let presigned_put = presigned_s3_put( + app, + PresignedS3PutRequest { video_id: s3_config.id.clone(), subpath: "screenshot/screen-capture.jpg".to_string(), + method: PresignedS3PutRequestMethod::Put, + meta: None, }, - }; - - let presigned_put = presigned_s3_put(app, body).await?; + ) + .await?; let compressed_image = compress_image(screenshot_path).await?; @@ -518,6 +489,16 @@ async fn compress_image(path: PathBuf) -> Result, String> { const CHUNK_SIZE: u64 = 5 * 1024 * 1024; // 5MB // const MIN_PART_SIZE: u64 = 5 * 1024 * 1024; // For non-final parts +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MultipartCompleteResponse<'a> { + video_id: &'a str, + upload_id: &'a str, + parts: &'a [UploadedPart], + #[serde(flatten)] + meta: Option, +} + pub struct InstantMultipartUpload { pub handle: tokio::task::JoinHandle>, } @@ -1017,19 +998,14 @@ impl InstantMultipartUpload { let complete_response = match app .authed_api_request("/api/upload/multipart/complete", |c, url| { - c.post(url) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "videoId": video_id, - "uploadId": upload_id, - "parts": uploaded_parts, - "duration": metadata.as_ref().map(|m| m.duration.clone()), - "bandwidth": metadata.as_ref().map(|m| m.bandwidth.clone()), - "resolution": metadata.as_ref().map(|m| m.resolution.clone()), - "videoCodec": metadata.as_ref().map(|m| m.video_codec.clone()), - "audioCodec": metadata.as_ref().map(|m| m.audio_codec.clone()), - "framerate": metadata.as_ref().map(|m| m.framerate.clone()), - })) + c.post(url).header("Content-Type", "application/json").json( + &MultipartCompleteResponse { + video_id, + upload_id, + parts: uploaded_parts, + meta: metadata, + }, + ) }) .await { diff --git a/apps/desktop/src/routes/editor/ExportDialog.tsx b/apps/desktop/src/routes/editor/ExportDialog.tsx index a94a6cb833..e119bb0e03 100644 --- a/apps/desktop/src/routes/editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/editor/ExportDialog.tsx @@ -360,6 +360,7 @@ export function ExportDialog() { setExportState({ type: "done" }); }, onError: (error) => { + console.error(error); commands.globalMessageDialog( error instanceof Error ? error.message : "Failed to upload recording", ); diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index 1c499466af..b3d0efef90 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -115,6 +115,7 @@ function ShareButton() { } }, onError: (error) => { + console.error(error); commands.globalMessageDialog( error instanceof Error ? error.message : "Failed to upload recording", ); diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index c17fd4a852..5021391469 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -59,6 +59,7 @@ export interface CapCardProps extends PropsWithChildren { ownerName: string | null; metadata?: VideoMetadata; hasPassword?: boolean; + duration?: number; }; analytics: number; isLoadingAnalytics: boolean; @@ -431,7 +432,7 @@ export const CapCard = ({ href={`/s/${cap.id}`} > `COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 3f8d976e3d..467647214e 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -197,6 +197,7 @@ export default async function SharedCapsPage({ name: videos.name, createdAt: videos.createdAt, metadata: videos.metadata, + duration: videos.duration, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, @@ -302,6 +303,7 @@ export default async function SharedCapsPage({ name: videos.name, createdAt: videos.createdAt, metadata: videos.metadata, + duration: videos.duration, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, ownerName: users.name, @@ -324,6 +326,7 @@ export default async function SharedCapsPage({ videos.createdAt, videos.metadata, users.name, + videos.duration, ) .orderBy( desc( diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 1567ce059c..229965f579 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -5,12 +5,12 @@ import { nanoId } from "@cap/database/helpers"; import { s3Buckets, videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { dub } from "@/utils/dub"; import { createBucketProvider } from "@/utils/s3"; - +import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; export const app = new Hono().use(withAuth); @@ -20,34 +20,48 @@ app.get( zValidator( "query", z.object({ - duration: z.coerce.number().optional(), recordingMode: z .union([z.literal("hls"), z.literal("desktopMP4")]) .optional(), isScreenshot: z.coerce.boolean().default(false), videoId: z.string().optional(), name: z.string().optional(), + durationInSecs: stringOrNumberOptional, + width: stringOrNumberOptional, + height: stringOrNumberOptional, + fps: stringOrNumberOptional, }), ), async (c) => { try { - const { duration, recordingMode, isScreenshot, videoId, name } = - c.req.valid("query"); + const { + recordingMode, + isScreenshot, + videoId, + name, + durationInSecs, + width, + height, + fps, + } = c.req.valid("query"); const user = c.get("user"); + const isUpgraded = user.stripeSubscriptionStatus === "active"; + + if (!isUpgraded && durationInSecs && durationInSecs > /* 5 min */ 5 * 60) + return c.json({ error: "upgrade_required" }, { status: 403 }); + console.log("Video create request:", { - duration, recordingMode, isScreenshot, videoId, userId: user.id, + durationInSecs, + height, + width, + fps, }); - const isUpgraded = user.stripeSubscriptionStatus === "active"; - - if (!isUpgraded && duration && duration > 300) - return c.json({ error: "upgrade_required" }, { status: 403 }); - const [customBucket] = await db() .select() .from(s3Buckets) @@ -103,9 +117,10 @@ app.get( isScreenshot, bucket: customBucket?.id, public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - metadata: { - duration, - }, + duration: durationInSecs, + width, + height, + fps, }); if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index e927d24272..667c461e4f 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -1,13 +1,14 @@ -import { db } from "@cap/database"; +import { db, updateIfDefined } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { withAuth } from "@/app/api/utils"; import { createBucketProvider } from "@/utils/s3"; +import { stringOrNumberOptional } from "@/utils/zod"; import { parseVideoIdOrFileKey } from "../utils"; export const app = new Hono().use(withAuth); @@ -162,12 +163,10 @@ app.post( size: z.number(), }), ), - duration: z.string().optional(), - bandwidth: z.string().optional(), - resolution: z.string().optional(), - videoCodec: z.string().optional(), - audioCodec: z.string().optional(), - framerate: z.string().optional(), + durationInSecs: stringOrNumberOptional, + width: stringOrNumberOptional, + height: stringOrNumberOptional, + fps: stringOrNumberOptional, }) .and( z.union([ @@ -275,24 +274,23 @@ app.post( console.error(`Warning: Unable to verify object: ${headError}`); } - const videoMetadata: VideoMetadata = { - duration: body.duration, - bandwidth: body.bandwidth, - resolution: body.resolution, - videoCodec: body.videoCodec, - audioCodec: body.audioCodec, - framerate: body.framerate, - }; + const videoIdFromFileKey = fileKey.split("/")[1]; - if (Object.values(videoMetadata).length > 1 && "videoId" in body) + const videoIdToUse = + "videoId" in body ? body.videoId : videoIdFromFileKey; + if (videoIdToUse) await db() .update(videos) .set({ - metadata: videoMetadata, + duration: updateIfDefined(body.durationInSecs, videos.duration), + width: updateIfDefined(body.width, videos.width), + height: updateIfDefined(body.height, videos.height), + fps: updateIfDefined(body.fps, videos.fps), }) - .where(eq(videos.id, body.videoId)); + .where( + and(eq(videos.id, videoIdToUse), eq(videos.ownerId, user.id)), + ); - const videoIdFromFileKey = fileKey.split("/")[1]; if (videoIdFromFileKey) { try { await fetch(`${serverEnv().WEB_URL}/api/revalidate`, { diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 29cd66f96f..06e8b132ed 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -2,15 +2,16 @@ import { CloudFrontClient, CreateInvalidationCommand, } from "@aws-sdk/client-cloudfront"; -import { db } from "@cap/database"; +import { db, updateIfDefined } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { createBucketProvider } from "@/utils/s3"; +import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; import { parseVideoIdOrFileKey } from "../utils"; @@ -22,13 +23,11 @@ app.post( "json", z .object({ - duration: z.string().optional(), - bandwidth: z.string().optional(), - resolution: z.string().optional(), - videoCodec: z.string().optional(), - audioCodec: z.string().optional(), - framerate: z.string().optional(), method: z.union([z.literal("post"), z.literal("put")]).default("post"), + durationInSecs: stringOrNumberOptional, + width: stringOrNumberOptional, + height: stringOrNumberOptional, + fps: stringOrNumberOptional, }) .and( z.union([ @@ -40,16 +39,8 @@ app.post( ), async (c) => { const user = c.get("user"); - const { - duration, - bandwidth, - resolution, - videoCodec, - audioCodec, - framerate, - method, - ...body - } = c.req.valid("json"); + const { durationInSecs, width, height, fps, method, ...body } = + c.req.valid("json"); const fileKey = parseVideoIdOrFileKey(user.id, body); @@ -126,11 +117,9 @@ app.post( const Fields = { "Content-Type": contentType, "x-amz-meta-userid": user.id, - "x-amz-meta-duration": duration ?? "", - "x-amz-meta-bandwidth": bandwidth ?? "", - "x-amz-meta-resolution": resolution ?? "", - "x-amz-meta-videocodec": videoCodec ?? "", - "x-amz-meta-audiocodec": audioCodec ?? "", + "x-amz-meta-duration": durationInSecs + ? durationInSecs.toString() + : "", }; data = bucket.getPresignedPostUrl(fileKey, { Fields, Expires: 1800 }); @@ -141,11 +130,7 @@ app.post( ContentType: contentType, Metadata: { userid: user.id, - duration: duration ?? "", - bandwidth: bandwidth ?? "", - resolution: resolution ?? "", - videocodec: videoCodec ?? "", - audiocodec: audioCodec ?? "", + duration: durationInSecs ? durationInSecs.toString() : "", }, }, { expiresIn: 1800 }, @@ -156,25 +141,21 @@ app.post( console.log("Presigned URL created successfully"); - const videoMetadata: VideoMetadata = { - duration, - bandwidth, - resolution, - videoCodec, - audioCodec, - framerate, - }; + // After successful presigned URL creation, trigger revalidation + const videoIdFromKey = fileKey.split("/")[1]; // Assuming fileKey format is userId/videoId/... - if (Object.values(videoMetadata).length > 1 && "videoIn" in body) + const videoIdToUse = "videoId" in body ? body.videoId : videoIdFromKey; + if (videoIdToUse) await db() .update(videos) .set({ - metadata: videoMetadata, + duration: updateIfDefined(durationInSecs, videos.duration), + width: updateIfDefined(width, videos.width), + height: updateIfDefined(height, videos.height), + fps: updateIfDefined(fps, videos.fps), }) - .where(eq(videos.id, body.videoId)); + .where(and(eq(videos.id, videoIdToUse), eq(videos.ownerId, user.id))); - // After successful presigned URL creation, trigger revalidation - const videoIdFromKey = fileKey.split("/")[1]; // Assuming fileKey format is userId/videoId/... if (videoIdFromKey) { try { await fetch(`${serverEnv().WEB_URL}/api/revalidate`, { diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 15aaf3c7f3..e3b97e921c 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -13,12 +13,11 @@ interface VideoThumbnailProps { imageClass?: string; objectFit?: string; containerClass?: string; - videoDuration?: string | number; + videoDuration?: number; } -const formatDuration = (duration: string) => { - const durationMs = parseFloat(duration); - const momentDuration = moment.duration(durationMs, "milliseconds"); +const formatDuration = (durationSecs: number) => { + const momentDuration = moment.duration(durationSecs, "seconds"); const totalHours = Math.floor(momentDuration.asHours()); const totalMinutes = Math.floor(momentDuration.asMinutes()); @@ -108,7 +107,7 @@ export const VideoThumbnail: React.FC = memo( ) )} - {videoDuration && Number(videoDuration) > 0 && ( + {videoDuration && (

{formatDuration(videoDuration.toString())}

diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 47c53f451f..907a8daea2 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -153,6 +153,7 @@ export async function getVideosByFolderId(folderId: string) { createdAt: videos.createdAt, public: videos.public, metadata: videos.metadata, + duration: videos.duration, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>` diff --git a/apps/web/utils/zod.ts b/apps/web/utils/zod.ts new file mode 100644 index 0000000000..a720985595 --- /dev/null +++ b/apps/web/utils/zod.ts @@ -0,0 +1,12 @@ +import z from "zod"; + +// `z.coerce.number().optional()` will turn `null` into `0` which is unintended. +// https://github.com/colinhacks/zod/discussions/2814#discussioncomment-7121766 +export const stringOrNumberOptional = z.preprocess((val) => { + if (val === null || val === undefined) return val; + if (typeof val === "string") { + const n = Number(val); + return Number.isNaN(n) ? val : n; // let z.number() reject non-numeric strings + } + return val; // numbers pass through +}, z.number().optional()); diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 8caa77a937..148f3631dc 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -435,7 +435,8 @@ impl TimelineSegment { } } - fn duration(&self) -> f64 { + /// in seconds + pub fn duration(&self) -> f64 { (self.end - self.start) / self.timescale } } diff --git a/packages/database/index.ts b/packages/database/index.ts index 1e323122f2..b29c14dfd7 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -1,5 +1,7 @@ import { serverEnv } from "@cap/env"; import { Client, type Config } from "@planetscale/database"; +import { sql } from "drizzle-orm"; +import type { AnyMySqlColumn } from "drizzle-orm/mysql-core"; import { drizzle } from "drizzle-orm/planetscale-serverless"; function createDrizzle() { @@ -31,3 +33,7 @@ export const db = () => { } return _cached; }; + +// Use the incoming value if one exists, else fallback to the DBs existing value. +export const updateIfDefined = (v: T | undefined, col: AnyMySqlColumn) => + sql`COALESCE(${v === undefined ? sql`NULL` : v}, ${col})`; diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ba1f70f5d2..db44f12312 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -235,6 +235,11 @@ export const videos = mysqlTable( ownerId: nanoId("ownerId").notNull(), name: varchar("name", { length: 255 }).notNull().default("My Video"), bucket: nanoIdNullable("bucket"), + // in seconds + duration: float("duration"), + width: int("width"), + height: int("height"), + fps: int("fps"), metadata: json("metadata").$type(), public: boolean("public").notNull().default(true), transcriptionStatus: varchar("transcriptionStatus", { length: 255 }).$type< diff --git a/packages/database/types/metadata.ts b/packages/database/types/metadata.ts index 74571410f5..06ddde4d68 100644 --- a/packages/database/types/metadata.ts +++ b/packages/database/types/metadata.ts @@ -15,18 +15,6 @@ export interface VideoMetadata { * Title of the captured monitor or window */ sourceName?: string; - /** - * Duration of the video in seconds - */ - duration?: string | number; - /** - * Resolution of the recording (e.g. 1920x1080) - */ - resolution?: string; - /** - * Frames per second of the recording - */ - fps?: number; /** * AI generated title for the video */ @@ -40,19 +28,18 @@ export interface VideoMetadata { */ chapters?: { title: string; start: number }[]; aiProcessing?: boolean; - [key: string]: any; } /** * Space metadata structure */ export interface SpaceMetadata { - [key: string]: any; + [key: string]: never; } /** * User metadata structure */ export interface UserMetadata { - [key: string]: any; + [key: string]: never; }