Flap Video Generator (preview)
Preview. This document has not been reviewed yet. Content may be incomplete or inaccurate.
1. Overview
Flap Video Generator lets any smart contract (a Flap vault, a game, a story contract) generate AI video clips on-chain. You call generateVideo() on FlapAIProvider with a text prompt and a flat BNB fee. Flap's backend generates the clip, pins it to IPFS, and writes the result back on-chain.
Each requester (msg.sender) owns an append-only video session. Each fulfilled clip is appended to it, and the clips are encoded so that playing them back-to-back gives one continuous stream. A contract can grow a story clip by clip, and anyone can watch the whole session as a single video.
Clips can be conditioned on reference images (IPFS CIDs). That gives you two separate controls:
- Continuity: the next clip starts exactly where the previous one ended.
- Character consistency: the same character keeps the same look across clips.
Section 4 explains how to combine the two, using a real deployed consumer as the example.
Live demo: video.munch.cash plays real on-chain sessions on BSC Testnet and BNB Mainnet (switch at the top of the page). Connect a wallet to extend the story with your own prompt.
flowchart TD
subgraph on-chain["🔗 On-Chain"]
Consumer(["🏦 Your Contract<br/>(vault / story consumer)"])
Provider(["⚙️ FlapAIProvider"])
Session(["🎞️ Video Session<br/>(per requester, append-only)"])
end
subgraph off-chain["☁️ Off-Chain"]
Backend(["🤖 Flap Video Backend"])
Model(["🎬 Video Model"])
IPFS(["📦 IPFS<br/>(clips + last frames)"])
end
Consumer -->|"① generateVideo(prompt, referenceType, referenceCids) + fee"| Provider
Provider -->|"② emit FlapAIProviderVideoRequested"| Backend
Backend -->|"③ prompt + reference images"| Model
Model -->|"④ video"| Backend
Backend -->|"⑤ upload clip + last frame"| IPFS
Backend -->|"⑥ submit clip CIDs"| Provider
Provider -->|"⑦ append clip"| Session
Provider -->|"⑧ onVideoRequestSettled(result)"| Consumer
Key properties
| Property | Value |
|---|---|
| Fee | Flat fee per clip, read it from videoGenerateFee(); msg.value must match exactly |
| Concurrency | One pending request per requester. A second call reverts until the first one is fulfilled or failed |
| Output | MPEG-TS clip CID + last-frame JPEG CID + duration, stored on-chain in the requester's session |
| Clip format (current) | ~8 s, 720p, 16:9 |
| Typical latency | ~1–2 minutes from request to fulfillment |
| Callback | Optional best-effort IFlapVideoReceiver.onVideoRequestSettled, which can never block fulfillment |
| Failure | Terminal FAILED with a machine-readable reason. No refund |
2. Deployed Addresses
The video generator lives on the same FlapAIProvider contract as the Flap AI Oracle.
| Network | Chain ID | FlapAIProvider | Fee per clip | Callback gas limit |
|---|---|---|---|---|
| BSC Testnet | 97 | 0xFfddcE44e8cFf7703Fd85118524bfC8B2f70b744 | 0.01 BNB | 2,000,000 |
| BNB Mainnet | 56 | 0xaEe3a7Ca6fe6b53f6c32a3e8407eC5A9dF8B7E39 | 0.01 BNB | 2,000,000 |
Always call videoGenerateFee() at request time. Never hardcode the fee. It is configurable and may change. A wrong msg.value (too much or too little) reverts with FlapAIProviderWrongVideoFee.
Testnet note: video generation is expensive, so the testnet backend runs on a limited budget and may stop fulfilling if it runs out. If you'd like to test on testnet, feel free to reach out to us.
Demo consumers
Both are ReferenceStoryVideoConsumer (§4) and can be watched and extended in the demo app.
| Network | Consumer | Character | Story | Watch |
|---|---|---|---|---|
| BSC Testnet | 0xB7f4398BEECE5Ec0da06497e647107D697c55c66 | Ice boy (2D cartoon) | The Ice Boy Who Melted Upward | video.munch.cash/?network=testnet |
| BNB Mainnet | 0x8b527D3f104A1945BD68B391fBd6b4a00B7EA3a2 | Arthur (photorealistic) | Arthur's Boat, 8 clips, 64 s | video.munch.cash/?network=mainnet |
3. Reference Types
generateVideo takes a uint8 referenceType that tells the backend how to use the reference image CIDs you pass:
| Code | Constant | referenceCids | How the image is used | Enabled |
|---|---|---|---|---|
0 | VIDEO_REF_NONE | empty | Text-to-video, no image | ✅ |
1 | VIDEO_REF_PREV_LAST_FRAME | empty (the provider resolves it on-chain) | The previous clip's last frame becomes the exact first frame of the new clip | ✅ |
2 | VIDEO_REF_FIRST_FRAME | exactly 1 | Your image becomes the exact first frame | ✅ |
3 | VIDEO_REF_FIRST_AND_LAST_FRAME | exactly 2 [first, last] | Pinned first and last frames | ❌ (not enabled yet) |
4 | VIDEO_REF_CHARACTER | 1–3 | Soft references that guide the character's look, subject and style. They are not pinned to any frame | ✅ |
Rules enforced on-chain:
- At most
MAX_VIDEO_REFERENCE_CIDS = 3CIDs per request, each non-empty and ≤MAX_VIDEO_REFERENCE_CID_LENGTH = 128bytes. - A disabled type reverts with
FlapAIProviderUnsupportedReferenceType. CheckisVideoReferenceTypeEnabled(type)first. - Type
1reverts withFlapAIProviderNoPreviousClipif the requester has no clip yet. The first clip of a session must use another type. - A wrong CID count reverts with
FlapAIProviderBadReferenceCidCount.
The legacy one-argument generateVideo(prompt) is still supported. It automatically uses type 0 for the first clip and type 1 for every later clip.
Frame references vs. character references: you can't fully have both in one clip
This is the most important thing to understand when designing your consumer:
flowchart LR
subgraph T1["Type 1: PREV_LAST_FRAME"]
A1["Previous clip's<br/>last frame"] -->|"pinned as frame 0"| B1["New clip"]
end
subgraph T4["Type 4: CHARACTER"]
A4["Character image"] -->|"identity guide"| B4["New clip"]
C4["Previous last frame<br/>(optional)"] -->|"scene guide only"| B4
end
| Type 1 (previous last frame) | Type 4 (character + last frame as references) | |
|---|---|---|
| Seamless join with the previous clip | ✅ Pixel-exact. The new clip opens on the old clip's final frame | ⚠️ Same scene, setting and style, but a visible cut at the join |
| Character consistency | ⚠️ Carried only through the pixels of the starting frame. Details drift over many clips (e.g. the model adds clothing that wasn't in the original design) | ✅ Strong. The character image is re-applied on every clip |
The current video model can't take a pinned starting frame and a soft character reference in the same clip. When a frame is pinned, soft references are ignored. That is why these are two separate types and not one combined mode.
Recommended pattern: alternate the two types.
- Use type 4
[character, previous last frame]to (re)anchor the character, at the start of each "scene". - Use type 1 for the following beat(s) to keep motion seamless.
Every type-4 clip resets any drift from the type-1 clips before it, and the type-1 clips keep the story flowing without cuts.
Prompt tips:
- In type 4, say which reference image is which at the start of the prompt (e.g. "Reference image 1 is the main character … Reference image 2 is the final frame of the previous clip …").
- Restate the character's key traits (colours, outfit, style) in the prompt. The model then follows them more closely, in both types.
4. Example: ReferenceStoryVideoConsumer
ReferenceStoryVideoConsumer is a small contract that builds a continuous story around one fixed character. Anyone can pay the fee to add the next clip and choose, per clip, between:
- Type 1: continue seamlessly from the previous clip.
- Type 4: re-anchor on the character, with the previous last frame as a scene hint.
The contract checks the reference CIDs and adds the "which image is which" prefix to the prompt itself, so the prompt always matches the images.
| Network | BSC Testnet |
| Consumer | 0xB7f4398BEECE5Ec0da06497e647107D697c55c66 (source verified) |
| Provider | 0xFfddcE44e8cFf7703Fd85118524bfC8B2f70b744 |
| Character image | bafkreigo6g3mkveu5w3l7ud56qr4oq3sa62hawcdmybdhbi5agurqwm5ye (an "ice boy", flat 2D cartoon style) |
Request flow
sequenceDiagram
participant U as Caller (anyone)
participant C as ReferenceStoryVideoConsumer
participant P as FlapAIProvider
participant B as Flap Video Backend
U->>C: generateVideo(prompt, type, cids) + fee
C->>P: getLastVideoFrameCid(this)
alt type 1 (PREV_LAST_FRAME)
C->>C: require a previous clip, cids == []
else type 4 (CHARACTER)
C->>C: require cids == [CHARACTER_CID] (first clip)<br/>or [CHARACTER_CID, lastFrame]
C->>C: prefix prompt with "image 1 = character, image 2 = previous scene"
end
C->>P: generateVideo{value: fee}(fullPrompt, type, cids)
P-->>B: FlapAIProviderVideoRequested
B->>P: submit clip (videoCid, lastFrameCid, duration)
P->>C: onVideoRequestSettled(result)
C->>C: store latest clip / failure
The contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {IFlapAIProvider, IFlapVideoReceiver} from "src/plugins/AIProvider/IFlapAIProvider.sol";
/// @dev Reference-type surface of FlapAIProvider used by this consumer.
interface IFlapAIProviderReferenceTypes {
function generateVideo(string calldata prompt, uint8 referenceType, string[] calldata referenceCids)
external
payable
returns (uint256 requestId);
function getLastVideoFrameCid(address user) external view returns (string memory);
}
contract ReferenceStoryVideoConsumer is IFlapVideoReceiver {
uint8 public constant VIDEO_REF_PREV_LAST_FRAME = 1;
uint8 public constant VIDEO_REF_CHARACTER = 4;
string internal constant CHARACTER_HINT =
"Reference image 1 is the main character: keep the exact same character design, colors, outfit and art style. ";
string internal constant SCENE_HINT =
"Reference image 2 is the final frame of the previous clip: continue from that scene, same setting, lighting and camera framing, as a natural continuation. ";
IFlapAIProvider public immutable provider;
string public CHARACTER_CID;
uint256 public lastRequestId;
string public latestVideoCid;
string public latestFrameCid;
uint32 public latestDurationMs;
bool public lastRequestFailed;
IFlapAIProvider.VideoFailureReason public lastFailureReason;
string public lastFailureDetail;
event VideoGenerationRequested(
uint256 indexed requestId, address indexed caller, string prompt, uint8 referenceType, string[] referenceCids
);
event VideoGenerationFulfilled(uint256 indexed requestId, string videoCid, string lastFrameCid, uint32 durationMs);
event VideoGenerationFailed(uint256 indexed requestId, IFlapAIProvider.VideoFailureReason reason, string failureDetail);
error WrongFee(uint256 sent, uint256 required);
error EmptyPrompt();
error OnlyProvider();
error RequestAlreadyPending(uint256 requestId);
error ReferenceTypeNotAllowed(uint8 referenceType);
error NoPreviousClip();
error BadCharacterReferences();
constructor(address provider_, string memory characterCid_) {
require(provider_ != address(0), "zero provider");
require(bytes(characterCid_).length != 0, "empty character cid");
provider = IFlapAIProvider(provider_);
CHARACTER_CID = characterCid_;
}
/// @param referenceType VIDEO_REF_PREV_LAST_FRAME (1) or VIDEO_REF_CHARACTER (4).
/// @param referenceCids Type 1: empty. Type 4: [CHARACTER_CID] for the first clip,
/// [CHARACTER_CID, current last frame] afterwards.
function generateVideo(string calldata prompt, uint8 referenceType, string[] calldata referenceCids)
external
payable
returns (uint256 requestId)
{
uint256 fee = provider.videoGenerateFee();
if (msg.value != fee) revert WrongFee(msg.value, fee);
if (bytes(prompt).length == 0) revert EmptyPrompt();
if (lastRequestId != 0) {
IFlapAIProvider.VideoRequestView memory prior = provider.getVideoRequest(lastRequestId);
if (prior.status == IFlapAIProvider.VideoRequestStatus.PENDING) revert RequestAlreadyPending(lastRequestId);
}
IFlapAIProviderReferenceTypes p = IFlapAIProviderReferenceTypes(address(provider));
// No race: the provider's one-pending rule means the last frame can't change
// before this request is recorded.
string memory lastFrame = p.getLastVideoFrameCid(address(this));
bool hasLastFrame = bytes(lastFrame).length != 0;
string memory fullPrompt;
if (referenceType == VIDEO_REF_PREV_LAST_FRAME) {
// Continuity: the provider resolves the previous last frame on-chain.
if (!hasLastFrame) revert NoPreviousClip();
if (referenceCids.length != 0) revert BadCharacterReferences();
fullPrompt = prompt;
} else if (referenceType == VIDEO_REF_CHARACTER) {
// Character consistency: [character] or [character, previous last frame].
uint256 expected = hasLastFrame ? 2 : 1;
if (referenceCids.length != expected) revert BadCharacterReferences();
if (keccak256(bytes(referenceCids[0])) != keccak256(bytes(CHARACTER_CID))) revert BadCharacterReferences();
if (hasLastFrame && keccak256(bytes(referenceCids[1])) != keccak256(bytes(lastFrame))) {
revert BadCharacterReferences();
}
fullPrompt = hasLastFrame
? string.concat(CHARACTER_HINT, SCENE_HINT, prompt)
: string.concat(CHARACTER_HINT, prompt);
} else {
revert ReferenceTypeNotAllowed(referenceType);
}
requestId = p.generateVideo{value: msg.value}(fullPrompt, referenceType, referenceCids);
lastRequestId = requestId;
emit VideoGenerationRequested(requestId, msg.sender, prompt, referenceType, referenceCids);
}
function onVideoRequestSettled(VideoRequestResult calldata result) external override {
if (msg.sender != address(provider)) revert OnlyProvider();
if (result.status == IFlapAIProvider.VideoRequestStatus.FULFILLED) {
latestVideoCid = result.videoCid;
latestFrameCid = result.lastFrameCid;
latestDurationMs = result.durationMs;
lastRequestFailed = false;
emit VideoGenerationFulfilled(result.requestId, result.videoCid, result.lastFrameCid, result.durationMs);
} else if (result.status == IFlapAIProvider.VideoRequestStatus.FAILED) {
lastRequestFailed = true;
lastFailureReason = result.failureReason;
lastFailureDetail = result.failureDetail;
emit VideoGenerationFailed(result.requestId, result.failureReason, result.failureDetail);
}
}
// ---- views ----
function getLastFrame() external view returns (string memory) {
return IFlapAIProviderReferenceTypes(address(provider)).getLastVideoFrameCid(address(this));
}
function getFullSession() external view returns (IFlapAIProvider.VideoClip[] memory) {
uint256 len = provider.getVideoSessionLength(address(this));
return provider.getVideoSessionSlice(address(this), 0, len);
}
}Design notes:
- The session belongs to the consumer contract, because it is the
msg.senderof the provider call. Many callers can extend one shared story. - The character CID is fixed in the constructor. Type-4 calls must pass it as reference image 1, so no caller can swap the character.
- The last frame is read from the provider (
getLastVideoFrameCid(address(this))), not from the consumer's own callback storage. The value is still correct if a callback was skipped or ran out of gas. - The prompt prefix matches the CID order the contract enforces. "Image 1 is the character, image 2 is the previous scene" is always true.
- The callback only records state. It is best-effort and gas-capped, so heavy logic doesn't belong there.
Example: an 8-clip story
This 8-clip story, "The Ice Boy Who Melted Upward", was generated on the testnet consumer above. It alternates the two types as recommended in §3:
| # | Type | referenceCids | Purpose | Prompt (abridged) |
|---|---|---|---|---|
| 1 | 4 | [character] | Establish the character | The ice boy wakes up alone inside a giant snow globe on a kitchen table… |
| 2 | 1 | [] | Seamless continuation | The glass softens like jelly; he pushes through and drops onto the table… |
| 3 | 4 | [character, lastFrame] | New scene, re-anchor character | He follows floating ice cubes out of the window into a sky where it snows upward… |
| 4 | 1 | [] | Seamless continuation | A whale made of folded newspaper swims past; he rides its fin higher… |
| 5 | 4 | [character, lastFrame] | New scene, re-anchor character | The whale delivers him to a floating island where tiny suns sunbathe… |
| 6 | 1 | [] | Seamless continuation | He melts, but his water flows upward into a cloud shaped like his face… |
| 7 | 4 | [character, lastFrame] | Re-anchor character | The cloud rains back down and rebuilds him, covered in crystal flowers… |
| 8 | 1 | [] | Seamless ending | He shakes a new snow globe; the camera reveals the whole world is inside one… |
Calling it with cast:
CONSUMER=0xB7f4398BEECE5Ec0da06497e647107D697c55c66
CHAR=bafkreigo6g3mkveu5w3l7ud56qr4oq3sa62hawcdmybdhbi5agurqwm5ye
RPC=https://bsc-testnet-rpc.publicnode.com
FEE=$(cast call 0xFfddcE44e8cFf7703Fd85118524bfC8B2f70b744 "videoGenerateFee()(uint256)" --rpc-url $RPC | awk '{print $1}')
# Type 4: re-anchor the character, previous last frame as scene reference
LAST=$(cast call $CONSUMER "getLastFrame()(string)" --rpc-url $RPC | tr -d '"')
cast send $CONSUMER "generateVideo(string,uint8,string[])" \
"The ice boy follows a trail of floating ice cubes into a sky where it snows upward." \
4 "[$CHAR,$LAST]" --value $FEE --rpc-url $RPC --private-key <KEY>
# Type 1: seamless continuation (wait for the previous request to settle first)
cast send $CONSUMER "generateVideo(string,uint8,string[])" \
"A whale made of folded newspaper swims past; the ice boy grabs its fin and rides it higher." \
1 "[]" --value $FEE --rpc-url $RPC --private-key <KEY>5. Playing a Session
A session is an ordered list of VideoClips. Every clip is a separate, immutable MPEG-TS file on IPFS (videoCid). Together they are designed to play as one continuous video: load the clip list from the chain, give it to an MPEG-TS player as consecutive segments, and the viewer sees one timeline across all clips.
Why MPEG-TS
Every VideoClip carries startPtsMs, its start time on the session's timeline: 0 for the first clip, then the previous clip's startPtsMs + durationMs (the cumulative duration of all earlier clips). The backend encodes each clip with its timestamps already shifted by that offset, so clip N's first frame is stamped exactly where clip N-1 ended.
This only works because of how MPEG-TS is built:
- No global header. An MPEG-TS file is a flat sequence of self-describing packets with absolute timestamps. Two TS files placed back-to-back (as player segments, or even byte-concatenated) are still a valid stream.
- Timestamps live in the stream. Each clip can be encoded once with its final position on the session timeline. Neither the player nor any server has to rewrite anything.
- Clean splices. Clips are encoded with no B-frames (so presentation and decode timestamps are always equal) and start with a keyframe. Decode time never jumps backwards at a clip boundary, and every clip can be decoded on its own from its first packet.
What this means for you:
| MPEG-TS (what the session uses) | Plain MP4 per clip | |
|---|---|---|
| Append a new clip | Upload one more file; earlier clips never change | Each file has its own header (moov) and a timeline starting at 0 |
| Play clips back-to-back | One continuous timeline, no reset between clips | Player resets or seeks at every clip, or you re-mux everything |
| Infrastructure | Static files on IPFS + the clip list on-chain | Needs re-encoding or a server-generated HLS/DASH manifest |
| Content addressing | Each clip CID is final the moment it's on-chain | A merged file would get a new CID every time it grows |
In short: the session is append-only and content-addressed, and MPEG-TS is the format where "append one more immutable file" and "play everything as one video" are compatible. The on-chain clip list is the playlist.
durationMs and startPtsMs are in milliseconds on-chain. mpegts.js segment durations are in seconds, so always pass durationMs / 1000.
Reading the session
From a contract:
uint256 len = provider.getVideoSessionLength(consumer);
IFlapAIProvider.VideoClip[] memory clips = provider.getVideoSessionSlice(consumer, 0, len);
// clips[i].videoCid → MPEG-TS segment
// clips[i].durationMs → segment duration (ms)
// clips[i].startPtsMs → segment start on the session timeline (ms)
// clips[i].lastFrameCid → thumbnail / reference for the next clipFrom a frontend, with viem. user is the address that called generateVideo (usually your consumer contract):
import { createPublicClient, http, type Address } from "viem";
import { bsc } from "viem/chains"; // use `bscTestnet` for testnet
// FlapAIProvider: BNB Mainnet. Testnet: 0xFfddcE44e8cFf7703Fd85118524bfC8B2f70b744
const FLAP_AI_PROVIDER: Address = "0xaEe3a7Ca6fe6b53f6c32a3e8407eC5A9dF8B7E39";
const abi = [
{
type: "function",
name: "getVideoSessionLength",
stateMutability: "view",
inputs: [{ name: "user", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "getVideoSessionSlice",
stateMutability: "view",
inputs: [
{ name: "user", type: "address" },
{ name: "start", type: "uint256" },
{ name: "count", type: "uint256" },
],
outputs: [
{
name: "clips",
type: "tuple[]",
components: [
{ name: "requestId", type: "uint256" },
{ name: "videoCid", type: "string" },
{ name: "lastFrameCid", type: "string" },
{ name: "durationMs", type: "uint32" },
{ name: "startPtsMs", type: "uint64" },
{ name: "createdAt", type: "uint64" },
{ name: "referenceType", type: "uint8" },
],
},
],
},
] as const;
const client = createPublicClient({ chain: bsc, transport: http() });
/** Reads every fulfilled clip of `user`'s session, in timeline order. */
export async function fetchSession(user: Address) {
const length = await client.readContract({
address: FLAP_AI_PROVIDER,
abi,
functionName: "getVideoSessionLength",
args: [user],
});
if (length === 0n) return [];
return client.readContract({
address: FLAP_AI_PROVIDER,
abi,
functionName: "getVideoSessionSlice",
args: [user, 0n, length],
});
// → readonly { requestId: bigint; videoCid: string; lastFrameCid: string;
// durationMs: number; startPtsMs: bigint; createdAt: bigint; referenceType: number }[]
}
export type VideoClip = Awaited<ReturnType<typeof fetchSession>>[number];For long sessions, page through with getVideoSessionSlice(user, start, count). It clamps count to the end of the session.
Playing with mpegts.js
mpegts.js is an open-source HTML5 MPEG-TS player. It transmuxes TS to fragmented MP4 in the browser and plays it through Media Source Extensions (MSE). Give it all clips as one multi-segment source: it loads and stitches the segments itself, and the native <video> seek bar covers the whole session.
npm install mpegts.jsSegment URLs point at any IPFS gateway: https://<your-gateway>/ipfs/<videoCid>. The gateway must send CORS headers (Access-Control-Allow-Origin), because mpegts.js downloads the segments with fetch. A dedicated gateway is recommended for anything beyond testing.
Minimal example (plain HTML, no build step)
A single HTML file that reads a session from BSC Testnet and plays it:
<!doctype html>
<html>
<body>
<video id="video" controls playsinline style="width: 100%; max-width: 960px"></video>
<!-- mpegts.js UMD build: exposes a global `mpegts` -->
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.8.2/dist/mpegts.js"></script>
<script type="module">
import { createPublicClient, http, parseAbi } from "https://esm.sh/viem@2";
import { bscTestnet } from "https://esm.sh/viem@2/chains";
const PROVIDER = "0xFfddcE44e8cFf7703Fd85118524bfC8B2f70b744"; // FlapAIProvider (BSC Testnet)
const USER = "0x..."; // the requester whose session you want to play
const GATEWAY = "https://<your-gateway>"; // IPFS gateway with CORS enabled
const abi = parseAbi([
"function getVideoSessionLength(address user) view returns (uint256)",
"function getVideoSessionSlice(address user, uint256 start, uint256 count) view returns ((uint256 requestId, string videoCid, string lastFrameCid, uint32 durationMs, uint64 startPtsMs, uint64 createdAt, uint8 referenceType)[])",
]);
const client = createPublicClient({ chain: bscTestnet, transport: http() });
const len = await client.readContract({
address: PROVIDER, abi, functionName: "getVideoSessionLength", args: [USER],
});
const clips = await client.readContract({
address: PROVIDER, abi, functionName: "getVideoSessionSlice", args: [USER, 0n, len],
});
const video = document.getElementById("video");
if (clips.length > 0 && mpegts.isSupported()) {
const player = mpegts.createPlayer({
type: "mse",
isLive: false,
segments: clips.map((c) => ({
url: `${GATEWAY}/ipfs/${c.videoCid}`,
duration: c.durationMs / 1000, // seconds
})),
});
player.attachMediaElement(video);
player.load();
player.play()?.catch(() => {}); // autoplay may need a user click first
} else {
// Fallback: no MSE (or empty session). Offer the individual clips instead.
video.replaceWith(...clips.map((c, i) => {
const a = document.createElement("a");
a.href = `${GATEWAY}/ipfs/${c.videoCid}`;
a.textContent = `Clip ${i + 1} (.ts) `;
return a;
}));
}
</script>
</body>
</html>
React / Next.js component
Sessions grow over time, so a real app re-reads the session (for example every few seconds) and appends new clips as they're fulfilled. mpegts.js can't add a segment to a running player, so the component below rebuilds the player whenever a new clip shows up. It then seeks back to the viewer's current position, so the rebuild doesn't look like a restart.
"use client";
import { useEffect, useRef } from "react";
import type Mpegts from "mpegts.js";
type Player = ReturnType<typeof Mpegts.createPlayer>;
export interface Clip {
requestId: bigint;
videoCid: string;
durationMs: number;
}
const GATEWAY = "https://<your-gateway>"; // IPFS gateway with CORS enabled
export function SessionPlayer({ clips }: { clips: readonly Clip[] }) {
const videoRef = useRef<HTMLVideoElement>(null);
const playerRef = useRef<Player | null>(null);
const builtForRef = useRef<bigint | null>(null); // requestId of the newest clip loaded
// (Re)build the player whenever a new clip is appended to the session.
useEffect(() => {
const video = videoRef.current;
if (!video || clips.length === 0) return;
const newest = clips[clips.length - 1].requestId;
if (builtForRef.current === newest) return; // nothing new (e.g. a poll re-render)
const isFirstBuild = builtForRef.current === null;
const resumeAt = isFirstBuild ? 0 : video.currentTime;
const shouldPlay = isFirstBuild || video.ended || !video.paused;
let cancelled = false;
(async () => {
// Dynamic import: mpegts.js touches `window`, so never load it during SSR.
const mpegts = (await import("mpegts.js")).default;
if (cancelled || !mpegts.isSupported()) return; // no MSE: render a fallback instead
// mpegts.js cannot append a segment to a running player, so replace it.
playerRef.current?.destroy();
const player = mpegts.createPlayer({
type: "mse",
isLive: false,
segments: clips.map((c) => ({
url: `${GATEWAY}/ipfs/${c.videoCid}`,
duration: c.durationMs / 1000, // SECONDS, not milliseconds
})),
});
playerRef.current = player;
player.attachMediaElement(video);
player.load();
builtForRef.current = newest;
// Put the viewer back where they were, so the rebuild is invisible.
if (resumeAt > 0) video.currentTime = resumeAt;
if (shouldPlay) {
const p = player.play();
if (p) p.catch(() => {}); // autoplay may be blocked until the user interacts
}
})();
return () => {
cancelled = true;
};
}, [clips]);
// Tear down on unmount.
useEffect(
() => () => {
playerRef.current?.destroy();
playerRef.current = null;
},
[]
);
return <video ref={videoRef} controls playsInline style={{ width: "100%" }} />;
}
Usage: keep the clips from fetchSession(user) in client state, re-fetch on an interval, and render <SessionPlayer clips={clips} />. Only replace the array when the session actually changed (compare length / last requestId), so that background polls don't trigger rebuilds.
Jump to a clip. Since startPtsMs is each clip's position on the timeline, seeking to clip i is video.currentTime = Number(clips[i].startPtsMs) / 1000. To highlight the clip that's playing, find the last clip whose startPtsMs <= video.currentTime * 1000.
Seeking inside a player built from every clip makes mpegts.js download all earlier clips first, which is slow for later clips. A faster option (used by the demo app) is to rebuild the player from clips.slice(i), so only clip i and later are downloaded. mpegts.js then starts its timeline at clip i, so add clips[i].startPtsMs back when mapping video.currentTime to the session timeline.
Caching clips
Clips are content-addressed, so a /ipfs/<cid> URL never changes and can be cached forever. The browser HTTP cache alone is not reliable for them, especially on mobile: clips are 5–6 MB each and mobile disk caches evict them quickly. The demo app adds a small service worker (clip-cache-sw.js) that keeps every played clip in Cache Storage, keyed by URL, and answers later requests (including Range requests) from there. Browsers may still clear site storage under pressure, and iOS Safari clears it after about 7 days without a visit.
Browser support and fallbacks
- mpegts.js needs Media Source Extensions. Always check
mpegts.isSupported()before creating a player. - Desktop Chrome, Edge, Firefox and Safari are supported. On iPhone, MSE isn't available before iOS 17.1. Newer iOS versions go through Apple's Managed Media Source, which mpegts.js supports, but test on your target devices.
- If MSE is unavailable, a simple fallback is to link or download the individual clips (
https://<your-gateway>/ipfs/<videoCid>) or show thelastFrameCidimages as a storyboard. - Downloading a whole session: because the clips are MPEG-TS with continuous timestamps, concatenating their bytes in order gives one valid
.tsfile (for examplecat clip0.ts clip1.ts … > session.ts). It plays in desktop players such as VLC and mpv, or can be converted to MP4 without re-encoding, e.g.ffmpeg -i session.ts -c copy session.mp4.
6. Interface Reference
Requesting
/// Text-to-video for the first clip, then automatically PREV_LAST_FRAME (legacy).
function generateVideo(string calldata prompt) external payable returns (uint256 requestId);
/// Explicit reference type + caller-supplied reference CIDs (see §3).
function generateVideo(string calldata prompt, uint8 referenceType, string[] calldata referenceCids)
external payable returns (uint256 requestId);
function videoGenerateFee() external view returns (uint256);
function isVideoReferenceTypeEnabled(uint8 referenceType) external view returns (bool);Reading
function getVideoRequest(uint256 requestId) external view returns (VideoRequestView memory);
function getVideoReferenceCids(uint256 requestId) external view returns (uint8 referenceType, string[] memory referenceCids);
function getVideoSessionLength(address user) external view returns (uint256);
function getVideoClip(address user, uint256 index) external view returns (VideoClip memory);
function getVideoSessionSlice(address user, uint256 start, uint256 count) external view returns (VideoClip[] memory);
function getLastVideoFrameCid(address user) external view returns (string memory);
function getLastVideoRequestId(address user) external view returns (uint256);
function videoCallbackGasLimit() external view returns (uint256);Data types
enum VideoRequestStatus { NONE, PENDING, FULFILLED, FAILED }
enum VideoFailureReason { NONE, CONTENT_POLICY, PROVIDER_ERROR, PIPELINE_ERROR, TIMEOUT, OTHER }
struct VideoClip {
uint256 requestId;
string videoCid; // MPEG-TS clip
string lastFrameCid; // JPEG of the clip's last frame
uint32 durationMs;
uint64 startPtsMs; // start on the session's continuous timeline
uint64 createdAt;
uint8 referenceType; // VIDEO_REF_* used for this clip
}
struct VideoRequestView {
uint256 requestId;
address requester;
VideoRequestStatus status;
uint8 referenceType;
uint64 timestamp;
uint128 feePaid;
VideoFailureReason failureReason;
}Callback (optional)
interface IFlapVideoReceiver {
struct VideoRequestResult {
uint256 requestId;
IFlapAIProvider.VideoRequestStatus status; // FULFILLED or FAILED
string videoCid; // FULFILLED only
string lastFrameCid; // FULFILLED only
uint32 durationMs; // FULFILLED only
IFlapAIProvider.VideoFailureReason failureReason; // FAILED only
string failureDetail; // FAILED only
}
function onVideoRequestSettled(VideoRequestResult calldata result) external;
}The callback runs after the clip has been appended to the session. It gets at most videoCallbackGasLimit() gas. A revert, out-of-gas, or missing implementation is tolerated and recorded in FlapAIProviderVideoCallbackAttempted(requestId, requester, success). EOA requesters are never called back.
CONTENT_POLICY means the same prompt would be refused again, so change it before retrying. PROVIDER_ERROR / TIMEOUT mean a fresh request may succeed. A FAILED request never adds a clip, so the next type-1 request still continues from the last fulfilled clip.
Events
event FlapAIProviderVideoRequested(
uint256 indexed requestId, address indexed requester, string prompt,
uint8 referenceType, string[] referenceCids, uint256 feePaid
);
event FlapAIProviderVideoFulfilled(
uint256 indexed requestId, address indexed requester, string videoCid,
string lastFrameCid, uint32 durationMs, uint64 startPtsMs, uint256 sessionIndex
);
event FlapAIProviderVideoFailed(
uint256 indexed requestId, address indexed requester, VideoFailureReason reason, string failureDetail
);
event FlapAIProviderVideoCallbackAttempted(uint256 indexed requestId, address indexed requester, bool success);In FlapAIProviderVideoRequested, referenceCids is already resolved: for type 1 it holds the previous clip's last-frame CID.
Errors
| Error | When |
|---|---|
FlapAIProviderWrongVideoFee(sent, required) | msg.value != videoGenerateFee() |
FlapAIProviderEmptyVideoPrompt() | Empty prompt |
FlapAIProviderVideoRequestAlreadyPending(requester, pendingRequestId) | The requester already has a pending request |
FlapAIProviderUnsupportedReferenceType(referenceType) | Type not enabled |
FlapAIProviderBadReferenceCidCount(referenceType, count) | Wrong number of CIDs for the type |
FlapAIProviderInvalidReferenceCid(index) | Empty CID or CID > 128 bytes |
FlapAIProviderNoPreviousClip(requester) | Type 1 with an empty session |
7. Checklist
- Read
videoGenerateFee()at call time and send exactly that amount - Handle the one-pending-request rule (check
getVideoRequest(lastRequestId).statusbefore submitting) - Start every session with type
0,2or4. Type1needs a previous clip - Pin your character image to IPFS and keep its CID fixed in your contract
- Use type
4[character, lastFrame]to keep the character consistent, and type1for seamless joins. Alternate them - In type-4 prompts, say which reference image is which, and restate the character's key traits
- Read the last frame from
getLastVideoFrameCid(address(this)), not from callback-only storage - Keep
onVideoRequestSettledlight and checkmsg.sender == provider