@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/analytics/server/lib/analytics-mcp.ts +1 -0
- package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/chat/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +17 -2
- package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/clips/AGENTS.md +1 -1
- package/corpus/templates/clips/actions/get-recording-player-data.ts +41 -9
- package/corpus/templates/clips/actions/import-loom-recording.ts +9 -3
- package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +2 -1
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +2 -1
- package/corpus/templates/clips/actions/lib/native-media.ts +22 -6
- package/corpus/templates/clips/actions/lib/youtube-import.ts +247 -0
- package/corpus/templates/clips/actions/request-transcript.ts +11 -2
- package/corpus/templates/clips/actions/set-embed-duration.ts +55 -0
- package/corpus/templates/clips/actions/view-screen.ts +12 -1
- package/corpus/templates/clips/app/components/library/import-loom-dialog.tsx +13 -7
- package/corpus/templates/clips/app/components/player/player-controls.tsx +50 -15
- package/corpus/templates/clips/app/components/player/use-optimistic-reactions.ts +87 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +48 -68
- package/corpus/templates/clips/app/components/player/youtube-embed-player.tsx +690 -0
- package/corpus/templates/clips/app/hooks/use-player-shortcuts.ts +13 -21
- package/corpus/templates/clips/app/i18n/en-US.ts +12 -1
- package/corpus/templates/clips/app/lib/youtube-iframe-api.ts +135 -0
- package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +59 -24
- package/corpus/templates/clips/app/routes/embed.$shareId.tsx +6 -4
- package/corpus/templates/clips/app/routes/import.tsx +13 -4
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +16 -8
- package/corpus/templates/clips/changelog/2026-10-16-youtube-embeds.md +6 -0
- package/corpus/templates/clips/server/lib/agent-frame-request.ts +11 -6
- package/corpus/templates/clips/server/lib/ensure-recording-thumbnail.ts +7 -4
- package/corpus/templates/clips/server/lib/player-video-url.ts +9 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +18 -21
- package/corpus/templates/clips/server/routes/api/agent-transcript.json.get.ts +3 -3
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +15 -0
- package/corpus/templates/clips/shared/comment-transcript-context.ts +25 -0
- package/corpus/templates/clips/shared/embed-provider.ts +32 -0
- package/corpus/templates/clips/shared/youtube.ts +103 -0
- package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/actions/apply-builder-edits.ts +85 -32
- package/corpus/templates/design/actions/list-builder-edits.ts +8 -3
- package/corpus/templates/design/actions/open-builder-branch-design.ts +4 -15
- package/corpus/templates/design/actions/send-builder-message.ts +4 -0
- package/corpus/templates/design/server/db/schema.ts +5 -1
- package/corpus/templates/design/server/lib/builder-identity.ts +37 -5
- package/corpus/templates/design/server/lib/design-response-guard.ts +4 -3
- package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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
|
|
320
|
-
ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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 {
|
|
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 &&
|