Skip to content

Commit f80a9d4

Browse files
committed
feat: handle videos linked via IPFS
1 parent a07a7ff commit f80a9d4

File tree

14 files changed

+528
-87
lines changed

14 files changed

+528
-87
lines changed

.changeset/bright-timers-roll.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@mod-protocol/react-ui-shadcn": minor
3+
"@miniapps/livepeer-video": minor
4+
"web": minor
5+
"@miniapps/video-render": minor
6+
"api": minor
7+
---
8+
9+
feat: support videos linked via IPFS

.changeset/great-nails-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mod-protocol/core": minor
3+
---
4+
5+
feat: add mimeType to UrlMetadata type

examples/api/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Needs to be an IPFS api key
22
INFURA_API_KEY="REQUIRED"
33
INFURA_API_SECRET="REQUIRED"
4+
IPFS_DEFAULT_GATEWAY="REQUIRED"
5+
MICROLINK_API_KEY="REQUIRED"
46
GIPHY_API_KEY="REQUIRED"
57
MICROLINK_API_KEY="REQUIRED"
68
OPENSEA_API_KEY="REQUIRED"

examples/api/src/app/api/livepeer-video/route.ts

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,92 +2,88 @@ import { NextRequest, NextResponse } from "next/server";
22

33
export async function POST(request: NextRequest) {
44
const form = await request.formData();
5-
// https://docs.livepeer.org/reference/api#upload-an-asset
6-
const requestedUrlReq = await fetch(
7-
"https://livepeer.studio/api/asset/request-upload",
5+
6+
const controller = new AbortController();
7+
const signal = controller.signal;
8+
9+
// Cancel upload if it takes longer than 15s
10+
setTimeout(() => {
11+
controller.abort();
12+
}, 15_000);
13+
14+
const uploadRes: Response | null = await fetch(
15+
"https://ipfs.infura.io:5001/api/v0/add",
816
{
917
method: "POST",
18+
body: form,
1019
headers: {
11-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
12-
"Content-Type": "application/json",
20+
Authorization:
21+
"Basic " +
22+
Buffer.from(
23+
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET
24+
).toString("base64"),
1325
},
14-
body: JSON.stringify({
15-
name: "video.mp4",
16-
staticMp4: true,
17-
playbackPolicy: {
18-
type: "public",
19-
},
20-
storage: {
21-
ipfs: true,
22-
},
23-
}),
26+
signal,
2427
}
2528
);
2629

27-
const requestedUrl = await requestedUrlReq.json();
30+
const { Hash: hash } = await uploadRes.json();
2831

29-
const url = requestedUrl.url;
32+
const responseData = { url: `ipfs://${hash}` };
3033

31-
const videoUpload = await fetch(url, {
32-
method: "PUT",
33-
headers: {
34-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
35-
"Content-Type": "video/mp4",
36-
},
37-
body: form.get("file"),
38-
});
34+
return NextResponse.json({ data: responseData });
35+
}
3936

40-
if (videoUpload.status >= 400) {
41-
return NextResponse.json(
42-
{ message: "Something went wrong" },
43-
{
44-
status: videoUpload.status,
45-
}
46-
);
47-
}
37+
// needed for preflight requests to succeed
38+
export const OPTIONS = async (request: NextRequest) => {
39+
return NextResponse.json({});
40+
};
4841

49-
// simpler than webhooks, but constrained by serverless function timeout time
50-
let isUploadSuccess = false;
51-
let maxTries = 10;
52-
let tries = 0;
53-
while (!isUploadSuccess && tries < maxTries) {
54-
const details = await fetch(
55-
`https://livepeer.studio/api/asset/${requestedUrl.asset.id}`,
56-
{
57-
method: "GET",
58-
headers: {
59-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
60-
"Content-Type": "application/json",
61-
},
62-
}
63-
);
64-
const detailsJson = await details.json();
42+
export const GET = async (request: NextRequest) => {
43+
let url = request.nextUrl.searchParams.get("url");
6544

66-
if (detailsJson.status !== "waiting") {
67-
break;
68-
}
45+
// Exchange for livepeer url
46+
const cid = url.replace("ipfs://", "");
47+
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;
6948

70-
// wait 1s
71-
await new Promise((resolve) => setTimeout(() => resolve(null), 1000));
72-
tries = tries + 1;
73-
}
49+
// Get HEAD to get content type
50+
const response = await fetch(gatewayUrl, { method: "HEAD" });
51+
const contentType = response.headers.get("content-type");
7452

75-
if (tries === maxTries) {
76-
return NextResponse.json(
77-
{
78-
message: "Took too long to upload. Try a smaller file",
53+
// TODO: Cache this
54+
const uploadRes = await fetch(
55+
"https://livepeer.studio/api/asset/upload/url",
56+
{
57+
method: "POST",
58+
headers: {
59+
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
60+
"Content-Type": "application/json",
7961
},
80-
{ status: 400 }
81-
);
62+
body: JSON.stringify({
63+
name: "filename.mp4",
64+
staticMp4: contentType === "video/mp4" ? true : false,
65+
playbackPolicy: {
66+
type: "public",
67+
},
68+
url: gatewayUrl,
69+
}),
70+
}
71+
);
72+
73+
if (!uploadRes.ok) {
74+
// console.error(uploadRes.status, await uploadRes.text());
75+
return NextResponse.error(uploadRes.status);
8276
}
8377

84-
// hack, wait at least 3s to make sure url doesn't error
85-
await new Promise((resolve) => setTimeout(() => resolve(null), 3000));
78+
const { asset } = await uploadRes.json();
8679

87-
return NextResponse.json({ data: requestedUrl });
88-
}
80+
const playbackUrl = `https://lp-playback.com/hls/${asset.playbackId}/index.m3u8`;
8981

90-
// needed for preflight requests to succeed
91-
export const OPTIONS = async (request: NextRequest) => {
92-
return NextResponse.json({});
82+
return NextResponse.json({
83+
url: playbackUrl,
84+
fallbackUrl: gatewayUrl,
85+
mimeType: contentType,
86+
});
9387
};
88+
89+
export const runtime = "edge";

examples/api/src/app/api/open-graph/lib/url-handlers/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import caip19 from "./caip-19";
33
import fallback from "./fallback";
44
import opensea from "./opensea";
55
import zora from "./zora";
6+
import ipfs from "./ipfs";
67

7-
const handlers: UrlHandler[] = [opensea, zora, caip19, fallback];
8+
const handlers: UrlHandler[] = [opensea, zora, caip19, ipfs, fallback];
89

910
export default handlers;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { UrlMetadata } from "@mod-protocol/core";
2+
import { UrlHandler } from "../../types/url-handler";
3+
4+
async function handleIpfsUrl(url: string): Promise<UrlMetadata | null> {
5+
const cid = url.replace("ipfs://", "");
6+
7+
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;
8+
9+
// Get HEAD only
10+
const response = await fetch(gatewayUrl, { method: "HEAD" });
11+
12+
if (!response.ok) {
13+
return null;
14+
}
15+
16+
const contentType = response.headers.get("content-type");
17+
18+
if (!contentType) {
19+
return null;
20+
}
21+
22+
// TODO: Generate thumbnail if image/video
23+
24+
const urlMetadata: UrlMetadata = {
25+
title: `IPFS ${cid}`,
26+
mimeType: contentType,
27+
};
28+
29+
return urlMetadata;
30+
}
31+
32+
const handler: UrlHandler = {
33+
matchers: ["ipfs://.*"],
34+
handler: handleIpfsUrl,
35+
};
36+
37+
export default handler;

examples/api/src/app/api/open-graph/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { NextResponse, NextRequest } from "next/server";
21
import { UrlMetadata } from "@mod-protocol/core";
2+
import { NextRequest, NextResponse } from "next/server";
33
import urlHandlers from "./lib/url-handlers";
44

55
export async function GET(request: NextRequest) {

examples/nextjs-shadcn/src/app/dummy-casts.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ export const dummyCastData: Array<{
115115
// video embed
116116
{
117117
url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8",
118+
// url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i",
118119
status: "loaded",
119-
metadata: {},
120+
metadata: {
121+
mimeType: "video/mp4",
122+
},
120123
},
121124
],
122125
},

miniapps/livepeer-video/src/upload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const upload: ModElement[] = [
1717
},
1818
onsuccess: {
1919
type: "ADDEMBED",
20-
url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
20+
// url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
21+
url: "{{refs.myFileUploadRequest.response.data.data.url}}",
2122
name: "{{refs.myOpenFileAction.files[0].name}}",
2223
mimeType: "{{refs.myOpenFileAction.files[0].mimeType}}",
2324
onsuccess: {

miniapps/video-render/src/manifest.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ const manifest: ModManifest = {
1818
},
1919
element: view,
2020
},
21+
{
22+
if: {
23+
value: "{{embed.metadata.mimeType}}",
24+
match: {
25+
startsWith: "video/",
26+
},
27+
},
28+
element: view,
29+
},
2130
],
2231
elements: {
2332
"#view": view,

packages/core/src/embeds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type UrlMetadata = {
5959
url: string;
6060
};
6161
nft?: NFTMetadata;
62+
mimeType?: string;
6263
};
6364

6465
export type Embed = {

packages/react-ui-shadcn/src/renderers/video.tsx

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useMemo, useEffect } from "react";
3+
import React, { useMemo, useEffect, useCallback } from "react";
44
import videojs from "video.js";
55
import "video.js/dist/video-js.css";
66

@@ -31,24 +31,67 @@ export const VideoRenderer = (props: PlayerProps) => {
3131
const videoRef = React.useRef<HTMLDivElement>(null);
3232
const playerRef = React.useRef<any>(null);
3333

34+
const [videoSrc, setVideoSrc] = React.useState<string | undefined>();
35+
const [overrideMimeType, setOverrideMimeType] = React.useState<
36+
string | undefined
37+
>(undefined);
38+
39+
const [hasStartedPlaying, setHasStartedPlaying] =
40+
React.useState<boolean>(false);
41+
42+
const pollUrl = useCallback(
43+
async (url: string) => {
44+
const res = await fetch(url, { method: "HEAD" });
45+
if (hasStartedPlaying) return;
46+
if (res.ok) {
47+
setVideoSrc(url);
48+
} else {
49+
setTimeout(() => {
50+
pollUrl(url);
51+
}, 1000);
52+
}
53+
},
54+
[setVideoSrc, hasStartedPlaying]
55+
);
56+
3457
const options = useMemo(
3558
() => ({
3659
...videoJSoptions,
3760
// video is not necessarily rewritten yet
3861
sources: [
3962
{
40-
src: props.videoSrc ?? "",
41-
type: props.videoSrc?.endsWith(".m3u8")
42-
? "application/x-mpegURL"
43-
: props.videoSrc?.endsWith(".mp4")
44-
? "video/mp4"
45-
: "",
63+
src: videoSrc ?? "",
64+
type:
65+
overrideMimeType ||
66+
(videoSrc?.endsWith(".m3u8")
67+
? "application/x-mpegURL"
68+
: videoSrc?.endsWith(".mp4")
69+
? "video/mp4"
70+
: ""),
4671
},
4772
],
4873
}),
49-
[props.videoSrc]
74+
[videoSrc, overrideMimeType]
5075
);
5176

77+
useEffect(() => {
78+
if (props.videoSrc.startsWith("ipfs://")) {
79+
// Exchange ipfs:// for .m3u8 url via /livepeer-video?url=ipfs://...
80+
const baseUrl = `${
81+
process.env.NEXT_PUBLIC_API_URL || "https://api.modprotocol.org"
82+
}/livepeer-video`;
83+
const endpointUrl = `${baseUrl}?url=${props.videoSrc}`;
84+
fetch(endpointUrl).then(async (res) => {
85+
const { url, fallbackUrl, mimeType } = await res.json();
86+
setOverrideMimeType(mimeType);
87+
setVideoSrc(`${fallbackUrl}`);
88+
pollUrl(url);
89+
});
90+
} else {
91+
setVideoSrc(props.videoSrc);
92+
}
93+
}, [props.videoSrc, pollUrl]);
94+
5295
useEffect(() => {
5396
// Make sure Video.js player is only initialized once
5497
if (!playerRef.current) {
@@ -61,16 +104,18 @@ export const VideoRenderer = (props: PlayerProps) => {
61104
const player = (playerRef.current = videojs(videoElement, options, () => {
62105
videojs.log("player is ready");
63106
}));
64-
65107
// You could update an existing player in the `else` block here
66108
// on prop change, for example:
67109
} else {
68110
const player = playerRef.current;
69111

70112
player.autoplay(options.autoplay);
71113
player.src(options.sources);
114+
player.on("play", () => {
115+
setHasStartedPlaying(true);
116+
});
72117
}
73-
}, [options, videoRef, props]);
118+
}, [options, videoRef, videoSrc]);
74119

75120
// Dispose the Video.js player when the functional component unmounts
76121
useEffect(() => {

turbo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"GIPHY_API_KEY",
2727
"INFURA_API_KEY",
2828
"INFURA_API_SECRET",
29+
"IPFS_DEFAULT_GATEWAY",
2930
"LIVEPEER_API_SECRET",
3031
"NEXT_PUBLIC_API_URL",
3132
"OPENSEA_API_KEY",

0 commit comments

Comments
 (0)