Skip to content

Commit 9bd3f6b

Browse files
authored
feat: add Basics learning category with YouTube tutorials (#15436)
## Summary Adds a "Basics" category (first in nav) to /learning with two beginner YouTube tutorials, teaching /learning to play YouTube embeds alongside self-hosted MP4s. ## Changes - **What**: New `basics` `LearningCategory` (nav-first) with two tutorials from the official ComfyUI channel. `LearningTutorial` now supports `youtubeId?` (YouTube embed) in addition to `videoSrc?` (self-hosted); exactly one is set. When `youtubeId` is present, `LearningWatchPage` renders a new `LearningVideoEmbed.vue` (youtube-nocookie iframe, `?autoplay=1&mute=1&rel=0`, mirroring `HeroLivestream01`) instead of `VideoPlayer`, which is left untouched. Posters use YouTube thumbnails. `VideoObject` JSON-LD gains `embedUrl` (and `contentUrl` becomes optional) for embedded items. New `tags.fundamentals/nodeGraph/lora/controlNet` and full en + zh-CN category copy. - **Breaking**: `LearningTutorial.videoSrc` and `VideoObjectInput.contentUrl` are now optional. Extensions/consumers of learning data must branch on `youtubeId` rather than assume `videoSrc` exists. ## Review Focus - `videoSrc`/`youtubeId` exactly-one invariant (unit-tested in `learningTutorials.test.ts`). - JSON-LD mapping for embedded videos: `embedUrl` = nocookie embed, `contentUrl` = canonical watch URL. - Posters use `img.youtube.com/vi/<id>/maxresdefault.jpg`; if any thumbnail 404s, `hqdefault.jpg` is the always-present fallback. - Playwright was not run locally (multi-worktree dev-server/port contention); it will run in CI. Verified via `vitest` (8 passed) + `astro check` (0 errors) + lint/format.
1 parent 28bd73a commit 9bd3f6b

12 files changed

Lines changed: 327 additions & 39 deletions

apps/website/e2e/learning.spec.ts

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ const categoryNav = (page: Page, locale: 'en' | 'zh-CN' = 'en') =>
2626
// metadata helpers, so these assertions catch a regression in the helpers or
2727
// the underlying strings — not just the wiring.
2828
const EXPECTED_META = {
29+
basics: {
30+
heading: 'ComfyUI Basics',
31+
description:
32+
'Beginner ComfyUI tutorials — learn the node graph, LoRAs, style transfer, and ControlNets from the ground up.',
33+
title: 'ComfyUI Basics - Comfy'
34+
},
2935
vfx: {
3036
heading: 'VFX Tutorials',
3137
description:
@@ -108,41 +114,34 @@ test.describe('Learning page @smoke', () => {
108114
}
109115
})
110116

111-
test('tutorials with a workflow link expose an external Try Workflow link', async ({
117+
test('tutorials with a CTA link expose their labelled external link', async ({
112118
page
113119
}) => {
114120
const linkedTutorials = learningTutorials.filter(
115121
(tutorial) => tutorial.href
116122
)
117-
const workflowLinks = page.getByRole('link', {
118-
name: t('cta.tryWorkflow', 'en')
119-
})
120-
const hrefs = await workflowLinks.evaluateAll((links) =>
121-
links.map((link) => link.getAttribute('href'))
122-
)
123+
expect(linkedTutorials.length).toBeGreaterThan(0)
123124
for (const tutorial of linkedTutorials) {
124-
expect(hrefs).toContain(tutorial.href)
125+
const link = page.locator(`a[href="${tutorial.href}"]`)
126+
await expect(link).toContainText(
127+
t(tutorial.ctaLabelKey ?? 'cta.tryWorkflow', 'en')
128+
)
125129
}
126130
})
127131

128-
test('newTab tutorials open their workflow link in a new tab', async ({
132+
test('newTab tutorials open their CTA link in a new tab', async ({
129133
page
130134
}) => {
131-
const links = page.getByRole('link', { name: t('cta.tryWorkflow', 'en') })
132-
const attrs = await links.evaluateAll((elements) =>
133-
elements.map((element) => ({
134-
href: element.getAttribute('href') ?? '',
135-
target: element.getAttribute('target')
136-
}))
137-
)
138-
// The page-level CTA shares the label; only judge tutorial links.
139-
const tutorialAttrs = attrs.filter(({ href }) =>
140-
learningTutorials.some((item) => item.href === href)
135+
const linkedTutorials = learningTutorials.filter(
136+
(tutorial) => tutorial.href
141137
)
142-
expect(tutorialAttrs.length).toBeGreaterThan(0)
143-
for (const { href, target } of tutorialAttrs) {
144-
const tutorial = learningTutorials.find((item) => item.href === href)
145-
expect(target, href).toBe(tutorial?.newTab ? '_blank' : null)
138+
for (const tutorial of linkedTutorials) {
139+
const link = page.locator(`a[href="${tutorial.href}"]`)
140+
if (tutorial.newTab) {
141+
await expect(link).toHaveAttribute('target', '_blank')
142+
} else {
143+
await expect(link).not.toHaveAttribute('target', '_blank')
144+
}
146145
}
147146
})
148147

@@ -239,6 +238,16 @@ test.describe('Learning category pages @smoke', () => {
239238

240239
test.describe('Learning tutorial page @smoke', () => {
241240
const [firstTutorial] = learningTutorials
241+
const selfHostedTutorial = learningTutorials.find(
242+
(tutorial) => tutorial.videoSrc && !tutorial.youtubeId
243+
)
244+
const youtubeTutorial = learningTutorials.find(
245+
(tutorial) => tutorial.youtubeId
246+
)
247+
const workflowTutorial = learningTutorials.find((tutorial) => tutorial.href)
248+
if (!selfHostedTutorial || !youtubeTutorial || !workflowTutorial) {
249+
throw new Error('expected self-hosted, youtube, and workflow tutorials')
250+
}
242251

243252
test('a thumbnail navigates to the dedicated tutorial page', async ({
244253
page
@@ -257,10 +266,10 @@ test.describe('Learning tutorial page @smoke', () => {
257266
test('the page exposes an indexable heading and autoplay video', async ({
258267
page
259268
}) => {
260-
await page.goto(tutorialPath(firstTutorial))
269+
await page.goto(tutorialPath(selfHostedTutorial))
261270

262271
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
263-
firstTutorial.title.en
272+
selfHostedTutorial.title.en
264273
)
265274
// Attribute-level autoplay check: blockExternalMedia aborts the video
266275
// request, so actual playback never starts in e2e.
@@ -270,6 +279,20 @@ test.describe('Learning tutorial page @smoke', () => {
270279
await expect(video).toHaveAttribute('muted', '')
271280
})
272281

282+
test('youtube tutorials embed a nocookie iframe instead of a video', async ({
283+
page
284+
}) => {
285+
await page.goto(tutorialPath(youtubeTutorial))
286+
287+
await expect(page.locator('video')).toHaveCount(0)
288+
const iframe = page.locator('iframe[src*="youtube-nocookie.com/embed/"]')
289+
await expect(iframe).toBeVisible()
290+
await expect(iframe).toHaveAttribute(
291+
'src',
292+
new RegExp(`/embed/${youtubeTutorial.youtubeId}\\b`)
293+
)
294+
})
295+
273296
test('the breadcrumb links back to the directory and category', async ({
274297
page
275298
}) => {
@@ -301,12 +324,13 @@ test.describe('Learning tutorial page @smoke', () => {
301324
}
302325
})
303326

304-
test('links to the workflow from the title block', async ({ page }) => {
305-
if (!firstTutorial.href) throw new Error('expected a workflow link')
306-
await page.goto(tutorialPath(firstTutorial))
327+
test('links to the CTA target from the title block', async ({ page }) => {
328+
await page.goto(tutorialPath(workflowTutorial))
307329

308-
const workflowLink = page.locator(`a[href="${firstTutorial.href}"]`)
309-
await expect(workflowLink).toHaveText(t('cta.tryWorkflow', 'en'))
330+
const ctaLink = page.locator(`a[href="${workflowTutorial.href}"]`)
331+
await expect(ctaLink).toHaveText(
332+
t(workflowTutorial.ctaLabelKey ?? 'cta.tryWorkflow', 'en')
333+
)
310334
})
311335

312336
test('the chapter strip links to same-category siblings', async ({

apps/website/src/components/learning/FeaturedTutorialCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const { tutorial, locale = 'en' } = defineProps<{
5656
size="default"
5757
class="ps-0"
5858
>
59-
{{ t('cta.tryWorkflow', locale) }}
59+
{{ t(tutorial.ctaLabelKey ?? 'cta.tryWorkflow', locale) }}
6060
</ButtonPill>
6161
</div>
6262
</div>

apps/website/src/components/learning/LearningTutorialPage.astro

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { externalLinks, localizeHref } from '../../config/routes'
99
import type { LearningTutorial } from '../../data/learningTutorials'
1010
import {
1111
learningCrumbs,
12-
tutorialDescription
12+
tutorialDescription,
13+
youtubeEmbedUrl
1314
} from '../../data/learningTutorials'
1415
import type { JsonLdNode } from '../../utils/jsonLd'
1516
import {
@@ -43,6 +44,9 @@ const video: JsonLdNode = videoObjectNode({
4344
description,
4445
thumbnailUrl: tutorial.poster,
4546
contentUrl: tutorial.videoSrc,
47+
embedUrl: tutorial.youtubeId
48+
? youtubeEmbedUrl(tutorial.youtubeId)
49+
: undefined,
4650
uploadDate: tutorial.publishedDate,
4751
locale
4852
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// @vitest-environment happy-dom
2+
import { render, screen } from '@testing-library/vue'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import LearningVideoEmbed from './LearningVideoEmbed.vue'
6+
7+
function renderEmbed(youtubeId = 'TQhIYT1ZYGQ', title = 'Node Graph Basics') {
8+
render(LearningVideoEmbed, { props: { youtubeId, title } })
9+
return screen.getByTitle(title)
10+
}
11+
12+
describe('LearningVideoEmbed', () => {
13+
it('embeds the privacy-friendly nocookie player for the given id', () => {
14+
const iframe = renderEmbed('abc123', 'Full Node Graph Basics')
15+
16+
expect(iframe.getAttribute('src')).toBe(
17+
'https://www.youtube-nocookie.com/embed/abc123?autoplay=1&mute=1&rel=0'
18+
)
19+
})
20+
21+
it('lazy-loads the titled frame and allows fullscreen', () => {
22+
const iframe = renderEmbed('abc123', 'Full Node Graph Basics')
23+
24+
expect(iframe.getAttribute('loading')).toBe('lazy')
25+
expect(iframe.hasAttribute('allowfullscreen')).toBe(true)
26+
})
27+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
4+
import { cn } from '@comfyorg/tailwind-utils'
5+
6+
import { youtubeEmbedUrl } from '../../data/learningTutorials'
7+
8+
const {
9+
youtubeId,
10+
title,
11+
class: className
12+
} = defineProps<{
13+
youtubeId: string
14+
title: string
15+
class?: HTMLAttributes['class']
16+
}>()
17+
</script>
18+
19+
<template>
20+
<div
21+
:class="
22+
cn(
23+
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
24+
className
25+
)
26+
"
27+
>
28+
<iframe
29+
:src="youtubeEmbedUrl(youtubeId)"
30+
:title
31+
class="size-full"
32+
loading="lazy"
33+
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
34+
allowfullscreen
35+
/>
36+
</div>
37+
</template>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @vitest-environment happy-dom
2+
import { render, screen } from '@testing-library/vue'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import type { LearningTutorial } from '../../data/learningTutorials'
6+
7+
import { filterByCategory } from '../../data/learningTutorials'
8+
import LearningWatchPage from './LearningWatchPage.vue'
9+
10+
const youtubeTutorial = filterByCategory('basics').find(
11+
(tutorial) => tutorial.youtubeId !== undefined
12+
)
13+
if (!youtubeTutorial)
14+
throw new Error('Expected a Basics tutorial with youtubeId')
15+
16+
const hostedTutorial = filterByCategory('vfx').find(
17+
(tutorial) => tutorial.videoSrc !== undefined
18+
)
19+
if (!hostedTutorial) throw new Error('Expected a VFX tutorial with videoSrc')
20+
21+
const stubs = {
22+
LearningVideoEmbed: { template: '<div data-testid="youtube-embed" />' },
23+
VideoPlayer: { template: '<div data-testid="hosted-video" />' }
24+
}
25+
26+
function renderWatchPage(tutorial: LearningTutorial) {
27+
render(LearningWatchPage, {
28+
props: { tutorial, locale: 'en' },
29+
global: { stubs }
30+
})
31+
}
32+
33+
describe('LearningWatchPage', () => {
34+
it('embeds the YouTube player for tutorials with a youtubeId', () => {
35+
renderWatchPage(youtubeTutorial)
36+
37+
expect(screen.getByTestId('youtube-embed')).toBeTruthy()
38+
expect(screen.queryByTestId('hosted-video')).toBeNull()
39+
})
40+
41+
it('falls back to the hosted VideoPlayer for self-hosted tutorials', () => {
42+
renderWatchPage(hostedTutorial)
43+
44+
expect(screen.getByTestId('hosted-video')).toBeTruthy()
45+
expect(screen.queryByTestId('youtube-embed')).toBeNull()
46+
})
47+
})

apps/website/src/components/learning/LearningWatchPage.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import WatchPageLayout from '../blocks/WatchPageLayout.vue'
1818
import WatchRecommendedCard from '../blocks/WatchRecommendedCard.vue'
1919
import Button from '../ui/button/Button.vue'
2020
import VideoPlayer from '../common/VideoPlayer.vue'
21+
import LearningVideoEmbed from './LearningVideoEmbed.vue'
2122
import Badge from '../ui/badge/Badge.vue'
2223
2324
const { tutorial, locale = 'en' } = defineProps<{
@@ -59,7 +60,15 @@ const recommended = recommendedFor(tutorial).map((item) => ({
5960
:read-more-label="t('ui.readMore', locale)"
6061
:read-less-label="t('ui.readLess', locale)"
6162
>
63+
<LearningVideoEmbed
64+
v-if="tutorial.youtubeId"
65+
:key="tutorial.id"
66+
:youtube-id="tutorial.youtubeId"
67+
:title="tutorial.title[locale]"
68+
class="w-full"
69+
/>
6270
<VideoPlayer
71+
v-else
6372
:key="tutorial.id"
6473
:locale
6574
:src="tutorial.videoSrc"
@@ -93,8 +102,9 @@ const recommended = recommendedFor(tutorial).map((item) => ({
93102
size="sm"
94103
:href="tutorial.href"
95104
:target="tutorial.newTab ? '_blank' : undefined"
105+
:rel="tutorial.newTab ? 'noopener noreferrer' : undefined"
96106
>
97-
{{ t('cta.tryWorkflow', locale) }}
107+
{{ t(tutorial.ctaLabelKey ?? 'cta.tryWorkflow', locale) }}
98108
</Button>
99109
</template>
100110

apps/website/src/components/learning/TutorialRow.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const { tutorial, locale = 'en' } = defineProps<{
6767
size="default"
6868
class="ps-0"
6969
>
70-
{{ t('cta.tryWorkflow', locale) }}
70+
{{ t(tutorial.ctaLabelKey ?? 'cta.tryWorkflow', locale) }}
7171
</ButtonPill>
7272
</li>
7373
</template>

apps/website/src/data/learningTutorials.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
filterByCategory,
66
learningCategories,
77
learningTutorials,
8-
recommendedFor
8+
recommendedFor,
9+
youtubeEmbedUrl
910
} from './learningTutorials'
1011

1112
const firstVfx = filterByCategory('vfx')[0]
@@ -42,6 +43,36 @@ describe('categoryChapters', () => {
4243
})
4344
})
4445

46+
describe('video source', () => {
47+
it('plays via exactly one of videoSrc or youtubeId', () => {
48+
for (const tutorial of learningTutorials) {
49+
expect(Boolean(tutorial.videoSrc) !== Boolean(tutorial.youtubeId)).toBe(
50+
true
51+
)
52+
}
53+
})
54+
55+
it('builds a privacy-friendly nocookie embed URL from an id', () => {
56+
expect(youtubeEmbedUrl('abc123')).toBe(
57+
'https://www.youtube-nocookie.com/embed/abc123?autoplay=1&mute=1&rel=0'
58+
)
59+
})
60+
})
61+
62+
describe('basics CTA', () => {
63+
it('sends basics tutorials to cloud signup with a Try for Free label', () => {
64+
const basics = filterByCategory('basics')
65+
expect(basics.length).toBeGreaterThan(0)
66+
for (const tutorial of basics) {
67+
expect(tutorial.href).toMatch(
68+
/^https:\/\/cloud\.comfy\.org\/\?.*utm_campaign=free_tier.*utm_content=learning_basics_/
69+
)
70+
expect(tutorial.newTab).toBe(true)
71+
expect(tutorial.ctaLabelKey).toBe('cta.tryForFree')
72+
}
73+
})
74+
})
75+
4576
describe('recommendedFor', () => {
4677
it('only recommends tutorials from other categories', () => {
4778
const recommended = recommendedFor(firstVfx)

0 commit comments

Comments
 (0)