Skip to content
Merged
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
12 changes: 11 additions & 1 deletion dev/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,17 @@ const buildConfigWithMemoryDB = async () => {
collections: [
{
slug: 'posts',
fields: [],
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'media',
Expand Down
26 changes: 18 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"types": "./src/adapters/index.ts",
"default": "./src/adapters/index.ts"
},
"./services": {
"import": "./src/services/index.ts",
"types": "./src/services/index.ts",
"default": "./src/services/index.ts"
},
"./client": {
"import": "./src/exports/client.ts",
"types": "./src/exports/client.ts",
Expand Down Expand Up @@ -71,7 +76,7 @@
"test:int": "vitest"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/eslintrc": "^3.3.3",
"@payloadcms/db-mongodb": "3.64.0",
"@payloadcms/db-postgres": "3.64.0",
"@payloadcms/db-sqlite": "3.64.0",
Expand All @@ -84,28 +89,28 @@
"@playwright/test": "1.56.1",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@types/node": "^22.19.1",
"@types/node": "^22.19.7",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"copyfiles": "2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint": "^9.39.2",
"eslint-config-next": "15.4.7",
"graphql": "^16.8.1",
"graphql": "^16.12.0",
"mongodb-memory-server": "10.1.4",
"next": "15.4.7",
"open": "^10.1.0",
"open": "^10.2.0",
"payload": "3.64.0",
"prettier": "^3.4.2",
"prettier": "^3.8.1",
"qs-esm": "7.0.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"rimraf": "3.0.2",
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"sort-package-json": "^2.15.1",
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
"vitest": "^3.2.4"
},
"peerDependencies": {
"payload": "^3.64.0"
Expand All @@ -126,6 +131,11 @@
"types": "./dist/adapters/index.d.ts",
"default": "./dist/adapters/index.js"
},
"./services": {
"import": "./dist/services/index.js",
"types": "./dist/services/index.d.ts",
"default": "./dist/services/index.js"
},
"./client": {
"import": "./dist/exports/client.js",
"types": "./dist/exports/client.d.ts",
Expand Down
2,786 changes: 1,588 additions & 1,198 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type StreamFieldData = {
downloadable?: boolean
durationInSeconds?: number
error: string
errorAt?: string
height?: number
provider: string
readyToStream: boolean
Expand Down
26 changes: 24 additions & 2 deletions src/fields/preview/client.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,35 @@ import React from 'react'
import styles from './preview.module.css'

type Props = {
error?: string
html: null | string
readyToStream?: boolean
}
export function StreamPreviewer({ html, readyToStream }: Props) {
export function StreamPreviewer({ error, html, readyToStream }: Props) {
return (
<div className={styles.container}>
{readyToStream ? (
{error ? (
<div className={styles.error}>
<div className={styles.errorIcon}>
<svg
fill="none"
height="48"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="48"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
</div>
<p className={styles.errorText}>Video processing failed</p>
<p className={styles.errorMessage}>{error}</p>
</div>
) : readyToStream ? (
html ? (
<div dangerouslySetInnerHTML={{ __html: html }} />
) : (
Expand Down
36 changes: 36 additions & 0 deletions src/fields/preview/preview.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,39 @@
font-size: 0.875rem;
margin: 0;
}

.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2d1a1a 0%, #3d2828 100%);
border: 2px solid #8b4444;
border-radius: 8px;
padding: 3rem 2rem;
min-height: 200px;
text-align: center;
position: relative;
overflow: hidden;
}

.errorIcon {
color: #cc6666;
margin-bottom: 1rem;
opacity: 0.8;
}

.errorText {
color: #dd8888;
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
}

.errorMessage {
color: #bb6666;
font-size: 0.875rem;
margin: 0;
max-width: 100%;
word-break: break-word;
}
2 changes: 1 addition & 1 deletion src/fields/preview/server.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function StreamPreview(props: Props) {
html = (await props.adapter.getHTMLVideoPlayer(stream)) || '<p>Video preview not available.</p>'
}

return <StreamPreviewer html={html} readyToStream={stream?.readyToStream} />
return <StreamPreviewer error={stream?.error} html={html} readyToStream={stream?.readyToStream} />
}

export function getStreamPreviewField(props: Props) {
Expand Down
8 changes: 8 additions & 0 deletions src/fields/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export const streamField = ({ adapter }: { adapter: StreamAdapter }): Field => {
readOnly: true,
},
},
{
name: 'errorAt',
type: 'date',
admin: {
hidden: true,
readOnly: true,
},
},
{
name: 'requireSignedURLs',
type: 'checkbox',
Expand Down
148 changes: 16 additions & 132 deletions src/hooks/afterOperation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { CollectionAfterOperationHook, CollectionBeforeDeleteHook } from 'payload'
import type { StreamAdapter } from 'src/adapters/streamAdapter.js'

import type { StreamAdapter } from '../adapters/streamAdapter.js'

import { streamingService } from '../services/streamingService.js'

export const updateStatusHook = (
adapter: StreamAdapter,
Expand All @@ -12,44 +15,9 @@ export const updateStatusHook = (
result.mimeType?.startsWith('video/') &&
result.stream.videoId
) {
req.payload.logger.info({
msg: 'Fetching latest stream status for videoId: ' + result.stream.videoId,
})

// if there is a stream uid but not ready to stream, fetch latest status
const response = await adapter.getStatus(result.stream.videoId)

// update video document with latest stream status
req.payload.logger.info({
id: result.id,
msg: 'Updating video document with latest stream status: collection=' + collectionSlug,
response,
})

await req.payload.update({
id: result.id,
collection: collectionSlug,
data: {
stream: {
durationInSeconds: response.result?.durationInSeconds,
error: response.result?.status?.errorReasonText || '',
height: response.result?.height,
provider: adapter.providerName || '',
readyToStream: response.result?.readyToStream,
readyToStreamAt: response.result?.readyToStreamAt,
requireSignedURLs: response.result?.requireSignedURLs || false,
size: response.result?.size,
thumbnailUrl: response.result?.thumbnail || '',
width: response.result?.width,
},
},
req,
})

req.payload.logger.info({
msg:
'Video document updated with latest stream status for videoId: ' + result.stream.videoId,
stream: result.stream,
await streamingService({ adapter, req }).updateStatus({
collectionSlug,
doc: result,
})
}

Expand All @@ -62,88 +30,20 @@ export const copyVideo = (
collectionSlug: string,
requireSignedURLs = false,
): CollectionAfterOperationHook => {
return async ({ operation, req, result }) => {
return ({ operation, req, result }) => {
if (
operation === 'findByID' &&
operation === 'create' &&
!result.stream.readyToStream &&
!result.stream.videoId &&
result.mimeType?.startsWith('video/')
) {
try {
req.payload.logger.info({
msg: 'Preparing to copy video to streaming service',
result,
setTimeout(async () => {
await streamingService({ adapter, req }).copyVideoToStreamingPlatform({
collectionSlug,
doc: result,
requireSignedURLs,
})
let videoUrl = `${req.protocol}//${req.host}${result.url}`

if (requireSignedURLs) {
// get signed URL for video
const signedVideoUrl = await fetch(`${req.protocol}//${req.host}${result.url}`, {
headers: {
accept: 'application/json',
cookie: req.headers.get('cookie') || '',
},
method: 'GET',
redirect: 'manual',
}).then((r) => r.headers.get('location') || '')
videoUrl = signedVideoUrl
}

req.payload.logger.info({ msg: 'Copying video to streaming service', videoUrl })

// copy video to stream
const response = await adapter.copyVideo({
meta: {
name: result.filename,
},
url: videoUrl,
})

// update video document with stream uid and thumbnail url
if (response.result) {
req.payload.logger.info({ msg: 'Video copied to streaming service', response })

const stream = {
provider: adapter.providerName || '',
readyToStream: response.result.readyToStream,
requireSignedURLs: response.result.requireSignedURLs || false,
thumbnailUrl: response.result.thumbnail,
videoId: response.result.videoId,
}

req.payload.logger.info({
msg:
'Updating video document with streaming info: collection=' +
collectionSlug +
', id=' +
result.id,
stream,
})

await req.payload.update({
id: result.id,
collection: collectionSlug,
data: {
stream,
},
req,
})
}
} catch (e) {
req.payload.logger.error({ err: e, msg: 'Error copying video to streaming service' })

// update video document with error
await req.payload.update({
id: result.id,
collection: collectionSlug,
data: {
stream: {
error: 'Error copying video to streaming service',
},
},
req,
})
}
}, 1000) // delay to allow initial create operation to complete
}

return result
Expand All @@ -166,22 +66,6 @@ export const deleteVideo = (
})

// delete video from streaming service if videoId exists
if (doc?.stream?.videoId) {
try {
req.payload.logger.info({
msg: 'Deleting video from streaming service',
stream: doc.stream,
})
// delete video from streaming service
await adapter.delete(doc.stream.videoId)

req.payload.logger.info({
msg: 'Video deleted from streaming service',
stream: doc.stream,
})
} catch (e) {
req.payload.logger.error({ err: e, msg: 'Error deleting video from streaming service' })
}
}
await streamingService({ adapter, req }).delete(doc)
}
}
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './streamingService.js'
Loading