From ff7ea2b2e268674e2cbb9c7ed698efe9caaacee5 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 23 Mar 2026 12:35:44 +0200 Subject: [PATCH 1/7] HTML Arweave rendering Signed-off-by: prxt6529 --- .../nft-image/utils/animation-source.test.ts | 14 ++++ components/nft-image/NFTModel.tsx | 2 + .../nft-image/renderers/NFTHTMLRenderer.tsx | 56 +++++++++++++- .../nft-image/utils/animation-source.ts | 13 ++-- .../nft-image/utils/arweave-fallback.ts | 77 +++++++++++++++---- .../waves/memes/MemesArtSubmissionFile.tsx | 4 +- .../memes/submission/constants/security.ts | 18 ++--- .../hooks/useArtworkSubmissionForm.ts | 3 +- config/nextConfig.ts | 7 +- config/securityHeaders.ts | 6 +- lib/media/arweave-gateways.ts | 70 +++++++++++++++++ 11 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 lib/media/arweave-gateways.ts diff --git a/__tests__/components/nft-image/utils/animation-source.test.ts b/__tests__/components/nft-image/utils/animation-source.test.ts index 499f2f26be..a3da47512d 100644 --- a/__tests__/components/nft-image/utils/animation-source.test.ts +++ b/__tests__/components/nft-image/utils/animation-source.test.ts @@ -88,6 +88,20 @@ describe("getResolvedAnimationSrc", () => { ); }); + it("uses metadata animation fields for HTML, not top-level animation", () => { + const nft = createMockNFT({ + animation: "https://example.com/processed-or-proxy.html", + metadata: { + animation_details: { format: "html" }, + animation_url: "https://example.com/original.html", + }, + }); + + expect(getResolvedAnimationSrc(nft)).toBe( + "https://example.com/original.html" + ); + }); + it("returns undefined when every candidate is empty or invalid", () => { const nft = createMockNFT({ animation: " ", diff --git a/components/nft-image/NFTModel.tsx b/components/nft-image/NFTModel.tsx index 64d38af947..4dea6fd07e 100644 --- a/components/nft-image/NFTModel.tsx +++ b/components/nft-image/NFTModel.tsx @@ -1,6 +1,7 @@ import "@google/model-viewer"; import type { BaseNFT } from "@/entities/INFT"; import { getResolvedAnimationSrc } from "./utils/animation-source"; +import { withArweaveFallback } from "./utils/arweave-fallback"; export default function NFTModel( props: Readonly<{ nft: BaseNFT; id?: string | undefined }> @@ -14,6 +15,7 @@ export default function NFTModel( auto-rotate camera-controls ar + onError={withArweaveFallback()} // @ts-ignore poster={props.nft.scaled} > diff --git a/components/nft-image/renderers/NFTHTMLRenderer.tsx b/components/nft-image/renderers/NFTHTMLRenderer.tsx index f01ece3bd6..03cdbaa31d 100644 --- a/components/nft-image/renderers/NFTHTMLRenderer.tsx +++ b/components/nft-image/renderers/NFTHTMLRenderer.tsx @@ -1,10 +1,14 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { Col } from "react-bootstrap"; import styles from "../NFTImage.module.scss"; import NFTImageBalance from "../NFTImageBalance"; import type { BaseRendererProps } from "../types/renderer-props"; import { getResolvedAnimationSrc } from "../utils/animation-source"; +import { getArweaveGatewayFallbackUrls } from "../utils/arweave-fallback"; + +const IFRAME_FALLBACK_TIMEOUT_MS = 8000; function getSrc(nft: BaseRendererProps["nft"]): string | undefined { return getResolvedAnimationSrc(nft); @@ -13,6 +17,51 @@ function getSrc(nft: BaseRendererProps["nft"]): string | undefined { export default function NFTHTMLRenderer(props: Readonly) { const src = getSrc(props.nft); const animationClassName = styles["nftAnimation"] ?? ""; + const urls = useMemo( + () => (src ? getArweaveGatewayFallbackUrls(src) : []), + [src] + ); + const [activeIndex, setActiveIndex] = useState(0); + const [didLoadCurrentUrl, setDidLoadCurrentUrl] = useState(false); + const activeUrl = urls[activeIndex]; + + useEffect(() => { + if (urls.length === 0) { + setActiveIndex(0); + setDidLoadCurrentUrl(false); + return; + } + setActiveIndex(0); + setDidLoadCurrentUrl(false); + }, [urls]); + + useEffect(() => { + setDidLoadCurrentUrl(false); + }, [activeUrl]); + + useEffect(() => { + if (!activeUrl || didLoadCurrentUrl || activeIndex + 1 >= urls.length) { + return; + } + + const timeoutId = window.setTimeout(() => { + setActiveIndex((current) => + current === activeIndex && current + 1 < urls.length + ? current + 1 + : current + ); + }, IFRAME_FALLBACK_TIMEOUT_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [activeIndex, activeUrl, didLoadCurrentUrl, urls.length]); + + const advanceToNextUrl = () => { + setActiveIndex((current) => + current + 1 < urls.length ? current + 1 : current + ); + }; return ( ) { )}