@agent-native/core 0.102.1 → 0.102.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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +9 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +7 -0
- package/corpus/core/src/client/AssistantChat.tsx +7 -0
- package/corpus/core/src/client/agent-chat-adapter.ts +7 -1
- package/corpus/core/src/client/chat/message-components.tsx +17 -3
- package/corpus/core/src/client/composer/ComposerPlusMenu.tsx +96 -6
- package/corpus/core/src/client/composer/asset-picker-url.ts +80 -0
- package/corpus/core/src/client/error-format.ts +24 -0
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +8 -1
- package/corpus/templates/assets/app/routes/library.tsx +69 -0
- package/corpus/templates/assets/changelog/2026-07-15-fixed-top-level-image-picker-selections-returning-to-the-com.md +6 -0
- package/corpus/templates/clips/actions/disconnect-calendar.ts +57 -9
- package/corpus/templates/clips/actions/generate-workflow.ts +102 -59
- package/corpus/templates/clips/actions/get-recording-player-data.ts +64 -1
- package/corpus/templates/clips/actions/regenerate-summary.ts +7 -0
- package/corpus/templates/clips/actions/regenerate-title.ts +29 -11
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +22 -11
- package/corpus/templates/clips/app/components/editor/thumbnail-picker.tsx +87 -9
- package/corpus/templates/clips/app/components/editor/trim-handles.tsx +15 -0
- package/corpus/templates/clips/app/components/editor/waveform.tsx +21 -8
- package/corpus/templates/clips/app/components/library/folder-tree.tsx +52 -0
- package/corpus/templates/clips/app/components/player/settings-panel.tsx +86 -46
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +43 -37
- package/corpus/templates/clips/app/components/player/video-player.tsx +45 -1
- package/corpus/templates/clips/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/clips/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
- package/corpus/templates/clips/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/clips/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/clips/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/clips/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/clips/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/clips/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/clips/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/clips/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +14 -0
- package/corpus/templates/clips/app/routes/_app.library._index.tsx +1 -2
- package/corpus/templates/clips/app/routes/_index.tsx +1 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +71 -31
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +4 -1
- package/corpus/templates/clips/changelog/2026-07-15-ai-workflow-generation-now-stays-visible-avoids-duplicate-re.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-clip-playback-and-cta-controls-remain-interactive-through-ed.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-clip-titles-no-longer-describe-the-product-as-a-loom-alterna.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-description-regeneration-now-opens-the-agent-chat-so-progres.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-direct-recording-urls-now-respect-share-link-and-password-ac.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-disconnecting-a-calendar-now-clears-unrecorded-synced-meetin.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-folders-now-expose-rename-and-delete-actions-directly-in-the.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-recording-pages-now-explain-sign-in-access-and-avoid-showing.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-the-clips-extension-quick-actions-toolbar-can-be-moved-out-o.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-the-editor-now-skips-cut-sections-during-preview-and-keeps-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-thumbnail-capture-now-updates-previews-while-scrubbing-and-l.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-15-windows-permission-links-open-the-matching-settings-page.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +117 -2
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +27 -0
- package/corpus/templates/clips/desktop/src-tauri/tauri.conf.json +1 -1
- package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +139 -101
- package/corpus/templates/clips/server/lib/recording-page-access.ts +35 -0
- package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +7 -3
- package/corpus/templates/design/changelog/2026-07-15-prototype-print-and-download-buttons-now-work-in-preview.md +6 -0
- package/corpus/templates/slides/actions/extract-pdf.ts +1 -1
- package/corpus/templates/slides/actions/import-file.ts +4 -1
- package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
- package/corpus/templates/slides/app/components/editor/ImageSearchPanel.tsx +9 -12
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +29 -21
- package/corpus/templates/slides/app/i18n/ar-SA.ts +1 -0
- package/corpus/templates/slides/app/i18n/de-DE.ts +1 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
- package/corpus/templates/slides/app/i18n/es-ES.ts +1 -0
- package/corpus/templates/slides/app/i18n/fr-FR.ts +1 -0
- package/corpus/templates/slides/app/i18n/hi-IN.ts +1 -0
- package/corpus/templates/slides/app/i18n/ja-JP.ts +1 -0
- package/corpus/templates/slides/app/i18n/ko-KR.ts +1 -0
- package/corpus/templates/slides/app/i18n/pt-BR.ts +1 -0
- package/corpus/templates/slides/app/i18n/zh-CN.ts +1 -0
- package/corpus/templates/slides/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/slides/changelog/2026-07-15-image-search-and-generation-controls-now-use-the-registered-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-15-pdf-deck-references-can-be-imported-for-slide-inspiration-ag.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-15-stable-style-inspector-dock.md +6 -0
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +5 -0
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +7 -0
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +8 -1
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +4 -0
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +10 -3
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/composer/ComposerPlusMenu.d.ts.map +1 -1
- package/dist/client/composer/ComposerPlusMenu.js +58 -6
- package/dist/client/composer/ComposerPlusMenu.js.map +1 -1
- package/dist/client/composer/asset-picker-url.d.ts +16 -0
- package/dist/client/composer/asset-picker-url.d.ts.map +1 -0
- package/dist/client/composer/asset-picker-url.js +64 -0
- package/dist/client/composer/asset-picker-url.js.map +1 -0
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +17 -0
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +6 -1
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { defineAction } from "@agent-native/core";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
readAppState,
|
|
20
|
+
writeAppState,
|
|
21
|
+
} from "@agent-native/core/application-state";
|
|
19
22
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
20
23
|
import { eq } from "drizzle-orm";
|
|
21
24
|
import { z } from "zod";
|
|
@@ -51,6 +54,11 @@ Output markdown.`,
|
|
|
51
54
|
Keep it concise, warm, and professional.`,
|
|
52
55
|
} as const;
|
|
53
56
|
|
|
57
|
+
// Actions for the same recording can arrive concurrently when the player and
|
|
58
|
+
// agent both retry a request. Keep the read-check-write/enqueue sequence
|
|
59
|
+
// single-flight within this runtime so only one request can claim generation.
|
|
60
|
+
const workflowGenerationLocks = new Set<string>();
|
|
61
|
+
|
|
54
62
|
export default defineAction({
|
|
55
63
|
description:
|
|
56
64
|
"Ask the agent to generate a structured workflow doc (pr/sop/ticket/email) from this recording's transcript (and the full video when Include full video is enabled). The agent writes the result to clips-workflow-<recordingId> in application_state.",
|
|
@@ -61,71 +69,106 @@ export default defineAction({
|
|
|
61
69
|
run: async (args) => {
|
|
62
70
|
await assertAccess("recording", args.recordingId, "viewer");
|
|
63
71
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
const stateKey = `clips-workflow-${args.recordingId}`;
|
|
73
|
+
if (workflowGenerationLocks.has(stateKey)) {
|
|
74
|
+
return {
|
|
75
|
+
queued: false,
|
|
76
|
+
duplicate: true,
|
|
77
|
+
recordingId: args.recordingId,
|
|
78
|
+
kind: args.kind,
|
|
79
|
+
stateKey,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
workflowGenerationLocks.add(stateKey);
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
try {
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const [rec] = await db
|
|
87
|
+
.select()
|
|
88
|
+
.from(schema.recordings)
|
|
89
|
+
.where(eq(schema.recordings.id, args.recordingId))
|
|
90
|
+
.limit(1);
|
|
91
|
+
if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
const [transcript] = await db
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.recordingTranscripts)
|
|
96
|
+
.where(eq(schema.recordingTranscripts.recordingId, args.recordingId))
|
|
97
|
+
.limit(1);
|
|
80
98
|
|
|
81
|
-
|
|
82
|
-
// a loading state immediately.
|
|
83
|
-
await writeAppState(stateKey, {
|
|
84
|
-
kind: args.kind,
|
|
85
|
-
status: "generating",
|
|
86
|
-
recordingId: args.recordingId,
|
|
87
|
-
requestedAt: new Date().toISOString(),
|
|
88
|
-
} as any);
|
|
99
|
+
const includeFullVideoInAi = await readIncludeFullVideoInAi();
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
const existing = await readAppState(stateKey).catch(() => null);
|
|
102
|
+
if (existing?.status === "generating") {
|
|
103
|
+
const requestedAt = Date.parse(String(existing.requestedAt ?? ""));
|
|
104
|
+
const isRecent =
|
|
105
|
+
!Number.isFinite(requestedAt) ||
|
|
106
|
+
Date.now() - requestedAt < 10 * 60_000;
|
|
107
|
+
if (isRecent) {
|
|
108
|
+
return {
|
|
109
|
+
queued: false,
|
|
110
|
+
duplicate: true,
|
|
111
|
+
recordingId: args.recordingId,
|
|
112
|
+
kind: existing.kind ?? args.kind,
|
|
113
|
+
stateKey,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
97
117
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
args.recordingId
|
|
118
|
+
// Seed the output state with a "generating" placeholder so the UI can show
|
|
119
|
+
// a loading state immediately.
|
|
120
|
+
await writeAppState(stateKey, {
|
|
121
|
+
kind: args.kind,
|
|
122
|
+
status: "generating",
|
|
123
|
+
recordingId: args.recordingId,
|
|
124
|
+
requestedAt: new Date().toISOString(),
|
|
125
|
+
} as any);
|
|
126
|
+
|
|
127
|
+
const baseMessage =
|
|
128
|
+
`Generate a ${args.kind.toUpperCase()} workflow document from recording ${args.recordingId} ` +
|
|
129
|
+
`(title: "${rec.title}"). Read the transcript from this request's context. ` +
|
|
130
|
+
`${KIND_PROMPTS[args.kind]} ` +
|
|
131
|
+
`Then write the final markdown to application_state key "${stateKey}" as ` +
|
|
132
|
+
`\`{ kind: "${args.kind}", status: "ready", content: "...", recordingId: "${args.recordingId}" }\`. ` +
|
|
133
|
+
`Finish by replying in chat with the same generated markdown so the user can read it immediately.`;
|
|
134
|
+
|
|
135
|
+
const request = {
|
|
136
|
+
kind: "generate-workflow" as const,
|
|
137
|
+
workflowKind: args.kind,
|
|
138
|
+
recordingId: args.recordingId,
|
|
139
|
+
requestedAt: new Date().toISOString(),
|
|
140
|
+
recordingTitle: rec.title,
|
|
141
|
+
recordingDescription: rec.description,
|
|
142
|
+
transcriptStatus: transcript?.status ?? "pending",
|
|
143
|
+
transcriptText: transcript?.fullText ?? "",
|
|
144
|
+
stateKey,
|
|
145
|
+
instructions: KIND_PROMPTS[args.kind],
|
|
113
146
|
includeFullVideoInAi,
|
|
114
|
-
|
|
115
|
-
|
|
147
|
+
message: withFullVideoAiInstructions(
|
|
148
|
+
baseMessage,
|
|
149
|
+
args.recordingId,
|
|
150
|
+
includeFullVideoInAi,
|
|
151
|
+
),
|
|
152
|
+
};
|
|
116
153
|
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
await writeAppState(
|
|
155
|
+
`clips-ai-request-${args.recordingId}`,
|
|
156
|
+
request as any,
|
|
157
|
+
);
|
|
158
|
+
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
119
159
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
console.log(
|
|
161
|
+
`Delegation queued: generate-workflow (${args.kind}) for ${args.recordingId}`,
|
|
162
|
+
);
|
|
163
|
+
return {
|
|
164
|
+
queued: true,
|
|
165
|
+
recordingId: args.recordingId,
|
|
166
|
+
kind: args.kind,
|
|
167
|
+
stateKey,
|
|
168
|
+
includeFullVideoInAi,
|
|
169
|
+
};
|
|
170
|
+
} finally {
|
|
171
|
+
workflowGenerationLocks.delete(stateKey);
|
|
172
|
+
}
|
|
130
173
|
},
|
|
131
174
|
});
|
|
@@ -20,12 +20,20 @@
|
|
|
20
20
|
import { defineAction, embedApp } from "@agent-native/core";
|
|
21
21
|
import { readAppState } from "@agent-native/core/application-state";
|
|
22
22
|
import { buildDeepLink } from "@agent-native/core/server";
|
|
23
|
+
import {
|
|
24
|
+
getRequestOrgId,
|
|
25
|
+
getRequestUserEmail,
|
|
26
|
+
} from "@agent-native/core/server/request-context";
|
|
23
27
|
import { resolveAccess, ForbiddenError } from "@agent-native/core/sharing";
|
|
24
|
-
import { asc, eq } from "drizzle-orm";
|
|
28
|
+
import { and, asc, eq, or, sql } from "drizzle-orm";
|
|
25
29
|
import { z } from "zod";
|
|
26
30
|
|
|
27
31
|
import { getDb, schema } from "../server/db/index.js";
|
|
28
32
|
import { resolvePlayerVideoUrl } from "../server/lib/player-video-url.js";
|
|
33
|
+
import {
|
|
34
|
+
canOpenDirectRecordingPage,
|
|
35
|
+
isRecordingExpired,
|
|
36
|
+
} from "../server/lib/recording-page-access.js";
|
|
29
37
|
import { parseSpaceIds } from "../server/lib/recordings.js";
|
|
30
38
|
import { parseBrowserDiagnosticsRow } from "../shared/browser-diagnostics.js";
|
|
31
39
|
import {
|
|
@@ -106,6 +114,61 @@ export default defineAction({
|
|
|
106
114
|
const db = getDb();
|
|
107
115
|
const rec: any = access.resource;
|
|
108
116
|
|
|
117
|
+
if (isRecordingExpired(rec.expiresAt)) {
|
|
118
|
+
throw new ForbiddenError("Recording has expired");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let hasExplicitShare = access.role === "owner";
|
|
122
|
+
if (rec.visibility === "public" && access.role !== "owner") {
|
|
123
|
+
const userEmail = getRequestUserEmail()?.trim().toLowerCase();
|
|
124
|
+
const orgId = getRequestOrgId();
|
|
125
|
+
const principals = [];
|
|
126
|
+
if (userEmail) {
|
|
127
|
+
principals.push(
|
|
128
|
+
and(
|
|
129
|
+
eq(schema.recordingShares.principalType, "user"),
|
|
130
|
+
sql`lower(${schema.recordingShares.principalId}) = ${userEmail}`,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (orgId) {
|
|
135
|
+
principals.push(
|
|
136
|
+
and(
|
|
137
|
+
eq(schema.recordingShares.principalType, "org"),
|
|
138
|
+
eq(schema.recordingShares.principalId, orgId),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (principals.length > 0) {
|
|
143
|
+
const [share] = await db
|
|
144
|
+
.select({ id: schema.recordingShares.id })
|
|
145
|
+
.from(schema.recordingShares)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(schema.recordingShares.resourceId, args.recordingId),
|
|
149
|
+
or(...principals),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
.limit(1);
|
|
153
|
+
hasExplicitShare = Boolean(share);
|
|
154
|
+
} else {
|
|
155
|
+
hasExplicitShare = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
!canOpenDirectRecordingPage({
|
|
161
|
+
role: access.role,
|
|
162
|
+
visibility: rec.visibility,
|
|
163
|
+
hasPassword: Boolean(rec.password),
|
|
164
|
+
hasExplicitShare,
|
|
165
|
+
})
|
|
166
|
+
) {
|
|
167
|
+
throw new ForbiddenError(
|
|
168
|
+
"Open this recording from its share link instead of the direct recording URL",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
109
172
|
const [transcript] = await db
|
|
110
173
|
.select()
|
|
111
174
|
.from(schema.recordingTranscripts)
|
|
@@ -22,6 +22,12 @@ export default defineAction({
|
|
|
22
22
|
"Ask the agent to regenerate this recording's description/summary based on its transcript (and the full video when Include full video is enabled).",
|
|
23
23
|
schema: z.object({
|
|
24
24
|
recordingId: z.string().describe("Recording ID"),
|
|
25
|
+
openInChat: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe(
|
|
29
|
+
"When true, focus the queued generation request in the agent chat instead of keeping it hidden.",
|
|
30
|
+
),
|
|
25
31
|
}),
|
|
26
32
|
run: async (args) => {
|
|
27
33
|
await assertAccess("recording", args.recordingId, "editor");
|
|
@@ -74,6 +80,7 @@ export default defineAction({
|
|
|
74
80
|
transcriptStatus: transcript?.status ?? "pending",
|
|
75
81
|
transcriptText: transcript?.fullText ?? "",
|
|
76
82
|
includeFullVideoInAi,
|
|
83
|
+
openInChat: args.openInChat === true,
|
|
77
84
|
message: withFullVideoAiInstructions(
|
|
78
85
|
baseMessage,
|
|
79
86
|
args.recordingId,
|
|
@@ -59,7 +59,9 @@ function buildTitleContext({
|
|
|
59
59
|
}): string | undefined {
|
|
60
60
|
const parts: string[] = [];
|
|
61
61
|
if (currentTitle && !isDefaultTitle(currentTitle)) {
|
|
62
|
-
parts.push(
|
|
62
|
+
parts.push(
|
|
63
|
+
`Current title: ${currentTitle}\nWhen regenerating, do not return this title verbatim; choose a more specific alternative when the transcript supports one.`,
|
|
64
|
+
);
|
|
63
65
|
}
|
|
64
66
|
if (agentsContext) parts.push(agentsContext);
|
|
65
67
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
@@ -237,9 +239,14 @@ export default defineAction({
|
|
|
237
239
|
|
|
238
240
|
if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
239
241
|
|
|
242
|
+
const titleMatchesCurrent =
|
|
243
|
+
fresh.title?.trim().toLocaleLowerCase() ===
|
|
244
|
+
generatedTitle.trim().toLocaleLowerCase();
|
|
245
|
+
|
|
240
246
|
if (
|
|
241
|
-
|
|
242
|
-
fresh.title
|
|
247
|
+
!titleMatchesCurrent &&
|
|
248
|
+
(isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
|
|
249
|
+
fresh.title === rec.title)
|
|
243
250
|
) {
|
|
244
251
|
await db
|
|
245
252
|
.update(schema.recordings)
|
|
@@ -272,12 +279,18 @@ export default defineAction({
|
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
if (!titleMatchesCurrent) {
|
|
283
|
+
return {
|
|
284
|
+
updated: false,
|
|
285
|
+
skipped: true,
|
|
286
|
+
reason: "Recording title changed before generation completed",
|
|
287
|
+
recordingId: args.recordingId,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.warn(
|
|
292
|
+
`[clips] AI title regeneration returned the existing title for ${args.recordingId}; queueing a refinement instead`,
|
|
293
|
+
);
|
|
281
294
|
}
|
|
282
295
|
} catch (err) {
|
|
283
296
|
builderCreditsPaused = isBuilderCreditsExhaustedMessage(
|
|
@@ -302,9 +315,14 @@ export default defineAction({
|
|
|
302
315
|
|
|
303
316
|
if (!fresh) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
304
317
|
|
|
318
|
+
const titleMatchesCurrent =
|
|
319
|
+
fresh.title?.trim().toLocaleLowerCase() ===
|
|
320
|
+
fallbackTitle.trim().toLocaleLowerCase();
|
|
321
|
+
|
|
305
322
|
if (
|
|
306
|
-
|
|
307
|
-
fresh.title
|
|
323
|
+
!titleMatchesCurrent &&
|
|
324
|
+
(isAutoTitleReplaceable(fresh.title, fresh.titleSource) ||
|
|
325
|
+
fresh.title === rec.title)
|
|
308
326
|
) {
|
|
309
327
|
await db
|
|
310
328
|
.update(schema.recordings)
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
parseEdits,
|
|
64
64
|
getExcludedRanges,
|
|
65
65
|
formatMs,
|
|
66
|
+
skipExcludedRange,
|
|
66
67
|
type EditsJson,
|
|
67
68
|
} from "@/lib/timestamp-mapping";
|
|
68
69
|
import { cn } from "@/lib/utils";
|
|
@@ -435,10 +436,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
435
436
|
useEffect(() => {
|
|
436
437
|
const v = videoRef.current;
|
|
437
438
|
if (!v) return;
|
|
438
|
-
const onTime = () =>
|
|
439
|
+
const onTime = () => {
|
|
440
|
+
const rawMs = v.currentTime * 1000;
|
|
441
|
+
const visibleMs = skipExcludedRange(rawMs, excludedRanges, durationMs);
|
|
442
|
+
if (visibleMs !== rawMs) v.currentTime = visibleMs / 1000;
|
|
443
|
+
setPlayheadMs(visibleMs);
|
|
444
|
+
};
|
|
439
445
|
v.addEventListener("timeupdate", onTime);
|
|
440
446
|
return () => v.removeEventListener("timeupdate", onTime);
|
|
441
|
-
}, [videoUrl]);
|
|
447
|
+
}, [durationMs, excludedRanges, videoUrl]);
|
|
442
448
|
|
|
443
449
|
// Expose the in-editor state so the agent can read "the user is editing and scrubbed to X".
|
|
444
450
|
useEffect(() => {
|
|
@@ -510,11 +516,15 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
510
516
|
[recordingId, trim],
|
|
511
517
|
);
|
|
512
518
|
|
|
513
|
-
const seek = useCallback(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
519
|
+
const seek = useCallback(
|
|
520
|
+
(ms: number) => {
|
|
521
|
+
const visibleMs = skipExcludedRange(ms, excludedRanges, durationMs);
|
|
522
|
+
const v = videoRef.current;
|
|
523
|
+
if (v) v.currentTime = visibleMs / 1000;
|
|
524
|
+
setPlayheadMs(visibleMs);
|
|
525
|
+
},
|
|
526
|
+
[durationMs, excludedRanges],
|
|
527
|
+
);
|
|
518
528
|
|
|
519
529
|
// --- keyboard shortcuts -------------------------------------------------
|
|
520
530
|
useEffect(() => {
|
|
@@ -585,8 +595,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
585
595
|
|
|
586
596
|
// Default selection window so the TrimHandles have something to render.
|
|
587
597
|
const effectiveSelection = selectionRange ?? {
|
|
588
|
-
startMs:
|
|
589
|
-
endMs:
|
|
598
|
+
startMs: 0,
|
|
599
|
+
endMs: durationMs || 1_000,
|
|
590
600
|
};
|
|
591
601
|
|
|
592
602
|
if (playerDataQuery.isLoading) {
|
|
@@ -684,7 +694,8 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
684
694
|
playheadMs={playheadMs}
|
|
685
695
|
durationMs={durationMs}
|
|
686
696
|
excludedRanges={excludedRanges}
|
|
687
|
-
selectionRange={
|
|
697
|
+
selectionRange={effectiveSelection}
|
|
698
|
+
splitPoints={splitPoints}
|
|
688
699
|
activityRanges={transcriptSegments}
|
|
689
700
|
onSeek={seek}
|
|
690
701
|
scrollLeft={scrollLeft}
|
|
@@ -707,6 +718,7 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
707
718
|
value={effectiveSelection}
|
|
708
719
|
onChange={setSelectionRange}
|
|
709
720
|
durationMs={durationMs}
|
|
721
|
+
splitPoints={splitPoints}
|
|
710
722
|
/>
|
|
711
723
|
</div>
|
|
712
724
|
</div>
|
|
@@ -727,7 +739,6 @@ export function EditorLayout({ recordingId, className }: EditorLayoutProps) {
|
|
|
727
739
|
durationMs={durationMs}
|
|
728
740
|
playheadMs={playheadMs}
|
|
729
741
|
chapters={chapters}
|
|
730
|
-
excludedRanges={excludedRanges}
|
|
731
742
|
splitPoints={splitPoints}
|
|
732
743
|
onSeek={seek}
|
|
733
744
|
onClickChapter={(c) => seek(c.startMs)}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
IconPhotoEdit,
|
|
5
5
|
IconUpload,
|
|
6
6
|
IconLoader2,
|
|
7
|
+
IconX,
|
|
7
8
|
} from "@tabler/icons-react";
|
|
8
9
|
import { useEffect, useRef, useState } from "react";
|
|
9
10
|
import { toast } from "sonner";
|
|
@@ -55,6 +56,10 @@ export function ThumbnailPicker({
|
|
|
55
56
|
const [gifDataUrl, setGifDataUrl] = useState<string | null>(null);
|
|
56
57
|
|
|
57
58
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
59
|
+
const framePreviewRef = useRef<HTMLVideoElement | null>(null);
|
|
60
|
+
const gifVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
61
|
+
const gifPreviewRef = useRef<HTMLVideoElement | null>(null);
|
|
62
|
+
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
58
63
|
|
|
59
64
|
const mutation = useActionMutation("set-thumbnail");
|
|
60
65
|
|
|
@@ -65,6 +70,7 @@ export function ThumbnailPicker({
|
|
|
65
70
|
setFrameDataUrl(null);
|
|
66
71
|
setGifDataUrl(null);
|
|
67
72
|
setGifProgress(null);
|
|
73
|
+
if (uploadInputRef.current) uploadInputRef.current.value = "";
|
|
68
74
|
}
|
|
69
75
|
}, [open]);
|
|
70
76
|
|
|
@@ -98,6 +104,18 @@ export function ThumbnailPicker({
|
|
|
98
104
|
}
|
|
99
105
|
};
|
|
100
106
|
|
|
107
|
+
const seekPreview = (video: HTMLVideoElement | null, timeMs: number) => {
|
|
108
|
+
if (!video || !Number.isFinite(timeMs)) return;
|
|
109
|
+
const apply = () => {
|
|
110
|
+
video.currentTime = Math.max(0, timeMs) / 1000;
|
|
111
|
+
};
|
|
112
|
+
if (video.readyState === HTMLMediaElement.HAVE_NOTHING) {
|
|
113
|
+
video.addEventListener("loadedmetadata", apply, { once: true });
|
|
114
|
+
} else {
|
|
115
|
+
apply();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
101
119
|
const handleGifGenerate = async () => {
|
|
102
120
|
if (!videoUrl) return;
|
|
103
121
|
try {
|
|
@@ -187,6 +205,7 @@ export function ThumbnailPicker({
|
|
|
187
205
|
<TabsContent value="upload" className="py-4 space-y-3">
|
|
188
206
|
<Label>{t("thumbnailPicker.uploadImage")}</Label>
|
|
189
207
|
<input
|
|
208
|
+
ref={uploadInputRef}
|
|
190
209
|
type="file"
|
|
191
210
|
accept="image/*"
|
|
192
211
|
onChange={(e) => {
|
|
@@ -196,14 +215,39 @@ export function ThumbnailPicker({
|
|
|
196
215
|
reader.onload = () => setUploadDataUrl(reader.result as string);
|
|
197
216
|
reader.readAsDataURL(file);
|
|
198
217
|
}}
|
|
199
|
-
className="
|
|
218
|
+
className="sr-only"
|
|
200
219
|
/>
|
|
220
|
+
<Button
|
|
221
|
+
type="button"
|
|
222
|
+
variant="secondary"
|
|
223
|
+
size="sm"
|
|
224
|
+
onClick={() => uploadInputRef.current?.click()}
|
|
225
|
+
>
|
|
226
|
+
<IconUpload className="mr-1 h-4 w-4" />
|
|
227
|
+
{t("shareDialog.chooseFile")}
|
|
228
|
+
</Button>
|
|
201
229
|
{uploadDataUrl ? (
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
230
|
+
<div className="relative w-fit">
|
|
231
|
+
<img
|
|
232
|
+
src={uploadDataUrl}
|
|
233
|
+
alt={t("thumbnailPicker.uploadedPreview")}
|
|
234
|
+
className="max-h-60 rounded border border-border"
|
|
235
|
+
/>
|
|
236
|
+
<Button
|
|
237
|
+
type="button"
|
|
238
|
+
variant="secondary"
|
|
239
|
+
size="icon"
|
|
240
|
+
className="absolute right-1 top-1 h-7 w-7"
|
|
241
|
+
aria-label={t("shareDialog.remove")}
|
|
242
|
+
onClick={() => {
|
|
243
|
+
setUploadDataUrl(null);
|
|
244
|
+
if (uploadInputRef.current)
|
|
245
|
+
uploadInputRef.current.value = "";
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<IconX className="h-4 w-4" />
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
207
251
|
) : currentThumbnailUrl ? (
|
|
208
252
|
<img
|
|
209
253
|
src={currentThumbnailUrl}
|
|
@@ -235,7 +279,13 @@ export function ThumbnailPicker({
|
|
|
235
279
|
max={Math.max(1000, durationMs)}
|
|
236
280
|
step={100}
|
|
237
281
|
value={[frameTime]}
|
|
238
|
-
onValueChange={([v]) =>
|
|
282
|
+
onValueChange={([v]) => {
|
|
283
|
+
const next = v ?? 0;
|
|
284
|
+
setFrameTime(next);
|
|
285
|
+
setFrameDataUrl(null);
|
|
286
|
+
seekPreview(videoRef.current, next);
|
|
287
|
+
seekPreview(framePreviewRef.current, next);
|
|
288
|
+
}}
|
|
239
289
|
/>
|
|
240
290
|
<Button
|
|
241
291
|
size="sm"
|
|
@@ -256,6 +306,15 @@ export function ThumbnailPicker({
|
|
|
256
306
|
alt={t("thumbnailPicker.capturedFrame")}
|
|
257
307
|
className="w-full rounded border border-border mt-1"
|
|
258
308
|
/>
|
|
309
|
+
) : videoUrl ? (
|
|
310
|
+
<video
|
|
311
|
+
ref={framePreviewRef}
|
|
312
|
+
src={videoUrl}
|
|
313
|
+
className="w-full rounded border border-border bg-black mt-1"
|
|
314
|
+
crossOrigin="anonymous"
|
|
315
|
+
preload="auto"
|
|
316
|
+
muted
|
|
317
|
+
/>
|
|
259
318
|
) : (
|
|
260
319
|
<div className="w-full aspect-video rounded border border-dashed border-border flex items-center justify-center text-xs text-muted-foreground mt-1">
|
|
261
320
|
{t("thumbnailPicker.capturePreview")}
|
|
@@ -269,6 +328,7 @@ export function ThumbnailPicker({
|
|
|
269
328
|
<div className="grid grid-cols-2 gap-4">
|
|
270
329
|
<div>
|
|
271
330
|
<video
|
|
331
|
+
ref={gifVideoRef}
|
|
272
332
|
src={videoUrl ?? undefined}
|
|
273
333
|
className="w-full rounded border border-border bg-black"
|
|
274
334
|
crossOrigin="anonymous"
|
|
@@ -287,7 +347,13 @@ export function ThumbnailPicker({
|
|
|
287
347
|
max={Math.max(0, durationMs - gifDuration)}
|
|
288
348
|
step={100}
|
|
289
349
|
value={[gifStart]}
|
|
290
|
-
onValueChange={([v]) =>
|
|
350
|
+
onValueChange={([v]) => {
|
|
351
|
+
const next = v ?? 0;
|
|
352
|
+
setGifStart(next);
|
|
353
|
+
setGifDataUrl(null);
|
|
354
|
+
seekPreview(gifVideoRef.current, next);
|
|
355
|
+
seekPreview(gifPreviewRef.current, next);
|
|
356
|
+
}}
|
|
291
357
|
/>
|
|
292
358
|
</div>
|
|
293
359
|
<div>
|
|
@@ -301,7 +367,10 @@ export function ThumbnailPicker({
|
|
|
301
367
|
max={10000}
|
|
302
368
|
step={100}
|
|
303
369
|
value={[gifDuration]}
|
|
304
|
-
onValueChange={([v]) =>
|
|
370
|
+
onValueChange={([v]) => {
|
|
371
|
+
setGifDuration(v ?? 500);
|
|
372
|
+
setGifDataUrl(null);
|
|
373
|
+
}}
|
|
305
374
|
/>
|
|
306
375
|
</div>
|
|
307
376
|
<Button
|
|
@@ -331,6 +400,15 @@ export function ThumbnailPicker({
|
|
|
331
400
|
alt={t("thumbnailPicker.animatedPreview")}
|
|
332
401
|
className="w-full rounded border border-border mt-1"
|
|
333
402
|
/>
|
|
403
|
+
) : videoUrl ? (
|
|
404
|
+
<video
|
|
405
|
+
ref={gifPreviewRef}
|
|
406
|
+
src={videoUrl}
|
|
407
|
+
className="w-full rounded border border-border bg-black mt-1"
|
|
408
|
+
crossOrigin="anonymous"
|
|
409
|
+
preload="auto"
|
|
410
|
+
muted
|
|
411
|
+
/>
|
|
334
412
|
) : (
|
|
335
413
|
<div className="w-full aspect-video rounded border border-dashed border-border flex items-center justify-center text-xs text-muted-foreground mt-1">
|
|
336
414
|
{t("thumbnailPicker.generateGifPreview")}
|
|
@@ -13,6 +13,7 @@ export interface TrimHandlesProps {
|
|
|
13
13
|
/** Fires when the user releases the mouse after drag. */
|
|
14
14
|
onCommit?: (value: { startMs: number; endMs: number }) => void;
|
|
15
15
|
durationMs: number;
|
|
16
|
+
splitPoints?: number[];
|
|
16
17
|
className?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -44,6 +45,7 @@ export function TrimHandles({
|
|
|
44
45
|
onChange,
|
|
45
46
|
onCommit,
|
|
46
47
|
durationMs,
|
|
48
|
+
splitPoints = [],
|
|
47
49
|
className,
|
|
48
50
|
}: TrimHandlesProps) {
|
|
49
51
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -111,6 +113,9 @@ export function TrimHandles({
|
|
|
111
113
|
|
|
112
114
|
const startX = (value.startMs / Math.max(durationMs, 1)) * width;
|
|
113
115
|
const endX = (value.endMs / Math.max(durationMs, 1)) * width;
|
|
116
|
+
const visibleSplitPoints = splitPoints.filter(
|
|
117
|
+
(ms) => ms > value.startMs && ms < value.endMs, // i18n-ignore numeric range predicate, not visible UI copy
|
|
118
|
+
);
|
|
114
119
|
|
|
115
120
|
return (
|
|
116
121
|
<div
|
|
@@ -133,6 +138,16 @@ export function TrimHandles({
|
|
|
133
138
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full bg-foreground text-background text-[10px] px-1.5 py-0.5 rounded font-mono whitespace-nowrap">
|
|
134
139
|
{formatMs(value.endMs - value.startMs)}
|
|
135
140
|
</div>
|
|
141
|
+
{visibleSplitPoints.map((splitMs) => (
|
|
142
|
+
<div
|
|
143
|
+
key={splitMs}
|
|
144
|
+
className="absolute top-0 h-full w-0.5 -translate-x-1/2 bg-rose-500/95"
|
|
145
|
+
style={{
|
|
146
|
+
left: `${((splitMs - value.startMs) / Math.max(value.endMs - value.startMs, 1)) * 100}%`,
|
|
147
|
+
}}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
136
151
|
</div>
|
|
137
152
|
|
|
138
153
|
{/* Left handle */}
|