@agent-native/core 0.84.22 → 0.84.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/model-config.ts +1 -1
- package/corpus/core/src/cli/skills.ts +17 -3
- package/corpus/core/src/notifications/index.ts +1 -0
- package/corpus/core/src/notifications/registry.ts +13 -1
- package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +18 -1
- package/corpus/templates/analytics/server/db/schema.ts +1 -1
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -1
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +76 -7
- package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
- package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
- package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
- package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
- package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
- package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
- package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +11 -1
- package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
- package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
- package/dist/agent/model-config.js +1 -1
- package/dist/agent/model-config.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +17 -3
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +1 -1
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/registry.d.ts +5 -0
- package/dist/notifications/registry.d.ts.map +1 -1
- package/dist/notifications/registry.js +4 -1
- package/dist/notifications/registry.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair an already-stored recording so it plays back and seeks instantly.
|
|
3
|
+
*
|
|
4
|
+
* This is the fetch-based counterpart to the inline seekable pass in
|
|
5
|
+
* `finalize-recording`. It re-fetches a recording's stored provider media,
|
|
6
|
+
* runs the format-appropriate seekable rewrite (MP4 faststart / WebM Cues
|
|
7
|
+
* remux), and, when that changed the bytes, re-uploads the fixed file and
|
|
8
|
+
* repoints the recording at it.
|
|
9
|
+
*
|
|
10
|
+
* Used by:
|
|
11
|
+
* - `reprocess-recording` — owner/agent triggered backfill of clips that were
|
|
12
|
+
* uploaded before the seekable pass existed (or via the streaming path,
|
|
13
|
+
* which never applied it).
|
|
14
|
+
* - `finalize-recording` — a best-effort background pass for the resumable
|
|
15
|
+
* streaming path, whose bytes live at the provider and were never buffered
|
|
16
|
+
* server-side for an inline rewrite.
|
|
17
|
+
*
|
|
18
|
+
* Everything is best-effort and non-destructive: if we can't fetch, can't
|
|
19
|
+
* improve, or can't re-upload, the existing recording is left exactly as-is.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
readAppState,
|
|
24
|
+
writeAppState,
|
|
25
|
+
} from "@agent-native/core/application-state";
|
|
26
|
+
import { uploadFile } from "@agent-native/core/file-upload";
|
|
27
|
+
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
28
|
+
import { and, eq } from "drizzle-orm";
|
|
29
|
+
|
|
30
|
+
import { getDb, schema } from "../../server/db/index.js";
|
|
31
|
+
import { ownerEmailMatches } from "../../server/lib/recordings.js";
|
|
32
|
+
import {
|
|
33
|
+
makeSeekable,
|
|
34
|
+
type VideoFormat,
|
|
35
|
+
} from "../../server/lib/video-remux.js";
|
|
36
|
+
import { isLoomRecordingSource } from "../../shared/loom.js";
|
|
37
|
+
|
|
38
|
+
const PROVIDER_FETCH_TIMEOUT_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
export type EnsureSeekableStatus =
|
|
41
|
+
| "optimized"
|
|
42
|
+
| "already-optimized"
|
|
43
|
+
| "skipped-not-ready"
|
|
44
|
+
| "skipped-no-media"
|
|
45
|
+
| "skipped-local-media"
|
|
46
|
+
| "skipped-too-large"
|
|
47
|
+
| "skipped-fetch-failed"
|
|
48
|
+
| "skipped-upload-failed"
|
|
49
|
+
| "not-found";
|
|
50
|
+
|
|
51
|
+
export interface EnsureSeekableResult {
|
|
52
|
+
recordingId: string;
|
|
53
|
+
status: EnsureSeekableStatus;
|
|
54
|
+
changed: boolean;
|
|
55
|
+
videoUrl?: string | null;
|
|
56
|
+
detail?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** application_state key marking a recording's media as already made seekable. */
|
|
60
|
+
export function seekableMarkerKey(recordingId: string): string {
|
|
61
|
+
return `recording-seekable-${recordingId}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record that `videoUrl` for `recordingId` has been made seekable, so later
|
|
66
|
+
* sweeps skip it. Scoped by URL so a re-upload that changes the URL is not
|
|
67
|
+
* mistaken for already-processed.
|
|
68
|
+
*/
|
|
69
|
+
export async function markRecordingSeekable(
|
|
70
|
+
recordingId: string,
|
|
71
|
+
videoUrl: string | null | undefined,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
await writeAppState(seekableMarkerKey(recordingId), {
|
|
74
|
+
recordingId,
|
|
75
|
+
videoUrl: videoUrl ?? null,
|
|
76
|
+
at: new Date().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function isAlreadyMarked(
|
|
81
|
+
recordingId: string,
|
|
82
|
+
videoUrl: string | null,
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
const marker = await readAppState(seekableMarkerKey(recordingId)).catch(
|
|
85
|
+
() => null,
|
|
86
|
+
);
|
|
87
|
+
return Boolean(
|
|
88
|
+
marker &&
|
|
89
|
+
typeof marker === "object" &&
|
|
90
|
+
(marker as { videoUrl?: unknown }).videoUrl === videoUrl,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRemoteProviderUrl(videoUrl: string | null | undefined): boolean {
|
|
95
|
+
return typeof videoUrl === "string" && /^https:\/\//i.test(videoUrl.trim());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchProviderBytes(
|
|
99
|
+
videoUrl: string,
|
|
100
|
+
): Promise<
|
|
101
|
+
{ ok: true; bytes: Uint8Array } | { ok: false; reason: EnsureSeekableStatus }
|
|
102
|
+
> {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeout = setTimeout(
|
|
105
|
+
() => controller.abort(),
|
|
106
|
+
PROVIDER_FETCH_TIMEOUT_MS,
|
|
107
|
+
);
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(videoUrl, { signal: controller.signal });
|
|
110
|
+
if (!res.ok) return { ok: false, reason: "skipped-fetch-failed" };
|
|
111
|
+
|
|
112
|
+
const declaredLength = Number(res.headers.get("content-length") ?? "0");
|
|
113
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BYTES) {
|
|
114
|
+
return { ok: false, reason: "skipped-too-large" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
118
|
+
if (buf.byteLength === 0)
|
|
119
|
+
return { ok: false, reason: "skipped-fetch-failed" };
|
|
120
|
+
if (buf.byteLength > MAX_UPLOAD_BYTES) {
|
|
121
|
+
return { ok: false, reason: "skipped-too-large" };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, bytes: buf };
|
|
124
|
+
} catch {
|
|
125
|
+
return { ok: false, reason: "skipped-fetch-failed" };
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Ensure a single recording's stored media is seekable. Owner-scoped: pass the
|
|
133
|
+
* resolved owner email so the DB lookup can only touch that owner's rows.
|
|
134
|
+
*/
|
|
135
|
+
export async function ensureRecordingSeekable(params: {
|
|
136
|
+
recordingId: string;
|
|
137
|
+
ownerEmail: string;
|
|
138
|
+
force?: boolean;
|
|
139
|
+
}): Promise<EnsureSeekableResult> {
|
|
140
|
+
const { recordingId, ownerEmail, force = false } = params;
|
|
141
|
+
const db = getDb();
|
|
142
|
+
|
|
143
|
+
const [rec] = await db
|
|
144
|
+
.select({
|
|
145
|
+
id: schema.recordings.id,
|
|
146
|
+
status: schema.recordings.status,
|
|
147
|
+
videoUrl: schema.recordings.videoUrl,
|
|
148
|
+
videoFormat: schema.recordings.videoFormat,
|
|
149
|
+
sourceAppName: schema.recordings.sourceAppName,
|
|
150
|
+
})
|
|
151
|
+
.from(schema.recordings)
|
|
152
|
+
.where(
|
|
153
|
+
and(
|
|
154
|
+
eq(schema.recordings.id, recordingId),
|
|
155
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (!rec) {
|
|
160
|
+
return { recordingId, status: "not-found", changed: false };
|
|
161
|
+
}
|
|
162
|
+
if (rec.status !== "ready") {
|
|
163
|
+
return { recordingId, status: "skipped-not-ready", changed: false };
|
|
164
|
+
}
|
|
165
|
+
if (isLoomRecordingSource(rec)) {
|
|
166
|
+
return { recordingId, status: "skipped-local-media", changed: false };
|
|
167
|
+
}
|
|
168
|
+
if (!rec.videoUrl) {
|
|
169
|
+
return { recordingId, status: "skipped-no-media", changed: false };
|
|
170
|
+
}
|
|
171
|
+
// Only provider-hosted media can be re-fetched and re-uploaded. Local/dev
|
|
172
|
+
// blobs (served from application_state via /api/video) and other relative
|
|
173
|
+
// URLs are left untouched.
|
|
174
|
+
if (!isRemoteProviderUrl(rec.videoUrl)) {
|
|
175
|
+
return {
|
|
176
|
+
recordingId,
|
|
177
|
+
status: "skipped-local-media",
|
|
178
|
+
changed: false,
|
|
179
|
+
videoUrl: rec.videoUrl,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!force && (await isAlreadyMarked(recordingId, rec.videoUrl))) {
|
|
184
|
+
return {
|
|
185
|
+
recordingId,
|
|
186
|
+
status: "already-optimized",
|
|
187
|
+
changed: false,
|
|
188
|
+
videoUrl: rec.videoUrl,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const fetched = await fetchProviderBytes(rec.videoUrl);
|
|
193
|
+
if (!fetched.ok) {
|
|
194
|
+
return { recordingId, status: fetched.reason, changed: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const videoFormat: VideoFormat = rec.videoFormat === "mp4" ? "mp4" : "webm";
|
|
198
|
+
const seekable = await makeSeekable({
|
|
199
|
+
mediaBytes: fetched.bytes,
|
|
200
|
+
videoFormat,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!seekable.changed) {
|
|
204
|
+
// Already seekable (or unimprovable) — remember so we don't refetch it.
|
|
205
|
+
await markRecordingSeekable(recordingId, rec.videoUrl);
|
|
206
|
+
return {
|
|
207
|
+
recordingId,
|
|
208
|
+
status: "already-optimized",
|
|
209
|
+
changed: false,
|
|
210
|
+
videoUrl: rec.videoUrl,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const mimeType = videoFormat === "mp4" ? "video/mp4" : "video/webm";
|
|
215
|
+
const upload = await uploadFile({
|
|
216
|
+
data: seekable.bytes,
|
|
217
|
+
filename: `${recordingId}.${videoFormat}`,
|
|
218
|
+
mimeType,
|
|
219
|
+
ownerEmail,
|
|
220
|
+
skipCompressionWait: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!upload?.url) {
|
|
224
|
+
// Could not re-upload — do NOT touch the row; the original still plays.
|
|
225
|
+
return {
|
|
226
|
+
recordingId,
|
|
227
|
+
status: "skipped-upload-failed",
|
|
228
|
+
changed: false,
|
|
229
|
+
videoUrl: rec.videoUrl,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await db
|
|
234
|
+
.update(schema.recordings)
|
|
235
|
+
.set({
|
|
236
|
+
videoUrl: upload.url,
|
|
237
|
+
videoSizeBytes: seekable.bytes.byteLength,
|
|
238
|
+
updatedAt: new Date().toISOString(),
|
|
239
|
+
})
|
|
240
|
+
.where(
|
|
241
|
+
and(
|
|
242
|
+
eq(schema.recordings.id, recordingId),
|
|
243
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await markRecordingSeekable(recordingId, upload.url);
|
|
248
|
+
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
recordingId,
|
|
252
|
+
status: "optimized",
|
|
253
|
+
changed: true,
|
|
254
|
+
videoUrl: upload.url,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair the playback of already-stored recordings.
|
|
3
|
+
*
|
|
4
|
+
* Clips uploaded via the streaming path (or before the seekable rewrite
|
|
5
|
+
* existed) can ship raw MediaRecorder output: an MP4 with a trailing `moov`
|
|
6
|
+
* atom or a WebM with no Cues index. Those load slowly and re-buffer on every
|
|
7
|
+
* seek even though the file downloads fine. This action re-fetches the stored
|
|
8
|
+
* media, rewrites it to be start-playable and seekable (MP4 faststart / WebM
|
|
9
|
+
* Cues remux), re-uploads the fixed file, and repoints the recording at it.
|
|
10
|
+
*
|
|
11
|
+
* It's idempotent and non-destructive: clips that are already seekable are
|
|
12
|
+
* skipped, and any clip we can't improve or re-upload is left untouched.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* pnpm action reprocess-recording --id=<recordingId>
|
|
16
|
+
* pnpm action reprocess-recording --ids='["id1","id2"]'
|
|
17
|
+
* pnpm action reprocess-recording --all --limit=20
|
|
18
|
+
* pnpm action reprocess-recording --id=<recordingId> --force
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { defineAction } from "@agent-native/core";
|
|
22
|
+
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
26
|
+
import {
|
|
27
|
+
getCurrentOwnerEmail,
|
|
28
|
+
ownerEmailMatches,
|
|
29
|
+
} from "../server/lib/recordings.js";
|
|
30
|
+
import {
|
|
31
|
+
ensureRecordingSeekable,
|
|
32
|
+
type EnsureSeekableResult,
|
|
33
|
+
} from "./lib/ensure-seekable-video.js";
|
|
34
|
+
|
|
35
|
+
const MAX_TARGETS_PER_CALL = 100;
|
|
36
|
+
const DEFAULT_ALL_LIMIT = 20;
|
|
37
|
+
|
|
38
|
+
function parseIds(value: unknown): string[] {
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return value.filter((v): v is string => typeof v === "string" && !!v);
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "string" && value.trim()) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(value);
|
|
45
|
+
if (Array.isArray(parsed)) {
|
|
46
|
+
return parsed.filter((v): v is string => typeof v === "string" && !!v);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Fall back to a single comma-separated / plain id string.
|
|
50
|
+
return value
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default defineAction({
|
|
60
|
+
description:
|
|
61
|
+
"Repair slow-loading or buffering recordings by rewriting their stored video to be start-playable and seekable (MP4 faststart / WebM Cues remux) and re-uploading it. Use for clips that take a long time to load or re-buffer when scrubbing. Pass `id` for one clip, `ids` for several, or `all: true` to sweep the caller's ready clips (bounded by `limit`). Already-seekable clips are skipped unless `force` is true.",
|
|
62
|
+
schema: z.object({
|
|
63
|
+
id: z.string().optional().describe("A single recording id to repair."),
|
|
64
|
+
ids: z
|
|
65
|
+
.union([z.array(z.string()), z.string()])
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(
|
|
68
|
+
"Multiple recording ids (array, or JSON/comma string for CLI).",
|
|
69
|
+
),
|
|
70
|
+
all: z.coerce
|
|
71
|
+
.boolean()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe(
|
|
74
|
+
"Repair the caller's own ready clips, most recent first, up to `limit`.",
|
|
75
|
+
),
|
|
76
|
+
limit: z.coerce
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(MAX_TARGETS_PER_CALL)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(
|
|
83
|
+
`Max clips to process when using \`all\` (default ${DEFAULT_ALL_LIMIT}).`,
|
|
84
|
+
),
|
|
85
|
+
force: z.coerce
|
|
86
|
+
.boolean()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Re-run even on clips already marked seekable."),
|
|
89
|
+
}),
|
|
90
|
+
run: async (args) => {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
const ownerEmail = getCurrentOwnerEmail();
|
|
93
|
+
const force = Boolean(args.force);
|
|
94
|
+
|
|
95
|
+
let targetIds: string[] = [];
|
|
96
|
+
if (args.id) targetIds.push(args.id);
|
|
97
|
+
targetIds.push(...parseIds(args.ids));
|
|
98
|
+
|
|
99
|
+
if (args.all) {
|
|
100
|
+
const limit = args.limit ?? DEFAULT_ALL_LIMIT;
|
|
101
|
+
const rows = await db
|
|
102
|
+
.select({ id: schema.recordings.id })
|
|
103
|
+
.from(schema.recordings)
|
|
104
|
+
.where(
|
|
105
|
+
and(
|
|
106
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
107
|
+
eq(schema.recordings.status, "ready"),
|
|
108
|
+
isNotNull(schema.recordings.videoUrl),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
.orderBy(desc(schema.recordings.createdAt))
|
|
112
|
+
.limit(limit);
|
|
113
|
+
targetIds.push(...rows.map((r) => r.id));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// De-dupe while preserving order, and bound the batch so a single call
|
|
117
|
+
// can't run unboundedly under the hosted foreground budget.
|
|
118
|
+
targetIds = Array.from(new Set(targetIds)).slice(0, MAX_TARGETS_PER_CALL);
|
|
119
|
+
|
|
120
|
+
if (targetIds.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
processed: 0,
|
|
124
|
+
changed: 0,
|
|
125
|
+
results: [] as EnsureSeekableResult[],
|
|
126
|
+
message:
|
|
127
|
+
"No recordings to reprocess. Pass id / ids, or all: true to sweep your clips.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const results: EnsureSeekableResult[] = [];
|
|
132
|
+
for (const recordingId of targetIds) {
|
|
133
|
+
try {
|
|
134
|
+
results.push(
|
|
135
|
+
await ensureRecordingSeekable({ recordingId, ownerEmail, force }),
|
|
136
|
+
);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.warn("[reprocess-recording] failed for", recordingId, err);
|
|
139
|
+
results.push({
|
|
140
|
+
recordingId,
|
|
141
|
+
status: "skipped-fetch-failed",
|
|
142
|
+
changed: false,
|
|
143
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const changed = results.filter((r) => r.changed).length;
|
|
149
|
+
console.log(
|
|
150
|
+
`Reprocessed ${results.length} recording(s); ${changed} rewritten for smoother playback.`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
processed: results.length,
|
|
156
|
+
changed,
|
|
157
|
+
results,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make recorded video seekable so browsers can start playback immediately and
|
|
3
|
+
* scrub without re-buffering the whole file.
|
|
4
|
+
*
|
|
5
|
+
* Two problems this fixes, both produced by `MediaRecorder` output:
|
|
6
|
+
* - MP4: the `moov` metadata atom is written AFTER `mdat`, so a player must
|
|
7
|
+
* download the entire file before it can start / seek. We relocate it with
|
|
8
|
+
* the pure-TypeScript {@link applyFaststart} (no ffmpeg needed).
|
|
9
|
+
* - WebM: MediaRecorder emits a "live" stream with no Cues (seek index) and an
|
|
10
|
+
* unknown Segment duration, so Chrome refuses to honor `currentTime = X`
|
|
11
|
+
* seeks and has to scan/download to move around. A cheap `ffmpeg -c copy`
|
|
12
|
+
* remux rewrites the container with a SeekHead + Cues index and a real
|
|
13
|
+
* duration — no re-encode, so it's fast and lossless.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is best-effort: on any failure (ffmpeg missing, bad input,
|
|
16
|
+
* timeout) we return the ORIGINAL bytes with `changed: false`, so callers never
|
|
17
|
+
* regress relative to uploading the raw recording.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { applyFaststart, hasPlayableMp4Metadata } from "./faststart.js";
|
|
28
|
+
|
|
29
|
+
const REMUX_TIMEOUT_MS = 120_000;
|
|
30
|
+
const STDERR_LIMIT = 16 * 1024;
|
|
31
|
+
const MAX_CONCURRENT_REMUXES = 2;
|
|
32
|
+
// EBML magic that every valid Matroska/WebM file starts with.
|
|
33
|
+
const EBML_MAGIC = [0x1a, 0x45, 0xdf, 0xa3];
|
|
34
|
+
|
|
35
|
+
const requireFromThisFile = createRequire(import.meta.url);
|
|
36
|
+
let cachedFfmpegStaticPath: string | null | undefined;
|
|
37
|
+
let activeRemuxes = 0;
|
|
38
|
+
const remuxWaiters: Array<() => void> = [];
|
|
39
|
+
|
|
40
|
+
export type VideoFormat = "webm" | "mp4";
|
|
41
|
+
|
|
42
|
+
export interface SeekableResult {
|
|
43
|
+
/** The seekable bytes, or the original bytes when nothing changed. */
|
|
44
|
+
bytes: Uint8Array;
|
|
45
|
+
/** True when the returned bytes differ from the input. */
|
|
46
|
+
changed: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ffmpegCommand(): string {
|
|
50
|
+
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
|
51
|
+
return resolveFfmpegStaticPath() ?? "ffmpeg";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveFfmpegStaticPath(): string | null {
|
|
55
|
+
if (cachedFfmpegStaticPath !== undefined) return cachedFfmpegStaticPath;
|
|
56
|
+
try {
|
|
57
|
+
const resolved = requireFromThisFile("ffmpeg-static");
|
|
58
|
+
cachedFfmpegStaticPath =
|
|
59
|
+
typeof resolved === "string" && resolved && existsSync(resolved)
|
|
60
|
+
? resolved
|
|
61
|
+
: null;
|
|
62
|
+
} catch {
|
|
63
|
+
cachedFfmpegStaticPath = null;
|
|
64
|
+
}
|
|
65
|
+
return cachedFfmpegStaticPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a server-side ffmpeg binary is resolvable. */
|
|
69
|
+
export function isFfmpegAvailable(): boolean {
|
|
70
|
+
return Boolean(process.env.FFMPEG_PATH) || resolveFfmpegStaticPath() !== null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startsWithMagic(bytes: Uint8Array, magic: number[]): boolean {
|
|
74
|
+
if (bytes.byteLength < magic.length) return false;
|
|
75
|
+
for (let i = 0; i < magic.length; i++) {
|
|
76
|
+
if (bytes[i] !== magic[i]) return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runFfmpeg(args: string[]): Promise<void> {
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
83
|
+
const child = spawn(ffmpegCommand(), args, {
|
|
84
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
let stderr = "";
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
child.kill("SIGKILL");
|
|
89
|
+
reject(new Error(`ffmpeg remux timed out\n${stderr}`));
|
|
90
|
+
}, REMUX_TIMEOUT_MS);
|
|
91
|
+
|
|
92
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
93
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-STDERR_LIMIT);
|
|
94
|
+
});
|
|
95
|
+
child.on("error", (err) => {
|
|
96
|
+
clearTimeout(timeout);
|
|
97
|
+
reject(new Error(`${err.message}\n${stderr}`));
|
|
98
|
+
});
|
|
99
|
+
child.on("close", (code) => {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
if (code === 0) {
|
|
102
|
+
resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
reject(new Error(`ffmpeg exited with code ${code}\n${stderr}`));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function withRemuxSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|
111
|
+
if (activeRemuxes >= MAX_CONCURRENT_REMUXES) {
|
|
112
|
+
await new Promise<void>((resolve) => remuxWaiters.push(resolve));
|
|
113
|
+
}
|
|
114
|
+
activeRemuxes += 1;
|
|
115
|
+
try {
|
|
116
|
+
return await fn();
|
|
117
|
+
} finally {
|
|
118
|
+
activeRemuxes = Math.max(0, activeRemuxes - 1);
|
|
119
|
+
remuxWaiters.shift()?.();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rewrite a WebM/Matroska file with a SeekHead + Cues index and a real
|
|
125
|
+
* duration via a lossless `ffmpeg -c copy` remux. Returns the original bytes
|
|
126
|
+
* unchanged when ffmpeg is unavailable, the input isn't WebM, or anything
|
|
127
|
+
* goes wrong.
|
|
128
|
+
*/
|
|
129
|
+
export async function remuxWebmToSeekable(
|
|
130
|
+
mediaBytes: Uint8Array,
|
|
131
|
+
): Promise<SeekableResult> {
|
|
132
|
+
const unchanged: SeekableResult = { bytes: mediaBytes, changed: false };
|
|
133
|
+
|
|
134
|
+
if (mediaBytes.byteLength === 0) return unchanged;
|
|
135
|
+
if (!startsWithMagic(mediaBytes, EBML_MAGIC)) return unchanged;
|
|
136
|
+
if (!isFfmpegAvailable()) return unchanged;
|
|
137
|
+
|
|
138
|
+
const dir = await mkdtemp(join(tmpdir(), "clips-remux-"));
|
|
139
|
+
const inputPath = join(dir, "input.webm");
|
|
140
|
+
const outputPath = join(dir, "output.webm");
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await writeFile(inputPath, mediaBytes);
|
|
144
|
+
await withRemuxSlot(() =>
|
|
145
|
+
runFfmpeg([
|
|
146
|
+
"-hide_banner",
|
|
147
|
+
"-loglevel",
|
|
148
|
+
"error",
|
|
149
|
+
"-nostdin",
|
|
150
|
+
"-y",
|
|
151
|
+
// Regenerate presentation timestamps so a live MediaRecorder stream
|
|
152
|
+
// gets a coherent, seekable timeline in the remuxed output.
|
|
153
|
+
"-fflags",
|
|
154
|
+
"+genpts",
|
|
155
|
+
"-i",
|
|
156
|
+
inputPath,
|
|
157
|
+
// Stream-copy every track: no re-encode, so this stays fast + lossless.
|
|
158
|
+
"-map",
|
|
159
|
+
"0",
|
|
160
|
+
"-c",
|
|
161
|
+
"copy",
|
|
162
|
+
"-f",
|
|
163
|
+
"webm",
|
|
164
|
+
outputPath,
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const info = await stat(outputPath).catch(() => null);
|
|
169
|
+
if (!info || info.size === 0) return unchanged;
|
|
170
|
+
|
|
171
|
+
const out = new Uint8Array(await readFile(outputPath));
|
|
172
|
+
// Validate the muxer actually produced a WebM before trusting it.
|
|
173
|
+
if (!startsWithMagic(out, EBML_MAGIC)) return unchanged;
|
|
174
|
+
|
|
175
|
+
return { bytes: out, changed: true };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn("[video-remux] webm remux failed, keeping original", {
|
|
178
|
+
err: err instanceof Error ? err.message : String(err),
|
|
179
|
+
});
|
|
180
|
+
return unchanged;
|
|
181
|
+
} finally {
|
|
182
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Make an MP4 start-playable by moving its `moov` atom ahead of `mdat`. Pure
|
|
188
|
+
* TypeScript — no ffmpeg. Returns the original bytes when already faststarted
|
|
189
|
+
* or when the result would fail metadata validation.
|
|
190
|
+
*/
|
|
191
|
+
export function faststartMp4(mediaBytes: Uint8Array): SeekableResult {
|
|
192
|
+
if (mediaBytes.byteLength === 0) return { bytes: mediaBytes, changed: false };
|
|
193
|
+
try {
|
|
194
|
+
const out = applyFaststart(mediaBytes);
|
|
195
|
+
if (out === mediaBytes) return { bytes: mediaBytes, changed: false };
|
|
196
|
+
if (!hasPlayableMp4Metadata(out)) {
|
|
197
|
+
return { bytes: mediaBytes, changed: false };
|
|
198
|
+
}
|
|
199
|
+
return { bytes: out, changed: true };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn("[video-remux] mp4 faststart failed, keeping original", {
|
|
202
|
+
err: err instanceof Error ? err.message : String(err),
|
|
203
|
+
});
|
|
204
|
+
return { bytes: mediaBytes, changed: false };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Make recorded media seekable based on its container format. Dispatches to
|
|
210
|
+
* {@link faststartMp4} for MP4 and {@link remuxWebmToSeekable} for WebM.
|
|
211
|
+
* Always resolves; unknown formats and failures return the input unchanged.
|
|
212
|
+
*/
|
|
213
|
+
export async function makeSeekable(input: {
|
|
214
|
+
mediaBytes: Uint8Array;
|
|
215
|
+
videoFormat: VideoFormat;
|
|
216
|
+
}): Promise<SeekableResult> {
|
|
217
|
+
if (input.videoFormat === "mp4") return faststartMp4(input.mediaBytes);
|
|
218
|
+
if (input.videoFormat === "webm") {
|
|
219
|
+
return remuxWebmToSeekable(input.mediaBytes);
|
|
220
|
+
}
|
|
221
|
+
return { bytes: input.mediaBytes, changed: false };
|
|
222
|
+
}
|
|
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
|
|
|
53
53
|
- **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
|
|
54
54
|
- **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
|
|
55
55
|
|
|
56
|
+
**Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
|
|
57
|
+
|
|
56
58
|
## Agent-Native UI Rules
|
|
57
59
|
|
|
58
60
|
- Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
|