iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🪝

Visualizing FFmpeg Progress with GraphQL and Satori

に公開

In the previous article, I confirmed that FFmpeg progress (stats) could be retrieved via Cloudflare.

Recap

For example, it is currently possible to retrieve stats as shown below. (I have slightly modified the script from the previous article.)
Display stats for the 20 minutes (since) before a specific time (end) in 10-minute intervals (bucket).

http -b GET localhost:8787/metrics/agg \
  stream_id==68a14c \
  end==2025-12-14T20:50:00+09:00 \
  bucket==600 \
  since==1200
{
    "bucket": 600,
    "end": {
        "epoch": 1765713000,
        "iso_jst": "2025-12-14T20:50:00+09:00",
        "iso_z": "2025-12-14T11:50:00.000Z"
    },
    "filtered_count": 2,
    "raw_count": 3,
    "results": [
        {
            "bitrate_kbps_avg": 1243.0848122866894,
            "bitrate_kbps_max": 1244.1,
            "bitrate_kbps_min": 1242.6,
            "bucket_time_iso_jst": "2025-12-14T20:30:00+09:00",
            "bucket_time_iso_z": "2025-12-14T11:30:00Z",
            "bucket_ts": 1765711800,
            "drop_sum": 0,
            "dup_sum": 0,
            "fps_avg": 15.018088737201365,
            "fps_max": 15.02,
            "fps_min": 15.01,
            "frame_avg": 16998.41467576792,
            "frame_max": 21486,
            "frame_min": 12511,
            "q_avg": 12.046075085324231,
            "q_max": 20,
            "q_min": 10,
            "samples": 586,
            "speed_avg": 1,
            "speed_max": 1,
            "speed_min": 1,
            "total_size_avg": 176099200.4095563,
            "total_size_max": 222556535,
            "total_size_min": 129729922
        },
        {
            "bitrate_kbps_avg": 1242.812776831346,
            "bitrate_kbps_max": 1243.2,
            "bitrate_kbps_min": 1242.6,
            "bucket_time_iso_jst": "2025-12-14T20:40:00+09:00",
            "bucket_time_iso_z": "2025-12-14T11:40:00Z",
            "bucket_ts": 1765712400,
            "drop_sum": 0,
            "dup_sum": 0,
            "fps_avg": 15.009999999999998,
            "fps_max": 15.01,
            "fps_min": 15.01,
            "frame_avg": 25998.132879045996,
            "frame_max": 30491,
            "frame_min": 21502,
            "q_avg": 11.979557069846678,
            "q_max": 20,
            "q_min": 10,
            "samples": 587,
            "speed_avg": 1,
            "speed_max": 1,
            "speed_min": 1,
            "total_size_avg": 269274894.70017034,
            "total_size_max": 315804806,
            "total_size_min": 222698319
        }
    ],
    "since_sec": 1200,
    "stream_id": "68a14c"
}

This time

I want to make this data continuously visualizable.

What is needed for visualization is tool integration.
What makes integration economical is standardization.

So,

  • Fit the current free-form API into a data transfer format ➜ GraphQL
  • Enable visualization tools to handle it ➜ Satori

I asked Gemini for help.

The pink section represents the scope of this work.

Advantages of Standardization

When fitted to GraphQL types (schema), it looks like this:

The data available is the same, but tools can suggest items to fetch.
There is no need for external documentation; it is the specification itself.
This is also friendly to the AI-driven world we live in.
If properly structured, AI should be able to understand it more efficiently than I can.

GraphQL Conversion

Hono has a GraphQL server middleware called @hono/graphql-server, so I will make use of it.

./src
./src
├── env.ts
├── graphql
│   ├── index.ts
│   └── resolvers.ts
├── index.ts
├── lib
│   ├── font.ts
│   ├── metrics.ts
│   └── time.ts
└── rest
    ├── graph.ts
    └── metrics.ts
index.ts
import { type RootResolver, graphqlServer } from '@hono/graphql-server'
import { schema } from './graphql'
import { resolvers } from './graphql/resolvers'

const rootResolver: RootResolver = (c) => {
  return {
    ...resolvers,
  }
}

app.use(
  '/graphql',
  graphqlServer({
    schema,
    rootResolver,
    graphiql: true,
  })
)

And the DB.

env.ts
export type Bindings = {
  ffmpeg_progress: D1Database;
};
./graphql

GraphQL schema types (query/response)

graphql/index.ts
import { buildSchema } from 'graphql'

export const schema = buildSchema(`

  type AggregatedMetrics {
    stream_id: String
    // ...

  type Query {
    """
    📈 Aggregates data for the specified bucket (seconds) and returns statistics.
    Suitable for graph rendering and tracking long-term trends.
    """
    aggregateMetrics(
      stream_id: String,
      bucket: Int,
      since: Int,
      end: String
    ): AggregatedMetrics
    // ...

Resolvers

graphql/resolver.ts
import { getAggregatedMetrics, getLatestMetrics, findLatestIncident } from '../lib/metrics'

const DEFAULT_SID = '68a14c'

export const resolvers = {
  aggregateMetrics: (args: any, c: any) => 
    getAggregatedMetrics(
      c.env.ffmpeg_progress,
      args.stream_id || DEFAULT_SID,
      args.bucket || 10,
      args.since || 300,
      args.end
    ),
    
./lib

D1 query functions (standardized for use in APIs, etc.)

lib/metrics.ts
/**
 * Common function to fetch aggregated data from D1
 * Removes noise from boundary values using inclusion buffers
 */
export async function getAggregatedMetrics(
  db: D1Database,
  streamId: string,
  bucket: number,
  since?: number,
  endParam?: string,
  order: 'ASC' | 'DESC' = 'ASC'
) {
  // ...
  
  const { results } = await db.prepare(sql)
    .bind(bucket, bucket, bucket, bucket, bucket, bucket, streamId, sinceTs, endTs)
    .all()
  // ...
  
  return {
    stream_id: streamId,
    bucket,
    since_sec: since,
    end: getTimeInfo(endTs),
    results: filteredResults
  }
 // ...

By using GraphQL, extracting the desired information from the DB has become clearer and easier.
Since it is guided, there is no need to get lost.

Creating Images with Satori

Moving away from GraphQL for a moment, I want to create a graph.
When running on Cloudflare Workers, I was recommended Satori.

rest/graph.ts
import satori from 'satori'
import { html } from 'satori-html'
// ...

export const graphRoutes = new Hono<{ Bindings: any }>()
// ..

I reused the D1 query function used in GraphQL and engaged in some trial and error as follows.

The optimal aggregation unit changes depending on the monitoring period. I applied a helper for automatic calculation.

rest/graph.ts
// Auto-calculate bucket size based on 'since' duration
const getAutoBucket = (since: number, manualBucket?: number) => {
  if (manualBucket && manualBucket > 0) return manualBucket
  if (since <= 600) return 1       // Within 10 mins: 1-sec interval
  if (since <= 3600) return 10     // Within 1 hour: 10-sec interval
  if (since <= 86400) return 300   // Within 1 day: 5-min interval
  if (since <= 604800) return 1800 // Within 1 week: 30-min interval
  return 86400                     // Beyond that: 1-day interval
}

I dynamically change the color of the bar graph according to data anomalies.

rest/graph.ts
const METRIC_CONFIG: Record<string, any> = {
  fps: { label: 'FPS', dbKey: 'fps_avg', max: 60, color: '#3b82f6', isInverse: false, threshold: 30 },
  // ..

graphRoutes.get('/:type', async (c) => {
  const type = c.req.param('type')
  const conf = METRIC_CONFIG[type] || METRIC_CONFIG.fps
  // ..

  const barsHtml = results.map(r => {
    const val = Number(r[conf.dbKey]) || 0
    const height = Math.max(Math.min((val / conf.max) * 100, 100), 2)
    const color = (conf.isInverse ? val > conf.threshold : val < conf.threshold) ? '#ef4444' : conf.color
    return `<div style="display: flex; background-color: ${color}; flex: 1; height: ${height}%; border-radius: 2px 2px 0 0;"></div>`
  }).join('')

GraphQL and Satori Eco-Integration

I will try searching for recent q values greater than 25 using GraphQL and enabling the retrieval of an image URL that spans a certain period from that point.

graphql/resolver.ts
  findIncident: async (args: FindIncidentArgs, c: any) => {
    const { stream_id: sId = DEFAULT_SID, metric: met = 'q', threshold: thr = 25, isInverse } = args
    const inv = isInverse ?? true

    const result = await findLatestIncident(c.env.ffmpeg_progress, sId, met, thr, inv) as any
   // ...
   
   return {
      ts: result.ts,
      time: result.created_at,
      value: Number(result.val),
      graphUrl: `/graph/${graphPath}?stream_id=${sId}&end=${isoTime}&since=300&bucket=1`
    }
  }

A recent threshold violation is found, and the image URL is also retrieved.

output
{
  "data": {
    "findIncident": {
      "ts": 1766303586,
      "time": "2025-12-21 07:53:06",
      "value": 26,
      "graphUrl": "/graph/q?stream_id=68a14c&end=2025-12-21T16%3A53%3A06%2B09%3A00&since=300&bucket=1"
    }
  }
}

Accessing the image URL:
The image shows the bars over 25 in red, following the logic above.

The integration between GraphQL and Satori has been achieved.
Having D1, Workers, and Hono is huge.
As I try it out, there is still room for improvement, but visualization has been achieved for now.

Next, I intend to turn this API into an MCP tool.
Done

Discussion