Skip to main content
This guide shows you how to embed the Pictory storyboard preview player in your web application using an iframe. You’ll learn to display video previews, communicate with the player via postMessage, and update preview elements in real-time without re-rendering.

Preview Player Demo

Here’s how the embedded preview player looks when integrated into your application:

Pictory Preview Player embedded in an iframe with full playback controls

The preview player supports play/pause, seek, fullscreen controls, and responds to real-time element updates via postMessage. You can replace background visuals, modify text, and adjust settings without re-rendering the video.
Stock Visual Watermarks: The preview may display watermarked stock visuals from media libraries. These watermarks are automatically removed when you render the final video.

What You’ll Learn

Embed Preview

Display the preview player in an iframe

Player Communication

Use postMessage API to communicate with the player

Real-time Updates

Update preview elements without re-rendering

Event Handling

Handle player events like loaded and error states

Before You Begin

Make sure you have:
  • A Pictory API key (get one here)
  • A completed storyboard preview job with a previewUrl
  • Basic knowledge of JavaScript and iframe communication

Workflow Overview

Step 1: Create Storyboard Preview

First, create a storyboard preview using the Create Storyboard Preview API:
import axios from "axios";

const API_BASE_URL = "https://api.pictory.ai/pictoryapis";
const API_KEY = "YOUR_API_KEY";

async function createStoryboardPreview() {
  const response = await axios.post(
    `${API_BASE_URL}/v2/video/storyboard`,
    {
      videoName: "my_preview_video",
      voiceOver: {
        enabled: true,
        aiVoices: [{ speaker: "Brian", speed: 100 }]
      },
      scenes: [
        {
          story: "Welcome to our product demo. Discover amazing features that will transform your workflow.",
          createSceneOnEndOfSentence: true
        }
      ]
    },
    {
      headers: {
        "Content-Type": "application/json",
        Authorization: API_KEY
      }
    }
  );

  return response.data.data.jobId;
}

Step 2: Get Preview URL and Render Params

Poll the job status until it’s completed to get the previewUrl and renderParams:
async function getPreviewData(jobId) {
  while (true) {
    const response = await axios.get(
      `${API_BASE_URL}/v1/jobs/${jobId}`,
      { headers: { Authorization: API_KEY } }
    );

    const { status, previewUrl, renderParams, storyboard } = response.data.data;

    if (status === "completed") {
      console.log("Preview ready!");
      return { previewUrl, renderParams, storyboard };
    } else if (status === "failed") {
      throw new Error("Preview generation failed");
    }

    // Wait 3 seconds before polling again
    await new Promise(resolve => setTimeout(resolve, 3000));
  }
}

// Usage
const jobId = await createStoryboardPreview();
const { previewUrl, renderParams, storyboard } = await getPreviewData(jobId);

console.log("Preview URL:", previewUrl);
console.log("Elements count:", renderParams.elements.length);
Example Job Response:
{
  "job_id": "be990033-b282-4809-b733-be5ac961770b",
  "success": true,
  "data": {
    "status": "completed",
    "renderParams": {
      "output": {
          "width": 1920,
          "height": 1080,
          "format": "mp4",
          "name": "demo_text_to_video.mp4"
      },
      "elements": [
        {
          "id": "backgroundElement_1",
          "elementType": "backgroundElement",
          "type": "video",
          "url": "https://example.com/video.mp4",
          "backgroundColor": "rgba(255,255,255,1)",
          "xPercent": "0%",
          "yPercent": "0%",
          "width": "100%",
          "objectMode": "cover",
          "loop": true,
          "mute": true,
          "segments": [],
          "startTime": 0,
          "duration": 6.86,
          "cursor": "default",
          "visualUrl": "https://example.com/video.mp4",
          "visualType": "video",
          "visualDescription": "AI Generated Background Video"
        }
      ]
    },
    "storyboard": { },
    "previewUrl": "https://video.pictory.ai/v2/preview/318e78f8-aec4-4217-a787-42f7e1302a2b?mode=player"
  }
}

Background Element Structure

The backgroundElement is the core visual element for each scene. Key properties you can modify:
PropertyTypeDescription
urlstringThe media URL for the background video or image
visualUrlstringSame as url, the visual media source
typestringMedia type: video or image
visualTypestringSame as type, the visual media type
durationnumberDuration in seconds
loopbooleanWhether to loop the video
mutebooleanWhether to mute video audio
backgroundColorstringFallback background color (RGBA format)
objectModestringHow media fits: cover, contain, fill

Step 3: Embed Preview Player in iframe

Basic HTML Implementation

<!DOCTYPE html>
<html>
<head>
  <title>Pictory Preview Player</title>
  <style>
    #preview-container {
      width: 100%;
      max-width: 1280px;
      height: 720px;
      margin: 0 auto;
      position: relative;
    }

    #preview-iframe {
      width: 100%;
      height: 100%;
      border: none;
    }

    .loader {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }
  </style>
</head>
<body>
  <div id="preview-container">
    <div id="loader" class="loader">Loading preview...</div>
    <iframe
      id="preview-iframe"
      src="https://video.pictory.ai/v2/preview/YOUR_PREVIEW_ID?mode=player"
      allow="autoplay; fullscreen"
      style="opacity: 0;"
    ></iframe>
  </div>

  <script src="preview-editor.js"></script>
  <script>
    // Initialize after page loads
    document.addEventListener('DOMContentLoaded', initPreview);
  </script>
</body>
</html>

Preview Editor Class

Create a preview-editor.js file with the PreviewEditor class that handles iframe communication:
import { v4 as uuid } from 'uuid';

export class PreviewEditor {
  constructor(containerElement, previewUrl, options = {}) {
    this.options = options;
    this.inProgressTasks = {};
    this.loaded = false;
    this.onLoaded = null;
    this.onError = null;

    // Create iframe
    const iframe = document.createElement('iframe');
    iframe.setAttribute('src', previewUrl);
    iframe.style.border = 'none';
    iframe.style.height = '100%';
    iframe.style.width = '100%';
    iframe.allow = 'autoplay; fullscreen';

    // Clear container and append iframe
    containerElement.innerHTML = '';
    containerElement.appendChild(iframe);

    this.iframe = iframe;

    // Listen for messages from the iframe
    window.addEventListener('message', this.receiveEditorMessages);
  }

  // Clean up resources
  close() {
    window.removeEventListener('message', this.receiveEditorMessages);
    if (this.iframe.parentNode) {
      this.iframe.parentNode.removeChild(this.iframe);
      this.iframe.setAttribute('src', '');
    }
    this.inProgressTasks = {};
  }

  // Update preview elements
  updatePreview = async (renderParams) => {
    await this.executeEditorAction({
      message: 'UPDATE_PREVIEW_ELEMENTS',
      elements: renderParams.elements
    }).catch(error => {
      throw new Error(`Failed to update preview: ${error.message}`);
    });
  }

  // Execute action on the editor iframe
  executeEditorAction = async (message, payload) => {
    if (!this.loaded) {
      throw new Error('The Editor is not loaded.');
    }

    const id = uuid();
    if (this.iframe.contentWindow) {
      this.iframe.contentWindow.postMessage(
        { id, ...JSON.parse(JSON.stringify(message)), ...payload },
        '*'
      );
    }

    // Return promise that resolves when iframe responds
    return new Promise((resolve, reject) => {
      this.inProgressTasks[id] = { resolve, reject };
    });
  }

  // Handle messages from the iframe
  receiveEditorMessages = async (event) => {
    if (!event.data || typeof event.data !== 'object') {
      return;
    }

    // Verify message is from our iframe
    if (this.iframe.contentWindow !== event.source) {
      return;
    }

    const { id, message, error, ...args } = event.data;

    if (id) {
      // Resolve pending promise
      const inProgressTask = this.inProgressTasks[id];
      if (inProgressTask) {
        if (error) {
          inProgressTask.reject(new Error(error));
        } else {
          inProgressTask.resolve(args);
        }
        delete this.inProgressTasks[id];
      }
    } else {
      // Handle broadcast messages
      switch (message) {
        case 'ON_LOADED':
          this.loaded = true;
          if (this.onLoaded) {
            await this.onLoaded(this);
          }
          break;
        case 'ON_ERROR':
          this.errored = true;
          if (this.onError) {
            await this.onError(this, error);
          }
          break;
      }
    }
  }
}

Step 4: React Integration

Here’s a complete React component for embedding the preview player:
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PreviewEditor } from './previewEditor';

const PreviewPlayer = ({
  previewUrl,
  renderParams,
  onLoaded,
  onError
}) => {
  const containerRef = useRef(null);
  const [editor, setEditor] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  // Initialize preview editor on mount
  useLayoutEffect(() => {
    if (!containerRef.current || !previewUrl) return;

    const previewEditor = new PreviewEditor(
      containerRef.current,
      previewUrl
    );

    // Handle loaded event
    previewEditor.onLoaded = async (editor) => {
      setIsLoading(false);
      onLoaded?.(editor);
    };

    // Handle error event
    previewEditor.onError = (editor, error) => {
      setIsLoading(false);
      onError?.(error);
    };

    setEditor(previewEditor);

    // Cleanup on unmount
    return () => {
      previewEditor.close();
    };
  }, [previewUrl]);

  // Update preview when renderParams change
  useEffect(() => {
    if (editor && renderParams && editor.loaded) {
      editor.updatePreview(renderParams);
    }
  }, [renderParams, editor]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      {isLoading && (
        <div style={{
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          zIndex: 10
        }}>
          Loading preview...
        </div>
      )}
      <div
        ref={containerRef}
        style={{
          width: '100%',
          height: '100%',
          opacity: isLoading ? 0 : 1,
          transition: 'opacity 0.3s ease'
        }}
      />
    </div>
  );
};

export default PreviewPlayer;

Usage in Your App

import React, { useState, useEffect } from 'react';
import PreviewPlayer from './PreviewPlayer';
import { createStoryboardPreview, getPreviewData } from './api';

function App() {
  const [previewUrl, setPreviewUrl] = useState(null);
  const [renderParams, setRenderParams] = useState(null);
  const [isGenerating, setIsGenerating] = useState(false);

  const generatePreview = async () => {
    setIsGenerating(true);
    try {
      const jobId = await createStoryboardPreview();
      const data = await getPreviewData(jobId);

      setPreviewUrl(data.previewUrl);
      setRenderParams(data.renderParams);
    } catch (error) {
      console.error('Failed to generate preview:', error);
    } finally {
      setIsGenerating(false);
    }
  };

  // Find and update background element by ID
  const updateBackgroundVisual = (elementId, newVisualUrl, visualType = 'video') => {
    const updatedElements = renderParams.elements.map(element => {
      if (element.id === elementId && element.elementType === 'backgroundElement') {
        return {
          ...element,
          url: newVisualUrl,
          visualUrl: newVisualUrl,
          type: visualType,
          visualType: visualType
        };
      }
      return element;
    });

    // Update renderParams to trigger preview update
    setRenderParams({
      ...renderParams,
      elements: updatedElements
    });
  };

  // Get background elements from renderParams
  const backgroundElements = renderParams?.elements?.filter(
    el => el.elementType === 'backgroundElement'
  ) || [];

  return (
    <div style={{ padding: '20px' }}>
      <button onClick={generatePreview} disabled={isGenerating}>
        {isGenerating ? 'Generating...' : 'Generate Preview'}
      </button>

      {previewUrl && (
        <div style={{ marginTop: '20px', height: '500px' }}>
          <PreviewPlayer
            previewUrl={previewUrl}
            renderParams={renderParams}
            onLoaded={() => console.log('Preview loaded!')}
            onError={(err) => console.error('Preview error:', err)}
          />
        </div>
      )}

      {backgroundElements.length > 0 && (
        <div style={{ marginTop: '20px' }}>
          <h3>Change Background Visual:</h3>
          {backgroundElements.map((element, index) => (
            <div key={element.id} style={{ marginBottom: '15px' }}>
              <label>Scene {index + 1} Background ({element.visualType}):</label>
              <input
                type="text"
                defaultValue={element.visualUrl}
                placeholder="Enter new video or image URL"
                onChange={(e) => updateBackgroundVisual(element.id, e.target.value)}
                style={{ width: '100%', padding: '10px', marginTop: '5px' }}
              />
              <small style={{ color: '#666' }}>
                Current: {element.visualDescription || 'No description'}
              </small>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default App;

Step 5: Update Preview Elements in Real-time

The key feature is updating preview elements without re-rendering the entire video. When you modify the elements array and call updatePreview(), the player updates instantly.

Replacing Background Visual

To replace a scene’s background video or image, find the backgroundElement and update its url and visualUrl properties:
// Get the current elements from renderParams
const elements = [...renderParams.elements];

// Find the background element you want to update
const backgroundElement = elements.find(
  el => el.id === 'backgroundElement_1' && el.elementType === 'backgroundElement'
);

if (backgroundElement) {
  // Update the visual URL (both url and visualUrl should be updated)
  backgroundElement.url = "https://your-cdn.com/new-background-video.mp4";
  backgroundElement.visualUrl = "https://your-cdn.com/new-background-video.mp4";
}

// Update the preview
await previewEditor.updatePreview({ elements });

Background Element Properties

PropertyTypeDescriptionExample
urlstringPrimary media URL"https://cdn.com/video.mp4"
visualUrlstringVisual media URL (same as url)"https://cdn.com/video.mp4"
typestringMedia type"video" or "image"
visualTypestringVisual media type"video" or "image"
durationnumberDuration in seconds6.86
loopbooleanLoop video playbacktrue
mutebooleanMute video audiotrue
backgroundColorstringFallback color"rgba(255,255,255,1)"
objectModestringMedia fit mode"cover", "contain", "fill"

Example: Building a Background Visual Editor

const BackgroundVisualEditor = ({ elements, onElementsChange }) => {
  // Filter only background elements
  const backgroundElements = elements.filter(
    el => el.elementType === 'backgroundElement'
  );

  const updateBackgroundElement = (elementId, updates) => {
    const newElements = elements.map(element => {
      if (element.id === elementId) {
        return { ...element, ...updates };
      }
      return element;
    });
    onElementsChange(newElements);
  };

  return (
    <div>
      <h3>Background Visuals</h3>
      {backgroundElements.map((element, index) => (
        <div
          key={element.id}
          style={{
            marginBottom: '20px',
            padding: '15px',
            border: '1px solid #ccc',
            borderRadius: '8px'
          }}
        >
          <h4>Scene {index + 1} Background</h4>

          {/* Visual URL Editor */}
          <div style={{ marginBottom: '10px' }}>
            <label>Visual URL:</label>
            <input
              type="text"
              value={element.visualUrl || ''}
              onChange={(e) => {
                updateBackgroundElement(element.id, {
                  url: e.target.value,
                  visualUrl: e.target.value
                });
              }}
              placeholder="Enter video or image URL"
              style={{ width: '100%', padding: '8px', marginTop: '4px' }}
            />
          </div>

          {/* Visual Type Selector */}
          <div style={{ marginBottom: '10px' }}>
            <label>Media Type:</label>
            <select
              value={element.visualType || 'video'}
              onChange={(e) => {
                updateBackgroundElement(element.id, {
                  type: e.target.value,
                  visualType: e.target.value
                });
              }}
              style={{ width: '100%', padding: '8px', marginTop: '4px' }}
            >
              <option value="video">Video</option>
              <option value="image">Image</option>
            </select>
          </div>
        </div>
      ))}
    </div>
  );
};

Replacing Multiple Backgrounds

// Replace all background visuals with new URLs from an array
function replaceAllBackgrounds(elements, newVisuals) {
  let visualIndex = 0;

  return elements.map(element => {
    if (element.elementType === 'backgroundElement' && newVisuals[visualIndex]) {
      const newVisual = newVisuals[visualIndex];
      visualIndex++;

      return {
        ...element,
        url: newVisual.url,
        visualUrl: newVisual.url,
        type: newVisual.type || 'video',
        visualType: newVisual.type || 'video',
        visualDescription: newVisual.description || element.visualDescription
      };
    }
    return element;
  });
}

// Usage
const newVisuals = [
  { url: 'https://cdn.com/scene1-background.mp4', type: 'video', description: 'Intro animation' },
  { url: 'https://cdn.com/scene2-background.jpg', type: 'image', description: 'Product shot' },
  { url: 'https://cdn.com/scene3-background.mp4', type: 'video', description: 'Demo footage' }
];

const updatedElements = replaceAllBackgrounds(renderParams.elements, newVisuals);
await previewEditor.updatePreview({ elements: updatedElements });

PostMessage API Reference

Messages Sent TO the Preview Player

MessagePayloadDescription
UPDATE_PREVIEW_ELEMENTS{ elements: [] }Update the preview with new element data

Messages Received FROM the Preview Player

MessagePayloadDescription
ON_LOADEDNonePlayer has finished loading and is ready
ON_ERROR{ error: string }An error occurred in the player

Message Format

All messages follow this structure:
// Outgoing message (to player)
{
  id: "unique-message-id",  // For tracking response
  message: "UPDATE_PREVIEW_ELEMENTS",
  elements: [...]
}

// Incoming message (from player)
{
  id: "unique-message-id",  // Matches outgoing message
  // ... response data
}

// Broadcast message (from player, no id)
{
  message: "ON_LOADED"
}

Complete Integration Example

Here’s a complete end-to-end example:
import axios from 'axios';
import { v4 as uuid } from 'uuid';

// ============================================
// API Functions
// ============================================

const API_BASE_URL = 'https://api.pictory.ai/pictoryapis';
const API_KEY = 'YOUR_API_KEY';

async function createPreview(story) {
  const response = await axios.post(
    `${API_BASE_URL}/v2/video/storyboard`,
    {
      videoName: 'embedded_preview_demo',
      voiceOver: { enabled: true, aiVoices: [{ speaker: 'Brian', speed: 100 }] },
      scenes: [{ story, createSceneOnEndOfSentence: true }]
    },
    { headers: { Authorization: API_KEY, 'Content-Type': 'application/json' } }
  );
  return response.data.data.jobId;
}

async function waitForPreview(jobId) {
  while (true) {
    const response = await axios.get(
      `${API_BASE_URL}/v1/jobs/${jobId}`,
      { headers: { Authorization: API_KEY } }
    );

    const { status, previewUrl, renderParams } = response.data.data;

    if (status === 'completed') {
      return { previewUrl, renderParams };
    } else if (status === 'failed') {
      throw new Error('Preview generation failed');
    }

    await new Promise(resolve => setTimeout(resolve, 3000));
  }
}

// ============================================
// Preview Editor Class
// ============================================

class PreviewEditor {
  constructor(container, url) {
    this.inProgressTasks = {};
    this.loaded = false;

    const iframe = document.createElement('iframe');
    iframe.src = url;
    iframe.style.cssText = 'border:none;width:100%;height:100%';
    iframe.allow = 'autoplay; fullscreen';

    container.innerHTML = '';
    container.appendChild(iframe);
    this.iframe = iframe;

    window.addEventListener('message', this.handleMessage);
  }

  close() {
    window.removeEventListener('message', this.handleMessage);
    this.iframe?.remove();
  }

  async updatePreview(renderParams) {
    if (!this.loaded) throw new Error('Not loaded');

    const id = uuid();
    this.iframe.contentWindow.postMessage({
      id,
      message: 'UPDATE_PREVIEW_ELEMENTS',
      elements: renderParams.elements
    }, '*');

    return new Promise((resolve, reject) => {
      this.inProgressTasks[id] = { resolve, reject };
    });
  }

  handleMessage = (event) => {
    if (event.source !== this.iframe?.contentWindow) return;

    const { id, message, error } = event.data || {};

    if (id && this.inProgressTasks[id]) {
      const task = this.inProgressTasks[id];
      error ? task.reject(new Error(error)) : task.resolve(event.data);
      delete this.inProgressTasks[id];
    } else if (message === 'ON_LOADED') {
      this.loaded = true;
      this.onLoaded?.(this);
    } else if (message === 'ON_ERROR') {
      this.onError?.(this, error);
    }
  };
}

// ============================================
// Usage
// ============================================

async function initializePreview() {
  const container = document.getElementById('preview-container');
  const statusEl = document.getElementById('status');

  statusEl.textContent = 'Creating preview...';

  // Create and wait for preview
  const jobId = await createPreview(
    'Welcome to our product demo. Discover amazing features. Start your journey today.'
  );

  statusEl.textContent = 'Generating preview...';
  const { previewUrl, renderParams } = await waitForPreview(jobId);

  statusEl.textContent = 'Loading player...';

  // Initialize editor
  const editor = new PreviewEditor(container, previewUrl);

  editor.onLoaded = () => {
    statusEl.textContent = 'Preview ready!';

    // Store for later updates
    window.previewEditor = editor;
    window.renderParams = renderParams;
  };

  editor.onError = (_, error) => {
    statusEl.textContent = `Error: ${error}`;
  };
}

// Update background visual with new URL
function updateBackgroundVisual(elementId, newUrl, visualType = 'video') {
  const { previewEditor, renderParams } = window;

  // Find and update the background element
  const updatedElements = renderParams.elements.map(element => {
    if (element.id === elementId && element.elementType === 'backgroundElement') {
      return {
        ...element,
        url: newUrl,
        visualUrl: newUrl,
        type: visualType,
        visualType: visualType
      };
    }
    return element;
  });

  renderParams.elements = updatedElements;
  previewEditor.updatePreview(renderParams);
}

// Example: Replace first background with a new video
function replaceFirstBackground() {
  updateBackgroundVisual(
    'backgroundElement_1',
    'https://your-cdn.com/new-product-video.mp4',
    'video'
  );
}

// Start initialization
initializePreview();

Best Practices

Always show a loading indicator while the preview is initializing. The player may take a few seconds to load depending on network conditions.
{isLoading && <LoadingSpinner />}
<div style={{ opacity: isLoading ? 0 : 1 }}>
  {/* Preview container */}
</div>
Always call close() when unmounting the component or navigating away to prevent memory leaks and remove event listeners.
useEffect(() => {
  return () => {
    editor?.close();
  };
}, [editor]);
When building live editors, debounce updates to prevent overwhelming the player with rapid changes.
import { debounce } from 'lodash';

const debouncedUpdate = debounce((elements) => {
  editor.updatePreview({ elements });
}, 300);
Implement comprehensive error handling for network failures and player errors.
editor.onError = (_, error) => {
  console.error('Player error:', error);
  showErrorNotification('Failed to load preview. Please try again.');
};

Troubleshooting

Problem: The iframe shows a blank screen or loading forever.Solutions:
  • Verify the previewUrl is valid and not expired
  • Check browser console for CORS or security errors
  • Verify your domain is allowed to embed the preview
Problem: Calling updatePreview() doesn’t change the preview.Solutions:
  • Ensure editor.loaded is true before calling update
  • Check that elements array structure is correct
  • Verify the message is being received (check browser console)
  • Wait for the ON_LOADED event before updating
Problem: Messages are not being received by the iframe.Solutions:
  • Verify iframe.contentWindow is not null
  • Check that you’re listening for messages correctly
  • Ensure the message origin matches expected sources
  • Use browser dev tools to inspect postMessage traffic
Problem: Application becomes slow after multiple preview loads.Solutions:
  • Always call editor.close() when done
  • Remove event listeners in cleanup functions
  • Clear the iframe src before removing
  • Use React’s useEffect cleanup or componentWillUnmount

Next Steps