Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion scripts/i18n-raw-literal-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ packages/docs/app/components/blocks/comparison.tsx|A side-by-side comparison of
packages/docs/app/components/blocks/steps.tsx|A numbered step-by-step procedure with circled step numbers.
packages/docs/app/components/blocks/steps.tsx|Step title
packages/docs/app/components/docBlocks.tsx|| Extract
templates/clips/app/components/editor/waveform.tsx|VISUAL_SILENCE_FLOOR && botY - topY
templates/clips/app/components/recorder/storage-setup-card.tsx|AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO
templates/clips/app/components/workspace/notifications-list.tsx|["formatDate"], formatRelativeTime: ReturnType
templates/crm/app/components/crm/CreateCrmRecordDialog.tsx|This record lives in the local-authoritative Native SQL CRM. You can connect or mirror another CRM separately.
Expand Down
31 changes: 31 additions & 0 deletions templates/clips/.agents/skills/video-editing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,37 @@ downsamples to a 2000-point peaks array. Peaks are cached in
recompute. The editor reads the cache first and falls back to computing only
when the key is missing or corrupted.

### Timeline filmstrip

The trim track shows video frames behind the waveform. Two paths, and the order
matters:

1. **Sprite (preferred).** `generate-filmstrip` runs one ffmpeg pass
(`fps` → `scale` → `pad` → `tile`) into a single JPEG grid, uploads it, and
stores the URL plus grid geometry on the recording. The editor renders cells
with CSS `background-position` — one cached image request, no video decoding
in the browser.
2. **Browser extraction (fallback).** `extractFilmstripThumbnails()` seeks a
detached `<video>` once per frame. Only used when there is no sprite, which
means hosts without ffmpeg and local/dev media the server cannot fetch.

Three traps this design exists to avoid:

- **Pass the proxied URL.** Frame extraction must use `getWaveformMediaUrl()`,
never `recording.videoUrl` — reading pixels back out of a cross-origin video
taints the canvas and `toDataURL` throws, so provider media silently yields no
filmstrip at all.
- **Both paths sample cell midpoints,** not `0 … duration` endpoints. The strip
renders N equal cells, so cell `i` must show the middle of the slot it
occupies or every thumbnail sits up to half a cell from the time beneath it.
- **Cell count comes from the geometry,** `trackWidth / (height × aspect)`, not
from the sprite's frame count. Rendering all 40 frames across an unzoomed
track makes each cell portrait, and `object-cover` then shows a narrow centre
slice of each frame instead of a recognisable thumbnail.

The filmstrip never replaces the waveform — peaks still draw on top of a scrim,
because frames say nothing about the audio.

### Keyboard shortcuts (editor scope)

- **Space** — play / pause (even while focused in the editor area)
Expand Down
1 change: 1 addition & 0 deletions templates/clips/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Read the matching skill before deeper work in that area:
| `move-recording` | Move `id` or `ids` to a folder or root |
| `archive-`, `trash-`, `restore-recording` | Lifecycle |
| `reprocess-recording` | Repair unseekable/frozen media |
| `generate-filmstrip` | Editor timeline frame sprite; `all` backfills |
| `request-transcript`, `cleanup-transcript` | Transcribe; `force`/`regenerate` |
| `regenerate-title`, `-summary`, `-chapters` | AI metadata |
| `trim-`, `split-recording`, `remove-silences`, `remove-filler-words` | Edits |
Expand Down
154 changes: 154 additions & 0 deletions templates/clips/actions/generate-filmstrip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Generate editor-timeline filmstrip sprites for stored recordings.
*
* The editor shows video frames behind the waveform so a trim range can be
* found visually. Generating them server-side with one ffmpeg pass replaces
* per-frame decoding in the browser, which cannot read cross-origin media at
* all and needs one seek per frame.
*
* Non-destructive and idempotent: a recording whose sprite already matches its
* current media is skipped, and any failure leaves the row untouched so the
* editor keeps its browser-side fallback.
*
* Usage:
* pnpm action generate-filmstrip --id=<recordingId>
* pnpm action generate-filmstrip --id=<recordingId> --force
* pnpm action generate-filmstrip --all --limit=20
*/

import { defineAction } from "@agent-native/core";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import {
getCurrentOwnerEmail,
ownerEmailMatches,
} from "../server/lib/recordings.js";
import {
DEFAULT_FILMSTRIP_FRAME_COUNT,
MAX_FILMSTRIP_FRAME_COUNT,
} from "../server/lib/video-filmstrip-sprite.js";
import {
ensureRecordingFilmstrip,
type EnsureFilmstripResult,
} from "./lib/ensure-recording-filmstrip.js";

const MAX_TARGETS_PER_CALL = 50;
const DEFAULT_ALL_LIMIT = 10;
const cliBoolean = z.preprocess((value) => {
if (value === "true" || value === "1") return true;
if (value === "false" || value === "0") return false;
return value;
}, z.boolean());

export default defineAction({
description:
"Generate the editor timeline filmstrip (a sprite of evenly-spaced video frames) for one or more of the caller's recordings, so the editor can show frames behind the waveform without decoding video in the browser. Pass `id` for one clip, `ids` for several, or `all: true` to backfill clips that have no filmstrip yet. Recordings whose sprite already matches their current media are skipped unless `force` is true. Requires ffmpeg on the server; without it this reports `skipped-no-ffmpeg` and the editor falls back to browser-side extraction.",
schema: z.object({
id: z
.string()
.optional()
.describe("A single recording id to generate a filmstrip for."),
ids: z
.array(z.string())
.optional()
.describe("Several recording ids to process in one call."),
all: cliBoolean
.optional()
.describe(
"Backfill the caller's ready clips that have no filmstrip yet, most recent first, up to `limit`.",
),
limit: z.coerce
.number()
.int()
.min(1)
.max(MAX_TARGETS_PER_CALL)
.optional()
.describe(
`Max clips to process when using \`all\` (default ${DEFAULT_ALL_LIMIT}).`,
),
frameCount: z.coerce
.number()
.int()
.min(4)
.max(MAX_FILMSTRIP_FRAME_COUNT)
.optional()
.describe(
`How many frames to sample across the clip (default ${DEFAULT_FILMSTRIP_FRAME_COUNT}). More frames means finer scrubbing detail and a larger sprite.`,
),
force: cliBoolean
.optional()
.describe("Regenerate even when a current filmstrip already exists."),
}),
run: async (args) => {
const db = getDb();
const ownerEmail = getCurrentOwnerEmail();

let targetIds: string[] = [];
if (args.id) targetIds.push(args.id);
if (args.ids?.length) targetIds.push(...args.ids.filter(Boolean));

if (args.all) {
const conditions = [
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
eq(schema.recordings.status, "ready"),
isNotNull(schema.recordings.videoUrl),
];
if (!args.force) {
conditions.push(isNull(schema.recordings.filmstripUrl));
}
const rows = await db
.select({ id: schema.recordings.id })
.from(schema.recordings)
.where(and(...conditions))
.orderBy(desc(schema.recordings.createdAt))
.limit(args.limit ?? DEFAULT_ALL_LIMIT);
targetIds.push(...rows.map((r) => r.id));
}

// De-dupe while preserving order, and bound the batch so one call can't run
// unboundedly under the hosted foreground budget.
targetIds = Array.from(new Set(targetIds)).slice(0, MAX_TARGETS_PER_CALL);

if (targetIds.length === 0) {
return {
ok: true,
processed: 0,
changed: 0,
results: [] as EnsureFilmstripResult[],
message:
"No recordings to process. Pass id / ids, or all: true to backfill clips without a filmstrip.",
};
}

const results: EnsureFilmstripResult[] = [];
for (const recordingId of targetIds) {
try {
results.push(
await ensureRecordingFilmstrip({
recordingId,
ownerEmail,
frameCount: args.frameCount,
force: Boolean(args.force),
}),
);
} catch (err) {
console.warn("[generate-filmstrip] failed for", recordingId, err);
results.push({
recordingId,
status: "failed-ffmpeg",
changed: false,
detail: err instanceof Error ? err.message : String(err),
});
}
}

const changed = results.filter((r) => r.changed).length;
console.log(
`Processed ${results.length} recording(s); ${changed} filmstrip(s) generated.`,
);

return { ok: true, processed: results.length, changed, results };
},
});
6 changes: 6 additions & 0 deletions templates/clips/actions/get-recording-player-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@ export default defineAction({
description: rec.description,
thumbnailUrl: rec.thumbnailUrl,
animatedThumbnailUrl: rec.animatedThumbnailUrl,
filmstripUrl: rec.filmstripUrl ?? null,
filmstripFrameCount: rec.filmstripFrameCount ?? 0,
filmstripColumns: rec.filmstripColumns ?? 0,
filmstripRows: rec.filmstripRows ?? 0,
filmstripFrameWidth: rec.filmstripFrameWidth ?? 0,
filmstripFrameHeight: rec.filmstripFrameHeight ?? 0,
sourceAppName: rec.sourceAppName,
sourceWindowTitle: rec.sourceWindowTitle,
durationMs: rec.durationMs,
Expand Down
Loading
Loading