> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livepeer.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Store and play videos on IPFS

> Upload a video to IPFS and play it back via Livepeer. Covers web3.storage integration, asset creation, and decentralised video delivery.

export const CenteredContainer = ({children, maxWidth = "800px", padding = "0", preset = "default", width = "", minWidth = "", marginRight = "", marginBottom = "", textAlign = "", style = {}, className = "", ...rest}) => {
  const presets = {
    default: {},
    fitContent: {
      width: "fit-content",
      minWidth: "fit-content"
    },
    readable70: {
      width: "70%",
      minWidth: "fit-content"
    },
    readable80: {
      width: "80%",
      minWidth: "fit-content"
    },
    readable90: {
      width: "90%"
    },
    wide900: {
      maxWidth: "900px"
    }
  };
  const presetStyle = presets[preset] || presets.default;
  return <div className={className} style={{
    maxWidth: presetStyle.maxWidth || maxWidth,
    margin: "0 auto",
    padding: padding,
    ...presetStyle.width ? {
      width: presetStyle.width
    } : {},
    ...presetStyle.minWidth ? {
      minWidth: presetStyle.minWidth
    } : {},
    ...width ? {
      width
    } : {},
    ...minWidth ? {
      minWidth
    } : {},
    ...marginRight ? {
      marginRight
    } : {},
    ...marginBottom ? {
      marginBottom
    } : {},
    ...textAlign ? {
      textAlign
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

export const StyledStep = ({title, icon, titleSize = 'h3', iconColor = null, titleColor = null, children, className = '', style = {}, ...rest}) => {
  const styledTitle = titleColor ? <span style={{
    color: titleColor
  }}>{title}</span> : title;
  return <Step title={styledTitle} icon={icon} iconColor={iconColor || undefined} titleSize={titleSize} className={className} style={style} {...rest}>
      {children}
    </Step>;
};

export const StyledSteps = ({children, iconColor, titleColor, lineColor, iconSize = '24px', className = '', style = {}, ...rest}) => {
  const resolvedIconColor = iconColor || 'var(--accent-dark, #18794E)';
  const resolvedTitleColor = titleColor || 'var(--lp-color-accent)';
  const resolvedLineColor = lineColor || 'var(--lp-color-accent)';
  return <div className={['docs-styled-steps', className].filter(Boolean).join(' ')} style={style} {...rest}>
      <style>{`
        .docs-styled-steps .steps > div > div.absolute > div {
          background-color: ${resolvedIconColor};
        }
        .docs-styled-steps .steps > div > div.w-full > p {
          color: ${resolvedTitleColor};
        }
        .docs-styled-steps .steps > div > div.absolute.w-px {
          background-color: ${resolvedLineColor};
        }
        .docs-styled-steps .steps > div:last-child > div.absolute.w-px::after {
          content: '';
          position: absolute;
          bottom: 0;
          left: 50%;
          transform: translateX(-50%);
          width: 6px;
          height: 6px;
          background-color: ${resolvedLineColor};
          transform: translateX(-50%) rotate(45deg);
        }
      `}</style>
      <div>
        <Steps>{children}</Steps>
      </div>
    </div>;
};

<CenteredContainer preset="readable90">
  <Tip>Livepeer transcodes IPFS-hosted videos on demand and returns a playback-optimised HLS stream. The original file stays on IPFS; Livepeer handles adaptive bitrate encoding and delivery.</Tip>
</CenteredContainer>

<CustomDivider />

Combining IPFS with Livepeer creates a fully decentralised video pipeline: content-addressed storage on IPFS with Livepeer transcoding for adaptive playback. This pattern is common in NFT video projects and decentralised social applications.

**What you will build:** A workflow that uploads a video to IPFS via web3.storage, creates a Livepeer asset from the IPFS URL, and plays it back using the Livepeer Player.

<CustomDivider />

## Prerequisites

* Node.js 18 or later
* A Gateway provider API key or a self-hosted Gateway that exposes the Livepeer Asset API
* A web3.storage account and API token from [web3.storage](https://web3.storage)

```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
npm install livepeer @web3-storage/w3up-client
```

<CustomDivider />

## Build the pipeline

<StyledSteps iconColor="#2d9a67" titleColor="var(--accent)">
  <StyledStep title="Upload the video to IPFS" icon="cloud-arrow-up">
    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import * as Client from '@web3-storage/w3up-client';

    const w3Client = await Client.create();

    // Authenticate with your web3.storage account
    await w3Client.login('your-email@example.com');
    await w3Client.setCurrentSpace(/* your space DID */);

    // Upload the video file
    const file = new File(
      [await fs.promises.readFile('./my-video.mp4')],
      'my-video.mp4',
      { type: 'video/mp4' }
    );

    const cid = await w3Client.uploadFile(file);

    // Construct the IPFS gateway URL
    const ipfsUrl = `https://w3s.link/ipfs/${cid}`;
    console.log('IPFS URL:', ipfsUrl);
    // e.g. https://w3s.link/ipfs/bafybei...
    ```

    The `cid` is the content identifier -- a cryptographic hash of the file. The URL `https://w3s.link/ipfs/<cid>` is a public IPFS HTTP Gateway URL that Livepeer can fetch.
  </StyledStep>

  <StyledStep title="Create a Livepeer asset from the IPFS URL" icon="wand-magic-sparkles">
    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import { Livepeer } from 'livepeer';

    const client = new Livepeer({
      apiKey: process.env.LIVEPEER_API_KEY,
      serverURL: process.env.LIVEPEER_API_URL,
    });

    // Import the IPFS-hosted video into Livepeer for transcoding
    const response = await client.asset.create({
      name: 'my-ipfs-video',
      url: ipfsUrl, // the IPFS gateway URL from step 1
    });

    const assetId = response.asset.id;
    console.log('Asset ID:', assetId);
    ```

    Livepeer fetches the file from IPFS, transcodes it to adaptive bitrate HLS, and stores the renditions. The original file remains on IPFS.
  </StyledStep>

  <StyledStep title="Wait for transcoding" icon="clock">
    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    // Poll until the asset is ready
    let asset;
    do {
      await new Promise(r => setTimeout(r, 5000));
      const response = await client.asset.get(assetId);
      asset = response.asset;
      console.log('Status:', asset.status.phase);
    } while (asset.status.phase === 'processing' || asset.status.phase === 'waiting');

    if (asset.status.phase !== 'ready') {
      throw new Error(`Asset failed: ${asset.status.error}`);
    }

    const playbackId = asset.playbackId;
    console.log('Playback ID:', playbackId);
    ```

    Alternatively, subscribe to the `asset.ready` webhook to avoid polling -- see the <a href="/v2/developers/build/video/live-events">webhooks guide</a>.
  </StyledStep>

  <StyledStep title="Play back with the Livepeer Player" icon="play">
    ```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import * as Player from '@livepeer/react/player';

    export const IPFSVideoPlayer = ({ playbackId }: { playbackId: string }) => (
      <Player.Root src={`${process.env.NEXT_PUBLIC_PLAYBACK_BASE_URL}/asset/hls/${playbackId}/index.m3u8`}>
        <Player.Container>
          <Player.Video />
          <Player.Controls />
        </Player.Container>
      </Player.Root>
    );
    ```

    The Player delivers the Livepeer-transcoded HLS stream, not the raw IPFS file. Viewers get adaptive bitrate streaming with no IPFS Gateway latency on playback.
  </StyledStep>
</StyledSteps>

<CustomDivider />

## AI agent prompt

```text theme={"theme":{"light":"github-light","dark":"dark-plus"}}
Build the "Store and play videos on IPFS" tutorial as a runnable Node/TypeScript workflow. Create a fresh project, install livepeer and @web3-storage/w3up-client, and implement scripts that upload ./my-video.mp4 to web3.storage, import the resulting https://w3s.link/ipfs/<cid> URL into a Livepeer-compatible Asset API, poll until the asset is ready, and print the playback ID. Use these placeholders exactly: LIVEPEER_API_URL=<gateway asset API base URL>, LIVEPEER_API_KEY=<gateway provider API key>, WEB3_STORAGE_EMAIL=<email>, WEB3_STORAGE_SPACE_DID=<space DID>, NEXT_PUBLIC_PLAYBACK_BASE_URL=<provider playback base URL>. Do not use Livepeer Studio-specific URLs or keys. Include package.json scripts, TypeScript source files, .env.example, error handling for failed asset states, and a final verification command that proves a playback URL can be constructed from the returned playback ID.
```

<CustomDivider />

## Storing the CID on-chain (optional)

For NFT video projects, store the IPFS CID in the NFT metadata to establish provenance. The Livepeer `playbackId` can be stored alongside it for delivery:

```json theme={"theme":{"light":"github-light","dark":"dark-plus"}}
{
  "name": "My NFT Video",
  "description": "A video NFT with decentralised storage and delivery.",
  "animation_url": "ipfs://bafybei...",
  "properties": {
    "livepeer_playback_id": "pla_abc123"
  }
}
```

The `animation_url` field (ERC-721 metadata standard) points to the IPFS CID. The `livepeer_playback_id` is a convenience property for applications that want to serve the transcoded stream instead of the raw IPFS file.

<CustomDivider />

## Related pages

<CardGroup cols={2}>
  <Card title="Upload a Video Asset" icon="upload" href="/v2/developers/build/video/vod-and-recording">
    Direct upload and URL-based import for video assets.
  </Card>

  <Card title="Token-Gated Video" icon="lock" href="/v2/developers/build/tutorials/token-gated-video">
    Gate video access with NFT ownership using Lit Protocol.
  </Card>
</CardGroup>
