🎥 Videography & Film · previsualization

Storyboard Stamp

Mint storyboards as NFT tokens to secure creative vision and enable easy sharing with teams.

NFT provenance mint· onchain authorship
Section · Onchain

The primitive.

full primer →

Videographers mint each previsualization as an ERC-721 token on BOB Mainnet pointing at an IPFS CID, so authorship and timestamp are provable from a single BOB Explorer link.

Why this primitiveNFT minting onchain timestamps storyboards with IPFS CIDs, verifying original creative concepts.

Kernel
an ERC-721 contract on BOB Sepolia that mints a creator-owned token pointing at an IPFS CID, verified on BOB Sepolia Explorer
Drives the UI as
a 'mint to claim authorship' button that returns the tokenId, owner address, and BOB Sepolia Explorer link
Appendix · Secrets

Required keys.

METAMASK_PRIVATE_KEY
Exported from MetaMask. Fund on BOB Mainnet via the Google Cloud faucet.
open ↗
ROOTSTOCK_TESTNET_RPC_URL
Alchemy BOB Mainnet HTTPS endpoint. Create a free app → copy the HTTPS URL.
open ↗
ROOTSTOCK_EXPLORER_API_KEY
Required for npx hardhat verify after deploy.
open ↗
PRIVY_APP_ID
Enables Google sign-in and faucet-funded transactions.
open ↗
PINATA_JWT
Pins images / JSON / manifests to IPFS.
open ↗

Add these in your Lovable project under Settings → Secrets before pasting the prompt below.

Appendix · Mega-prompt

The build prompt.

Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →

Build "Storyboard Stamp" in ONE Lovable message. Single-page demo. This prompt is self-contained: everything you need is below — do not ask follow-up questions.

CONCEPT
Mint storyboards as NFT tokens to secure creative vision and enable easy sharing with teams.
Discipline: Videography & Film (previsualization).
Onchain primitive: NFT provenance mint. Why this primitive: NFT minting onchain timestamps storyboards with IPFS CIDs, verifying original creative concepts.

5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth flows beyond Privy drop-in.
- ONE Solidity contract, <=80 lines, deployed to BOB Sepolia, verified on BOB Sepolia Explorer (Blockscout).
- Privy is always the auth + sponsored-tx layer (Google login, embedded wallet).
- Pinata/IPFS only if the idea genuinely needs to store a file or metadata.
- At most ONE AI call per user action (use Lovable AI Gateway with LOVABLE_API_KEY if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.

NETWORK — BOB Sepolia (testnet, everything below is required verbatim)
- chainId 808813 (hex 0xc57cd). BOB is an OP-Stack L2 secured by Bitcoin.
- Gas token is ETH. Never BTC, and never the BOB ERC-20 token — holding BOB
  tokens on Ethereum L1 does not pay for gas on the BOB network.
- RPC:      https://bob-sepolia.rpc.gobob.xyz
- Explorer: https://bob-sepolia.explorer.gobob.xyz  (Blockscout, not Etherscan)
- Funding the deployer: claim Sepolia ETH at https://sepolia-faucet.pk910.de/
  then bridge it to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- Reference: https://docs.gobob.xyz/docs/user-hub/networks
- Create src/lib/chains.ts exactly like this:
```ts
import type { Chain } from "viem";

export const bobSepolia: Chain = {
  id: 808813,
  name: "BOB Sepolia",
  nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: {
    default: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
    public: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
  },
  blockExplorers: {
    default: { name: "BOB Sepolia Explorer", url: "https://bob-sepolia.explorer.gobob.xyz" },
  },
  testnet: true,
};

export const EXPLORER = "https://bob-sepolia.explorer.gobob.xyz";
```

FILE LAYOUT (create every one of these — nothing else)
```text
contracts/StoryboardStamp.sol          the contract source below
hardhat.config.cjs              Blockscout verify config below
scripts/deploy.cjs              deploy script below
src/lib/chains.ts               bobSepolia viem Chain (above)
src/lib/contract.ts             ABI + address helper, viem public client
src/data/contract.json          { "address": "0x…", "deployTx": "0x…" } written after deploy
src/components/privy-root.tsx   ClientOnly + Suspense + lazy wrapper
src/components/privy-client-entry.tsx  the ONLY file importing @privy-io/react-auth
src/pages/Index.tsx             the entire demo UI (single page)
```

STACK
- React + Vite single page (the index route).
- SSR-safe Privy mount is mandatory. Never import @privy-io/react-auth at
  module scope of a route file — it crashes SSR. Use
  lazy(() => import('./privy-client-entry')) inside <ClientOnly> + <Suspense>,
  and put <PrivyProvider> only inside privy-client-entry.tsx.
```tsx
// src/components/privy-root.tsx
import { lazy, Suspense, type ReactNode } from "react";
import { ClientOnly } from "@tanstack/react-router"; // or a { mounted } useEffect guard on plain Vite

const PrivyClientEntry = lazy(() => import("./privy-client-entry"));

export function PrivyRoot({ children }: { children: ReactNode }) {
  return (
    <ClientOnly fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
      <Suspense fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
        <PrivyClientEntry>{children}</PrivyClientEntry>
      </Suspense>
    </ClientOnly>
  );
}
```
- PrivyProvider config — pass bobSepolia as `defaultChain` + `supportedChains`.
  chainId is also passed per-call:
```tsx
// src/components/privy-client-entry.tsx
import { PrivyProvider } from "@privy-io/react-auth";
import { bobSepolia } from "@/lib/chains";

export default function PrivyClientEntry({ children }: { children: React.ReactNode }) {
  return (
    <PrivyProvider
      appId={import.meta.env.VITE_PRIVY_APP_ID}
      config={{
        loginMethods: ["google", "email"],
        embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } },
        defaultChain: bobSepolia,
        supportedChains: [bobSepolia],
        appearance: { theme: "dark" },
      }}
    >
      {children}
    </PrivyProvider>
  );
}
```
- Read the embedded wallet from useWallets, not user.wallet:
    const embedded = wallets.find(w => w.walletClientType === 'privy');
- Every send goes through Privy `useSendTransaction` with BOTH `address`
  and `sponsor`, wrapped in a 45s Promise.race timeout whose reject message
  names the exact dashboard toggles:
    await Promise.race([
      sendTransaction(
        { to, data, chainId: 808813 },
        { address: embedded.address, sponsor: true }
      ),
      new Promise((_, r) => setTimeout(() => r(new Error(
        "Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
      )), 45_000)),
    ]);
- Do NOT pass uiOptions:{ showWalletUIs:false } — it aborts with
  "signal is aborted without reason". The approval sheet still shows on
  the embedded-EOA path; the fee reads US$0.00.
- Do NOT add ZeroDev / SmartWalletsProvider / a paymaster URL. Native
  Privy sponsorship on BOB Sepolia works with the toggles above and nothing else.
- DASHBOARD PREREQUISITE (one-time): Privy dashboard -> Gas sponsorship
  -> App pays -> add custom chain "BOB Sepolia" (chainId 808813, rpc
  https://bob-sepolia.rpc.gobob.xyz) -> toggle "Allow transactions from
  the client" ON. Without this, sendTransaction hangs silently.
- src/lib/pinata.ts uploads via `fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', { method:'POST', headers:{ Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` }, body: fd })`.

CONTRACT (contracts/StoryboardStamp.sol):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
/// @title StoryboardStamp
/// @notice ERC-721 provenance for: Mint storyboards as NFT tokens to secure creative vision and enable easy sharing with teams.
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
contract StoryboardStamp is ERC721 {
    uint256 public nextId;
    mapping(uint256 => string) public cidOf;
    constructor() ERC721("StoryboardStamp", "STORYB") {}
    /// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
    function mint(string calldata cid) external returns (uint256 id) {
        id = ++nextId; cidOf[id] = cid; _safeMint(msg.sender, id);
    }
    function tokenURI(uint256 id) public view override returns (string memory) {
        return string(abi.encodePacked("ipfs://", cidOf[id]));
    }
}
```

DEPLOY + VERIFY
- Hardhat lives at the repo root with sources in /contracts (kept outside the
  Vite bundle). Install `@nomicfoundation/hardhat-toolbox` AND
  `@nomicfoundation/hardhat-verify@latest`.
- hardhat.config.cjs uses hardhat-verify's Blockscout customChain shape
  (BOB Sepolia Explorer is Blockscout-based, not Etherscan):
```js
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");

module.exports = {
  solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
  networks: {
    bobSepolia: {
      url: process.env.BOB_SEPOLIA_RPC_URL || "https://bob-sepolia.rpc.gobob.xyz",
      accounts: [process.env.METAMASK_PRIVATE_KEY.startsWith("0x")
        ? process.env.METAMASK_PRIVATE_KEY : "0x" + process.env.METAMASK_PRIVATE_KEY],
      chainId: 808813,
    },
  },
  etherscan: {
    apiKey: { bobSepolia: "empty" }, // Blockscout ignores the key but the field is required
    customChains: [{
      network: "bobSepolia",
      chainId: 808813,
      urls: {
        apiURL: "https://bob-sepolia.explorer.gobob.xyz/api",
        browserURL: "https://bob-sepolia.explorer.gobob.xyz/",
      },
    }],
  },
  sourcify: { enabled: false },
};
```
```js
// scripts/deploy.cjs
const hre = require("hardhat");
const fs = require("fs");

async function main() {
  const f = await hre.ethers.getContractFactory("StoryboardStamp");
  const c = await f.deploy();
  await c.waitForDeployment();
  const address = await c.getAddress();
  console.log("deployed:", address);
  fs.writeFileSync("src/data/contract.json", JSON.stringify({
    address,
    deployTx: c.deploymentTransaction().hash,
    chainId: 808813,
    explorer: "https://bob-sepolia.explorer.gobob.xyz",
  }, null, 2));
}
main().catch((e) => { console.error(e); process.exit(1); });
```
- Deploy: `npx hardhat run scripts/deploy.cjs --network bobSepolia`.
- Verify (run RIGHT AFTER deploy, no constructor args for these contracts):
  `npx hardhat verify --network bobSepolia <address>`.
  On success Blockscout returns "Successfully verified contract" and the
  source becomes readable at
  `https://bob-sepolia.explorer.gobob.xyz/address/<address>#code`.
  Blockscout does NOT auto-verify — always run the command explicitly.
- Frontend reads: create a viem public client with the BOB Sepolia RPC —
  `createPublicClient({ chain: bobSepolia, transport: http(import.meta.env.VITE_BOB_SEPOLIA_RPC_URL ?? "https://bob-sepolia.rpc.gobob.xyz") })`.

FRONTEND CODE (src/pages/Index.tsx — adapt copy, keep the choreography)
```tsx
import { useState } from "react";
import { usePrivy, useWallets, useSendTransaction } from "@privy-io/react-auth";
import { createPublicClient, encodeFunctionData, http } from "viem";
import { bobSepolia, EXPLORER } from "@/lib/chains";
import { ABI } from "@/lib/contract";
import contract from "@/data/contract.json";

const client = createPublicClient({ chain: bobSepolia, transport: http() });

export default function Index() {
  const { ready, authenticated, login, logout } = usePrivy();
  const { wallets } = useWallets();
  const { sendTransaction } = useSendTransaction();
  const embedded = wallets.find((w) => w.walletClientType === "privy");

  const [input, setInput] = useState("");
  const [status, setStatus] = useState<"idle" | "pending" | "done" | "error">("idle");
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function submit() {
    if (!embedded) return;
    setStatus("pending"); setError(null); setTxHash(null);
    try {
      const data = encodeFunctionData({ abi: ABI, functionName: "mint", args: [input] });
      const res: any = await Promise.race([
        sendTransaction(
          { to: contract.address as `0x${string}`, data, chainId: 808813 },
          { address: embedded.address, sponsor: true }
        ),
        new Promise((_, r) => setTimeout(() => r(new Error(
          "Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
        )), 45_000)),
      ]);
      const hash = res?.hash ?? res;
      setTxHash(hash);
      await client.waitForTransactionReceipt({ hash });
      setStatus("done");
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
      setStatus("error");
    }
  }

  if (!ready) return <main className="p-10">Loading…</main>;

  return (
    <main className="min-h-screen mx-auto max-w-2xl px-6 py-16">
      <h1 className="text-4xl font-semibold tracking-tight">Storyboard Stamp</h1>
      <p className="mt-3 opacity-70">Mint storyboards as NFT tokens to secure creative vision and enable easy sharing with teams.</p>

      {!authenticated ? (
        <button onClick={login} className="mt-8 px-6 py-3 rounded-md bg-primary text-primary-foreground">
          Sign in with Google
        </button>
      ) : (
        <section className="mt-8 space-y-4">
          <div className="text-xs font-mono opacity-60">{embedded?.address}</div>
          <input value={input} onChange={(e) => setInput(e.target.value)}
                 placeholder="Describe the previsualization entry…"
                 className="w-full rounded-md border px-4 py-3 bg-transparent" />
          <button onClick={submit} disabled={status === "pending" || !input}
                  className="px-6 py-3 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
            {status === "pending" ? "Writing to BOB Sepolia…" : "Mint provenance token"}
          </button>
          {txHash && (
            <a href={`${EXPLORER}/tx/${txHash}`} target="_blank" rel="noreferrer"
               className="block text-sm underline break-all">
              {status === "done" ? "Confirmed" : "Pending"} · {txHash}
            </a>
          )}
          {error && <p className="text-sm text-red-500 break-all">{error}</p>}
          <button onClick={logout} className="text-xs uppercase tracking-widest opacity-60">Sign out</button>
        </section>
      )}

      <footer className="mt-20 text-xs opacity-60">Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14</footer>
    </main>
  );
}
```
Also export the ABI from src/lib/contract.ts so the page and any read call share it.

HOOK INTEGRATION — IPFS via Pinata
```ts
// src/lib/pinata.ts
export async function pinFile(file: File): Promise<string> {
  const fd = new FormData();
  fd.append("file", file);
  const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST",
    headers: { Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` },
    body: fd,
  });
  if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
  const { IpfsHash } = await res.json();
  return IpfsHash as string;
}

export async function pinJson(json: unknown): Promise<string> {
  const res = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(json),
  });
  if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
  return (await res.json()).IpfsHash as string;
}
```
Pin FIRST, then send the onchain tx with the returned CID. Preview the pinned
asset at `https://gateway.pinata.cloud/ipfs/<cid>` and show the CID as a chip
next to the explorer link. Never send the file itself onchain.

HOOK INTEGRATION — ERC-721 provenance mint
- Pin the artefact with `pinFile`, pin a metadata JSON
  (`{ name, description, image: "ipfs://<cid>" }`) with `pinJson`, then call
  `mint(metadataCid)`.
- Read the new tokenId back from the receipt logs, or call `nextId()` after
  confirmation.
- Render an owned-token strip: loop ids 1..nextId, call `ownerOf(id)` and skip
  the ones the signed-in address does not own, showing `cidOf(id)` previews.
```ts
const nextId = await client.readContract({ address, abi: ABI, functionName: "nextId" });
const cid = await client.readContract({ address, abi: ABI, functionName: "cidOf", args: [id] });
```
- Link each token to `https://bob-sepolia.explorer.gobob.xyz/token/<address>/instance/<id>`.

USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> embedded wallet auto-provisioned.
2. After the user creates a previsualization artefact, pin the file to IPFS via Pinata, then call `mint(cid)` on the deployed contract through Privy's sponsored transaction. Show tokenId, IPFS preview (`https://gateway.pinata.cloud/ipfs/<cid>`), and BOB Sepolia Explorer mint-tx link.
3. While the tx is in flight the button reads 'Writing to BOB Sepolia…' and the hash appears as a pending link; on receipt it flips to 'Confirmed'.
4. The page re-reads chain state so a refresh shows the same result.
5. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"

DESIGN DIRECTION
Commit to one distinctive look drawn from videography & film — specifically
previsualization. No purple-on-white gradient hero, no Inter/Poppins default stack, no
generic three-card feature row. Pick a real typeface pairing, one accent colour
plus a near-monochrome base, and a layout with an obvious focal point. Define
colours as CSS variables/tokens; never hardcode text-white or bg-black. Dark by
default. The proof-of-onchain elements (address, tx hash, CID) should read as
deliberate typographic detail in a mono face, not as debug output.

TROUBLESHOOTING (these are the failures you will actually hit)
- Blank page / "document is not defined" on load -> @privy-io/react-auth was
  imported at module scope of a route. Move it behind the lazy client entry.
- sendTransaction never resolves, no error -> the Privy dashboard gas-sponsorship
  toggle for BOB Sepolia is off. Add the custom chain and enable "Allow
  transactions from the client".
- "signal is aborted without reason" -> you passed uiOptions:{ showWalletUIs:false }.
  Remove it.
- Tx reverts with EMPTY revert data -> you encoded a function the deployed
  contract does not have. Check the verified ABI on Blockscout before
  debugging the UI.
- Tx sits pending forever -> underpriced. Resend the same nonce with a higher
  maxFeePerGas (~5 gwei) and 1 gwei priority fee.
- `hardhat verify` fails with "missing API key" -> keep the dummy
  `apiKey: { bobSepolia: "empty" }` entry; Blockscout ignores the value but
  hardhat-verify requires the field.
- Explorer shows "not verified" after deploy -> Blockscout never auto-verifies.
  Run the verify command.
- Reads return nothing -> you are pointed at the wrong chain. Confirm chainId
  808813 and the BOB Sepolia RPC, not Ethereum Sepolia (11155111).

REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- METAMASK_PRIVATE_KEY  BOB Sepolia deployer key. Fund flow: get Sepolia ETH from https://sepolia-faucet.pk910.de/ then bridge to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- BOB_SEPOLIA_RPC_URL   HTTPS RPC (default: https://bob-sepolia.rpc.gobob.xyz). Public RPC works for hackathon load; swap for a dedicated node provider if you need higher rate limits.
- PRIVY_APP_ID          Google sign-in + sponsored tx. Docs: https://docs.privy.io/llms-full.txt
- PINATA_JWT            IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt

CREDIT (must appear in UI footer AND as NatSpec on every deployed contract):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Appendix · Market

Market sizing.

TAM
$1.1B
video production previsualization tools
SAM
$100M
film studios and directors
SOM
$8M
indie filmmakers and video creators

Indicative figures for hackathon pitches — refine with your own research before raising.

See also

Adjacent entries.