Skip to content

Commit 5a3b9d1

Browse files
committed
great progress
1 parent 10433f1 commit 5a3b9d1

File tree

81 files changed

+7465
-2265
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+7465
-2265
lines changed

exercises/01.advanced-tools/01.problem.annotations/src/resources.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { invariant } from '@epic-web/invariant'
22
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
33
import { type EpicMeMCP } from './index.ts'
4+
import { getVideoBase64, listVideos } from './video.ts'
45

56
export async function initializeResources(agent: EpicMeMCP) {
67
agent.server.registerResource(
@@ -105,4 +106,45 @@ export async function initializeResources(agent: EpicMeMCP) {
105106
}
106107
},
107108
)
109+
110+
agent.server.registerResource(
111+
'video',
112+
new ResourceTemplate('epicme://videos/{videoId}', {
113+
complete: {
114+
async videoId(value) {
115+
const videos = await listVideos()
116+
return videos.filter((video) => video.includes(value))
117+
},
118+
},
119+
list: async () => {
120+
const videos = await listVideos()
121+
return {
122+
resources: videos.map((video) => ({
123+
name: video,
124+
uri: `epicme://videos/${video}`,
125+
mimeType: 'application/json',
126+
})),
127+
}
128+
},
129+
}),
130+
{
131+
title: 'EpicMe Videos',
132+
description: 'A single video with the given ID',
133+
},
134+
async (uri, { videoId }) => {
135+
invariant(typeof videoId === 'string', 'Video ID is required')
136+
137+
const videoBase64 = await getVideoBase64(videoId)
138+
invariant(videoBase64, `Video with ID "${videoId}" not found`)
139+
return {
140+
contents: [
141+
{
142+
mimeType: 'video/mp4',
143+
text: videoBase64,
144+
uri: uri.toString(),
145+
},
146+
],
147+
}
148+
},
149+
)
108150
}

exercises/01.advanced-tools/01.problem.annotations/src/sampling.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

exercises/01.advanced-tools/01.problem.annotations/src/tools.ts

Lines changed: 3 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { spawn } from 'node:child_process'
2-
import * as fs from 'node:fs/promises'
3-
import { userInfo } from 'node:os'
41
import { invariant } from '@epic-web/invariant'
52
import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
63
import { z } from 'zod'
@@ -14,6 +11,7 @@ import {
1411
updateTagInputSchema,
1512
} from './db/schema.ts'
1613
import { type EpicMeMCP } from './index.ts'
14+
import { createWrappedVideo } from './video.ts'
1715

1816
export async function initializeTools(agent: EpicMeMCP) {
1917
agent.server.registerTool(
@@ -285,15 +283,13 @@ export async function initializeTools(agent: EpicMeMCP) {
285283
),
286284
mockTime: z
287285
.number()
286+
.optional()
288287
.describe(
289288
'If set to > 0, use mock mode and this is the mock wait time in milliseconds',
290289
),
291290
},
292291
},
293-
async (
294-
{ year = new Date().getFullYear(), mockTime },
295-
{ sendNotification, _meta, signal },
296-
) => {
292+
async ({ year = new Date().getFullYear(), mockTime }) => {
297293
const entries = await agent.db.getEntries()
298294
const filteredEntries = entries.filter(
299295
(entry) => new Date(entry.createdAt * 1000).getFullYear() === year,
@@ -307,20 +303,6 @@ export async function initializeTools(agent: EpicMeMCP) {
307303
tags: filteredTags,
308304
year,
309305
mockTime,
310-
onProgress: (progress) => {
311-
const { progressToken } = _meta ?? {}
312-
if (!progressToken) return
313-
void sendNotification({
314-
method: 'notifications/progress',
315-
params: {
316-
progressToken,
317-
progress,
318-
total: 1,
319-
message: 'Creating video...',
320-
},
321-
})
322-
},
323-
signal,
324306
})
325307
return {
326308
content: [
@@ -395,159 +377,3 @@ function createTagResourceContent(tag: { id: number }): ResourceContent {
395377
},
396378
}
397379
}
398-
399-
async function createWrappedVideo({
400-
entries,
401-
tags,
402-
year,
403-
mockTime,
404-
onProgress,
405-
signal,
406-
}: {
407-
entries: Array<{ id: number; content: string }>
408-
tags: Array<{ id: number; name: string }>
409-
year: number
410-
mockTime: number
411-
onProgress: (progress: number) => void
412-
signal: AbortSignal
413-
}) {
414-
if (signal.aborted) {
415-
throw new Error('Cancelled')
416-
}
417-
signal.addEventListener('abort', onAbort)
418-
let ffmpeg: ReturnType<typeof spawn> | undefined
419-
function onAbort() {
420-
if (ffmpeg && !ffmpeg.killed) {
421-
ffmpeg.kill('SIGKILL')
422-
}
423-
}
424-
try {
425-
if (mockTime > 0) {
426-
const step = mockTime / 10
427-
for (let i = 0; i < mockTime; i += step) {
428-
if (signal.aborted) throw new Error('Cancelled')
429-
const progress = i / mockTime
430-
if (progress >= 1) break
431-
onProgress(progress)
432-
await new Promise((resolve) => setTimeout(resolve, step))
433-
}
434-
onProgress(1)
435-
return 'epicme://videos/wrapped-2025'
436-
}
437-
438-
const totalDurationSeconds = 60 * 2
439-
const texts = [
440-
{
441-
text: `Hello ${userInfo().username}!`,
442-
color: 'white',
443-
fontsize: 72,
444-
},
445-
{
446-
text: `It's ${new Date().toLocaleDateString('en-US', {
447-
month: 'long',
448-
day: 'numeric',
449-
year: 'numeric',
450-
})}`,
451-
color: 'green',
452-
fontsize: 72,
453-
},
454-
{
455-
text: `Here's your EpicMe wrapped video for ${year}`,
456-
color: 'yellow',
457-
fontsize: 72,
458-
},
459-
{
460-
text: `You wrote ${entries.length} entries in ${year}`,
461-
color: '#ff69b4',
462-
fontsize: 72,
463-
},
464-
{
465-
text: `And you created ${tags.length} tags in ${year}`,
466-
color: 'yellow',
467-
fontsize: 72,
468-
},
469-
{ text: `Good job!`, color: 'red', fontsize: 72 },
470-
{
471-
text: `Keep Journaling in ${year + 1}!`,
472-
color: '#ffa500',
473-
fontsize: 72,
474-
},
475-
]
476-
const numTexts = texts.length
477-
const perTextDuration = totalDurationSeconds / numTexts
478-
const outputFile = `./videos/wrapped-${year}.mp4`
479-
await fs.mkdir('./videos', { recursive: true })
480-
const fontPath = './other/caveat-variable-font.ttf'
481-
const timings = texts.map((_, i) => {
482-
const start = perTextDuration * i
483-
const end = perTextDuration * (i + 1)
484-
return { start, end }
485-
})
486-
const drawtexts = texts.map((t, i) => {
487-
const { start, end } = timings[i]!
488-
const fadeInEnd = start + perTextDuration / 3
489-
const fadeOutStart = end - perTextDuration / 3
490-
const scrollExpr = `h-((t-${start})*(h+text_h)/${perTextDuration})`
491-
const fontcolor = t.color.startsWith('#')
492-
? t.color.replace('#', '0x')
493-
: t.color
494-
const safeText = t.text
495-
.replace(/\\/g, '\\\\')
496-
.replace(/'/g, "'\\''")
497-
.replace(/\n/g, '\\n')
498-
return `drawtext=fontfile=${fontPath}:text='${safeText}':fontcolor=${fontcolor}:fontsize=${t.fontsize}:x=(w-text_w)/2:y=${scrollExpr}:alpha='if(lt(t,${start}),0,if(lt(t,${fadeInEnd}),1,if(lt(t,${fadeOutStart}),1,if(lt(t,${end}),((${end}-t)/${perTextDuration / 3}),0))))':shadowcolor=black:shadowx=4:shadowy=4`
499-
})
500-
501-
const ffmpegPromise = new Promise((resolve, reject) => {
502-
ffmpeg = spawn('ffmpeg', [
503-
'-f',
504-
'lavfi',
505-
'-i',
506-
`color=c=black:s=1280x720:d=${totalDurationSeconds}`,
507-
'-vf',
508-
drawtexts.join(','),
509-
'-c:v',
510-
'libx264',
511-
'-preset',
512-
'ultrafast',
513-
'-crf',
514-
'18',
515-
'-pix_fmt',
516-
'yuv420p',
517-
'-y',
518-
outputFile,
519-
])
520-
521-
if (ffmpeg.stderr) {
522-
ffmpeg.stderr.on('data', (data) => {
523-
const str = data.toString()
524-
const timeMatch = str.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/)
525-
if (timeMatch) {
526-
const hours = Number(timeMatch[1])
527-
const minutes = Number(timeMatch[2])
528-
const seconds = Number(timeMatch[3])
529-
const fraction = Number(timeMatch[4])
530-
const currentSeconds =
531-
hours * 3600 + minutes * 60 + seconds + fraction / 100
532-
const progress = Math.min(currentSeconds / totalDurationSeconds, 1)
533-
onProgress(progress)
534-
}
535-
})
536-
}
537-
538-
ffmpeg.on('close', (code) => {
539-
if (signal.aborted) {
540-
reject(new Error('Cancelled'))
541-
} else if (code === 0) resolve(undefined)
542-
else reject(new Error(`ffmpeg exited with code ${code}`))
543-
})
544-
})
545-
546-
await ffmpegPromise
547-
548-
const videoUri = `epicme://videos/wrapped-${year}`
549-
return videoUri
550-
} finally {
551-
signal.removeEventListener('abort', onAbort)
552-
}
553-
}

0 commit comments

Comments
 (0)