Uploading Video
Video uploads differ slightly from image uploads, since they are typically much larger files and have a significantly long processing time.
The Bluesky video CDN also applies additional account-level limits on publishing videos. Bluesky-hosted accounts need to have verified their account email before uploading video, and there are limits on the number of video posts which can be posted per day. Apps can check email verification status using the com.atproto.server.getSession
endpoint on the PDS.
Simple method
The easiest way to upload a video is to upload it as you would an image - use uploadBlob
to upload the file directly to the PDS, and reference the returned blob.
- Typescript
const { data } = await userAgent.com.atproto.repo.uploadBlob(
fs.readFileSync(videoPath),
);
await agent.post({
text: "This post should have a video attached",
langs: ["en"],
embed: {
$type: "app.bsky.embed.video",
video: data.blob,
aspectRatio: await getAspectRatio(videoPath),
} satisfies AppBskyEmbedVideo.Main,
});
However, this has the significant downside that the video only starts processing after the post is submitted - the video service only knows about the video once the post appears in the firehose. This means that people will be able to see the post before processing is complete, which will show a missing video for several seconds.
Instead, we can send the video directly to the video service for preprocessing.
Recommended method
This is a little more involved, and involves communicating directly with the video service. The steps are:
- Create a service token with an audience of your PDS, a scope allowing
uploadBlob
, and a slightly longer expiry - 30 minutes is recommended - Upload the video directly to
https://video.bsky.app
with the appropriate access token - Query
https://video.bsky.app
for the status of the processing job until it returns the BlobRef of the video - Use the BlobRef in your post
Here is a code snippet for the full flow:
- Typescript
const { data: serviceAuth } = await userAgent.com.atproto.server.getServiceAuth(
{
aud: `did:web:${userAgent.dispatchUrl.host}`,
lxm: "com.atproto.repo.uploadBlob",
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
);
const token = serviceAuth.token;
const uploadUrl = new URL(
"https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
);
uploadUrl.searchParams.append("did", userAgent.session!.did);
uploadUrl.searchParams.append("name", videoPath.split("/").pop()!);
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "video/mp4",
"Content-Length": fs.statSync(videoPath).size
},
body: fs.readFileSync(videoPath),
});
const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus;
let blob: BlobRef | undefined = jobStatus.blob;
const videoAgent = new AtpAgent({ service: "https://video.bsky.app" });
while (!blob) {
const { data: status } = await videoAgent.app.bsky.video.getJobStatus(
{ jobId: jobStatus.jobId },
);
console.log(
"Status:",
status.jobStatus.state,
status.jobStatus.progress || "",
);
if (status.jobStatus.blob) {
blob = status.jobStatus.blob;
}
// wait a second
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await userAgent.post({
text: "This post should have a video attached",
langs: ["en"],
embed: {
$type: "app.bsky.embed.video",
video: blob,
aspectRatio: await getAspectRatio(videoPath),
} satisfies AppBskyEmbedVideo.Main,
});
What is happening behind the scenes is the video service runs the processing step on your video, then saves an optimised version of the video to your PDS on your behalf using the service token. It then returns the BlobRef it gets from your PDS to you via the getJobStatus
API. Then, when you make your post, the video service already has the video ready immediately without a delay.
This method also allows for better UX, since you can show the processing state to the user and let them know if the processing job fails before they make the post.
There are complete code samples for both methods here (using Deno):
Note: If the source video has already been processed by the video service, getJobStatus
will return an error with the message already_exists
. When this happens, it will return the BlobRef of the previously processed video for you to use, hence why we recommend checking for the presence of a BlobRef in the response regardless of the success or failure of the job.
Aspect Ratios
As with images, we need to give video embeds an aspect ratio. This metadata can be a little tricky to compute. If you’re in the browser, you can load the video into a <video>
and observe the dimensions when loaded. If you’re in a native app, you’ll most likely be able to get it via the media picker APIs. Alternatively, you can use a tool like ffmprobe
- here’s how you’d do it with Deno:
- Deno
import { ffprobe } from "https://deno.land/x/fast_forward@0.1.6/ffprobe.ts";
export async function getAspectRatio(fileName: string) {
const { streams } = await ffprobe(fileName, {});
const videoSteam = streams.find((stream) => stream.codec_type === "video");
return {
width: videoSteam.width,
height: videoSteam.height,
};
}