upload a video to bluesky. the video is pre-processed by uploading to the video service before making the post
video-upload.ts edited
121 lines 3.2 kB view raw
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}