2026-08-16 · 4 MIN

Filling a 16:9 card from a 9:16 still shows 31% of the frame, so cut the wide crop in ffmpeg

— WRITING

+

2026-08-16 · 4 MIN

Every picture on this publication is a still lifted out of a short film. The footage is 1080x1920, portrait, because that is the shape it was made in. The page's picture slots run the other way: the entry hero's still box declares aspectRatio: "16 / 9", and the films card on the front page declares width={1600} height={900}.

The first version cut one still per clip at 900x1600 and let cover sort out the rest. The arithmetic is why that looks wrong. Covering a 16:9 box with a 0.5625 image scales it to the box width and overflows the height, so the visible share is (9/16) divided by (16/9): 31.6% of the frame, taken from the middle. On the 1360x400 card the sync script's comment names, it is 16.5%.

The middle band of a portrait desk shot is table.

The wide cut comes out of the video, in the video's geometry

Each source clip produces two files. The wide one is cropped before anything is scaled, in the source frame's coordinates:

function plate(src, wide = false) {
  const abs = resolveBroll(src);
  if (!existsSync(abs)) return null;
  const w = wide ? WIDE_WIDTH : WIDTH;
  const key = basename(abs).replace(/\.\w+$/, '') + `-${wide ? 'w' : 'p'}${w}q${QUALITY}.jpg`;
  const dst = join(CACHE, key);
  if (!existsSync(dst) || FORCE) {
    const crop = wide ? `crop=iw:iw*0.42:0:ih*0.42,` : '';
    execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', abs,
      '-vf', `thumbnail=120,${crop}scale=${w}:-1,${GRADE}`,
      '-frames:v', '1', '-q:v', String(QUALITY), dst]);
  }
  return dst;
}

crop=iw:iw*0.42:0:ih*0.42 takes the full 1080 width, 42% of that width as height (about 454px), starting 806px down. Low on purpose. A desk shot puts the object below centre, and the same window taken from the middle returns the wall behind it.

The order in the filter chain is load bearing. Cropping before scale means the scale operates on the window that will actually be shown, at the width it will be shown, rather than resampling pixels that are about to be thrown away. thumbnail=120 picks a representative frame out of a 120 frame window instead of a fixed index, which lands on a motion blurred or half cut frame often enough to matter. The grade is normalize with independence=0, so the channels move together and the exposure gets evened out without dragging the white balance off warm.

Measured on the files the live site serves:

The cutffmpeg stepOutput (measured)AspectReaches a 16:9 slot40 files on disk
tallscale=900:-1, no crop900x16000.56:131.6% of the frame height3,576,409 bytes (87KB avg)
widecrop=iw:iw*0.42:0:ih*0.42 then scale=1600:-11600x6732.38:1all of the crop's height, 75% of its width2,306,054 bytes (56KB avg)

p00-w.jpg comes over the wire from the live host at 54,224 bytes, the same byte count as the file on disk.

Article layout with headline and body text about avoidance and its hidden costs.

Article layout with headline and body text about avoidance and its hidden costs.

The numbering was counting the wrong map

The emit loop keeps two maps: plateName maps a source clip reference to a plate name, plateBytes maps an output file to its size. The name came from the second one.

const name = 'p' + String(plateBytes.size).padStart(2, '0');

plateBytes gains two entries per plate, the tall cut and the wide one, so the counter advanced by two every time. Thirty-two plates were named p00, p02, p04, and on to p62. Nothing failed anywhere. The stylesheet is generated from that same map, so every class matched a real file and every picture rendered. The fix is one identifier: plateName.size. The current tree names 40 plates p00 through p39, contiguous.

What a write-only output directory keeps

Before that commit, public/plates held p00-w, p00, p01, p02-w, p02, p03, up through p32, and then only even names to p62. Two naming schemes from two different runs, layered. The 16 odd numbered PNGs were survivors of an earlier contiguous run, and the generated stylesheet referenced zero of them.

The script writes plates and never deletes any, so a rename does not remove the old file, it adds a new one beside it. Those 16 were committed and served out of public/ while nothing on the site pointed at them.

This is how we built Soft Money Journal: https://softmoney.kynth.studio/?utm_source=kynth-devto&utm_medium=social&utm_campaign=kynth


One shipped product, taken apart, once a month. What it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did — read off the repository and the live site, not written from memory. Join the list.

← All writing