Skip to content

Commit 9228d0c

Browse files
committed
good progress
1 parent cdce732 commit 9228d0c

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ data.db
1616

1717
.wrangler/
1818
db.sqlite
19+
20+
videos/
Binary file not shown.

exercises/99.finished/01.solution.finished/src/tools.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { spawn } from 'node:child_process'
2+
import * as fs from 'node:fs/promises'
3+
import { userInfo } from 'node:os'
14
import { invariant } from '@epic-web/invariant'
25
import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
36
import { z } from 'zod'
@@ -367,6 +370,67 @@ export async function initializeTools(agent: EpicMeMCP) {
367370
}
368371
},
369372
)
373+
374+
agent.server.registerTool(
375+
'create_wrapped_video',
376+
{
377+
title: 'Create Wrapped Video',
378+
description:
379+
'Create a "wrapped" video highlighting stats of your journaling this year',
380+
annotations: {
381+
destructiveHint: false,
382+
openWorldHint: false,
383+
},
384+
inputSchema: {
385+
year: z
386+
.number()
387+
.default(new Date().getFullYear())
388+
.describe(
389+
'The year to create a wrapped video for (defaults to current year)',
390+
),
391+
},
392+
outputSchema: { videoUri: z.string().describe('The URI of the video') },
393+
},
394+
async (
395+
{ year = new Date().getFullYear() },
396+
{ sendNotification, _meta },
397+
) => {
398+
const entries = await agent.db.getEntries()
399+
const filteredEntries = entries.filter(
400+
(entry) => new Date(entry.createdAt * 1000).getFullYear() === year,
401+
)
402+
const tags = await agent.db.getTags()
403+
const filteredTags = tags.filter(
404+
(tag) => new Date(tag.createdAt * 1000).getFullYear() === year,
405+
)
406+
const videoUri = await createWrappedVideo({
407+
entries: filteredEntries,
408+
tags: filteredTags,
409+
year,
410+
onProgress: (progress) => {
411+
const { progressToken } = _meta ?? {}
412+
if (!progressToken) return
413+
void sendNotification({
414+
method: 'notifications/progress',
415+
params: {
416+
progressToken,
417+
progress,
418+
total: 1,
419+
message: 'Creating video...',
420+
},
421+
})
422+
},
423+
})
424+
return {
425+
structuredContent: { videoUri },
426+
content: [
427+
createTextContent(
428+
`Video created successfully with URI "${videoUri}"`,
429+
),
430+
],
431+
}
432+
},
433+
)
370434
}
371435

372436
function createTextContent(text: unknown): CallToolResult['content'][number] {
@@ -456,3 +520,140 @@ async function elicitConfirmation(agent: EpicMeMCP, message: string) {
456520
})
457521
return result.action === 'accept' && result.content?.confirmed === true
458522
}
523+
524+
async function createWrappedVideo({
525+
entries,
526+
tags,
527+
year,
528+
onProgress,
529+
}: {
530+
entries: Array<{ id: number; content: string }>
531+
tags: Array<{ id: number; name: string }>
532+
year: number
533+
onProgress: (progress: number) => void
534+
}) {
535+
// Create a video with multiple lines of text fading/scrolling up in sequence
536+
const totalDurationSeconds = 60 * 2
537+
const texts = [
538+
{
539+
text: `Hello ${userInfo().username}!`,
540+
color: 'white',
541+
fontsize: 72,
542+
},
543+
{
544+
text: `It's ${new Date().toLocaleDateString('en-US', {
545+
month: 'long',
546+
day: 'numeric',
547+
year: 'numeric',
548+
})}`,
549+
color: 'green',
550+
fontsize: 72,
551+
},
552+
{
553+
text: `Here's your EpicMe wrapped video for ${year}`,
554+
color: 'yellow',
555+
fontsize: 72,
556+
},
557+
{
558+
text: `You wrote ${entries.length} entries in ${year}`,
559+
color: '#ff69b4',
560+
fontsize: 72,
561+
},
562+
{
563+
text: `And you created ${tags.length} tags in ${year}`,
564+
color: 'yellow',
565+
fontsize: 72,
566+
},
567+
{ text: `Good job!`, color: 'red', fontsize: 72 },
568+
{
569+
text: `Keep Journaling in ${year + 1}!`,
570+
color: '#ffa500',
571+
fontsize: 72,
572+
},
573+
]
574+
const numTexts = texts.length
575+
const perTextDuration = totalDurationSeconds / numTexts
576+
const outputFile = `./videos/wrapped-${year}.mp4`
577+
// create directory if it doesn't exist
578+
await fs.mkdir('./videos', { recursive: true })
579+
const fontPath = './other/caveat-variable-font.ttf'
580+
581+
// Calculate timing for each text
582+
const timings = texts.map((_, i) => {
583+
const start = perTextDuration * i
584+
const end = perTextDuration * (i + 1)
585+
return { start, end }
586+
})
587+
588+
// drawtext filters for fade, scroll, color, and scale
589+
const drawtexts = texts.map((t, i) => {
590+
const { start, end } = timings[i]!
591+
const fadeInEnd = start + perTextDuration / 3
592+
const fadeOutStart = end - perTextDuration / 3
593+
// Calculate scroll rate so text moves from bottom to top during its segment
594+
// y = h - (t - start) * (h + text_h) / perTextDuration
595+
const scrollExpr = `h-((t-${start})*(h+text_h)/${perTextDuration})`
596+
const fontcolor = t.color.startsWith('#')
597+
? t.color.replace('#', '0x')
598+
: t.color
599+
// Escape single quotes and backslashes in text for ffmpeg
600+
const safeText = t.text
601+
.replace(/\\/g, '\\\\') // escape backslashes first!
602+
.replace(/'/g, "'\\''") // escape single quotes for ffmpeg
603+
.replace(/\n/g, '\\n')
604+
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`
605+
})
606+
607+
const ffmpeg = spawn('ffmpeg', [
608+
'-f',
609+
'lavfi',
610+
'-i',
611+
`color=c=black:s=1280x720:d=${totalDurationSeconds}`,
612+
'-vf',
613+
drawtexts.join(','),
614+
'-c:v',
615+
'libx264',
616+
'-preset',
617+
'ultrafast',
618+
'-crf',
619+
'18',
620+
'-pix_fmt',
621+
'yuv420p',
622+
'-y',
623+
outputFile,
624+
])
625+
626+
ffmpeg.stderr.on('data', (data) => {
627+
const str = data.toString()
628+
console.error(str)
629+
const timeMatch = str.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/)
630+
if (timeMatch) {
631+
const hours = Number(timeMatch[1])
632+
const minutes = Number(timeMatch[2])
633+
const seconds = Number(timeMatch[3])
634+
const fraction = Number(timeMatch[4])
635+
const currentSeconds =
636+
hours * 3600 + minutes * 60 + seconds + fraction / 100
637+
const progress = Math.min(currentSeconds / totalDurationSeconds, 1)
638+
console.error({
639+
hours,
640+
minutes,
641+
seconds,
642+
fraction,
643+
currentSeconds,
644+
progress,
645+
})
646+
onProgress(progress)
647+
}
648+
})
649+
650+
await new Promise((resolve, reject) => {
651+
ffmpeg.on('close', (code) => {
652+
if (code === 0) resolve(undefined)
653+
else reject(new Error(`ffmpeg exited with code ${code}`))
654+
})
655+
})
656+
657+
const videoUri = `epicme://videos/wrapped-${year}`
658+
return videoUri
659+
}

0 commit comments

Comments
 (0)