From e32dcd4eb4a20f2de8057294cd496b6573accb62 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:31:21 -0700 Subject: [PATCH 1/4] feat(s3): optional customFilename in uploadToS3 Adds an optional third param to uploadToS3 so callers with a stable ID (e.g., a slug) can override the URL-derived hash key with a human-readable filename. Existing callers unaffected. Co-Authored-By: Claude Opus 4.7 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- src/data-layer/s3.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/data-layer/s3.ts b/src/data-layer/s3.ts index 9bd05fcc9b9..bac828c3a56 100644 --- a/src/data-layer/s3.ts +++ b/src/data-layer/s3.ts @@ -131,11 +131,13 @@ function buildS3Url(bucket: string, key: string): string { * * @param sourceUrl - The external image URL to fetch and upload * @param prefix - S3 key prefix (e.g., 'apps/logos', 'events') + * @param customFilename - Optional full filename (with extension) to use instead of the URL-derived hash. * @returns S3 URL if successful, null on failure */ export async function uploadToS3( sourceUrl: string, - prefix: string + prefix: string, + customFilename?: string ): Promise { // Skip empty or invalid URLs if (!sourceUrl || !isValidImageUrl(sourceUrl)) { @@ -191,7 +193,9 @@ export async function uploadToS3( return null } - const key = generateKey(prefix, sourceUrl, ext) + const key = customFilename + ? `${prefix}/${customFilename}` + : generateKey(prefix, sourceUrl, ext) // Check if already exists in S3 try { From 01ba45778afaff6eeceb32e9da1f4a25926d3fcb Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:31:36 -0700 Subject: [PATCH 2/4] feat(videos): sync thumbnails to S3 Adds a daily Trigger.dev task that downloads YouTube video thumbnails to S3, keyed by slug. Tries sddefault.jpg first, falls back to hqdefault.jpg. customThumbnailUrl takes priority when set. Wires the thumbnail map into getVideos() with a graceful fallback to the YouTube URL when the map is missing, so the gallery still renders before the first sync runs (or if it fails). Follows the existing fetchApps/fetchEvents pattern for data-layer, task registration, getters, and cached wrappers. Co-Authored-By: Claude Opus 4.7 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../fetchers/fetchVideoThumbnails.ts | 110 ++++++++++++++++++ src/data-layer/index.ts | 1 + src/data-layer/tasks.ts | 3 + src/lib/data/index.ts | 6 + src/lib/utils/videos.ts | 20 +++- 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/data-layer/fetchers/fetchVideoThumbnails.ts diff --git a/src/data-layer/fetchers/fetchVideoThumbnails.ts b/src/data-layer/fetchers/fetchVideoThumbnails.ts new file mode 100644 index 00000000000..588927f94a8 --- /dev/null +++ b/src/data-layer/fetchers/fetchVideoThumbnails.ts @@ -0,0 +1,110 @@ +import { readdir, readFile } from "fs/promises" +import { join } from "path" + +import matter from "gray-matter" + +import type { VideoFrontmatter } from "@/lib/interfaces" + +import { CONTENT_DIR } from "@/lib/constants" + +import { uploadToS3 } from "../s3" + +const THUMBNAIL_PREFIX = "videos/thumbnails" + +/** + * Derive an S3 key extension from a URL path. Falls back to "jpg". + */ +function extFromUrl(url: string): string { + const last = url.split("?")[0].split(".").pop()?.toLowerCase() + const valid = ["jpg", "jpeg", "png", "webp", "gif", "avif", "svg"] + return last && valid.includes(last) ? last : "jpg" +} + +/** + * YouTube thumbnail URL for a given video ID and quality level. + * - sddefault (640x480): best quality, but returns 404 for some videos + * - hqdefault (480x360): always exists + */ +function youtubeThumbnailUrl(youtubeId: string, quality: "sd" | "hq"): string { + return `https://img.youtube.com/vi/${youtubeId}/${quality}default.jpg` +} + +/** + * Fetch video thumbnails from YouTube (or custom URLs) and upload to S3. + * Keyed by video slug so each video maps to exactly one S3 object. + * + * For each video: + * 1. If customThumbnailUrl exists, upload that + * 2. Otherwise try sddefault.jpg first (best quality) + * 3. Fall back to hqdefault.jpg if sd fails + * + * Returns a map of video slug -> S3 thumbnail URL. + */ +export async function fetchVideoThumbnails(): Promise> { + console.log("Starting video thumbnail sync to S3") + + const videosDir = join(process.cwd(), CONTENT_DIR, "videos") + const entries = await readdir(videosDir, { withFileTypes: true }) + const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name) + + const results = await Promise.all( + slugs.map(async (slug) => { + try { + const mdPath = join(videosDir, slug, "index.md") + const raw = await readFile(mdPath, "utf-8") + const { data } = matter(raw) + const fm = data as VideoFrontmatter + + // YouTube thumbs are always jpg; custom URLs derive ext from the path. + const ext = fm.customThumbnailUrl + ? extFromUrl(fm.customThumbnailUrl) + : "jpg" + const filename = `${slug}.${ext}` + + let s3Url: string | null = null + + if (fm.customThumbnailUrl) { + s3Url = await uploadToS3( + fm.customThumbnailUrl, + THUMBNAIL_PREFIX, + filename + ) + } else if (fm.youtubeId) { + // Try best quality first, fall back to guaranteed quality + s3Url = await uploadToS3( + youtubeThumbnailUrl(fm.youtubeId, "sd"), + THUMBNAIL_PREFIX, + filename + ) + if (!s3Url) { + s3Url = await uploadToS3( + youtubeThumbnailUrl(fm.youtubeId, "hq"), + THUMBNAIL_PREFIX, + filename + ) + } + } + + if (s3Url) return [slug, s3Url] as const + console.warn(`[VideoThumbnails] No thumbnail uploaded for ${slug}`) + return null + } catch (error) { + console.warn(`[VideoThumbnails] Skipping ${slug}:`, error) + return null + } + }) + ) + + const thumbnailMap: Record = {} + for (const result of results) { + if (result) { + thumbnailMap[result[0]] = result[1] + } + } + + console.log( + `Video thumbnail sync complete: ${Object.keys(thumbnailMap).length}/${slugs.length} uploaded` + ) + + return thumbnailMap +} diff --git a/src/data-layer/index.ts b/src/data-layer/index.ts index e24e4da021f..b7f77f02f18 100644 --- a/src/data-layer/index.ts +++ b/src/data-layer/index.ts @@ -52,3 +52,4 @@ export const getDeveloperToolsData = () => get(KEYS. export const getAccountHolders = () => get(KEYS.ACCOUNT_HOLDERS) export const getTranslationGlossary = () => get(KEYS.TRANSLATION_GLOSSARY) export const getGitHubContributors = () => get(KEYS.GITHUB_CONTRIBUTORS) +export const getVideoThumbnails = () => get>(KEYS.VIDEO_THUMBNAILS) diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 860c7ad06e8..ed8f7c9fed1 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -35,6 +35,7 @@ import { fetchStablecoinsData } from "./fetchers/fetchStablecoinsData" import { fetchTotalEthStaked } from "./fetchers/fetchTotalEthStaked" import { fetchTotalValueLocked } from "./fetchers/fetchTotalValueLocked" import { fetchTranslationGlossary } from "./fetchers/fetchTranslationGlossary" +import { fetchVideoThumbnails } from "./fetchers/fetchVideoThumbnails" import { set } from "./storage" export const KEYS = { @@ -64,6 +65,7 @@ export const KEYS = { STABLECOINS_DATA: "fetch-stablecoins-data", ACCOUNT_HOLDERS: "fetch-account-holders", TRANSLATION_GLOSSARY: "fetch-translation-glossary", + VIDEO_THUMBNAILS: "fetch-video-thumbnails", } as const // Task definition: storage key + fetch function @@ -89,6 +91,7 @@ const DAILY: TaskDef[] = [ [KEYS.DEVELOPER_TOOLS, fetchDeveloperTools], [KEYS.TRANSLATION_GLOSSARY, fetchTranslationGlossary], [KEYS.BEACONCHAIN, fetchBeaconChain], + [KEYS.VIDEO_THUMBNAILS, fetchVideoThumbnails], ] const HOURLY: TaskDef[] = [ diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts index 4ca5dc85f12..42a0af4f863 100644 --- a/src/lib/data/index.ts +++ b/src/lib/data/index.ts @@ -199,6 +199,12 @@ export const getGitHubContributors = createCachedGetter( CACHE_REVALIDATE_DAY ) +export const getVideoThumbnails = createCachedGetter( + dataLayer.getVideoThumbnails, + ["video-thumbnails"], + CACHE_REVALIDATE_DAY +) + /** * Static-cached version of getGitHubContributors — no revalidation. * Use this in static pages (e.g., md content pages via [...slug]) to avoid diff --git a/src/lib/utils/videos.ts b/src/lib/utils/videos.ts index 387e05f36f8..a09559b7578 100644 --- a/src/lib/utils/videos.ts +++ b/src/lib/utils/videos.ts @@ -8,6 +8,8 @@ import type { VideoFrontmatter } from "@/lib/interfaces" import { CONTENT_DIR, DEFAULT_LOCALE } from "@/lib/constants" +import { getVideoThumbnails } from "@/lib/data" + // Build-time caches to avoid redundant filesystem reads during static generation. // These are module-scoped Maps that persist for the duration of the build process. const videoDataCache = new Map() @@ -99,8 +101,12 @@ export async function getVideoData( /** * Convert VideoData to a flat VideoCardData suitable for client components. + * Prefers S3-hosted thumbnail when available, falls back to YouTube URL. */ -function toVideoCardData(data: VideoData): VideoCardData { +function toVideoCardData( + data: VideoData, + thumbnailMap: Record | null +): VideoCardData { const { slug, frontmatter: fm } = data return { slug, @@ -109,7 +115,10 @@ function toVideoCardData(data: VideoData): VideoCardData { uploadDate: fm.uploadDate, duration: fm.duration, topic: fm.topic, - thumbnailUrl: fm.customThumbnailUrl || getDefaultThumbnailUrl(fm.youtubeId), + thumbnailUrl: + thumbnailMap?.[slug] || + fm.customThumbnailUrl || + getDefaultThumbnailUrl(fm.youtubeId), } } @@ -137,13 +146,16 @@ export async function getVideos( const cached = videosCache.get(locale) if (cached) return cached - const slugs = await getVideoSlugs() + const [slugs, thumbnailMap] = await Promise.all([ + getVideoSlugs(), + getVideoThumbnails().catch(() => null), + ]) const results = await Promise.all( slugs.map(async (slug) => { try { const data = await getVideoData(slug, locale) - return toVideoCardData(data) + return toVideoCardData(data, thumbnailMap) } catch { console.warn(`Skipping video ${slug}: missing index.md`) return null From fb1737fdead7fb23b173b277ec620fe86df26812 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:06:14 -0700 Subject: [PATCH 3/4] refactor(videos): drop YouTube URL fallbacks Gallery cards now rely entirely on the S3 thumbnail map; the pre-S3 fallback to img.youtube.com is removed along with the getDefaultThumbnailUrl helper and the img.youtube.com entry in next.config.js remotePatterns. JSON-LD thumbnailUrl is inlined with the canonical YouTube URL (customThumbnailUrl || hqdefault.jpg) since structured data is consumed by crawlers as a plain string and doesn't pass through Next.js Image optimization. Co-Authored-By: Claude Opus 4.7 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- app/[locale]/videos/[slug]/page-jsonld.tsx | 3 +-- next.config.js | 1 - src/lib/utils/videos.ts | 16 +++------------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/app/[locale]/videos/[slug]/page-jsonld.tsx b/app/[locale]/videos/[slug]/page-jsonld.tsx index 49c7a2511af..9dd6b72f614 100644 --- a/app/[locale]/videos/[slug]/page-jsonld.tsx +++ b/app/[locale]/videos/[slug]/page-jsonld.tsx @@ -7,7 +7,6 @@ import PageJsonLD from "@/components/PageJsonLD" import { stripMarkdown } from "@/lib/utils/md" import { toIsoDuration } from "@/lib/utils/time" import { normalizeUrlForJsonLd } from "@/lib/utils/url" -import { getDefaultThumbnailUrl } from "@/lib/utils/videos" import { BASE_GRAPH_NODES, REFERENCE } from "@/lib/jsonld/constants" import { resolveAuthorsFromFrontmatter } from "@/lib/jsonld/utils" @@ -92,7 +91,7 @@ export default async function VideoPageJsonLD({ isPartOf: { "@id": videoGalleryUrl }, thumbnailUrl: frontmatter.customThumbnailUrl || - getDefaultThumbnailUrl(frontmatter.youtubeId), + `https://img.youtube.com/vi/${frontmatter.youtubeId}/hqdefault.jpg`, embedUrl: `https://www.youtube.com/embed/${frontmatter.youtubeId}`, contentUrl: `https://www.youtube.com/watch?v=${frontmatter.youtubeId}`, educationalLevel: frontmatter.educationLevel, diff --git a/next.config.js b/next.config.js index 52da595ff32..6f9ed826071 100644 --- a/next.config.js +++ b/next.config.js @@ -147,7 +147,6 @@ module.exports = (phase) => { { protocol: "https", hostname: "cdn.charmverse.io" }, { protocol: "https", hostname: "ethwingman.com" }, { protocol: "https", hostname: "eth-mcp.dev" }, - { protocol: "https", hostname: "img.youtube.com", pathname: "/vi/**" }, ], }, async headers() { diff --git a/src/lib/utils/videos.ts b/src/lib/utils/videos.ts index a09559b7578..3c2582f3370 100644 --- a/src/lib/utils/videos.ts +++ b/src/lib/utils/videos.ts @@ -30,14 +30,6 @@ function videoPath(slug: string, locale: string): string { ) } -/** - * Default YouTube thumbnail URL derived from a video ID. - * Returns hqdefault (480x360) which is guaranteed to exist. - */ -export function getDefaultThumbnailUrl(youtubeId: string): string { - return `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg` -} - /** * Read and parse a video's index.md file for a given slug and locale. * Returns the full frontmatter and markdown body content. @@ -101,7 +93,8 @@ export async function getVideoData( /** * Convert VideoData to a flat VideoCardData suitable for client components. - * Prefers S3-hosted thumbnail when available, falls back to YouTube URL. + * Thumbnails are served from S3 (populated by the fetchVideoThumbnails task, + * which handles both YouTube and customThumbnailUrl sources). */ function toVideoCardData( data: VideoData, @@ -115,10 +108,7 @@ function toVideoCardData( uploadDate: fm.uploadDate, duration: fm.duration, topic: fm.topic, - thumbnailUrl: - thumbnailMap?.[slug] || - fm.customThumbnailUrl || - getDefaultThumbnailUrl(fm.youtubeId), + thumbnailUrl: thumbnailMap?.[slug] || "", } } From 74d160946856d4d5b5e96107080c9d515914aac0 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:46:27 -0700 Subject: [PATCH 4/4] chore: add mock data for thumbnails Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../mocks/fetch-video-thumbnails.json | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/data-layer/mocks/fetch-video-thumbnails.json diff --git a/src/data-layer/mocks/fetch-video-thumbnails.json b/src/data-layer/mocks/fetch-video-thumbnails.json new file mode 100644 index 00000000000..548ef0a419b --- /dev/null +++ b/src/data-layer/mocks/fetch-video-thumbnails.json @@ -0,0 +1,60 @@ +{ + "ai-agents-interview-luna": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ai-agents-interview-luna.jpg", + "atoms-institutions-blockchains-josh-stark": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/atoms-institutions-blockchains-josh-stark.jpg", + "blobspace-101-dencun": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blobspace-101-dencun.jpg", + "blockchain-101-visual-demo": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blockchain-101-visual-demo.jpg", + "blockchain-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blockchain-eth-build.jpg", + "crypto-apps-desocial-linda-xie": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/crypto-apps-desocial-linda-xie.jpg", + "crypto-security-passwords": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/crypto-security-passwords.jpg", + "danny-ryan-leading-cryptos-biggest-upgrade": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/danny-ryan-leading-cryptos-biggest-upgrade.jpg", + "dao-build-next-great-city": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/dao-build-next-great-city.jpg", + "dao-hack-ethereum-classic": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/dao-hack-ethereum-classic.jpg", + "decentralized-identity-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/decentralized-identity-explained.jpg", + "decentralized-social-media": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/decentralized-social-media.jpg", + "defi-future-of-finance": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/defi-future-of-finance.jpg", + "defi-history-inception-to-2021": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/defi-history-inception-to-2021.jpg", + "desci-movement-juan-benet": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/desci-movement-juan-benet.jpg", + "devcon-mumbai-coming-2026": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devcon-mumbai-coming-2026.jpg", + "devconnect-argentina-2025-recap": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devconnect-argentina-2025-recap.jpg", + "devconnect-buenos-aires-promo": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devconnect-buenos-aires-promo.jpg", + "eigenlayer-permissionless-features": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/eigenlayer-permissionless-features.jpg", + "eip-4844-dencun-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/eip-4844-dencun-explained.jpg", + "ethereum-basics-intro": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-basics-intro.jpg", + "ethereum-core-governance-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-core-governance-explained.jpg", + "ethereum-evolution-glamsterdam": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-evolution-glamsterdam.jpg", + "ethereum-in-30-minutes-vitalik-buterin": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-in-30-minutes-vitalik-buterin.jpg", + "ethereum-institutional-privacy-panel": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-institutional-privacy-panel.jpg", + "ethereum-localism-global-protocols-local-power": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-localism-global-protocols-local-power.jpg", + "ethereum-staking-withdrawals": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-staking-withdrawals.jpg", + "ethereum-things-i-like-mariano-conti": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-things-i-like-mariano-conti.jpg", + "ethereums-quantum-plan-justin-drake": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereums-quantum-plan-justin-drake.jpg", + "fusaka-upgrade-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/fusaka-upgrade-explained.jpg", + "hash-function-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/hash-function-eth-build.jpg", + "how-to-be-cypherpunk-juan-benet": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/how-to-be-cypherpunk-juan-benet.jpg", + "how-to-make-a-guerilla-l2": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/how-to-make-a-guerilla-l2.jpg", + "key-pair-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/key-pair-eth-build.jpg", + "layer-2-scaling-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/layer-2-scaling-explained.jpg", + "learn-nfts-and-defi": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/learn-nfts-and-defi.jpg", + "next-10-years-of-ethereum": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/next-10-years-of-ethereum.jpg", + "pectra-upgrade-overview": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pectra-upgrade-overview.jpg", + "pectra-what-stakers-need-to-know": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pectra-what-stakers-need-to-know.jpg", + "pos-reorgs-attack-defense": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pos-reorgs-attack-defense.jpg", + "post-quantum-security-ethereum-roadmap": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/post-quantum-security-ethereum-roadmap.jpg", + "pow-vs-pos": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pow-vs-pos.jpg", + "privacy-is-existential": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/privacy-is-existential.jpg", + "proof-of-authority-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proof-of-authority-explained.jpg", + "proof-of-work-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proof-of-work-explained.jpg", + "proposer-builder-separation": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proposer-builder-separation.jpg", + "real-state-of-l2s-bartek-kiepuszewski": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/real-state-of-l2s-bartek-kiepuszewski.jpg", + "regenerative-finance-refi": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/regenerative-finance-refi.jpg", + "restaking-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/restaking-explained.jpg", + "rollups-scaling-strategy": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/rollups-scaling-strategy.jpg", + "security-through-obscurity-microdots": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/security-through-obscurity-microdots.jpg", + "smart-contracts-code-is-law": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/smart-contracts-code-is-law.jpg", + "stani-kulechov-building-aave": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/stani-kulechov-building-aave.jpg", + "surveillance-silence-reclaiming-privacy": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/surveillance-silence-reclaiming-privacy.jpg", + "transactions-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/transactions-eth-build.jpg", + "understanding-consensus-mechanisms": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/understanding-consensus-mechanisms.jpg", + "what-is-a-dapp": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/what-is-a-dapp.jpg", + "zero-knowledge-proofs-5-levels": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/zero-knowledge-proofs-5-levels.jpg" +} \ No newline at end of file