@agent-native/core 0.128.0 → 0.128.2

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.
Files changed (132) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +27 -0
  3. package/corpus/core/package.json +3 -1
  4. package/corpus/core/src/server/builder-design-systems.ts +299 -82
  5. package/corpus/core/src/server/email-template.ts +33 -0
  6. package/corpus/core/src/server/index.ts +8 -0
  7. package/corpus/templates/analytics/_gitignore +2 -0
  8. package/corpus/templates/assets/_gitignore +2 -0
  9. package/corpus/templates/calendar/_gitignore +2 -0
  10. package/corpus/templates/chat/_gitignore +2 -0
  11. package/corpus/templates/clips/AGENTS.md +6 -1
  12. package/corpus/templates/clips/_gitignore +2 -0
  13. package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
  14. package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
  15. package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
  16. package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
  17. package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
  18. package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
  19. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
  20. package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
  21. package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
  22. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
  23. package/corpus/templates/clips/app/global.css +23 -0
  24. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
  25. package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
  26. package/corpus/templates/clips/app/routes/_app.tsx +2 -0
  27. package/corpus/templates/clips/app/routes/import.tsx +396 -0
  28. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
  29. package/corpus/templates/clips/app/routes/record.tsx +13 -71
  30. package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
  31. package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
  32. package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
  33. package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
  34. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
  35. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  36. package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
  37. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  38. package/corpus/templates/content/AGENTS.md +8 -0
  39. package/corpus/templates/content/_gitignore +2 -0
  40. package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
  41. package/corpus/templates/content/actions/_property-utils.ts +32 -2
  42. package/corpus/templates/content/actions/configure-document-property.ts +12 -2
  43. package/corpus/templates/content/actions/delete-document-property.ts +13 -3
  44. package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
  45. package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
  46. package/corpus/templates/content/actions/list-document-properties.ts +12 -3
  47. package/corpus/templates/content/actions/move-database-item.ts +74 -55
  48. package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
  49. package/corpus/templates/content/actions/set-document-property.ts +10 -1
  50. package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
  51. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
  52. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
  53. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
  54. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
  55. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
  56. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
  57. package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
  58. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
  59. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
  60. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
  61. package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
  62. package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
  63. package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
  64. package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
  65. package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
  66. package/corpus/templates/content/app/i18n-data.ts +261 -3
  67. package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
  68. package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
  69. package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
  70. package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
  71. package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
  72. package/corpus/templates/content/shared/api.ts +28 -0
  73. package/corpus/templates/design/_gitignore +2 -0
  74. package/corpus/templates/design/app/i18n-data.ts +10 -0
  75. package/corpus/templates/design/app/lib/builder-design-system-upload.ts +280 -0
  76. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +90 -54
  77. package/corpus/templates/design/changelog/2026-07-28-figma-fig-uploads-now-show-indexing-progress-and-an-open-in-.md +6 -0
  78. package/corpus/templates/design/server/handlers/design-system-decode-job-status.ts +49 -0
  79. package/corpus/templates/design/server/handlers/design-system-upload-start.ts +87 -0
  80. package/corpus/templates/design/server/handlers/index-design-system-sources.ts +82 -0
  81. package/corpus/templates/design/server/routes/api/design-system-decode-job-status.get.ts +1 -0
  82. package/corpus/templates/design/server/routes/api/design-system-upload-start.post.ts +1 -0
  83. package/corpus/templates/design/server/routes/api/index-design-system-sources.post.ts +1 -0
  84. package/corpus/templates/dispatch/_gitignore +2 -0
  85. package/corpus/templates/forms/_gitignore +2 -0
  86. package/corpus/templates/macros/_gitignore +2 -0
  87. package/corpus/templates/mail/_gitignore +2 -0
  88. package/corpus/templates/plan/_gitignore +2 -0
  89. package/corpus/templates/slides/_gitignore +2 -0
  90. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +126 -53
  91. package/corpus/templates/slides/app/components/design-system/builder-design-system-upload.ts +271 -0
  92. package/corpus/templates/slides/app/components/design-system/builder-index-response.ts +1 -1
  93. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  94. package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +49 -0
  95. package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +87 -0
  96. package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +79 -0
  97. package/corpus/templates/slides/server/routes/api/design-system-decode-job-status.get.ts +1 -0
  98. package/corpus/templates/slides/server/routes/api/design-system-upload-start.post.ts +1 -0
  99. package/corpus/templates/slides/server/routes/api/index-design-system-sources.post.ts +1 -0
  100. package/corpus/templates/tasks/_gitignore +2 -0
  101. package/corpus/toolkit/CHANGELOG.md +6 -0
  102. package/corpus/toolkit/package.json +1 -1
  103. package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
  104. package/dist/collab/routes.d.ts +1 -1
  105. package/dist/collab/struct-routes.d.ts +1 -1
  106. package/dist/observability/routes.d.ts +3 -3
  107. package/dist/progress/routes.d.ts +1 -1
  108. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  109. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  110. package/dist/server/builder-design-systems.d.ts +49 -0
  111. package/dist/server/builder-design-systems.d.ts.map +1 -1
  112. package/dist/server/builder-design-systems.js +221 -69
  113. package/dist/server/builder-design-systems.js.map +1 -1
  114. package/dist/server/email-template.d.ts +7 -0
  115. package/dist/server/email-template.d.ts.map +1 -1
  116. package/dist/server/email-template.js +24 -0
  117. package/dist/server/email-template.js.map +1 -1
  118. package/dist/server/index.d.ts +1 -1
  119. package/dist/server/index.d.ts.map +1 -1
  120. package/dist/server/index.js +1 -1
  121. package/dist/server/index.js.map +1 -1
  122. package/dist/server/transcribe-voice.d.ts +1 -1
  123. package/dist/templates/chat/_gitignore +2 -0
  124. package/package.json +5 -3
  125. package/src/server/builder-design-systems.ts +299 -82
  126. package/src/server/email-template.ts +33 -0
  127. package/src/server/index.ts +8 -0
  128. package/src/templates/chat/_gitignore +2 -0
  129. package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +0 -151
  130. package/corpus/templates/design/server/routes/api/index-design-system-with-builder.post.ts +0 -1
  131. package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +0 -119
  132. package/corpus/templates/slides/server/routes/api/index-design-system-with-builder.post.ts +0 -1
@@ -44,6 +44,11 @@ Read the matching skill before deeper work in that area:
44
44
  activation. Navigate the user to the recording view instead of a server action.
45
45
  - Native transcript first; cloud transcription is fallback-only. Never hide a
46
46
  usable native transcript behind failed metadata work.
47
+ - Use `import-loom-recording` for Loom or direct MP4/WebM URLs. Loom media and
48
+ public transcripts import in the background; direct videos need
49
+ `request-transcript` afterward.
50
+ - Internal transactional-email actions claim bounded two-Clip summary work and
51
+ complete it with one plain-text sentence after reviewing both context packets.
47
52
  - The `view-screen` transcript is a bounded preview: when `previewTruncated` is
48
53
  true it may end mid-sentence and says nothing about where transcription
49
54
  ended. Call `get-recording-player-data` before judging completeness or
@@ -82,7 +87,7 @@ Read the matching skill before deeper work in that area:
82
87
  | `list-recordings`, `search-recordings` | Library, trash, `--view=shared` |
83
88
  | `get-recording-player-data` | Full transcript, chapters, diagnostics |
84
89
  | `create-recording`, `finalize-recording` | Create row; finish upload |
85
- | `import-loom-recording` | Import a Loom share/embed URL |
90
+ | `import-loom-recording` | Import Loom or direct MP4/WebM URL |
86
91
  | `update-recording` | Title, password, expiry, visibility |
87
92
  | `move-recording` | Move `id` or `ids` to a folder or root |
88
93
  | `archive-`, `trash-`, `restore-recording` | Lifecycle |
@@ -8,8 +8,10 @@ npm-debug.log*
8
8
  pnpm-debug.log*
9
9
 
10
10
  node_modules
11
+ build/
11
12
  dist
12
13
  dist-ssr
14
+ .output/
13
15
  *.local
14
16
 
15
17
  # Editor directories and files
@@ -0,0 +1,56 @@
1
+ import { defineAction } from "@agent-native/core/action";
2
+ import { z } from "zod";
3
+
4
+ import { getCurrentOwnerEmail } from "../server/lib/recordings.js";
5
+ import { transactionalEmailStore } from "../server/lib/transactional-email-store.js";
6
+
7
+ export const MAX_TRANSACTIONAL_EMAIL_SUMMARY_LENGTH = 320;
8
+
9
+ export function normalizeTransactionalEmailSummary(value: string): string {
10
+ return value.replace(/\s+/g, " ").trim();
11
+ }
12
+
13
+ export function validateTransactionalEmailSummary(value: string): string {
14
+ const summary = normalizeTransactionalEmailSummary(value);
15
+ if (!summary) throw new Error("Summary must be one nonempty sentence.");
16
+ if (summary.length > MAX_TRANSACTIONAL_EMAIL_SUMMARY_LENGTH) {
17
+ throw new Error(
18
+ `Summary must be at most ${MAX_TRANSACTIONAL_EMAIL_SUMMARY_LENGTH} characters.`,
19
+ );
20
+ }
21
+ if (/[<>]/.test(summary)) {
22
+ throw new Error("Summary must be plain text without HTML.");
23
+ }
24
+ const sentenceEndings = summary.match(/[.!?](?=\s|$)/g) ?? [];
25
+ if (sentenceEndings.length !== 1 || !/[.!?]$/.test(summary)) {
26
+ throw new Error("Summary must be exactly one sentence.");
27
+ }
28
+ return summary;
29
+ }
30
+
31
+ export default defineAction({
32
+ description:
33
+ "Complete only the claimed two-Clip transactional email summary workflow.",
34
+ schema: z.object({
35
+ jobId: z.string().trim().min(1),
36
+ summary: z.string().max(2_000),
37
+ }),
38
+ run: async ({ jobId, summary }) => {
39
+ const claimantEmail = getCurrentOwnerEmail().trim().toLowerCase();
40
+ const normalizedSummary = validateTransactionalEmailSummary(summary);
41
+ const completed = await transactionalEmailStore.completeClaimedAi(
42
+ jobId,
43
+ claimantEmail,
44
+ normalizedSummary,
45
+ );
46
+ if (!completed) {
47
+ throw new Error("This transactional email AI claim is unavailable.");
48
+ }
49
+ return {
50
+ jobId: completed.logicalKey,
51
+ logicalKey: completed.logicalKey,
52
+ state: completed.state,
53
+ generatedSummary: completed.generatedSummary,
54
+ };
55
+ },
56
+ });
@@ -1,12 +1,14 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
3
  import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
4
+ import { uploadFile } from "@agent-native/core/file-upload";
4
5
  import { buildDeepLink } from "@agent-native/core/server";
5
6
  import { extractLoomVideoId, normalizeLoomShareUrl } from "@shared/loom.js";
6
7
  import { and, eq } from "drizzle-orm";
7
8
  import { z } from "zod";
8
9
 
9
10
  import { getDb, schema } from "../server/db/index.js";
11
+ import { queueBuilderMediaCompression } from "../server/lib/builder-media-compression.js";
10
12
  import { dispatchPostFinalizeJob } from "../server/lib/post-finalize-dispatch.js";
11
13
  import {
12
14
  getCurrentOwnerEmail,
@@ -18,7 +20,16 @@ import {
18
20
  stringifySpaceIds,
19
21
  } from "../server/lib/recordings.js";
20
22
  import { hasRequestVideoStorage } from "../server/lib/video-storage.js";
21
- import { failLoomImport } from "./lib/loom-import-job.js";
23
+ import {
24
+ downloadDirectVideo,
25
+ isCandidateDirectVideoUrl,
26
+ } from "./lib/direct-video.js";
27
+ import {
28
+ enqueueFirstImportEmailIfEligible,
29
+ failLoomImport,
30
+ } from "./lib/loom-import-job.js";
31
+
32
+ export { enqueueFirstImportEmailIfEligible };
22
33
 
23
34
  const LoomOembedSchema = z
24
35
  .object({
@@ -42,14 +53,16 @@ const ImportLoomRecordingSchema = z.object({
42
53
  .min(1)
43
54
  .max(2048)
44
55
  .describe(
45
- "Loom share or embed URL, such as https://www.loom.com/share/...",
56
+ "Loom share/embed URL, or a direct link to an MP4/WebM video file",
46
57
  ),
47
58
  title: z
48
59
  .string()
49
60
  .trim()
50
61
  .max(200)
51
62
  .optional()
52
- .describe("Optional title override; defaults to Loom's oEmbed title"),
63
+ .describe(
64
+ "Optional title override; defaults to Loom's oEmbed title when available",
65
+ ),
53
66
  folderId: z.string().nullish().describe("Optional folder ID"),
54
67
  spaceIds: z
55
68
  .array(z.string().min(1))
@@ -71,12 +84,14 @@ const ImportLoomRecordingSchema = z.object({
71
84
  .string()
72
85
  .optional()
73
86
  .describe(
74
- "Existing waiting Loom recording ID to retry after storage is connected",
87
+ "Existing waiting recording ID to retry after storage is connected",
75
88
  ),
76
89
  });
77
90
 
78
91
  const LOOM_STORAGE_SETUP_REQUIRED_REASON =
79
92
  "Video storage is not connected yet. Connect Builder.io or configure S3-compatible storage, then retry this Loom import.";
93
+ const DIRECT_VIDEO_STORAGE_SETUP_REQUIRED_REASON =
94
+ "Video storage is not connected yet. Connect Builder.io or configure S3-compatible storage, then retry this import.";
80
95
 
81
96
  function recordingDeepLink(recordingId: string): string {
82
97
  return buildDeepLink({
@@ -127,14 +142,26 @@ async function fetchLoomOembed(shareUrl: string) {
127
142
 
128
143
  export default defineAction({
129
144
  description:
130
- "Import a public Loom share URL into Clips as a playable recording. Creates the recording immediately and downloads Loom's public MP4, reuploads it to the configured Clips storage provider, and imports Loom's public transcript in the background (Loom's CDN plus a reupload can take longer than a single request should block on). If storage is not connected, creates a waiting recording that can be retried after storage setup.",
145
+ "Import a public Loom share URL, or a direct link to a video file, into Clips as a playable recording. Loom links create the recording immediately and download/reupload Loom's public MP4 plus import Loom's public transcript in the background, since Loom's CDN plus a reupload can take longer than a single request should block on. Other direct video links (e.g. an MP4/WebM/MOV hosted by another screen recorder) are downloaded and reuploaded synchronously without transcript metadata — use request-transcript afterward. If storage is not connected, creates a waiting recording that can be retried after storage setup.",
131
146
  schema: ImportLoomRecordingSchema,
132
147
  run: async (args) => {
133
- const shareUrl = normalizeLoomShareUrl(args.url);
134
148
  const loomId = extractLoomVideoId(args.url);
135
- if (!shareUrl || !loomId) {
149
+ const isLoom = Boolean(loomId);
150
+ const loomShareUrl = isLoom ? normalizeLoomShareUrl(args.url) : null;
151
+ if (isLoom && !loomShareUrl) {
136
152
  throw new Error("Paste a Loom share or embed URL.");
137
153
  }
154
+ if (!isLoom && !isCandidateDirectVideoUrl(args.url)) {
155
+ throw new Error(
156
+ "Paste a Loom share URL, or a direct link to a video file.",
157
+ );
158
+ }
159
+ const sourceUrl = isLoom ? loomShareUrl! : args.url.trim();
160
+ const sourceAppName = isLoom ? "Loom" : "Video link";
161
+ const storageSetupReason = isLoom
162
+ ? LOOM_STORAGE_SETUP_REQUIRED_REASON
163
+ : DIRECT_VIDEO_STORAGE_SETUP_REQUIRED_REASON;
164
+ const providerId = isLoom ? ("loom" as const) : ("video-link" as const);
138
165
 
139
166
  const db = getDb();
140
167
  const ownerEmail = getCurrentOwnerEmail();
@@ -150,25 +177,36 @@ export default defineAction({
150
177
  ),
151
178
  );
152
179
  if (!existingRecording) {
153
- throw new Error("Waiting Loom recording not found.");
180
+ throw new Error("Waiting recording not found.");
154
181
  }
155
- if (existingRecording.sourceAppName?.trim().toLowerCase() !== "loom") {
156
- throw new Error("Only Loom recordings can be retried this way.");
182
+ if (
183
+ existingRecording.sourceAppName?.trim().toLowerCase() !==
184
+ sourceAppName.toLowerCase()
185
+ ) {
186
+ throw new Error(
187
+ "Only a matching waiting import can be retried this way.",
188
+ );
157
189
  }
158
- const existingSourceUrl = normalizeLoomShareUrl(
159
- existingRecording.sourceWindowTitle ?? "",
160
- );
190
+ const isWaitingStorageRetry =
191
+ existingRecording.status === "uploading" &&
192
+ !existingRecording.videoUrl &&
193
+ existingRecording.failureReason === storageSetupReason &&
194
+ existingRecording.sourceWindowTitle === sourceUrl;
161
195
  const isRetryableLoomImport =
196
+ isLoom &&
162
197
  !existingRecording.videoUrl &&
163
- existingSourceUrl === shareUrl &&
198
+ existingRecording.sourceWindowTitle === sourceUrl &&
164
199
  (existingRecording.status === "processing" ||
165
200
  existingRecording.status === "failed" ||
166
- (existingRecording.status === "uploading" &&
167
- existingRecording.failureReason ===
168
- LOOM_STORAGE_SETUP_REQUIRED_REASON));
169
- if (!isRetryableLoomImport) {
201
+ isWaitingStorageRetry);
202
+ const isRetryable = isLoom
203
+ ? isRetryableLoomImport
204
+ : isWaitingStorageRetry;
205
+ if (!isRetryable) {
170
206
  throw new Error(
171
- "Only an incomplete Loom import can be retried in place.",
207
+ isLoom
208
+ ? "Only an incomplete Loom import can be retried in place."
209
+ : "Only a waiting-storage import can be retried in place.",
172
210
  );
173
211
  }
174
212
  }
@@ -181,7 +219,8 @@ export default defineAction({
181
219
 
182
220
  const now = new Date().toISOString();
183
221
  const id = existingRecording?.id ?? nanoid();
184
- const oembed = await fetchLoomOembed(shareUrl);
222
+ const createdAt = existingRecording?.createdAt ?? now;
223
+ const oembed = isLoom ? await fetchLoomOembed(loomShareUrl!) : null;
185
224
 
186
225
  const spaceIds = (
187
226
  args.spaceIds ?? parseSpaceIds(existingRecording?.spaceIds)
@@ -192,11 +231,13 @@ export default defineAction({
192
231
  existingRecording.title !== "Untitled recording"
193
232
  ? existingRecording.title
194
233
  : null) ||
195
- oembed.title?.trim() ||
196
- `Loom recording ${loomId.slice(0, 8)}`;
197
- const durationMs = boundedDurationMs(oembed.duration);
198
- const width = boundedDimension(oembed.width ?? oembed.thumbnail_width);
199
- const height = boundedDimension(oembed.height ?? oembed.thumbnail_height);
234
+ oembed?.title?.trim() ||
235
+ (isLoom
236
+ ? `Loom recording ${loomId!.slice(0, 8)}`
237
+ : `Imported video ${id.slice(0, 8)}`);
238
+ const durationMs = boundedDurationMs(oembed?.duration);
239
+ const width = boundedDimension(oembed?.width ?? oembed?.thumbnail_width);
240
+ const height = boundedDimension(oembed?.height ?? oembed?.thumbnail_height);
200
241
  const folderId = args.folderId ?? existingRecording?.folderId ?? null;
201
242
  const visibility =
202
243
  args.visibility ?? existingRecording?.visibility ?? defaultVisibility;
@@ -204,20 +245,23 @@ export default defineAction({
204
245
  ? "manual"
205
246
  : (existingRecording?.titleSource ?? "upload");
206
247
 
207
- const buildRecordingValues = (videoSizeBytes: number) => ({
248
+ const buildRecordingValues = (
249
+ videoSizeBytes: number,
250
+ videoFormat: "mp4" | "webm" = "mp4",
251
+ ) => ({
208
252
  organizationId,
209
253
  orgId: organizationId,
210
254
  folderId,
211
255
  spaceIds: stringifySpaceIds(spaceIds),
212
256
  title,
213
257
  titleSource,
214
- sourceAppName: "Loom",
215
- sourceWindowTitle: shareUrl,
258
+ sourceAppName,
259
+ sourceWindowTitle: sourceUrl,
216
260
  description: existingRecording?.description ?? "",
217
261
  thumbnailUrl:
218
- oembed.thumbnail_url ?? existingRecording?.thumbnailUrl ?? null,
262
+ oembed?.thumbnail_url ?? existingRecording?.thumbnailUrl ?? null,
219
263
  durationMs,
220
- videoFormat: "mp4" as const,
264
+ videoFormat,
221
265
  videoSizeBytes,
222
266
  width,
223
267
  height,
@@ -237,7 +281,7 @@ export default defineAction({
237
281
  ...recordingValues,
238
282
  status: "uploading",
239
283
  videoUrl: null,
240
- failureReason: LOOM_STORAGE_SETUP_REQUIRED_REASON,
284
+ failureReason: storageSetupReason,
241
285
  loomImportClaimId: null,
242
286
  loomImportClaimedAt: null,
243
287
  })
@@ -248,19 +292,19 @@ export default defineAction({
248
292
  ...recordingValues,
249
293
  videoUrl: null,
250
294
  status: "uploading",
251
- failureReason: LOOM_STORAGE_SETUP_REQUIRED_REASON,
295
+ failureReason: storageSetupReason,
252
296
  ownerEmail,
253
- createdAt: now,
297
+ createdAt,
254
298
  });
255
299
  }
256
300
 
257
301
  await writeAppState(`recording-upload-${id}`, {
258
302
  recordingId: id,
259
303
  status: "waiting_storage",
260
- failureReason: LOOM_STORAGE_SETUP_REQUIRED_REASON,
304
+ failureReason: storageSetupReason,
261
305
  progress: 100,
262
- provider: "loom",
263
- sourceUrl: shareUrl,
306
+ provider: providerId,
307
+ sourceUrl,
264
308
  durationMs,
265
309
  width,
266
310
  height,
@@ -276,13 +320,13 @@ export default defineAction({
276
320
  title,
277
321
  status: "waiting_storage" as const,
278
322
  storageSetupRequired: true,
279
- provider: "loom" as const,
280
- sourceUrl: shareUrl,
281
- thumbnailUrl: oembed.thumbnail_url ?? null,
323
+ provider: providerId,
324
+ sourceUrl,
325
+ thumbnailUrl: oembed?.thumbnail_url ?? null,
282
326
  durationMs,
283
327
  importMode: "reuploaded" as const,
284
328
  videoSizeBytes,
285
- note: LOOM_STORAGE_SETUP_REQUIRED_REASON,
329
+ note: storageSetupReason,
286
330
  };
287
331
  };
288
332
 
@@ -292,22 +336,115 @@ export default defineAction({
292
336
  );
293
337
  }
294
338
 
295
- // Storage is connected: create the row now and hand the slow Loom
296
- // download + reupload + transcript off to a durable background job.
297
- // Loom's CDN plus a reupload can outlast the platform's request timeout;
298
- // doing it inline previously crashed the function mid-download instead of
299
- // returning a clean error (see post-finalize-worker.post.ts's "loom-import"
300
- // kind / runLoomImportJob).
301
- const recordingValues = buildRecordingValues(
302
- existingRecording?.videoSizeBytes ?? 0,
303
- );
339
+ if (isLoom) {
340
+ // Storage is connected: create/refresh the row now and hand the slow
341
+ // Loom download + reupload + transcript off to a durable background
342
+ // job (post-finalize-worker.post.ts's "loom-import" kind /
343
+ // runLoomImportJob). Loom's CDN plus a reupload can outlast a single
344
+ // request; the worker claims the row (loomImportClaimId) and moves it
345
+ // to "ready" or "failed" once done.
346
+ const recordingValues = buildRecordingValues(
347
+ existingRecording?.videoSizeBytes ?? 0,
348
+ );
349
+ if (existingRecording) {
350
+ await db
351
+ .update(schema.recordings)
352
+ .set({
353
+ ...recordingValues,
354
+ status: "processing",
355
+ videoUrl: null,
356
+ failureReason: null,
357
+ loomImportClaimId: null,
358
+ loomImportClaimedAt: null,
359
+ })
360
+ .where(eq(schema.recordings.id, id));
361
+ } else {
362
+ await db.insert(schema.recordings).values({
363
+ id,
364
+ ...recordingValues,
365
+ videoUrl: null,
366
+ status: "processing",
367
+ failureReason: null,
368
+ ownerEmail,
369
+ createdAt,
370
+ });
371
+ }
372
+
373
+ await writeAppState(`recording-upload-${id}`, {
374
+ recordingId: id,
375
+ status: "processing",
376
+ progress: 100,
377
+ provider: providerId,
378
+ sourceUrl,
379
+ durationMs,
380
+ width,
381
+ height,
382
+ hasAudio: true,
383
+ hasCamera: false,
384
+ updatedAt: now,
385
+ });
386
+ await writeAppState("refresh-signal", { ts: Date.now() });
387
+ await writeAppState("navigate", { view: "recording", recordingId: id });
388
+
389
+ try {
390
+ await dispatchPostFinalizeJob({
391
+ recordingId: id,
392
+ kind: "loom-import",
393
+ requireAccepted: true,
394
+ });
395
+ } catch (err) {
396
+ const failureReason = `Could not start the Loom import: ${
397
+ err instanceof Error ? err.message : String(err)
398
+ }`;
399
+ await failLoomImport(id, failureReason);
400
+ throw new Error(failureReason);
401
+ }
402
+
403
+ return {
404
+ recordingId: id,
405
+ title,
406
+ status: "processing" as const,
407
+ provider: providerId,
408
+ sourceUrl,
409
+ thumbnailUrl: oembed?.thumbnail_url ?? null,
410
+ durationMs,
411
+ importMode: "reuploaded" as const,
412
+ note: "Downloading and importing this Loom recording in the background.",
413
+ };
414
+ }
415
+
416
+ // Direct video links stay synchronous: they typically download and
417
+ // reupload well within a single request, and this keeps
418
+ // request-transcript as the deliberate next step for a transcript.
419
+ const media = await downloadDirectVideo(sourceUrl);
420
+ const videoFormat = media.mimeType === "video/webm" ? "webm" : "mp4";
421
+ const upload = await uploadFile({
422
+ data: media.bytes,
423
+ filename: `${id}.${videoFormat}`,
424
+ mimeType: media.mimeType,
425
+ ownerEmail,
426
+ stableUrl: true,
427
+ recordAsset: false,
428
+ });
429
+
430
+ if (upload === null) {
431
+ return await saveWaitingForStorage(media.sizeBytes);
432
+ }
433
+ if (!upload?.url) {
434
+ throw new Error(
435
+ "File upload returned no URL. Check your storage provider configuration.",
436
+ );
437
+ }
438
+
439
+ const videoUrl = upload.url;
440
+ const recordingValues = buildRecordingValues(media.sizeBytes, videoFormat);
304
441
  if (existingRecording) {
305
442
  await db
306
443
  .update(schema.recordings)
307
444
  .set({
308
445
  ...recordingValues,
309
- status: "processing",
310
- videoUrl: null,
446
+ status: "ready",
447
+ videoUrl,
311
448
  failureReason: null,
312
449
  loomImportClaimId: null,
313
450
  loomImportClaimedAt: null,
@@ -317,20 +454,75 @@ export default defineAction({
317
454
  await db.insert(schema.recordings).values({
318
455
  id,
319
456
  ...recordingValues,
320
- videoUrl: null,
321
- status: "processing",
457
+ videoUrl,
458
+ status: "ready",
322
459
  failureReason: null,
323
460
  ownerEmail,
461
+ createdAt,
462
+ });
463
+ }
464
+
465
+ void queueBuilderMediaCompression({
466
+ recordingId: id,
467
+ ownerEmail,
468
+ videoUrl,
469
+ mimeType: media.mimeType,
470
+ providerId: upload.provider,
471
+ assetDbId: upload.id,
472
+ sourceSizeBytes: media.sizeBytes,
473
+ }).catch((err) => {
474
+ console.warn("[clips] Video import media compression queue failed", {
475
+ recordingId: id,
476
+ error: err instanceof Error ? err.message : String(err),
477
+ });
478
+ });
479
+
480
+ const transcriptValues = {
481
+ ownerEmail,
482
+ language: "en",
483
+ segmentsJson: "[]",
484
+ fullText: "",
485
+ status: "failed" as const,
486
+ failureReason:
487
+ "Transcript import isn't available for direct video links yet. Use request-transcript to transcribe the uploaded media.",
488
+ updatedAt: now,
489
+ };
490
+ const [existingTranscript] = await db
491
+ .select({ recordingId: schema.recordingTranscripts.recordingId })
492
+ .from(schema.recordingTranscripts)
493
+ .where(eq(schema.recordingTranscripts.recordingId, id));
494
+ if (existingTranscript) {
495
+ await db
496
+ .update(schema.recordingTranscripts)
497
+ .set(transcriptValues)
498
+ .where(eq(schema.recordingTranscripts.recordingId, id));
499
+ } else {
500
+ await db.insert(schema.recordingTranscripts).values({
501
+ recordingId: id,
502
+ ...transcriptValues,
324
503
  createdAt: now,
325
504
  });
326
505
  }
327
506
 
507
+ try {
508
+ await enqueueFirstImportEmailIfEligible(
509
+ { recordingId: id, ownerEmail, createdAt },
510
+ db,
511
+ );
512
+ } catch (err) {
513
+ console.warn("[clips] First-import email enqueue failed", {
514
+ recordingId: id,
515
+ error: err instanceof Error ? err.message : String(err),
516
+ });
517
+ }
518
+
328
519
  await writeAppState(`recording-upload-${id}`, {
329
520
  recordingId: id,
330
- status: "processing",
521
+ status: "ready",
331
522
  progress: 100,
332
- provider: "loom",
333
- sourceUrl: shareUrl,
523
+ videoUrl,
524
+ provider: providerId,
525
+ sourceUrl,
334
526
  durationMs,
335
527
  width,
336
528
  height,
@@ -341,30 +533,20 @@ export default defineAction({
341
533
  await writeAppState("refresh-signal", { ts: Date.now() });
342
534
  await writeAppState("navigate", { view: "recording", recordingId: id });
343
535
 
344
- try {
345
- await dispatchPostFinalizeJob({
346
- recordingId: id,
347
- kind: "loom-import",
348
- requireAccepted: true,
349
- });
350
- } catch (err) {
351
- const failureReason = `Could not start the Loom import: ${
352
- err instanceof Error ? err.message : String(err)
353
- }`;
354
- await failLoomImport(id, failureReason);
355
- throw new Error(failureReason);
356
- }
357
-
358
536
  return {
359
537
  recordingId: id,
360
538
  title,
361
- status: "processing" as const,
362
- provider: "loom" as const,
363
- sourceUrl: shareUrl,
364
- thumbnailUrl: oembed.thumbnail_url ?? null,
539
+ status: "ready" as const,
540
+ provider: providerId,
541
+ sourceUrl,
542
+ videoUrl,
543
+ embedUrl: videoUrl,
544
+ thumbnailUrl: oembed?.thumbnail_url ?? null,
365
545
  durationMs,
366
546
  importMode: "reuploaded" as const,
367
- note: "Downloading and importing this Loom recording in the background.",
547
+ storageProvider: upload.provider,
548
+ videoSizeBytes: media.sizeBytes,
549
+ note: "Imported as a Clips-hosted video. Use request-transcript to transcribe the uploaded media.",
368
550
  };
369
551
  },
370
552
  link: ({ result }) => {
@@ -373,7 +555,7 @@ export default defineAction({
373
555
  if (typeof recordingId !== "string") return null;
374
556
  return {
375
557
  url: recordingDeepLink(recordingId),
376
- label: "Open imported Loom clip in Clips",
558
+ label: "Open imported clip in Clips",
377
559
  view: "recording",
378
560
  };
379
561
  },