diff options
-rw-r--r-- | src/lib/api/index.ts | 38 | ||||
-rw-r--r-- | src/lib/media/video/types.ts | 36 | ||||
-rw-r--r-- | src/state/queries/video/util.ts | 11 | ||||
-rw-r--r-- | src/state/queries/video/video-upload.ts | 23 | ||||
-rw-r--r-- | src/state/queries/video/video-upload.web.ts | 69 | ||||
-rw-r--r-- | src/state/queries/video/video.ts | 60 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx | 2 |
8 files changed, 116 insertions, 126 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 94c8869a1..fa2e4ba6c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -3,12 +3,14 @@ import { AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, AppBskyFeedPostgate, + AtUri, + BlobRef, BskyAgent, ComAtprotoLabelDefs, RichText, } from '@atproto/api' -import {AtUri} from '@atproto/api' import {logger} from '#/logger' import {writePostgateRecord} from '#/state/queries/postgate' @@ -43,10 +45,7 @@ interface PostOpts { uri: string cid: string } - video?: { - uri: string - cid: string - } + video?: BlobRef extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] @@ -61,18 +60,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) { | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | AppBskyEmbedRecord.Main + | AppBskyEmbedVideo.Main | AppBskyEmbedRecordWithMedia.Main | undefined let reply - let rt = new RichText( - {text: opts.rawText.trimEnd()}, - { - cleanNewlines: true, - }, - ) + let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) opts.onStateChange?.('Processing...') + await rt.detectFacets(agent) + rt = shortenLinks(rt) rt = stripInvalidMentions(rt) @@ -129,6 +126,25 @@ export async function post(agent: BskyAgent, opts: PostOpts) { } } + // add video embed if present + if (opts.video) { + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.video', + video: opts.video, + } as AppBskyEmbedVideo.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { + $type: 'app.bsky.embed.video', + video: opts.video, + } as AppBskyEmbedVideo.Main + } + } + // add external embed if present if (opts.extLink && !opts.images?.length) { if (opts.extLink.embed) { diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts deleted file mode 100644 index c458da96e..000000000 --- a/src/lib/media/video/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. - * @temporary - * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. - * Not joking, this is temporary. - */ - -export interface JobStatus { - jobId: string - did: string - cid: string - state: JobState - progress?: number - errorHuman?: string - errorMachine?: string -} - -export enum JobState { - JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', - JOB_STATE_CREATED = 'JOB_STATE_CREATED', - JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', - JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', - JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', - JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', - JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', - JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', - JOB_STATE_FAILED = 'JOB_STATE_FAILED', - JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', -} - -export interface UploadVideoResponse { - job_id: string - did: string - cid: string - state: JobState -} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts index 266d8aee3..9224c776d 100644 --- a/src/state/queries/video/util.ts +++ b/src/state/queries/video/util.ts @@ -1,3 +1,6 @@ +import {useMemo} from 'react' +import {AtpAgent} from '@atproto/api' + const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' export const createVideoEndpointUrl = ( @@ -13,3 +16,11 @@ export const createVideoEndpointUrl = ( } return url.href } + +export function useVideoAgent() { + return useMemo(() => { + return new AtpAgent({ + service: UPLOAD_ENDPOINT, + }) + }, []) +} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index cf741b251..d806249c9 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -1,20 +1,18 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {AppBskyVideoDefs} from '@atproto/api' import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' import {CompressedVideo} from '#/lib/media/video/compress' -import {UploadVideoResponse} from '#/lib/media/video/types' import {createVideoEndpointUrl} from '#/state/queries/video/util' import {useAgent, useSession} from '#/state/session' -const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' - export const useUploadVideoMutation = ({ onSuccess, onError, setProgress, }: { - onSuccess: (response: UploadVideoResponse) => void + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void onError: (e: any) => void setProgress: (progress: number) => void }) => { @@ -23,7 +21,7 @@ export const useUploadVideoMutation = ({ return useMutation({ mutationFn: async (video: CompressedVideo) => { - const uri = createVideoEndpointUrl('/upload', { + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did: currentAccount!.did, name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? }) @@ -33,19 +31,19 @@ export const useUploadVideoMutation = ({ throw new Error('Agent does not have a PDS URL') } - const {data: serviceAuth} = - await agent.api.com.atproto.server.getServiceAuth({ + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth( + { aud: `did:web:${agent.pdsUrl.hostname}`, lxm: 'com.atproto.repo.uploadBlob', - }) + }, + ) const uploadTask = createUploadTask( uri, video.uri, { headers: { - 'dev-key': UPLOAD_HEADER, - 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? + 'content-type': 'video/mp4', Authorization: `Bearer ${serviceAuth.token}`, }, httpMethod: 'POST', @@ -59,10 +57,7 @@ export const useUploadVideoMutation = ({ throw new Error('No response') } - // @TODO rm, useful for debugging/getting video cid - console.log('[VIDEO]', res.body) - const responseBody = JSON.parse(res.body) as UploadVideoResponse - onSuccess(responseBody) + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus return responseBody }, onError, diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index b9b0bacfa..09d107423 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,19 +1,17 @@ +import {AppBskyVideoDefs} from '@atproto/api' import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' import {CompressedVideo} from '#/lib/media/video/compress' -import {UploadVideoResponse} from '#/lib/media/video/types' import {createVideoEndpointUrl} from '#/state/queries/video/util' import {useAgent, useSession} from '#/state/session' -const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' - export const useUploadVideoMutation = ({ onSuccess, onError, setProgress, }: { - onSuccess: (response: UploadVideoResponse) => void + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void onError: (e: any) => void setProgress: (progress: number) => void }) => { @@ -22,9 +20,9 @@ export const useUploadVideoMutation = ({ return useMutation({ mutationFn: async (video: CompressedVideo) => { - const uri = createVideoEndpointUrl('/upload', { + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did: currentAccount!.did, - name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' }) // a logged-in agent should have this set, but we'll check just in case @@ -32,46 +30,45 @@ export const useUploadVideoMutation = ({ throw new Error('Agent does not have a PDS URL') } - const {data: serviceAuth} = - await agent.api.com.atproto.server.getServiceAuth({ + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth( + { aud: `did:web:${agent.pdsUrl.hostname}`, lxm: 'com.atproto.repo.uploadBlob', - }) + }, + ) const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) const xhr = new XMLHttpRequest() - const res = (await new Promise((resolve, reject) => { - xhr.upload.addEventListener('progress', e => { - const progress = e.loaded / e.total - setProgress(progress) - }) - xhr.onloadend = () => { - if (xhr.readyState === 4) { - const uploadRes = JSON.parse( - xhr.responseText, - ) as UploadVideoResponse - resolve(uploadRes) - onSuccess(uploadRes) - } else { + const res = await new Promise<AppBskyVideoDefs.JobStatus>( + (resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as AppBskyVideoDefs.JobStatus + resolve(uploadRes) + onSuccess(uploadRes) + } else { + reject() + onError(new Error('Failed to upload video')) + } + } + xhr.onerror = () => { reject() onError(new Error('Failed to upload video')) } - } - xhr.onerror = () => { - reject() - onError(new Error('Failed to upload video')) - } - xhr.open('POST', uri) - xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? - // @TODO remove this header for prod - xhr.setRequestHeader('dev-key', UPLOAD_HEADER) - xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) - xhr.send(bytes) - })) as UploadVideoResponse + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', 'video/mp4') + xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) + xhr.send(bytes) + }, + ) - // @TODO rm for prod - console.log('[VIDEO]', res) return res }, onError, diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index 295db38b4..64390801e 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,5 +1,6 @@ import React from 'react' import {ImagePickerAsset} from 'expo-image-picker' +import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQuery} from '@tanstack/react-query' @@ -7,37 +8,29 @@ import {useQuery} from '@tanstack/react-query' import {logger} from '#/logger' import {CompressedVideo} from 'lib/media/video/compress' import {VideoTooLargeError} from 'lib/media/video/errors' -import {JobState, JobStatus} from 'lib/media/video/types' import {useCompressVideoMutation} from 'state/queries/video/compress-video' -import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useVideoAgent} from 'state/queries/video/util' import {useUploadVideoMutation} from 'state/queries/video/video-upload' type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' type Action = - | { - type: 'SetStatus' - status: Status - } - | { - type: 'SetProgress' - progress: number - } - | { - type: 'SetError' - error: string | undefined - } + | {type: 'SetStatus'; status: Status} + | {type: 'SetProgress'; progress: number} + | {type: 'SetError'; error: string | undefined} | {type: 'Reset'} | {type: 'SetAsset'; asset: ImagePickerAsset} | {type: 'SetVideo'; video: CompressedVideo} - | {type: 'SetJobStatus'; jobStatus: JobStatus} + | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} + | {type: 'SetBlobRef'; blobRef: BlobRef} export interface State { status: Status progress: number asset?: ImagePickerAsset video: CompressedVideo | null - jobStatus?: JobStatus + jobStatus?: AppBskyVideoDefs.JobStatus + blobRef?: BlobRef error?: string } @@ -54,6 +47,7 @@ function reducer(state: State, action: Action): State { status: 'idle', progress: 0, video: null, + blobRef: undefined, } } else if (action.type === 'SetAsset') { updatedState = {...state, asset: action.asset} @@ -61,6 +55,8 @@ function reducer(state: State, action: Action): State { updatedState = {...state, video: action.video} } else if (action.type === 'SetJobStatus') { updatedState = {...state, jobStatus: action.jobStatus} + } else if (action.type === 'SetBlobRef') { + updatedState = {...state, blobRef: action.blobRef} } return updatedState } @@ -80,7 +76,7 @@ export function useUploadVideo({ }) const {setJobId} = useUploadStatusQuery({ - onStatusChange: (status: JobStatus) => { + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user // Leaving it for now though dispatch({ @@ -89,7 +85,11 @@ export function useUploadVideo({ }) setStatus(status.state.toString()) }, - onSuccess: () => { + onSuccess: blobRef => { + dispatch({ + type: 'SetBlobRef', + blobRef, + }) dispatch({ type: 'SetStatus', status: 'idle', @@ -104,7 +104,7 @@ export function useUploadVideo({ type: 'SetStatus', status: 'processing', }) - setJobId(response.job_id) + setJobId(response.jobId) }, onError: e => { dispatch({ @@ -179,21 +179,27 @@ const useUploadStatusQuery = ({ onStatusChange, onSuccess, }: { - onStatusChange: (status: JobStatus) => void - onSuccess: () => void + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void + onSuccess: (blobRef: BlobRef) => void }) => { + const videoAgent = useVideoAgent() const [enabled, setEnabled] = React.useState(true) const [jobId, setJobId] = React.useState<string>() const {isLoading, isError} = useQuery({ - queryKey: ['video-upload'], + queryKey: ['video-upload', jobId], queryFn: async () => { - const url = createVideoEndpointUrl(`/job/${jobId}/status`) - const res = await fetch(url) - const status = (await res.json()) as JobStatus - if (status.state === JobState.JOB_STATE_COMPLETED) { + if (!jobId) return // this won't happen, can ignore + + const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) + const status = data.jobStatus + if (status.state === 'JOB_STATE_COMPLETED') { setEnabled(false) - onSuccess() + if (!status.blob) + throw new Error('Job completed, but did not return a blob') + onSuccess(status.blob) + } else if (status.state === 'JOB_STATE_FAILED') { + throw new Error('Job failed to process') } onStatusChange(status) return status diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index eefd0affc..c726d307e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -178,7 +178,7 @@ export const ComposePost = observer(function ComposePost({ clearVideo, state: videoUploadState, } = useUploadVideo({ - setStatus: (status: string) => setProcessingState(status), + setStatus: setProcessingState, onSuccess: () => { if (publishOnUpload) { onPressPublish(true) @@ -348,6 +348,7 @@ export const ComposePost = observer(function ComposePost({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), + video: videoUploadState.blobRef, }) ).uri try { diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index 7a4c6e914..c97f5e935 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx @@ -557,7 +557,7 @@ function Scrubber({ {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, {height: hovered || scrubberActive ? 6 : 3}, ]}> - {currentTime && duration && ( + {currentTime > 0 && duration > 0 && ( <View style={[ a.h_full, |