Skip to content

feat: IPFS-based video support #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
68 changes: 68 additions & 0 deletions examples/api/src/app/api/livepeer-video/[assetId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
const form = await request.formData();

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:
"Basic " +
Buffer.from(
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET
).toString("base64"),
},
signal,
}
);

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

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

return NextResponse.json({ data: responseData });
}

// needed for preflight requests to succeed
export const OPTIONS = async (request: NextRequest) => {
return NextResponse.json({});
};

export const GET = async (
req: NextRequest,
{ params }: { params: { assetId: string } }
) => {
const assetRequest = await fetch(
`https://livepeer.studio/api/asset/${params.assetId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
},
}
);

const assetResponseJson = await assetRequest.json();
const { playbackUrl } = assetResponseJson;

if (!playbackUrl) {
return NextResponse.json({}, { status: 404 });
}

return NextResponse.json({
url: playbackUrl,
});
};

export const runtime = "edge";
134 changes: 64 additions & 70 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,86 @@ 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}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried it, still no caching

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok will try emailing them again

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephancill can you try ipfs://{CID}?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting - it uploads fine without specifying a gateway, but still no caching (returns a different asset id each time it uploads)


// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think livepeer automatically caches this - if you request via an IPFS cid it doesn't re-upload if it's been uploaded to livepeer previously. Worth confirming we're doing it correctly to get this benefit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticing that their API is returning a different playbackId for each request. Can we check with them?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok will do

Copy link
Contributor

@davidfurlong davidfurlong Oct 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could it be this:

For IPFS HTTP gateway URLs, the API currently only supports
 “path style” URLs and does not support “subdomain style” URLs. The API will support both styles of URLs in a future update.

https://docs.livepeer.org/reference/api#upload-an-asset

I think the IPFS url uses our subdomain gateway as it stands

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I haven't asked them yet)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try upload to web3.storage instead of infura for our gateway

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: url,
}),
}
);

// hack, wait at least 3s to make sure url doesn't error
await new Promise((resolve) => setTimeout(() => resolve(null), 3000));
if (!uploadRes.ok) {
// console.error(uploadRes.status, await uploadRes.text());
return NextResponse.error();
}

return NextResponse.json({ data: requestedUrl });
}
const { asset } = await uploadRes.json();

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

export const runtime = "edge";
2 changes: 2 additions & 0 deletions examples/api/src/app/api/open-graph/lib/url-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import opensea from "./opensea";
import zora from "./zora";
import zoraPremint from "./zora-premint";
import imageFileUrl from "./image-file";
import ipfs from "./ipfs";
import metascraper from "./metascraper";
import localFetch from "./local-fetch";

Expand All @@ -13,6 +14,7 @@ const handlers: UrlHandler[] = [
zora,
caip19,
imageFileUrl,
ipfs,
localFetch,
metascraper,
];
Expand Down
38 changes: 38 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,38 @@
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice to include this


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

return urlMetadata;
}

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

export default handler;
7 changes: 5 additions & 2 deletions examples/nextjs-shadcn/src/app/dummy-casts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ export const dummyCastData: Array<{
embeds: [
// video embed
{
url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8",
// url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8",
url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i",
status: "loaded",
metadata: {},
metadata: {
mimeType: "video/mp4",
},
},
],
},
Expand Down
21 changes: 21 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Config } from 'jest';

const jestConfig: Config = {
testEnvironment: 'node',
moduleNameMapper: {
'^~/(.*)$': '<rootDir>/src/$1',
'^(.+)_generated.js$': '$1_generated', // Support flatc generated files
},
coveragePathIgnorePatterns: ['<rootDir>/build/', '<rootDir>/node_modules/'],
testPathIgnorePatterns: ['<rootDir>/build', '<rootDir>/node_modules'],
extensionsToTreatAsEsm: ['.ts'],
/**
* For high performance with minimal configuration transform with TS with swc.
* @see https://github.com/farcasterxyz/hubble/issues/314
*/
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
};

export default jestConfig;
3 changes: 2 additions & 1 deletion mods/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 mods/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
Loading