Skip to content
Open
Show file tree
Hide file tree
Changes from all 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