> ## 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.

# Frameworks Recent Announcements

> Latest community activity, Discord announcements, and GitHub updates for Frameworks.

export const frameworksSocials = [{
  icon: 'globe',
  href: 'https://frameworks.network/',
  label: 'Frameworks'
}, {
  icon: 'file-code',
  href: 'https://docs.frameworks.network/',
  label: 'Frameworks Docs'
}, {
  icon: 'grid',
  href: 'https://app.frameworks.network/',
  label: 'Frameworks App'
}, {
  icon: 'github',
  href: 'https://github.com/livepeer-frameworks/monorepo',
  label: 'Frameworks GitHub'
}, {
  icon: 'envelope',
  href: 'https://frameworks.network/contact',
  label: 'Contact'
}];

export const frameworksDiscordData = [{
  id: "1485297913952210974",
  content: "FrameWorks #announcements",
  author: "_alisonwonderland",
  timestamp: "2026-03-22T15:23:38.187Z",
  url: "https://discord.com/channels/1066890817425387581/1485293282991800350/1485297913952210974"
}];

export const youtubeDataStatic = [{
  title: 'Livepeer Fireside with the Frameworks SPE',
  href: 'https://www.youtube.com/watch?v=DKBRp0U-RKw',
  author: 'By Livepeer',
  content: '...',
  publishedDate: 'Feb 24, 2025'
}];

export const YouTubeVideoData = ({items = [], limit, cols = 2, className = "", style = {}, ...rest}) => {
  const displayItems = limit ? items.slice(0, limit) : items;
  if (!displayItems || displayItems.length === 0) {
    return <Note>
        <p style={{
      color: "var(--text-secondary)",
      textAlign: "center"
    }}>
          No videos at this time.
        </p>
      </Note>;
  }
  const getEmbedUrl = href => {
    if (!href) return "";
    const videoId = href.split("v=")[1]?.split("&")[0];
    return videoId ? `https://www.youtube.com/embed/${videoId}` : href;
  };
  return <Columns cols={cols} className={className} style={style} {...rest}>
      {displayItems.map((item, idx) => {
    if (!item || !item.href) return null;
    const needsSpacer = idx > 0 && idx % cols === 0;
    return <>
            {needsSpacer && <div key={`spacer-${idx}`} style={{
      height: "var(--lp-spacing-6)",
      width: "100%"
    }} />}
            {needsSpacer && <div key={`spacer2-${idx}`} style={{
      height: "var(--lp-spacing-6)",
      width: "100%"
    }} />}
            <YouTubeVideo key={item.href || idx} embedUrl={getEmbedUrl(item.href)} title={item.title || ""} caption={`${item.author || ""} • ${item.publishedDate || ""}`} />
          </>;
  })}
    </Columns>;
};

export const YouTubeVideo = ({embedUrl, url, title = "", author = "", hint = "", caption, className = "", style = {}, ...rest}) => {
  const toEmbedUrl = value => {
    if (!value || typeof value !== "string") return "";
    const source = value.trim();
    if (!source) return "";
    try {
      const parsed = new URL(source);
      const host = parsed.hostname.replace(/^www\./, "");
      if (host === "youtube.com" || host === "youtube-nocookie.com") {
        if (parsed.pathname.startsWith("/embed/")) return source;
        const videoId = parsed.pathname.startsWith("/shorts/") ? parsed.pathname.split("/").filter(Boolean)[1] : parsed.searchParams.get("v");
        if (!videoId) return "";
        const params = new URLSearchParams(parsed.search);
        params.delete("v");
        const query = params.toString();
        return `https://www.youtube.com/embed/${videoId}${query ? `?${query}` : ""}`;
      }
      if (host === "youtu.be") {
        const videoId = parsed.pathname.split("/").filter(Boolean)[0];
        if (!videoId) return "";
        const query = parsed.searchParams.toString();
        return `https://www.youtube.com/embed/${videoId}${query ? `?${query}` : ""}`;
      }
    } catch (_err) {
      return "";
    }
    return "";
  };
  const resolvedEmbedUrl = toEmbedUrl(embedUrl || url);
  if (!resolvedEmbedUrl) {
    return null;
  }
  const isValidYouTubeUrl = resolvedEmbedUrl.includes("youtube.com/embed/");
  if (!isValidYouTubeUrl) {
    console.warn("Invalid YouTube embed URL:", embedUrl || url);
    return null;
  }
  const buildCaption = () => {
    if (caption) return caption;
    if (!author && !title) return null;
    return <span style={{
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      textAlign: "center",
      lineHeight: 1.2
    }}>
        <span>
          {author && <>
              <Icon icon="microphone" size={16} /> <strong>{author}</strong>
            </>}
          {author && title ? `${" "} • ${title}` : title}
        </span>
      </span>;
  };
  const captionContent = buildCaption();
  return <Frame className={className} style={style} {...hint ? {
    hint
  } : {}} {...captionContent ? {
    caption: captionContent
  } : {}} {...rest}>
      <iframe className="w-full aspect-video rounded-xl" src={resolvedEmbedUrl} title={title || author || "YouTube Video"} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen />
    </Frame>;
};

export const BorderedBox = ({children, variant = "default", padding = "var(--lp-spacing-4)", borderRadius = "var(--lp-spacing-px-8)", margin = "", accentBar = "", style = {}, className = "", ...rest}) => {
  const variants = {
    default: {
      border: "1px solid var(--lp-color-border-default)",
      backgroundColor: "var(--lp-color-bg-card)"
    },
    accent: {
      border: "1px solid var(--lp-color-accent)",
      backgroundColor: "var(--lp-color-bg-card)"
    },
    muted: {
      border: "1px solid var(--lp-color-border-default)",
      backgroundColor: "transparent"
    }
  };
  const accentBarColors = {
    accent: "var(--lp-color-accent)",
    positive: "var(--green-9)"
  };
  return <div data-docs-bordered-box="" data-accent-bar={accentBarColors[accentBar] ? "" : undefined} className={className} style={{
    ...variants[variant],
    padding: padding,
    borderRadius: borderRadius,
    ...margin ? {
      margin
    } : {},
    ...accentBarColors[accentBar] ? {
      position: "relative",
      '--accent-bar-color': accentBarColors[accentBar]
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

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 LazyLoad = ({children, height = "200px", offset = "200px", fadeDuration = 400, className = "", style = {}, ...rest}) => {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  const [ready, setReady] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setVisible(true);
        observer.disconnect();
      }
    }, {
      rootMargin: offset
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    if (!visible) return;
    const frameId = requestAnimationFrame(() => {
      setReady(true);
    });
    return () => cancelAnimationFrame(frameId);
  }, [visible]);
  const placeholder = <div ref={ref} className={className} style={{
    minHeight: height,
    ...style
  }} {...rest} />;
  if (!visible) return placeholder;
  return <div ref={ref} className={className} style={{
    opacity: ready ? 1 : 0,
    transition: `opacity ${fadeDuration}ms ease-in`,
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const ScrollBox = ({children, maxHeight = 300, showHint = true, ariaLabel = "Scrollable content", style = {}, className = "", ...rest}) => {
  const contentRef = useRef(null);
  const [isOverflowing, setIsOverflowing] = useState(false);
  useEffect(() => {
    const checkOverflow = () => {
      if (contentRef.current) {
        const maxHeightPx = typeof maxHeight === "number" ? maxHeight : parseInt(maxHeight, 10) || 300;
        setIsOverflowing(contentRef.current.scrollHeight > maxHeightPx);
      }
    };
    checkOverflow();
    window.addEventListener("resize", checkOverflow);
    return () => window.removeEventListener("resize", checkOverflow);
  }, [maxHeight, children]);
  return <div className={className} style={{
    position: "relative",
    ...style
  }} {...rest}>
      <div ref={contentRef} role="region" tabIndex={0} aria-label={ariaLabel} style={{
    maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
    overflowY: "auto",
    paddingRight: 4
  }} onScroll={e => {
    const el = e.target;
    const atBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 10;
    const hint = el.parentNode.querySelector("[data-scroll-hint]");
    if (hint) hint.style.opacity = atBottom ? "0" : "1";
  }}>
        {children}
      </div>
      {showHint && isOverflowing && <div data-scroll-hint style={{
    fontSize: 11,
    color: "var(--lp-color-text-muted)",
    textAlign: "center",
    marginTop: 8,
    transition: "opacity 0.2s"
  }}>
          Scroll for more ↓
        </div>}
    </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 MarkdownEmbed = ({url, className = '', style = {}, ...rest}) => {
  const [html, setHtml] = useState('');
  useEffect(() => {
    fetch(url).then(res => res.text()).then(md => {
      const converted = md.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%" />').replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>').replace(/^######\s+(.+?)(?:\s+#+)?$/gm, '<h6>$1</h6>').replace(/^#####\s+(.+?)(?:\s+#+)?$/gm, '<h5>$1</h5>').replace(/^####\s+(.+?)(?:\s+#+)?$/gm, '<h4>$1</h4>').replace(/^###\s+(.+?)(?:\s+#+)?$/gm, '<h3>$1</h3>').replace(/^##\s+(.+?)(?:\s+#+)?$/gm, '<h2>$1</h2>').replace(/^#\s+(.+?)(?:\s+#+)?$/gm, '<h1>$1</h1>').replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/^---$/gm, '<hr />').replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br />');
      setHtml('<p>' + converted + '</p>');
    });
  }, [url]);
  if (!html) return <div className={className} style={style} {...rest}>
        <p style={{
    color: 'var(--text-secondary)'
  }}>Loading...</p>
      </div>;
  return <div className={className} {...rest} style={{
    maxWidth: '100%',
    overflowWrap: 'anywhere',
    wordBreak: 'break-word',
    ...style
  }} dangerouslySetInnerHTML={{
    __html: html
  }} />;
};

export const DiscordAnnouncements = ({serverName = 'Livepeer', items = [], limit, ScrollBox, scrollMaxHeight = 300, className = '', style = {}, ...rest}) => {
  const sanitiseHTML = html => {
    if (!html || typeof html !== 'string') return '';
    return html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<iframe[\s\S]*?<\/iframe>/gi, '').replace(/<object[\s\S]*?<\/object>/gi, '').replace(/<embed[\s\S]*?>/gi, '').replace(/<form[\s\S]*?<\/form>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<link[\s\S]*?>/gi, '').replace(/\bon\w+\s*=\s*(['"])[^'"]*\1/gi, '').replace(/\bon\w+\s*=\s*[^\s>]*/gi, '').replace(/javascript\s*:/gi, '');
  };
  const displayItems = limit ? items.slice(0, limit) : items;
  if (!displayItems || displayItems.length === 0) {
    return <Note>
        <p style={{
      color: 'var(--text-secondary)',
      textAlign: 'center'
    }}>
          No announcements at this time.
        </p>
      </Note>;
  }
  const parseContent = content => {
    const withLinks = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1 ↗</a>');
    return withLinks;
  };
  return <div className={className} style={{
    display: 'flex',
    flexDirection: 'column',
    gap: "var(--lp-spacing-4)",
    border: '1px solid var(--lp-color-accent)',
    borderRadius: "var(--lp-spacing-2)",
    padding: "var(--lp-spacing-4)",
    ...style
  }} {...rest}>
      {displayItems.map((announcement, index) => <div key={announcement.id} href={announcement.url} target="_blank" rel="noopener noreferrer">
          <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: "var(--lp-spacing-2)",
    fontSize: '0.875rem',
    marginBottom: "var(--lp-spacing-3)",
    width: '100%'
  }}>
            <Icon icon="discord" color="var(--lp-color-brand-discord)" size={16} />
            <span style={{
    fontWeight: 600,
    color: 'var(--lp-color-accent)',
    fontSize: 'medium'
  }}>
              {serverName}
              {}
            </span>
            <span style={{
    color: 'var(--lp-color-text-secondary)'
  }}>•</span>
            <time dateTime={announcement.timestamp} style={{
    color: 'var(--lp-color-text-secondary)'
  }}>
              {new Date(announcement.timestamp).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric'
  })}
            </time>
            <span style={{
    fontSize: '0.875rem',
    color: 'var(--lp-color-text-secondary)',
    marginLeft: 'auto'
  }}>
              View in Discord{' '}
              <Icon icon="arrow-up-right" size={12} color="var(--lp-color-accent)" />
            </span>
          </div>
          <ScrollBox maxHeight={scrollMaxHeight} ariaLabel={`Announcement from ${serverName}`}>
            <div style={{
    color: 'var(--lp-color-text-secondary)',
    fontSize: 'small'
  }} dangerouslySetInnerHTML={{
    __html: sanitiseHTML(parseContent(announcement.content))
  }} />
          </ScrollBox>
          {index < displayItems.length - 1 && <div style={{
    marginTop: "var(--lp-spacing-3)"
  }}>
              <hr style={{
    border: 'none',
    borderBottom: '1px solid var(--lp-color-border-default)',
    margin: '0'
  }} />
            </div>}
        </div>)}
    </div>;
};

export const LinkArrow = ({href, label, description, newline = true, borderColor, className = '', style = {}, ...rest}) => {
  const linkArrowStyle = {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    gap: "var(--lp-spacing-1)",
    width: 'fit-content',
    ...borderColor && ({
      borderColor
    })
  };
  return <span className={className} style={style} {...rest}>
      {newline && <br />}
      <span style={linkArrowStyle}>
        <a href={href} target="_blank" rel="noopener noreferrer">
          {label}
        </a>
        <Icon icon="arrow-up-right" size={14} color="var(--lp-color-accent)" />
      </span>
      {description && description}
      {description && <div style={{
    height: "var(--lp-spacing-3)"
  }} />}
    </span>;
};

export const SocialLinks = ({links, size = 20, gap = "var(--lp-spacing-3)", justify = "center", iconColor, color, className = "", style = {}, ...rest}) => {
  const resolvedIconColor = iconColor || color;
  const linkStyle = {
    border: "none",
    borderBottom: "none",
    textDecoration: "none",
    display: "inline-flex"
  };
  const colors = {
    discord: resolvedIconColor || "var(--lp-color-brand-discord)",
    twitter: resolvedIconColor || "var(--lp-color-text-primary)",
    github: resolvedIconColor || "var(--lp-color-brand-github)",
    forum: resolvedIconColor || "var(--lp-color-brand-forum)",
    website: resolvedIconColor || "var(--lp-color-accent)",
    blog: resolvedIconColor || "var(--lp-color-accent)",
    globe: resolvedIconColor || "var(--lp-color-brand-globe)",
    twitch: resolvedIconColor || "var(--lp-color-brand-twitch)",
    youtube: resolvedIconColor || "var(--lp-color-brand-youtube)",
    instagram: resolvedIconColor || "var(--lp-color-brand-instagram)",
    linkedin: resolvedIconColor || "var(--lp-color-brand-linkedin)"
  };
  const iconColorMap = {
    discord: "discord",
    "x-twitter": "twitter",
    github: "github",
    "comment-pen": "forum",
    "pen-line": "blog",
    "pencil-line": "blog",
    globe: "globe",
    "book-open": "website",
    twitch: "twitch",
    youtube: "youtube",
    instagram: "instagram",
    linkedin: "linkedin"
  };
  const defaultLinks = [{
    icon: "discord",
    href: "https://discord.com/invite/livepeer",
    label: "Livepeer Discord"
  }, {
    icon: "globe",
    href: "https://livepeer.org",
    label: "Livepeer Website"
  }, {
    icon: "github",
    href: "https://github.com/livepeer",
    label: "Livepeer GitHub"
  }, {
    icon: "comment-pen",
    href: "https://forum.livepeer.org",
    label: "Livepeer Forum"
  }, {
    icon: "pen-line",
    href: "https://livepeer.org/blog",
    label: "Livepeer Blog"
  }, {
    icon: "x-twitter",
    href: "https://x.com/livepeer",
    label: "Livepeer X"
  }];
  const items = links || defaultLinks;
  return <div className={className} style={style} {...rest}>
      <style>{`
        .social-links a {
          border: none;
          border-bottom: none;
        }
      `}</style>
      <span className="social-links" style={{
    display: "flex",
    justifyContent: justify,
    gap: gap,
    marginTop: "var(--lp-spacing-2)"
  }}>
        {items.map((item, i) => <a key={i} href={item.href} target="_blank" rel="noopener noreferrer" aria-label={item.label} style={linkStyle}>
            <Tooltip headline={item.label}>
              <Icon icon={item.icon} size={size} color={colors[iconColorMap[item.icon] || "website"] || "var(--lp-color-accent)"} aria-hidden="true" />
            </Tooltip>
          </a>)}
      </span>
    </div>;
};

{/* DATA */}

<CenteredContainer maxWidth="fit-content">
  <BorderedBox margin="0" padding="0.5rem 1rem 1rem 1rem" variant="muted">
    <SocialLinks iconColor="var(--hero-text)" links={frameworksSocials} />
  </BorderedBox>
</CenteredContainer>

<br />

<CenteredContainer width="80%" minWidth="fit-content">
  <Tip>
    This page is an automated workflow.
  </Tip>
</CenteredContainer>

## <Icon icon="camera-movie" size={24} /> Videos

A collection of Frameworks SPE Latest Videos

<LazyLoad height="400px">
  <YouTubeVideoData items={youtubeDataStatic} limit={2} />
</LazyLoad>

<CustomDivider style={{margin: "0.5rem 0 -2.5rem 0"}} />

## <Icon icon="discord" size={24} /> Discord Announcements

Latest from the <LinkArrow label="Frameworks Discord" href="https://discord.gg/9J6haUjdAq" newline={false} />

<LazyLoad height="300px">
  <DiscordAnnouncements serverName="Frameworks" items={frameworksDiscordData} limit={4} ScrollBox={ScrollBox} scrollMaxHeight={200} />
</LazyLoad>

<CustomDivider style={{margin: "0 0 -2.5rem 0"}} />

## Featured X Post <Icon icon="x-twitter" size={24} />

<br />

<LazyLoad height="650px">
  <iframe src="https://platform.twitter.com/embed/Tweet.html?id=2026328570520555831&theme=dark" className="lp-tweet-frame-large" title="Frameworks on X" />
</LazyLoad>

<CustomDivider style={{margin: "0 0 -2.5rem 0"}} />

## <Icon icon="github" size={24} /> GitHub

<LazyLoad height="600px">
  <BorderedBox>
    <ScrollBox maxHeight={600}>
      <BorderedBox variant="accent">
        Frameworks Github

        <LinkArrow label="View Frameworks on GitHub" href="https://github.com/livepeer-frameworks/monorepo" newline={true} />
      </BorderedBox>

      <MarkdownEmbed url="https://raw.githubusercontent.com/livepeer-frameworks/monorepo/master/README.md" />
    </ScrollBox>
  </BorderedBox>
</LazyLoad>

<CustomDivider />

{/* <iframe
  src="https://platform.twitter.com/embed/Tweet.html?id=2029225629434925247&theme=dark"
  className="lp-tweet-frame-large"
  title="Daydream on X"
/> */}

## Related Pages

<CardGroup cols={2}>
  <Card title="Frameworks Overview" icon="book-open" href="/solutions/frameworks/overview" />

  <Card title="Frameworks Changelog" icon="clock" href="/solutions/frameworks/changelog" />
</CardGroup>
