@agent-native/core-corpus 0.210.0 → 0.210.1-nightly-20261019212919

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 (59) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  3. package/corpus/templates/analytics/server/lib/analytics-mcp.ts +1 -0
  4. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  5. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  6. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  7. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  8. package/corpus/templates/chat/server/plugins/agent-chat.ts +1 -0
  9. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +17 -2
  10. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  11. package/corpus/templates/clips/AGENTS.md +1 -1
  12. package/corpus/templates/clips/actions/get-recording-player-data.ts +41 -9
  13. package/corpus/templates/clips/actions/import-loom-recording.ts +9 -3
  14. package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +2 -1
  15. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +2 -1
  16. package/corpus/templates/clips/actions/lib/native-media.ts +22 -6
  17. package/corpus/templates/clips/actions/lib/youtube-import.ts +247 -0
  18. package/corpus/templates/clips/actions/request-transcript.ts +11 -2
  19. package/corpus/templates/clips/actions/set-embed-duration.ts +55 -0
  20. package/corpus/templates/clips/actions/view-screen.ts +12 -1
  21. package/corpus/templates/clips/app/components/library/import-loom-dialog.tsx +13 -7
  22. package/corpus/templates/clips/app/components/player/player-controls.tsx +50 -15
  23. package/corpus/templates/clips/app/components/player/use-optimistic-reactions.ts +87 -0
  24. package/corpus/templates/clips/app/components/player/video-player.tsx +48 -68
  25. package/corpus/templates/clips/app/components/player/youtube-embed-player.tsx +690 -0
  26. package/corpus/templates/clips/app/hooks/use-player-shortcuts.ts +13 -21
  27. package/corpus/templates/clips/app/i18n/en-US.ts +12 -1
  28. package/corpus/templates/clips/app/lib/youtube-iframe-api.ts +135 -0
  29. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +59 -24
  30. package/corpus/templates/clips/app/routes/embed.$shareId.tsx +6 -4
  31. package/corpus/templates/clips/app/routes/import.tsx +13 -4
  32. package/corpus/templates/clips/app/routes/share.$shareId.tsx +16 -8
  33. package/corpus/templates/clips/changelog/2026-10-16-youtube-embeds.md +6 -0
  34. package/corpus/templates/clips/server/lib/agent-frame-request.ts +11 -6
  35. package/corpus/templates/clips/server/lib/ensure-recording-thumbnail.ts +7 -4
  36. package/corpus/templates/clips/server/lib/player-video-url.ts +9 -0
  37. package/corpus/templates/clips/server/lib/public-agent-context.ts +18 -21
  38. package/corpus/templates/clips/server/routes/api/agent-transcript.json.get.ts +3 -3
  39. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +15 -0
  40. package/corpus/templates/clips/shared/comment-transcript-context.ts +25 -0
  41. package/corpus/templates/clips/shared/embed-provider.ts +32 -0
  42. package/corpus/templates/clips/shared/youtube.ts +103 -0
  43. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  44. package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
  45. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  46. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  47. package/corpus/templates/design/actions/apply-builder-edits.ts +85 -32
  48. package/corpus/templates/design/actions/list-builder-edits.ts +8 -3
  49. package/corpus/templates/design/actions/open-builder-branch-design.ts +4 -15
  50. package/corpus/templates/design/actions/send-builder-message.ts +4 -0
  51. package/corpus/templates/design/server/db/schema.ts +5 -1
  52. package/corpus/templates/design/server/lib/builder-identity.ts +37 -5
  53. package/corpus/templates/design/server/lib/design-response-guard.ts +4 -3
  54. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  55. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  56. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  57. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  58. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  59. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -32,4 +32,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core-corpus/corpu
32
32
 
33
33
  ## Generated Counts
34
34
 
35
- - template files: 12899
35
+ - template files: 12956
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","analytics-alert-jobs","analytics-rollup-backfill-jobs","auth","collab","config","core-routes","creative-context","dashboard-report-jobs","db","feature-flags","labs","onboarding","otel","session-replay-retention-jobs","transactional-emails","uptime-monitor-jobs"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -4,6 +4,7 @@ import type { AgentChatMcpOptions } from "@agent-native/core/server";
4
4
  // framework's cross-app builtins. `mcp-action-contract.spec.ts` serves this
5
5
  // config through the real MCP handler and pins the resulting tools/list.
6
6
  export const ANALYTICS_MCP = {
7
+ connectorCatalog: [],
7
8
  externalAgents: { writes: "allowlisted" },
8
9
  keyToolNames: [
9
10
  "list-sql-dashboards",
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","creative-context","db","labs","onboarding","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","brain-jobs","core-routes","db","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","core-routes","db","event-rules","oauth-refresh","otel","transactional-emails"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","otel"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -16,6 +16,7 @@ const INITIAL_TOOL_NAMES = [
16
16
  export default createAgentChatPlugin({
17
17
  appId: "chat",
18
18
  actions: loadActionsFromStaticRegistry(actionsRegistry),
19
+ mcp: { connectorCatalog: [] },
19
20
  initialToolNames: INITIAL_TOOL_NAMES,
20
21
  resolveOrgId: async (event) => (await getOrgContext(event)).orgId,
21
22
  systemPrompt: `You are the Chat app agent.
@@ -16,7 +16,7 @@ Reach for this skill any time you touch the recorder: the record button, the in-
16
16
 
17
17
  ## Data model touched
18
18
 
19
- - **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and become ready with either a Clips-hosted MP4 or a Loom embed when MP4 export is unavailable.
19
+ - **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and become ready with either a Clips-hosted MP4 or a Loom embed when MP4 export is unavailable. YouTube imports go through the same action and are always embeds.
20
20
  - **`recordings.storage_mode`** — pins each video recording to `own` or `hosted`; null is legacy. Own storage takes precedence when available. Existing hosted recordings keep using the private Clips bucket even if the user connects Builder later.
21
21
  - **`application_state.record-intent`** — the agent writes this when it wants to start a recording. The UI reads and clears it, then prompts for permission.
22
22
  - **`application_state.navigation`** — set to `{ view: "record" }` while the recorder is active.
@@ -122,7 +122,7 @@ repoints the row only after verified output is stored; any transcode, audio
122
122
  verification, upload, or concurrent-update failure leaves the original URL and
123
123
  format untouched.
124
124
 
125
- ## Loom import
125
+ ## Loom and YouTube import
126
126
 
127
127
  Direct-video and downloadable Loom MP4 imports use the same pinned storage
128
128
  resolution, hosted byte limit, and allowance as recorder uploads. A Loom import
@@ -147,6 +147,21 @@ Reuploaded Loom imports support Clips-native trimming, exports, frame extraction
147
147
  and upload-based transcription; ask the user to upload the original video file
148
148
  when those capabilities are needed for an embed-backed import.
149
149
 
150
+ YouTube links (watch, Shorts, youtu.be, embed) also go through
151
+ `import-loom-recording`. Clips never downloads a YouTube video: the action reads
152
+ YouTube's public oEmbed for the title and thumbnail and creates a `ready` clip
153
+ that plays in YouTube's embedded player. Comments, reactions, chapters, and
154
+ transcript highlighting follow YouTube's playback. oEmbed has no duration, so
155
+ the clip starts with `durationMs` 0 and the first editor to open it records the
156
+ length YouTube's player reports (`set-embed-duration`). A YouTube clip has no
157
+ frames, editing, downloads, or transcription; add its transcript with
158
+ `import-transcript`. Private, removed, and embed-disabled videos fail the import
159
+ with a specific error, and a video that becomes unavailable later shows
160
+ YouTube's reason in the player with a Watch on YouTube link. YouTube's player
161
+ policy forbids drawing anything over its player, so the Clips controls sit below
162
+ it and the clip shows no on-video captions or comment bubbles; never add an
163
+ overlay to the YouTube player.
164
+
150
165
  ## Browser diagnostics and recorder install options
151
166
 
152
167
  Browser recordings can save bounded, redacted diagnostics through
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","auth","calendar-jobs","config","core-routes","db","feature-flags","labs","onboarding","onboarding-calendar","otel","transactional-email-catalog"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -14,7 +14,7 @@ Use local docs only (no web research): `pnpm action docs-search --query "<topic>
14
14
  - Use actions for recording metadata, transcripts, cleanup, chapters, comments, meetings, folders, sharing, and collaboration. Respect access helpers. Keep large media/blob payloads out of SQL, state, settings, and resources; store bytes in configured storage and persist references.
15
15
  - For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. Never hardcode credentials, webhook URLs, private/customer data, or credential-like literals.
16
16
  - Recording start/stop/pause are browser gestures requiring user activation; navigate to the recording view instead of using a server action. Prefer native transcripts; cloud transcription is fallback-only. Do not hide a usable transcript after failed metadata work.
17
- - Use `import-loom-recording` for Loom or direct MP4/WebM URLs. Loom/public transcripts import in the background; request a transcript afterward for direct video. Transactional email claims two-Clip summary work and ends in one sentence.
17
+ - Use `import-loom-recording` for Loom, YouTube, or direct MP4/WebM URLs. Loom/public transcripts import in the background; request a transcript afterward for direct video. Transactional email claims two-Clip summary work and ends in one sentence.
18
18
  - `view-screen` may return a transcript preview truncated mid-sentence without an ending marker. When `previewTruncated`, read `get-recording-player-data` before judging completeness or quoting.
19
19
  - Clips are unlisted-by-link, not searchable. Only inspect recordings the user owns, viewed, or identified by URL/id. Never use `list-recordings` / `search-recordings` to find another person's clip, answer a date question, or recover a failed lookup; report the failure.
20
20
  - Sharing actions control access; passwords/expiry only tighten it. Screen Memory is local-only and disabled by default; raw segments are never uploaded or shared. The one exception is an owner's explicit request: a chosen window of Rewind history is uploaded as an owner-only recording attached to one Clip, that Clip follows its normal share rules, and sharing the Clip shares its earlier screen time with it.
@@ -2,6 +2,12 @@ import { defineAction, embedApp } from "@agent-native/core";
2
2
  import { readAppState } from "@agent-native/core/application-state";
3
3
  import { buildDeepLink, signShortLivedToken } from "@agent-native/core/server";
4
4
  import { resolveAccess, ForbiddenError } from "@agent-native/core/sharing";
5
+ import { getAgentClipReadiness } from "@shared/agent-context";
6
+ import { transcriptContextAt } from "@shared/comment-transcript-context";
7
+ import {
8
+ embedFramesUnavailableReason,
9
+ recordingEmbedProvider,
10
+ } from "@shared/embed-provider";
5
11
  import { isImageRecording, resolveRecordingKind } from "@shared/recording-kind";
6
12
  import { asc, eq } from "drizzle-orm";
7
13
  import { z } from "zod";
@@ -130,7 +136,7 @@ function recordingDeepLink(recordingId: string): string {
130
136
 
131
137
  export default defineAction({
132
138
  description:
133
- "Fetch everything the player page needs for a recording: metadata, transcript, comments, reactions, chapters, tags, CTAs, the counted-view total, and the caller's effective role. Agent calls receive a bounded transcript chunk; pass transcriptOffset from nextFullTextOffset until it is null to read the complete transcript. Browser player calls receive the full transcript.",
139
+ "Fetch everything the player page needs for a recording: metadata, transcript, comments, reactions, chapters, tags, CTAs, the counted-view total, and the caller's effective role. Agent calls receive a bounded transcript chunk; pass transcriptOffset from nextFullTextOffset until it is null to read the complete transcript. Agent calls also get media (whether frames exist; a YouTube clip has none) and, on each comment, transcriptContext: the transcript spoken around its videoTimestampMs. Browser player calls receive the full transcript.",
134
140
  schema: z.object({
135
141
  recordingId: z.string().describe("Recording ID"),
136
142
  transcriptOffset: z.coerce.number().int().min(0).optional(),
@@ -316,14 +322,17 @@ export default defineAction({
316
322
  !transcript.fullText?.trim() &&
317
323
  transcriptSegments.length === 0;
318
324
  const transcriptPresentation = resolveTranscriptPresentation(transcript);
319
- const agentTranscript =
320
- ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"
321
- ? boundTranscriptForAgent({
322
- fullText: transcript?.fullText,
323
- segments: transcriptSegments,
324
- fullTextOffset: args.transcriptOffset,
325
- })
326
- : null;
325
+ const isAgentCall =
326
+ ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a";
327
+ const embedProvider = recordingEmbedProvider(rec);
328
+ const readiness = getAgentClipReadiness(rec.status);
329
+ const agentTranscript = isAgentCall
330
+ ? boundTranscriptForAgent({
331
+ fullText: transcript?.fullText,
332
+ segments: transcriptSegments,
333
+ fullTextOffset: args.transcriptOffset,
334
+ })
335
+ : null;
327
336
 
328
337
  const resolvedVideoUrl = resolvePlayerVideoUrl(rec, {
329
338
  addPasswordToken: access.role !== "owner",
@@ -346,6 +355,21 @@ export default defineAction({
346
355
  return {
347
356
  role: access.role,
348
357
  canComment: canCommentRecording,
358
+ ...(isAgentCall
359
+ ? {
360
+ media: {
361
+ embedProvider,
362
+ framesAvailable: !embedProvider && readiness.state === "ready",
363
+ ...(embedProvider
364
+ ? {
365
+ note: `${embedFramesUnavailableReason(embedProvider)} Each comment carries transcriptContext, the transcript spoken around its videoTimestampMs.`,
366
+ }
367
+ : readiness.instruction
368
+ ? { note: readiness.instruction }
369
+ : {}),
370
+ },
371
+ }
372
+ : {}),
349
373
  viewCount,
350
374
  agentViewCount,
351
375
  recording: {
@@ -495,6 +519,14 @@ export default defineAction({
495
519
  content: c.content,
496
520
  mentions: displayCommentMentions(c.mentionsJson),
497
521
  videoTimestampMs: c.videoTimestampMs,
522
+ ...(isAgentCall
523
+ ? {
524
+ transcriptContext: transcriptContextAt(
525
+ transcriptSegments,
526
+ c.videoTimestampMs,
527
+ ),
528
+ }
529
+ : {}),
498
530
  emojiReactionsJson: c.emojiReactionsJson,
499
531
  resolved: Boolean(c.resolved),
500
532
  createdAt: c.createdAt,
@@ -8,6 +8,7 @@ import { uploadFile } from "@agent-native/core/file-upload";
8
8
  import { buildDeepLink } from "@agent-native/core/server";
9
9
  import { extractLoomVideoId, normalizeLoomShareUrl } from "@shared/loom.js";
10
10
  import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
11
+ import { isYouTubeUrl } from "@shared/youtube.js";
11
12
  import { and, eq, isNull, ne, or } from "drizzle-orm";
12
13
  import { z } from "zod";
13
14
 
@@ -49,6 +50,7 @@ import {
49
50
  failLoomImport,
50
51
  } from "./lib/loom-import-job.js";
51
52
  import { validateRecordingScope } from "./lib/recording-scope.js";
53
+ import { importYouTubeVideo } from "./lib/youtube-import.js";
52
54
 
53
55
  export { enqueueFirstImportEmailIfEligible };
54
56
 
@@ -74,7 +76,7 @@ const ImportLoomRecordingSchema = z.object({
74
76
  .min(1)
75
77
  .max(2048)
76
78
  .describe(
77
- "Loom share/embed URL, or a direct link to an MP4/WebM video file",
79
+ "Loom share/embed URL, YouTube video URL, or a direct link to an MP4/WebM video file",
78
80
  ),
79
81
  title: z
80
82
  .string()
@@ -82,7 +84,7 @@ const ImportLoomRecordingSchema = z.object({
82
84
  .max(200)
83
85
  .optional()
84
86
  .describe(
85
- "Optional title override; defaults to Loom's oEmbed title when available",
87
+ "Optional title override; defaults to Loom's or YouTube's oEmbed title when available",
86
88
  ),
87
89
  folderId: z.string().nullish().describe("Optional folder ID"),
88
90
  spaceIds: z
@@ -183,9 +185,13 @@ async function fetchLoomOembed(shareUrl: string) {
183
185
 
184
186
  export default defineAction({
185
187
  description:
186
- "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 when available, or keep a playable Loom embed when Loom allows playback but not MP4 export; public transcripts are imported in the background. 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.",
188
+ "Import a public Loom share URL, a YouTube video URL, or a direct link to a video file, into Clips as a playable recording. YouTube links (watch, Shorts, youtu.be) create a ready clip that plays in YouTube's embedded player: nothing is downloaded, so frames, editing and transcription aren't available, and its transcript has to come from import-transcript. Loom links create the recording immediately and download/reupload Loom's public MP4 when available, or keep a playable Loom embed when Loom allows playback but not MP4 export; public transcripts are imported in the background. 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.",
187
189
  schema: ImportLoomRecordingSchema,
188
190
  run: async (args, actionContext) => {
191
+ // YouTube videos are never downloaded, so they skip storage entirely.
192
+ if (isYouTubeUrl(args.url)) {
193
+ return importYouTubeVideo(args, actionContext);
194
+ }
189
195
  const loomId = extractLoomVideoId(args.url);
190
196
  const isLoom = Boolean(loomId);
191
197
  const loomShareUrl = isLoom ? normalizeLoomShareUrl(args.url) : null;
@@ -14,6 +14,7 @@ import {
14
14
  type FilmstripSpriteGrid,
15
15
  } from "../../server/lib/video-filmstrip-sprite.js";
16
16
  import { isLoomRecordingSource } from "../../shared/loom.js";
17
+ import { isYouTubeEmbedBackedRecording } from "../../shared/youtube.js";
17
18
  import {
18
19
  fetchProviderBytes,
19
20
  isRemoteProviderUrl,
@@ -97,7 +98,7 @@ export async function ensureRecordingFilmstrip(params: {
97
98
  if (rec.status !== "ready") {
98
99
  return { recordingId, status: "skipped-not-ready", changed: false };
99
100
  }
100
- if (isLoomRecordingSource(rec)) {
101
+ if (isLoomRecordingSource(rec) || isYouTubeEmbedBackedRecording(rec)) {
101
102
  return { recordingId, status: "skipped-local-media", changed: false };
102
103
  }
103
104
  if (!rec.videoUrl) {
@@ -21,6 +21,7 @@ import {
21
21
  type VideoFormat,
22
22
  } from "../../server/lib/video-remux.js";
23
23
  import { isLoomRecordingSource } from "../../shared/loom.js";
24
+ import { isYouTubeEmbedBackedRecording } from "../../shared/youtube.js";
24
25
 
25
26
  const PROVIDER_FETCH_TIMEOUT_MS = 60_000;
26
27
 
@@ -165,7 +166,7 @@ async function ensureRecordingSeekableInternal(params: {
165
166
  if (rec.status !== "ready") {
166
167
  return { recordingId, status: "skipped-not-ready", changed: false };
167
168
  }
168
- if (isLoomRecordingSource(rec)) {
169
+ if (isLoomRecordingSource(rec) || isYouTubeEmbedBackedRecording(rec)) {
169
170
  return { recordingId, status: "skipped-local-media", changed: false };
170
171
  }
171
172
  if (!rec.videoUrl) {
@@ -1,10 +1,12 @@
1
1
  import { fail } from "@agent-native/core/action";
2
2
 
3
+ import { recordingEmbedProvider } from "../../shared/embed-provider.js";
3
4
  import { isLoomEmbedBackedRecording } from "../../shared/loom.js";
4
5
  import { isImageRecording } from "../../shared/recording-kind.js";
5
6
 
6
7
  type RecordingMediaLike = {
7
8
  sourceAppName?: string | null;
9
+ sourceWindowTitle?: string | null;
8
10
  videoUrl?: string | null;
9
11
  kind?: string | null;
10
12
  };
@@ -19,6 +21,9 @@ const SCREENSHOT_MESSAGE =
19
21
  const LOOM_NATIVE_MEDIA_MESSAGE =
20
22
  "This action requires a Clips-hosted video. This Loom import is embed-backed; reimport it so Clips can store the video file before using native editing, frame extraction, stitching, or upload-based transcription.";
21
23
 
24
+ const YOUTUBE_NATIVE_MEDIA_MESSAGE =
25
+ "This action requires a Clips-hosted video. This clip plays in YouTube's embedded player and Clips stores no copy of it, so editing, frame extraction, stitching, and transcription aren't available. To give it a transcript, import one with import-transcript.";
26
+
22
27
  export function isLoomRecording(recording: RecordingMediaLike): boolean {
23
28
  return isLoomEmbedBackedRecording(recording);
24
29
  }
@@ -32,14 +37,25 @@ export function assertNativeRecordingMedia(
32
37
  statusCode: 422,
33
38
  });
34
39
  }
35
- if (isLoomRecording(recording)) {
36
- fail(LOOM_NATIVE_MEDIA_MESSAGE, {
40
+ const embedProvider = recordingEmbedProvider(recording);
41
+ if (embedProvider) {
42
+ fail(
43
+ embedProvider === "youtube"
44
+ ? YOUTUBE_NATIVE_MEDIA_MESSAGE
45
+ : LOOM_NATIVE_MEDIA_MESSAGE,
46
+ { errorCode: "native_media_required", statusCode: 422 },
47
+ );
48
+ }
49
+ }
50
+
51
+ /** Loom embeds still import Loom's transcript; a YouTube clip has no source. */
52
+ export function assertTranscribableRecordingMedia(
53
+ recording: RecordingMediaLike,
54
+ ): void {
55
+ if (recordingEmbedProvider(recording) === "youtube") {
56
+ fail(YOUTUBE_NATIVE_MEDIA_MESSAGE, {
37
57
  errorCode: "native_media_required",
38
58
  statusCode: 422,
39
59
  });
40
60
  }
41
61
  }
42
-
43
- export function nativeMediaRequiredMessage(): string {
44
- return LOOM_NATIVE_MEDIA_MESSAGE;
45
- }
@@ -0,0 +1,247 @@
1
+ import { fail } from "@agent-native/core/action";
2
+ import {
3
+ writeAppState,
4
+ writeAppStateForCurrentTab,
5
+ } from "@agent-native/core/application-state";
6
+ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
7
+ import {
8
+ extractYouTubeVideoId,
9
+ YOUTUBE_SOURCE_APP_NAME,
10
+ youtubeEmbedUrl,
11
+ youtubeWatchUrl,
12
+ } from "@shared/youtube.js";
13
+ import { z } from "zod";
14
+
15
+ import { getDb, schema } from "../../server/db/index.js";
16
+ import { trackRecordingCreated } from "../../server/lib/recording-created.js";
17
+ import {
18
+ getCurrentOwnerEmail,
19
+ getDefaultRecordingVisibility,
20
+ nanoid,
21
+ requireOrganizationAccess,
22
+ stringifySpaceIds,
23
+ } from "../../server/lib/recordings.js";
24
+ import { validateRecordingScope } from "./recording-scope.js";
25
+
26
+ export const YOUTUBE_IMPORT_NOTE =
27
+ "This clip plays in YouTube's embedded player. Clips stores no copy of the video, so there are no frames to extract and nothing to edit or transcribe. Add a transcript with import-transcript (YouTube's own transcript panel text works); comments, chapters and transcript highlighting follow YouTube's playback.";
28
+
29
+ const YouTubeOembedSchema = z
30
+ .object({
31
+ type: z.literal("video"),
32
+ title: z.string().optional(),
33
+ author_name: z.string().optional(),
34
+ width: z.number().nullable().optional(),
35
+ height: z.number().nullable().optional(),
36
+ thumbnail_url: z.string().url().optional(),
37
+ })
38
+ .passthrough();
39
+
40
+ type YouTubeOembed = z.infer<typeof YouTubeOembedSchema>;
41
+
42
+ export type YouTubeImportArgs = {
43
+ url: string;
44
+ title?: string;
45
+ folderId?: string | null;
46
+ spaceIds?: string[] | null;
47
+ organizationId?: string;
48
+ visibility?: "private" | "org" | "public";
49
+ recordingId?: string;
50
+ };
51
+
52
+ export async function fetchYouTubeOembed(
53
+ watchUrl: string,
54
+ ): Promise<YouTubeOembed> {
55
+ const endpoint = new URL("https://www.youtube.com/oembed");
56
+ endpoint.searchParams.set("url", watchUrl);
57
+ endpoint.searchParams.set("format", "json");
58
+
59
+ let res: Response;
60
+ try {
61
+ res = await ssrfSafeFetch(
62
+ endpoint.href,
63
+ {
64
+ headers: { Accept: "application/json" },
65
+ signal: AbortSignal.timeout(15_000),
66
+ },
67
+ { maxRedirects: 2 },
68
+ );
69
+ } catch (err) {
70
+ fail(
71
+ `Clips couldn't reach YouTube (${(err as Error)?.message ?? String(err)}). Try again in a moment.`,
72
+ { errorCode: "youtube_unreachable", statusCode: 502 },
73
+ );
74
+ }
75
+ if (res.status === 401 || res.status === 403) {
76
+ fail(
77
+ "This YouTube video is private or its owner doesn't allow playing it outside YouTube, so Clips can't embed it.",
78
+ { errorCode: "youtube_embed_unavailable", statusCode: 422 },
79
+ );
80
+ }
81
+ if (res.status === 400 || res.status === 404) {
82
+ fail(
83
+ "YouTube has no public video at that link. It may have been removed, or the link may be incomplete.",
84
+ { errorCode: "youtube_video_not_found", statusCode: 404 },
85
+ );
86
+ }
87
+ if (!res.ok) {
88
+ fail(
89
+ `YouTube didn't return the video's details (${res.status}). Try again in a moment.`,
90
+ { errorCode: "youtube_unreachable", statusCode: 502 },
91
+ );
92
+ }
93
+
94
+ const parsed = YouTubeOembedSchema.safeParse(
95
+ // coercion-ok: null fails the schema, so a body that isn't JSON fails below as youtube_unreachable
96
+ await res.json().catch(() => null),
97
+ );
98
+ if (!parsed.success) {
99
+ fail("YouTube returned an unexpected response for that video.", {
100
+ errorCode: "youtube_unreachable",
101
+ statusCode: 502,
102
+ });
103
+ }
104
+ return parsed.data;
105
+ }
106
+
107
+ // oEmbed reports the default embed's size, not the video's, so only its
108
+ // shape is kept: a 16:9 video becomes 1280x720 and a Short 720x1280.
109
+ function playerDimensions(oembed: YouTubeOembed): {
110
+ width: number;
111
+ height: number;
112
+ } {
113
+ const width = oembed.width ?? 0;
114
+ const height = oembed.height ?? 0;
115
+ if (!(width > 0 && height > 0)) return { width: 1280, height: 720 };
116
+ return width >= height
117
+ ? { width: 1280, height: Math.round((1280 * height) / width) }
118
+ : { width: Math.round((1280 * width) / height), height: 1280 };
119
+ }
120
+
121
+ /**
122
+ * Creates an embed-backed clip for a YouTube video. Only YouTube's public
123
+ * oEmbed metadata is fetched: YouTube's terms don't allow downloading the
124
+ * video, so it plays through YouTube's embedded player.
125
+ */
126
+ export async function importYouTubeVideo(
127
+ args: YouTubeImportArgs,
128
+ actionContext: { userEmail?: string | null } | undefined,
129
+ ) {
130
+ const videoId = extractYouTubeVideoId(args.url);
131
+ if (!videoId) {
132
+ fail(
133
+ "That YouTube link doesn't point to a video. Paste a watch, Shorts, or youtu.be link.",
134
+ { errorCode: "youtube_url_invalid", statusCode: 400 },
135
+ );
136
+ }
137
+ if (args.recordingId) {
138
+ fail(
139
+ "YouTube imports finish immediately, so there's nothing to retry. Import the link again instead.",
140
+ { errorCode: "youtube_retry_unsupported", statusCode: 400 },
141
+ );
142
+ }
143
+
144
+ const db = getDb();
145
+ const ownerEmail = getCurrentOwnerEmail();
146
+ const { organizationId } = await requireOrganizationAccess(
147
+ args.organizationId,
148
+ );
149
+ const spaceIds = await validateRecordingScope(db, {
150
+ organizationId,
151
+ ownerEmail,
152
+ spaceIds: args.spaceIds ?? [],
153
+ folderId: args.folderId,
154
+ });
155
+ const sourceUrl = youtubeWatchUrl(videoId);
156
+ const videoUrl = youtubeEmbedUrl(videoId);
157
+ const oembed = await fetchYouTubeOembed(sourceUrl);
158
+ const title =
159
+ args.title?.trim() || oembed.title?.trim() || `YouTube video ${videoId}`;
160
+ const { width, height } = playerDimensions(oembed);
161
+ const visibility =
162
+ args.visibility ??
163
+ (await getDefaultRecordingVisibility(
164
+ organizationId,
165
+ actionContext?.userEmail ?? ownerEmail,
166
+ ));
167
+ const id = nanoid();
168
+ const now = new Date().toISOString();
169
+ const thumbnailUrl = oembed.thumbnail_url ?? null;
170
+
171
+ await db.insert(schema.recordings).values({
172
+ id,
173
+ organizationId,
174
+ orgId: organizationId,
175
+ folderId: args.folderId ?? null,
176
+ spaceIds: stringifySpaceIds(spaceIds),
177
+ title,
178
+ titleSource: args.title ? "manual" : "upload",
179
+ recordingPlatform: "import",
180
+ storageMode: null,
181
+ failureCode: null,
182
+ failureReason: null,
183
+ sourceAppName: YOUTUBE_SOURCE_APP_NAME,
184
+ sourceWindowTitle: sourceUrl,
185
+ description: "",
186
+ thumbnailUrl,
187
+ // Only YouTube's player knows the length; the first editor to open the
188
+ // clip records it (see set-embed-duration).
189
+ durationMs: 0,
190
+ videoUrl,
191
+ videoFormat: "mp4",
192
+ videoSizeBytes: 0,
193
+ width,
194
+ height,
195
+ hasAudio: true,
196
+ hasCamera: false,
197
+ status: "ready",
198
+ uploadProgress: 100,
199
+ visibility,
200
+ ownerEmail,
201
+ createdAt: now,
202
+ updatedAt: now,
203
+ });
204
+ trackRecordingCreated({
205
+ recordingId: id,
206
+ userId: ownerEmail,
207
+ platform: "import",
208
+ source: "import",
209
+ hasAudio: true,
210
+ hasCamera: false,
211
+ });
212
+
213
+ await writeAppState(`recording-upload-${id}`, {
214
+ recordingId: id,
215
+ status: "ready",
216
+ progress: 100,
217
+ videoUrl,
218
+ provider: "youtube",
219
+ sourceUrl,
220
+ durationMs: 0,
221
+ width,
222
+ height,
223
+ hasAudio: true,
224
+ hasCamera: false,
225
+ updatedAt: now,
226
+ });
227
+ await writeAppState("refresh-signal", { ts: Date.now() });
228
+ await writeAppStateForCurrentTab("navigate", {
229
+ view: "recording",
230
+ recordingId: id,
231
+ });
232
+
233
+ return {
234
+ recordingId: id,
235
+ title,
236
+ status: "ready" as const,
237
+ provider: "youtube" as const,
238
+ sourceUrl,
239
+ videoUrl,
240
+ embedUrl: videoUrl,
241
+ thumbnailUrl,
242
+ durationMs: 0,
243
+ importMode: "embed" as const,
244
+ framesAvailable: false,
245
+ note: YOUTUBE_IMPORT_NOTE,
246
+ };
247
+ }
@@ -58,7 +58,10 @@ import {
58
58
  fetchLoomTranscript,
59
59
  loomTranscriptUnavailableMessage,
60
60
  } from "./lib/loom-transcript.js";
61
- import { isLoomRecording } from "./lib/native-media.js";
61
+ import {
62
+ assertTranscribableRecordingMedia,
63
+ isLoomRecording,
64
+ } from "./lib/native-media.js";
62
65
  import { isLikelyMismatchedTranscriptLanguage } from "./lib/provider-transcript.js";
63
66
  import { prepareRecordingTranscriptionAudio } from "./lib/recording-audio-source.js";
64
67
  import regenerateSummary from "./regenerate-summary.js";
@@ -1030,11 +1033,12 @@ const requestTranscriptAction = defineAction({
1030
1033
  ),
1031
1034
  }),
1032
1035
  run: async (args, context?: ActionRunContext) => {
1033
- await assertAccess("recording", args.recordingId, "editor");
1036
+ const access = await assertAccess("recording", args.recordingId, "editor");
1034
1037
 
1035
1038
  const db = getDb();
1036
1039
 
1037
1040
  if (context?.caller === "tool" || context?.caller === "frontend") {
1041
+ assertTranscribableRecordingMedia(access.resource);
1038
1042
  const [existingTranscript] = await db
1039
1043
  .select({
1040
1044
  status: schema.recordingTranscripts.status,
@@ -1199,6 +1203,11 @@ const requestTranscriptAction = defineAction({
1199
1203
  }
1200
1204
  }
1201
1205
 
1206
+ // Past here the transcript comes from the recording's audio. A ready
1207
+ // transcript, such as an imported one on a YouTube clip, was kept above,
1208
+ // so the follow-up job import-transcript queues still fills its summary.
1209
+ assertTranscribableRecordingMedia(access.resource);
1210
+
1202
1211
  if (
1203
1212
  !args.force &&
1204
1213
  existingNativeTranscript &&