From 87b11d6f34cab35b2445cb8ae78d3517bb63fd8e Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:12:31 +0200 Subject: [PATCH 1/5] feat: handle videos linked via IPFS --- .changeset/bright-timers-roll.md | 9 ++ .changeset/great-nails-deny.md | 5 + examples/api/.env.example | 2 + .../api/src/app/api/livepeer-video/route.ts | 134 +++++++++--------- .../api/open-graph/lib/url-handlers/index.ts | 2 + .../api/open-graph/lib/url-handlers/ipfs.ts | 37 +++++ examples/nextjs-shadcn/src/app/dummy-casts.ts | 5 +- mods/livepeer-video/src/upload.ts | 3 +- mods/video-render/src/manifest.ts | 9 ++ .../react-ui-shadcn/src/renderers/video.tsx | 64 +++++++-- turbo.json | 1 + yarn.lock | 5 + 12 files changed, 196 insertions(+), 80 deletions(-) create mode 100644 .changeset/bright-timers-roll.md create mode 100644 .changeset/great-nails-deny.md create mode 100644 examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts diff --git a/.changeset/bright-timers-roll.md b/.changeset/bright-timers-roll.md new file mode 100644 index 00000000..163b301d --- /dev/null +++ b/.changeset/bright-timers-roll.md @@ -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 diff --git a/.changeset/great-nails-deny.md b/.changeset/great-nails-deny.md new file mode 100644 index 00000000..dc9276d3 --- /dev/null +++ b/.changeset/great-nails-deny.md @@ -0,0 +1,5 @@ +--- +"@mod-protocol/core": minor +--- + +feat: add mimeType to UrlMetadata type diff --git a/examples/api/.env.example b/examples/api/.env.example index 11526295..914ff082 100644 --- a/examples/api/.env.example +++ b/examples/api/.env.example @@ -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" diff --git a/examples/api/src/app/api/livepeer-video/route.ts b/examples/api/src/app/api/livepeer-video/route.ts index bbeaf623..c317985e 100644 --- a/examples/api/src/app/api/livepeer-video/route.ts +++ b/examples/api/src/app/api/livepeer-video/route.ts @@ -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"; diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/index.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/index.ts index 923dd829..2994a855 100644 --- a/examples/api/src/app/api/open-graph/lib/url-handlers/index.ts +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/index.ts @@ -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"; @@ -13,6 +14,7 @@ const handlers: UrlHandler[] = [ zora, caip19, imageFileUrl, + ipfs, localFetch, metascraper, ]; diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts new file mode 100644 index 00000000..08458c2f --- /dev/null +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts @@ -0,0 +1,37 @@ +import { UrlMetadata } from "@mod-protocol/core"; +import { UrlHandler } from "../../types/url-handler"; + +async function handleIpfsUrl(url: string): Promise { + 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; diff --git a/examples/nextjs-shadcn/src/app/dummy-casts.ts b/examples/nextjs-shadcn/src/app/dummy-casts.ts index 3968f8f1..5baf9e51 100644 --- a/examples/nextjs-shadcn/src/app/dummy-casts.ts +++ b/examples/nextjs-shadcn/src/app/dummy-casts.ts @@ -114,8 +114,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", + }, }, ], }, diff --git a/mods/livepeer-video/src/upload.ts b/mods/livepeer-video/src/upload.ts index feb11fbc..9616d778 100644 --- a/mods/livepeer-video/src/upload.ts +++ b/mods/livepeer-video/src/upload.ts @@ -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: { diff --git a/mods/video-render/src/manifest.ts b/mods/video-render/src/manifest.ts index aafe82f3..9b877413 100644 --- a/mods/video-render/src/manifest.ts +++ b/mods/video-render/src/manifest.ts @@ -18,6 +18,15 @@ const manifest: ModManifest = { }, element: view, }, + { + if: { + value: "{{embed.metadata.mimeType}}", + match: { + startsWith: "video/", + }, + }, + element: view, + }, ], elements: { "#view": view, diff --git a/packages/react-ui-shadcn/src/renderers/video.tsx b/packages/react-ui-shadcn/src/renderers/video.tsx index e8db6d5a..04abcdf1 100644 --- a/packages/react-ui-shadcn/src/renderers/video.tsx +++ b/packages/react-ui-shadcn/src/renderers/video.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useEffect } from "react"; +import React, { useMemo, useEffect, useCallback } from "react"; import videojs from "video.js"; interface PlayerProps { @@ -30,24 +30,67 @@ export const VideoRenderer = (props: PlayerProps) => { const videoRef = React.useRef(null); const playerRef = React.useRef(null); + const [videoSrc, setVideoSrc] = React.useState(); + const [overrideMimeType, setOverrideMimeType] = React.useState< + string | undefined + >(undefined); + + const [hasStartedPlaying, setHasStartedPlaying] = + React.useState(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) { @@ -63,8 +106,11 @@ export const VideoRenderer = (props: PlayerProps) => { 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(() => { diff --git a/turbo.json b/turbo.json index 4d0611b9..2d77e3fb 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,7 @@ "GIPHY_API_KEY", "INFURA_API_KEY", "INFURA_API_SECRET", + "IPFS_DEFAULT_GATEWAY", "LIVEPEER_API_SECRET", "NEXT_PUBLIC_API_URL", "OPENSEA_API_KEY", diff --git a/yarn.lock b/yarn.lock index 8423fbd0..39d29819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4233,6 +4233,11 @@ resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== +"@types/long@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" + integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== + "@types/mdast@^3.0.0": version "3.0.15" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" From f626dfd722558e39fd787694af186436f41f4d39 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Thu, 7 Dec 2023 10:44:38 +0200 Subject: [PATCH 2/5] fix: yarn.lock --- yarn.lock | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/yarn.lock b/yarn.lock index 39d29819..a5443c5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4233,11 +4233,6 @@ resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== -"@types/long@^4.0.1": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" - integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== - "@types/mdast@^3.0.0": version "3.0.15" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" @@ -12418,7 +12413,7 @@ minimatch@^9.0.0: dependencies: brace-expansion "^2.0.1" -minimist-options@4.1.0, minimist-options@^4.0.2: +minimist-options@^4.0.2: version "4.1.0" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A== @@ -12848,7 +12843,7 @@ normalize-package-data@^2.5.0: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-package-data@^3.0.0, normalize-package-data@^3.0.2: +normalize-package-data@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e" integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA== @@ -16042,11 +16037,6 @@ type-fest@^0.13.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== -type-fest@^0.18.0: - version "0.18.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" - integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== - type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -16994,11 +16984,6 @@ yargs-parser@^18.1.2, yargs-parser@^18.1.3: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.3: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" From 6b6d9f4602554ec6cc05e2e4d31d163fa22029ba Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:17:07 +0200 Subject: [PATCH 3/5] feat: replaceInlineContext commands: split, index --- jest.config.ts | 21 +++++++++++++++++++++ package.json | 2 ++ packages/core/src/renderer.test.ts | 15 +++++++++++++++ packages/core/src/renderer.ts | 28 ++++++++++++++++++++++++++-- yarn.lock | 8 ++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 jest.config.ts create mode 100644 packages/core/src/renderer.test.ts diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..632efcbd --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from 'jest'; + +const jestConfig: Config = { + testEnvironment: 'node', + moduleNameMapper: { + '^~/(.*)$': '/src/$1', + '^(.+)_generated.js$': '$1_generated', // Support flatc generated files + }, + coveragePathIgnorePatterns: ['/build/', '/node_modules/'], + testPathIgnorePatterns: ['/build', '/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; diff --git a/package.json b/package.json index aa89398b..bac3c86d 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "devDependencies": { "@changesets/cli": "2.26.2", "@turbo/gen": "^1.9.7", + "@types/jest": "^29.5.11", "eslint": "^8.46.0", "eslint-config-custom": "*", + "jest": "^29.7.0", "prettier": "^2.5.1", "react": "^18.2.0", "tsup": "^7.2.0", diff --git a/packages/core/src/renderer.test.ts b/packages/core/src/renderer.test.ts new file mode 100644 index 00000000..ce5ab2f4 --- /dev/null +++ b/packages/core/src/renderer.test.ts @@ -0,0 +1,15 @@ +import { replaceInlineContext } from "./renderer"; // Adjust the import path as needed + +describe("replaceInlineContext", () => { + it("replaces simple path without operation", () => { + const context = { user: { name: "Jane Doe" } }; + const template = "Hello {{user.name}}"; + expect(replaceInlineContext(template, context)).toBe("Hello Jane Doe"); + }); + + it("applies split and index operations to extract part of a string", () => { + const context = { refs: { example: { url: "ipfs://exampleCID" } } }; + const template = "{{refs.example.url | split ipfs:// | index 1}}"; + expect(replaceInlineContext(template, context)).toBe("exampleCID"); + }); +}); diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index f7a0d885..b576b4d0 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -316,8 +316,32 @@ export interface ExitActionResolver { (): void; } -function replaceInlineContext(target: string, context: any): string { - return target.replace(/{{([^{{}}]+)}}/g, (_, key) => get(context, key, ``)); +function executeCommand(input: string, command: string) { + const [cmd, ...args] = command.split(" "); + switch (cmd) { + case "split": + return input.split(args[0]); + case "index": + return input[parseInt(args[0], 10)]; + default: + return input; + } +} + +export function replaceInlineContext(target: string, context: any): string { + return target.replace(/{{([^{{}}]+)}}/g, (_, key) => { + const parts = key.split("|").map((part: string) => part.trim()); + let value = get(context, parts[0], ""); + + // Process additional commands if they exist + if (parts.length > 1) { + for (let i = 1; i < parts.length; i++) { + value = executeCommand(value, parts[i]); + } + } + + return value; + }); } function matchesOp(value: string, op: Op, context: any): boolean { diff --git a/yarn.lock b/yarn.lock index a5443c5d..0671c55c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4147,6 +4147,14 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/jest@^29.5.11": + version "29.5.11" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.11.tgz#0c13aa0da7d0929f078ab080ae5d4ced80fa2f2c" + integrity sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/js-yaml@^4.0.0": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" From ed0f0d0980e1cd829dfc8fbd0d0ad9b19b596ce3 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:17:33 +0200 Subject: [PATCH 4/5] chore: refactor ipfs video player into mod --- .../app/api/livepeer-video/[assetId]/route.ts | 68 +++++++++++++++++++ .../api/src/app/api/livepeer-video/route.ts | 4 +- .../api/open-graph/lib/url-handlers/ipfs.ts | 1 + examples/nextjs-shadcn/src/app/dummy-casts.ts | 4 +- mods/video-render/src/view.ts | 63 ++++++++++++++++- packages/core/src/manifest.ts | 10 ++- packages/core/src/renderer.ts | 25 ++++++- .../react-ui-shadcn/src/renderers/video.tsx | 41 ++--------- 8 files changed, 171 insertions(+), 45 deletions(-) create mode 100644 examples/api/src/app/api/livepeer-video/[assetId]/route.ts diff --git a/examples/api/src/app/api/livepeer-video/[assetId]/route.ts b/examples/api/src/app/api/livepeer-video/[assetId]/route.ts new file mode 100644 index 00000000..677d96f2 --- /dev/null +++ b/examples/api/src/app/api/livepeer-video/[assetId]/route.ts @@ -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"; diff --git a/examples/api/src/app/api/livepeer-video/route.ts b/examples/api/src/app/api/livepeer-video/route.ts index c317985e..c31567a9 100644 --- a/examples/api/src/app/api/livepeer-video/route.ts +++ b/examples/api/src/app/api/livepeer-video/route.ts @@ -77,10 +77,8 @@ export const GET = async (request: NextRequest) => { const { asset } = await uploadRes.json(); - const playbackUrl = `https://lp-playback.com/hls/${asset.playbackId}/index.m3u8`; - return NextResponse.json({ - url: playbackUrl, + id: asset.id, fallbackUrl: gatewayUrl, mimeType: contentType, }); diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts index 08458c2f..788cbd9b 100644 --- a/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/ipfs.ts @@ -30,6 +30,7 @@ async function handleIpfsUrl(url: string): Promise { } const handler: UrlHandler = { + name: "IPFS", matchers: ["ipfs://.*"], handler: handleIpfsUrl, }; diff --git a/examples/nextjs-shadcn/src/app/dummy-casts.ts b/examples/nextjs-shadcn/src/app/dummy-casts.ts index 5baf9e51..7577fa6d 100644 --- a/examples/nextjs-shadcn/src/app/dummy-casts.ts +++ b/examples/nextjs-shadcn/src/app/dummy-casts.ts @@ -113,8 +113,8 @@ export const dummyCastData: Array<{ embeds: [ // video embed { - url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8", - // url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i", + // url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8", + url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i", status: "loaded", metadata: { mimeType: "video/mp4", diff --git a/mods/video-render/src/view.ts b/mods/video-render/src/view.ts index 535a2283..bf3a264b 100644 --- a/mods/video-render/src/view.ts +++ b/mods/video-render/src/view.ts @@ -2,8 +2,67 @@ import { ModElement } from "@mod-protocol/core"; const view: ModElement[] = [ { - type: "video", - videoSrc: "{{embed.url}}", + type: "vertical-layout", + elements: [ + { + if: { + value: "{{embed.url}}", + match: { + startsWith: "ipfs://", + }, + }, + then: { + type: "vertical-layout", + elements: [ + { + if: { + value: "{{refs.transcodedResponse.response.data.url}}", + match: { + NOT: { + equals: "", + }, + }, + }, + then: { + type: "video", + videoSrc: "{{refs.transcodedResponse.response.data.url}}", + // .m3u8 + mimeType: "application/x-mpegURL", + }, + else: { + type: "vertical-layout", + elements: [ + { + type: "video", + videoSrc: + "https://cloudflare-ipfs.com/ipfs/{{embed.url | split ipfs:// | index 1}}", + mimeType: "{{embed.metadata.mimeType}}", + }, + { + type: "button", + label: "Load stream", + onclick: { + type: "GET", + url: "{{api}}/livepeer-video", + searchParams: { + url: "{{embed.url}}", + }, + ref: "transcodingResponse", + onsuccess: { + type: "GET", + url: "{{api}}/livepeer-video/{{refs.transcodingResponse.response.data.id}}", + ref: "transcodedResponse", + retryTimeout: 1000, + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], }, ]; diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 3e47b32c..5bc6fff5 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -89,7 +89,14 @@ type HTTPBody = formData: Record; }; -export type HTTPAction = BaseAction & { url: string } & ( +export type HTTPAction = BaseAction & { + url: string; + retryTimeout?: number; + retryCount?: number; +} & ( + | { + type: "HEAD"; + } | { type: "GET"; searchParams?: Record; @@ -240,6 +247,7 @@ export type ModElement = | { type: "video"; videoSrc: string; + mimeType?: string; } | { type: "tabs"; diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index b576b4d0..d4db9598 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -31,6 +31,7 @@ export type ModElementRef = | { type: "video"; videoSrc: string; + mimeType?: string; } | { type: "link"; @@ -628,6 +629,7 @@ export class Renderer { case "POST": case "PUT": case "PATCH": + case "HEAD": case "DELETE": { const options = this.constructHttpAction(action); @@ -677,8 +679,26 @@ export class Renderer { } if (action.ref) { - set(this.refs, action.ref, { error }); + const actionRef = get(this.refs, action.ref); + const retries = actionRef?._retries || 0; + set(this.refs, action.ref, { + ...actionRef, + error, + _retries: retries + 1, + }); this.onTreeChange(); + + if (action.retryTimeout) { + if ( + action.retryCount !== undefined + ? retries < action.retryCount + : true + ) { + setTimeout(() => { + this.stepIntoOrTriggerAction(action); + }, action.retryTimeout); + } + } } this.asyncAction = null; @@ -1213,6 +1233,9 @@ export class Renderer { { type: "video", videoSrc: this.replaceInlineContext(el.videoSrc), + mimeType: el.mimeType + ? this.replaceInlineContext(el.mimeType) + : undefined, }, key ); diff --git a/packages/react-ui-shadcn/src/renderers/video.tsx b/packages/react-ui-shadcn/src/renderers/video.tsx index 04abcdf1..086b3f96 100644 --- a/packages/react-ui-shadcn/src/renderers/video.tsx +++ b/packages/react-ui-shadcn/src/renderers/video.tsx @@ -5,6 +5,7 @@ import videojs from "video.js"; interface PlayerProps { videoSrc: string; + mimeType?: string; } const videoJSoptions: { @@ -31,28 +32,10 @@ export const VideoRenderer = (props: PlayerProps) => { const playerRef = React.useRef(null); const [videoSrc, setVideoSrc] = React.useState(); - const [overrideMimeType, setOverrideMimeType] = React.useState< - string | undefined - >(undefined); const [hasStartedPlaying, setHasStartedPlaying] = React.useState(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, @@ -61,7 +44,7 @@ export const VideoRenderer = (props: PlayerProps) => { { src: videoSrc ?? "", type: - overrideMimeType || + props.mimeType || (videoSrc?.endsWith(".m3u8") ? "application/x-mpegURL" : videoSrc?.endsWith(".mp4") @@ -70,26 +53,12 @@ export const VideoRenderer = (props: PlayerProps) => { }, ], }), - [videoSrc, overrideMimeType] + [videoSrc, props.mimeType] ); 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]); + setVideoSrc(props.videoSrc); + }, [props.videoSrc]); useEffect(() => { // Make sure Video.js player is only initialized once From 38d94200438791e4d535a7e285898bd9aea9893b Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Tue, 12 Dec 2023 16:35:35 +0200 Subject: [PATCH 5/5] fix: pass raw ipfs cid to livepeer api --- examples/api/src/app/api/livepeer-video/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/api/src/app/api/livepeer-video/route.ts b/examples/api/src/app/api/livepeer-video/route.ts index c31567a9..d8a74083 100644 --- a/examples/api/src/app/api/livepeer-video/route.ts +++ b/examples/api/src/app/api/livepeer-video/route.ts @@ -65,7 +65,7 @@ export const GET = async (request: NextRequest) => { playbackPolicy: { type: "public", }, - url: gatewayUrl, + url: url, }), } );