Skip to content

Add video review page #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 5 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:<br>`https://<your app's domain>/api/moderate`
1. Upload an image to use as the default image (for example <a href="https://res.cloudinary.com/cld-demo-ugc/image/upload/v1729679379/avatar-pic.jpg" target=_blank>this image</a>), 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
Expand All @@ -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:

Expand Down
127 changes: 76 additions & 51 deletions app/api/moderate/route.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,96 @@
import { NextResponse } from 'next/server'

// This map will store the moderation results temporarily
const moderationResults = new Map<string, { status: string, message: string }>()
// This map will store the processing results temporarily
const processingResults = new Map<string, {
moderation: { status: string, message: string },
autoChaptering: { status: string, message: string },
autoTranscription: { status: string, message: string },
eagerTransformation: { status: string, message: string }
}>()

// 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' })
}
}

return NextResponse.json({ success: true })
}

11 changes: 0 additions & 11 deletions app/components/cld.ts

This file was deleted.

107 changes: 107 additions & 0 deletions app/components/cloudinary-video-player.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLVideoElement>(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/[email protected]/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/[email protected]/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/[email protected]/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 (
<video
ref={videoRef}
className="cld-video-player"
controls
/>
)
}

Loading