1- import { spawn } from 'node:child_process'
2- import * as fs from 'node:fs/promises'
3- import { userInfo } from 'node:os'
41import { invariant } from '@epic-web/invariant'
52import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
63import { z } from 'zod'
@@ -14,6 +11,7 @@ import {
1411 updateTagInputSchema ,
1512} from './db/schema.ts'
1613import { type EpicMeMCP } from './index.ts'
14+ import { createWrappedVideo } from './video.ts'
1715
1816export 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 ( / t i m e = ( \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