|
| 1 | +import { spawn } from 'node:child_process' |
| 2 | +import * as fs from 'node:fs/promises' |
| 3 | +import { userInfo } from 'node:os' |
1 | 4 | import { invariant } from '@epic-web/invariant' |
2 | 5 | import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js' |
3 | 6 | import { z } from 'zod' |
@@ -367,6 +370,67 @@ export async function initializeTools(agent: EpicMeMCP) { |
367 | 370 | } |
368 | 371 | }, |
369 | 372 | ) |
| 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 | + ) |
370 | 434 | } |
371 | 435 |
|
372 | 436 | function createTextContent(text: unknown): CallToolResult['content'][number] { |
@@ -456,3 +520,140 @@ async function elicitConfirmation(agent: EpicMeMCP, message: string) { |
456 | 520 | }) |
457 | 521 | return result.action === 'accept' && result.content?.confirmed === true |
458 | 522 | } |
| 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