Skip to content

Commit

Permalink
feat: handle videos linked via IPFS
Browse files Browse the repository at this point in the history
  • Loading branch information
stephancill committed Oct 16, 2023
1 parent a07a7ff commit 0d85d8e
Show file tree
Hide file tree
Showing 14 changed files with 528 additions and 87 deletions.
9 changes: 9 additions & 0 deletions .changeset/bright-timers-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@mod-protocol/react-ui-shadcn": minor
"@miniapps/livepeer-video": minor
"web": minor
"@miniapps/video-render": minor
"api": minor
---

feat: support videos linked via IPFS
5 changes: 5 additions & 0 deletions .changeset/great-nails-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mod-protocol/core": minor
---

feat: add mimeType to UrlMetadata type
2 changes: 2 additions & 0 deletions examples/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Needs to be an IPFS api key
INFURA_API_KEY="REQUIRED"
INFURA_API_SECRET="REQUIRED"
IPFS_DEFAULT_GATEWAY="REQUIRED"
MICROLINK_API_KEY="REQUIRED"
GIPHY_API_KEY="REQUIRED"
MICROLINK_API_KEY="REQUIRED"
OPENSEA_API_KEY="REQUIRED"
Expand Down
134 changes: 65 additions & 69 deletions examples/api/src/app/api/livepeer-video/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,88 @@ import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
const form = await request.formData();
// https://docs.livepeer.org/reference/api#upload-an-asset
const requestedUrlReq = await fetch(
"https://livepeer.studio/api/asset/request-upload",

const controller = new AbortController();
const signal = controller.signal;

// Cancel upload if it takes longer than 15s
setTimeout(() => {
controller.abort();
}, 15_000);

const uploadRes: Response | null = await fetch(
"https://ipfs.infura.io:5001/api/v0/add",
{
method: "POST",
body: form,
headers: {
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
"Content-Type": "application/json",
Authorization:
"Basic " +
Buffer.from(
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET
).toString("base64"),
},
body: JSON.stringify({
name: "video.mp4",
staticMp4: true,
playbackPolicy: {
type: "public",
},
storage: {
ipfs: true,
},
}),
signal,
}
);

const requestedUrl = await requestedUrlReq.json();
const { Hash: hash } = await uploadRes.json();

const url = requestedUrl.url;
const responseData = { url: `ipfs://${hash}` };

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

if (videoUpload.status >= 400) {
return NextResponse.json(
{ message: "Something went wrong" },
{
status: videoUpload.status,
}
);
}
// needed for preflight requests to succeed
export const OPTIONS = async (request: NextRequest) => {
return NextResponse.json({});
};

// simpler than webhooks, but constrained by serverless function timeout time
let isUploadSuccess = false;
let maxTries = 10;
let tries = 0;
while (!isUploadSuccess && tries < maxTries) {
const details = await fetch(
`https://livepeer.studio/api/asset/${requestedUrl.asset.id}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
"Content-Type": "application/json",
},
}
);
const detailsJson = await details.json();
export const GET = async (request: NextRequest) => {
let url = request.nextUrl.searchParams.get("url");

if (detailsJson.status !== "waiting") {
break;
}
// Exchange for livepeer url
const cid = url.replace("ipfs://", "");
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;

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

if (tries === maxTries) {
return NextResponse.json(
{
message: "Took too long to upload. Try a smaller file",
// TODO: Cache this
const uploadRes = await fetch(
"https://livepeer.studio/api/asset/upload/url",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
"Content-Type": "application/json",
},
{ status: 400 }
);
body: JSON.stringify({
name: "filename.mp4",
staticMp4: contentType === "video/mp4" ? true : false,
playbackPolicy: {
type: "public",
},
url: gatewayUrl,
}),
}
);

if (!uploadRes.ok) {
// console.error(uploadRes.status, await uploadRes.text());
return NextResponse.error();
}

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

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

// needed for preflight requests to succeed
export const OPTIONS = async (request: NextRequest) => {
return NextResponse.json({});
return NextResponse.json({
url: playbackUrl,
fallbackUrl: gatewayUrl,
mimeType: contentType,
});
};

export const runtime = "edge";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import caip19 from "./caip-19";
import fallback from "./fallback";
import opensea from "./opensea";
import zora from "./zora";
import ipfs from "./ipfs";

const handlers: UrlHandler[] = [opensea, zora, caip19, fallback];
const handlers: UrlHandler[] = [opensea, zora, caip19, ipfs, fallback];

export default handlers;
37 changes: 37 additions & 0 deletions examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { UrlMetadata } from "@mod-protocol/core";
import { UrlHandler } from "../../types/url-handler";

async function handleIpfsUrl(url: string): Promise<UrlMetadata | null> {
const cid = url.replace("ipfs://", "");

const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;

// Get HEAD only
const response = await fetch(gatewayUrl, { method: "HEAD" });

if (!response.ok) {
return null;
}

const contentType = response.headers.get("content-type");

if (!contentType) {
return null;
}

// TODO: Generate thumbnail if image/video

const urlMetadata: UrlMetadata = {
title: `IPFS ${cid}`,
mimeType: contentType,
};

return urlMetadata;
}

const handler: UrlHandler = {
matchers: ["ipfs://.*"],
handler: handleIpfsUrl,
};

export default handler;
2 changes: 1 addition & 1 deletion examples/api/src/app/api/open-graph/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse, NextRequest } from "next/server";
import { UrlMetadata } from "@mod-protocol/core";
import { NextRequest, NextResponse } from "next/server";
import urlHandlers from "./lib/url-handlers";

export async function GET(request: NextRequest) {
Expand Down
5 changes: 4 additions & 1 deletion examples/nextjs-shadcn/src/app/dummy-casts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ export const dummyCastData: Array<{
// video embed
{
url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8",
// url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i",
status: "loaded",
metadata: {},
metadata: {
mimeType: "video/mp4",
},
},
],
},
Expand Down
3 changes: 2 additions & 1 deletion miniapps/livepeer-video/src/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const upload: ModElement[] = [
},
onsuccess: {
type: "ADDEMBED",
url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
// url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
url: "{{refs.myFileUploadRequest.response.data.data.url}}",
name: "{{refs.myOpenFileAction.files[0].name}}",
mimeType: "{{refs.myOpenFileAction.files[0].mimeType}}",
onsuccess: {
Expand Down
9 changes: 9 additions & 0 deletions miniapps/video-render/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ const manifest: ModManifest = {
},
element: view,
},
{
if: {
value: "{{embed.metadata.mimeType}}",
match: {
startsWith: "video/",
},
},
element: view,
},
],
elements: {
"#view": view,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/embeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type UrlMetadata = {
url: string;
};
nft?: NFTMetadata;
mimeType?: string;
};

export type Embed = {
Expand Down
65 changes: 55 additions & 10 deletions packages/react-ui-shadcn/src/renderers/video.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

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

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

const [videoSrc, setVideoSrc] = React.useState<string | undefined>();
const [overrideMimeType, setOverrideMimeType] = React.useState<
string | undefined
>(undefined);

const [hasStartedPlaying, setHasStartedPlaying] =
React.useState<boolean>(false);

const pollUrl = useCallback(
async (url: string) => {
const res = await fetch(url, { method: "HEAD" });
if (hasStartedPlaying) return;
if (res.ok) {
setVideoSrc(url);
} else {
setTimeout(() => {
pollUrl(url);
}, 1000);
}
},
[setVideoSrc, hasStartedPlaying]
);

const options = useMemo(
() => ({
...videoJSoptions,
// video is not necessarily rewritten yet
sources: [
{
src: props.videoSrc ?? "",
type: props.videoSrc?.endsWith(".m3u8")
? "application/x-mpegURL"
: props.videoSrc?.endsWith(".mp4")
? "video/mp4"
: "",
src: videoSrc ?? "",
type:
overrideMimeType ||
(videoSrc?.endsWith(".m3u8")
? "application/x-mpegURL"
: videoSrc?.endsWith(".mp4")
? "video/mp4"
: ""),
},
],
}),
[props.videoSrc]
[videoSrc, overrideMimeType]
);

useEffect(() => {
if (props.videoSrc.startsWith("ipfs://")) {
// Exchange ipfs:// for .m3u8 url via /livepeer-video?url=ipfs://...
const baseUrl = `${
process.env.NEXT_PUBLIC_API_URL || "https://api.modprotocol.org"
}/livepeer-video`;
const endpointUrl = `${baseUrl}?url=${props.videoSrc}`;
fetch(endpointUrl).then(async (res) => {
const { url, fallbackUrl, mimeType } = await res.json();
setOverrideMimeType(mimeType);
setVideoSrc(`${fallbackUrl}`);
pollUrl(url);
});
} else {
setVideoSrc(props.videoSrc);
}
}, [props.videoSrc, pollUrl]);

useEffect(() => {
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
Expand All @@ -61,16 +104,18 @@ export const VideoRenderer = (props: PlayerProps) => {
const player = (playerRef.current = videojs(videoElement, options, () => {
videojs.log("player is ready");
}));

// You could update an existing player in the `else` block here
// on prop change, for example:
} else {
const player = playerRef.current;

player.autoplay(options.autoplay);
player.src(options.sources);
player.on("play", () => {
setHasStartedPlaying(true);
});
}
}, [options, videoRef, props]);
}, [options, videoRef, videoSrc]);

// Dispose the Video.js player when the functional component unmounts
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"GIPHY_API_KEY",
"INFURA_API_KEY",
"INFURA_API_SECRET",
"IPFS_DEFAULT_GATEWAY",
"LIVEPEER_API_SECRET",
"NEXT_PUBLIC_API_URL",
"OPENSEA_API_KEY",
Expand Down
Loading

0 comments on commit 0d85d8e

Please sign in to comment.