diff --git a/README.md b/README.md index 75c651c..65e5d04 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,12 @@ -# UGC Sample App for Profile Pictures and Posts +# UGC Sample App for Video Reviews -This is a social media-style application that handles user-generated content (UGC) using Cloudinary's advanced capabilities. The app features a Profile page where users can manage their personal information and upload a profile picture, along with a Posts page where they can share thoughts and images. +This is a mockup of a product page that handles user-generated content (UGC) using Cloudinary's advanced capabilities. People can upload a video review of the product, which is moderated for inappropriate content and malware, as well as being processed for video chapters and transcription for captions. The video is displayed at 16:9 aspect ratio with automatic gravity and captions displayed. It's a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app), built on Next.js 14 and the Next.js App Router. ## Overview -The app serves as a demonstration platform for handling user-generated content in a social media context. It implements these main features: - -On the Profile page: - -* The uploaded image is moderated for appropriate content and checked for malware before being displayed on the page. -* If the image is poor quality, then the quality is improved. -* The image is displayed as a square, focusing on the face, if there is one, or the most interesting part of the image, if not. - -On the Posts page: - -* The post is displayed against the profile picture, which is resized and made circular with an outline. -* The uploaded image, if there is one, is moderated for appropriate content and checked for malware before being displayed on the page. -* The post image is displayed with padding, if required, to show the whole image in a dedicated space. +The app serves as a demonstration platform for handling user-generated content in a product review context. ## Run the app @@ -27,9 +15,8 @@ To run the app yourself: 1. Clone or fork this GitHub repo. 1. In **app/config/cloudinary.ts**, replace **MY_CLOUD_NAME** with your Cloudinary product environment cloud name. You can find your **Cloud name** near the top of the Programmable Media [Dashboard](https://console.cloudinary.com/pm/developer-dashboard) of the Cloudinary Console. [Sign up for free](https://cloudinary.com/users/register_free) if you don't yet have a Cloudinary account. 1. To try out your app locally, you need to set up a secure tunnel connecting the internet to your locally-running application so that the webhooks sent by Cloudinary on upload are caught and handled by the app. You can use a tool such as [Ngrok](https://ngrok.com/) to do this. Otherwise, you need to deploy the app using a service such as [Vercel](https://vercel.com/). Whichever method you choose, make a note of your app's domain (for example, `a-b-c-d.ngrok-free.app` or `a-b-c-d.vercel.app`). By default, the app runs on port 3000. -1. Create an upload preset called **ugc-profile-photo**. (You can use a different name, but if you do, you also need update the `uploadPreset` value in **cloudinary.ts**.) See instructions on how to [configure your upload preset](https://cloudinary.com/documentation/profile_picture_sample_project#upload_preset_configuration). +1. Create an upload preset called **ugc-video-langs**. (You can use a different name, but if you do, you also need update the `uploadPreset` value in **cloudinary.ts**.) See instructions on how to [configure your upload preset](https://cloudinary.com/documentation/video_review_sample_project#upload_preset_configuration). 1. Ensure that the **Notification URL** in your upload preset is set to:
`https:///api/moderate` -1. Upload an image to use as the default image (for example this image), and set its public ID to `avatar-pic`. Alternatively, use an image that's already in your product environment, and change the value of `defaultImage` in **cloudinary.ts** to its public ID. 1. If running locally, run the development server: ```terminal @@ -46,7 +33,7 @@ To run the app yourself: ## Learn More -Learn more about this app: [Cloudinary docs](https://cloudinary.com/documentation/profile_picture_sample_project). +Learn more about this app: [Cloudinary docs](https://cloudinary.com/documentation/video_review_sample_project). To learn more about Next.js, take a look at the following resources: diff --git a/app/api/moderate/route.ts b/app/api/moderate/route.ts index a3f7119..c057f0c 100644 --- a/app/api/moderate/route.ts +++ b/app/api/moderate/route.ts @@ -1,71 +1,96 @@ import { NextResponse } from 'next/server' -// This map will store the moderation results temporarily -const moderationResults = new Map() +// This map will store the processing results temporarily +const processingResults = new Map() -// This endpoint is used to catch the webhook sent from Cloudinary when the moderation -// is complete. It is also used by the frontend to find out the moderation result for -// a particular uploaded asset. export async function POST(request: Request) { - const data = await request.json() - - // Check if this is a webhook from Cloudinary - if (data.notification_type === 'moderation' || data.notification_type === 'moderation_summary') { - const { moderation_status, moderation_kind, asset_id } = data - - let status = 'pending' - let message = '' - - // Check the moderation status + console.log("Received webhook data:", data) + + const { asset_id, public_id, notification_type } = data + + if (!processingResults.has(asset_id)) { + processingResults.set(asset_id, { + moderation: { status: 'pending', message: '' }, + autoChaptering: { status: 'pending', message: '' }, + autoTranscription: { status: 'pending', message: '' }, + eagerTransformation: { status: 'pending', message: '' } + }) + } + + const result = processingResults.get(asset_id)! + + if (notification_type === 'moderation' || notification_type === 'moderation_summary') { + const { moderation_status, moderation_kind } = data + if (moderation_status === 'rejected') { - status = 'rejected' - if (moderation_kind === 'aws_rek') { - message = 'Your image was rejected due to unsuitable content' - } else if (moderation_kind === 'perception_point') { - message = 'Your image was rejected due to potential malware' + result.moderation = { + status: 'rejected', + message: moderation_kind === 'aws_rek_video' + ? 'Your video was rejected due to unsuitable content' + : 'Your video was rejected due to potential malware' } } else if (moderation_status === 'approved') { - status = 'approved' - message = 'Image approved' + result.moderation = { status: 'approved', message: 'Video approved' } } + } else if (notification_type === 'info') { + const { info_kind, info_status } = data - // Store the result - moderationResults.set(asset_id, { status, message }) - - return NextResponse.json({ success: true }) + if (info_kind === 'auto_chaptering') { + result.autoChaptering = { + status: info_status, + message: info_status === 'failed' ? data.info_data : 'Chaptering completed' + } + } else if (info_kind === 'auto_transcription') { + result.autoTranscription = { + status: info_status, + message: info_status === 'failed' ? data.info_data : 'Transcription completed' + } + } + } else if (notification_type === 'eager') { + result.eagerTransformation = { status: 'complete', message: 'Eager transformation completed' } } - // If it's not a webhook, it's a request from our frontend - const { public_id, asset_id } = data + processingResults.set(asset_id, result) - // Check if we have a moderation result for this asset - const moderationResult = moderationResults.get(asset_id) + // If it's a request from our frontend + if (data.checkStatus) { + const processingResult = processingResults.get(asset_id) - if (!moderationResult) { - return NextResponse.json({ status: 'pending', message: 'Moderation in progress' }) - } + if (!processingResult) { + return NextResponse.json({ status: 'pending', message: 'Processing in progress' }) + } - // Clear the result from our temporary storage - moderationResults.delete(asset_id) + const { moderation, autoChaptering, autoTranscription, eagerTransformation } = processingResult - if (moderationResult.status === 'approved') { + if (moderation.status === 'rejected') { + return NextResponse.json({ status: 'rejected', message: moderation.message }) + } - let poorQuality = false + if (moderation.status === 'approved' && + (autoChaptering.status === 'complete' || autoChaptering.status === 'failed') && + (autoTranscription.status === 'complete' || autoTranscription.status === 'failed') && + eagerTransformation.status === 'complete') { + // Clear the result from our temporary storage + processingResults.delete(asset_id) - if (data.tags && data.tags.includes('poor_quality')) { - poorQuality = true + return NextResponse.json({ + status: 'approved', + publicId: public_id, + autoChaptering: autoChaptering.status === 'complete', + autoTranscription: autoTranscription.status === 'complete', + eagerTransformationComplete: true + }) } - - return NextResponse.json({ - status: 'approved', - poorQuality: poorQuality, - publicId: public_id - }) - } else { - return NextResponse.json({ - status: 'rejected', - message: moderationResult.message - }) + + return NextResponse.json({ status: 'pending', message: 'Processing in progress' }) } -} \ No newline at end of file + + return NextResponse.json({ success: true }) +} + diff --git a/app/components/cld.ts b/app/components/cld.ts deleted file mode 100644 index abb177b..0000000 --- a/app/components/cld.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Cloudinary } from "@cloudinary/url-gen" -import { CLOUDINARY_CONFIG } from "../config/cloudinary" - -// Create a Cloudinary instance for the product environment. -const cld = new Cloudinary({ - cloud: { - cloudName: CLOUDINARY_CONFIG.cloudName - } -}) - -export default cld \ No newline at end of file diff --git a/app/components/cloudinary-video-player.tsx b/app/components/cloudinary-video-player.tsx new file mode 100644 index 0000000..c88845e --- /dev/null +++ b/app/components/cloudinary-video-player.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect, useRef, useState } from "react" +import { CLOUDINARY_CONFIG } from '../config/cloudinary' + +interface CloudinaryVideoPlayerProps { + publicId: string +} + +export function CloudinaryVideoPlayer({ publicId }: CloudinaryVideoPlayerProps) { + const videoRef = useRef(null) + const [isScriptLoaded, setIsScriptLoaded] = useState(false) + + useEffect(() => { + if (!window.cloudinary || !window.cloudinary.videoPlayer) { + const coreScript = document.createElement("script") + coreScript.src = "https://unpkg.com/cloudinary-core@latest/cloudinary-core-shrinkwrap.min.js" + coreScript.async = true + + coreScript.onload = () => { + const videoPlayerScript = document.createElement("script") + videoPlayerScript.src = "https://unpkg.com/cloudinary-video-player@2.2.0/dist/cld-video-player.min.js" + videoPlayerScript.async = true + videoPlayerScript.onload = () => setIsScriptLoaded(true) + videoPlayerScript.onerror = (error) => console.error("Error loading Cloudinary Video Player 2.2.0 script:", error) + document.head.appendChild(videoPlayerScript) + } + + coreScript.onerror = (error) => console.error("Error loading Cloudinary Core script:", error) + document.head.appendChild(coreScript) + + const link = document.createElement("link") + link.href = "https://unpkg.com/cloudinary-video-player@2.2.0/dist/cld-video-player.min.css" + link.rel = "stylesheet" + document.head.appendChild(link) + + return () => { + document.head.removeChild(coreScript) + const videoPlayerScript = document.querySelector('script[src="https://unpkg.com/cloudinary-video-player@2.2.0/dist/cld-video-player.min.js"]') + if (videoPlayerScript) { + document.head.removeChild(videoPlayerScript) + } + document.head.removeChild(link) + } + } else { + setIsScriptLoaded(true) + } + }, []) + + useEffect(() => { + if (isScriptLoaded && videoRef.current && window.cloudinary && window.cloudinary.videoPlayer) { + const player = window.cloudinary.videoPlayer(videoRef.current, { + cloud_name: CLOUDINARY_CONFIG.cloudName, + controls: true, + chaptersButton: true, + width: 600, + sourceTypes: ['webm','mp4'], + posterOptions: {aspect_ratio: "16:9", crop: "fill", gravity: "auto", width: 600} + }) + + player.source({ + publicId, + transformation: [{aspect_ratio: "16:9", crop: "fill", gravity: "auto", width: 600}], + textTracks: { + captions: { + label: 'English(captions)', + default: true, + maxWords: 5, + }, + subtitles: [ + { + label: 'French', + language: 'fr-FR', + maxWords: 5, + }, + { + label: 'Spanish', + language: 'es-ES', + maxWords: 5, + }, + { + label: 'German', + language: 'de-DE', + maxWords: 5, + } + ] + }, + chapters: true + }) + + + + return () => { + player.dispose() + } + } + }, [isScriptLoaded, publicId]) + + return ( +