video-upload.ts
edited
1// NOTE: this uses Deno
2
3import {
4 AppBskyEmbedVideo,
5 AppBskyVideoDefs,
6 AtpAgent,
7 BlobRef,
8} from "npm:@atproto/api";
9
10const userAgent = new AtpAgent({
11 service: prompt("Service URL (default: https://bsky.social):") ||
12 "https://bsky.social",
13});
14
15await userAgent.login({
16 identifier: prompt("Handle:")!,
17 password: prompt("Password:")!,
18});
19
20console.log(`Logged in as ${userAgent.session?.handle}`);
21
22const videoPath = prompt("Video file (.mp4):")!;
23
24const { data: serviceAuth } = await userAgent.com.atproto.server.getServiceAuth(
25 {
26 aud: `did:web:${userAgent.dispatchUrl.host}`,
27 lxm: "com.atproto.repo.uploadBlob",
28 exp: Date.now() / 1000 + 60 * 30, // 30 minutes
29 },
30);
31
32const token = serviceAuth.token;
33
34const file = await Deno.open(videoPath);
35const { size } = await file.stat();
36
37// optional: print upload progress
38let bytesUploaded = 0;
39const progressTrackingStream = new TransformStream({
40 transform(chunk, controller) {
41 controller.enqueue(chunk);
42 bytesUploaded += chunk.byteLength;
43 console.log(
44 "upload progress:",
45 Math.trunc(bytesUploaded / size * 100) + "%",
46 );
47 },
48 flush() {
49 console.log("upload complete ✨");
50 },
51});
52
53const uploadUrl = new URL(
54 "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
55);
56uploadUrl.searchParams.append("did", userAgent.session!.did);
57uploadUrl.searchParams.append("name", videoPath.split("/").pop()!);
58
59const uploadResponse = await fetch(uploadUrl, {
60 method: "POST",
61 headers: {
62 Authorization: `Bearer ${token}`,
63 "Content-Type": "video/mp4",
64 "Content-Length": String(size),
65 },
66 body: file.readable.pipeThrough(progressTrackingStream),
67});
68
69const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus;
70
71console.log("JobId:", jobStatus.jobId);
72
73let blob: BlobRef | undefined = jobStatus.blob;
74
75const videoAgent = new AtpAgent({ service: "https://video.bsky.app" });
76
77while (!blob) {
78 const { data: status } = await videoAgent.app.bsky.video.getJobStatus(
79 { jobId: jobStatus.jobId },
80 );
81 console.log(
82 "Status:",
83 status.jobStatus.state,
84 status.jobStatus.progress || "",
85 );
86 if (status.jobStatus.blob) {
87 blob = status.jobStatus.blob;
88 }
89 // wait a second
90 await new Promise((resolve) => setTimeout(resolve, 1000));
91}
92
93console.log("posting...");
94
95await userAgent.post({
96 text: "This post should have a video attached",
97 langs: ["en"],
98 embed: {
99 $type: "app.bsky.embed.video",
100 video: blob,
101 aspectRatio: await getAspectRatio(videoPath),
102 } satisfies AppBskyEmbedVideo.Main,
103});
104
105console.log("done ✨");
106
107// bonus: get aspect ratio using ffprobe
108// in the browser, you can just put the video uri in a <video> element
109// and measure the dimensions once it loads. in React Native, the image picker
110// will give you the dimensions directly
111
112import { ffprobe } from "https://deno.land/x/fast_forward@0.1.6/ffprobe.ts";
113
114async function getAspectRatio(fileName: string) {
115 const { streams } = await ffprobe(fileName, {});
116 const videoSteam = streams.find((stream) => stream.codec_type === "video");
117 return {
118 width: videoSteam.width,
119 height: videoSteam.height,
120 };
121}