@ossy/media-tasks 3.17.0 → 3.19.0

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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Embed-resource task (#952).
3
+ *
4
+ * Converts a resource's text content into a dense vector via OpenRouter's
5
+ * embeddings API and stores it for semantic search and clustering.
6
+ *
7
+ * Text source resolution (priority order):
8
+ * 1. Explicit `text` input
9
+ * 2. Markdown `content.body`
10
+ * 3. text/* file bytes (via originalObjectKey)
11
+ * 4. VCD descriptor — `taskArtifacts.visual-content-descriptors.descriptors.body.description`
12
+ * 5. run-prompt completion — `taskArtifacts.run-prompt.completion.body.text`
13
+ *
14
+ * This means image resources get embedded once VCD or run-prompt has run on them.
15
+ */
16
+
17
+ import { MARKDOWN_DOCUMENT_TYPE, isTextContentType } from './run-prompt.js'
18
+
19
+ export { MARKDOWN_DOCUMENT_TYPE, isTextContentType }
20
+
21
+ export const DEFAULT_EMBED_MODEL = 'openai/text-embedding-3-small'
22
+ export const DEFAULT_EMBED_DIMENSIONS = 1536
23
+ /** Soft character ceiling — text-embedding-3-small supports ~8k tokens (~32k chars). */
24
+ export const MAX_EMBED_CHARS = 32_000
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @returns {string}
29
+ */
30
+ export function normalizeEmbedModel (value) {
31
+ const raw = typeof value === 'string' ? value.trim() : ''
32
+ return raw || DEFAULT_EMBED_MODEL
33
+ }
34
+
35
+ /**
36
+ * @param {string} text
37
+ * @returns {{ text: string, truncated: boolean }}
38
+ */
39
+ export function clampEmbedText (text) {
40
+ const t = typeof text === 'string' ? text.trim() : ''
41
+ if (t.length <= MAX_EMBED_CHARS) return { text: t, truncated: false }
42
+ return { text: t.slice(0, MAX_EMBED_CHARS), truncated: true }
43
+ }
44
+
45
+ /**
46
+ * Resolve the text to embed from the resource and optional inputs.
47
+ *
48
+ * @param {{
49
+ * inputText?: unknown,
50
+ * resource?: object | null,
51
+ * fileText?: string | null,
52
+ * }} opts
53
+ * @returns {{ text: string, source: string }}
54
+ */
55
+ export function resolveEmbedText ({ inputText, resource, fileText } = {}) {
56
+ const fromInput = typeof inputText === 'string' ? inputText.trim() : ''
57
+ if (fromInput) return { text: fromInput, source: 'input' }
58
+
59
+ if (resource?.type === MARKDOWN_DOCUMENT_TYPE) {
60
+ const body = resource?.content?.body
61
+ if (typeof body === 'string' && body.trim()) {
62
+ return { text: body.trim(), source: 'markdown-body' }
63
+ }
64
+ }
65
+
66
+ if (typeof fileText === 'string' && fileText.trim()) {
67
+ return { text: fileText.trim(), source: 'file-bytes' }
68
+ }
69
+
70
+ // VCD: description from visual-content-descriptors artifact
71
+ const vcdDescription = resource?.content?.taskArtifacts?.['visual-content-descriptors']?.descriptors?.body?.description
72
+ if (typeof vcdDescription === 'string' && vcdDescription.trim()) {
73
+ return { text: vcdDescription.trim(), source: 'vcd-description' }
74
+ }
75
+
76
+ // run-prompt completion text
77
+ const promptText = resource?.content?.taskArtifacts?.['run-prompt']?.completion?.body?.text
78
+ if (typeof promptText === 'string' && promptText.trim()) {
79
+ return { text: promptText.trim(), source: 'run-prompt-completion' }
80
+ }
81
+
82
+ return { text: '', source: 'none' }
83
+ }
84
+
85
+ /**
86
+ * Call OpenRouter embeddings and return the float vector.
87
+ *
88
+ * @param {{ embeddings: { create: Function } }} client OpenAI-compatible client
89
+ * @param {{ text: string, model?: string }} opts
90
+ * @returns {Promise<{ vector: number[], dimensions: number, inputTokens: number }>}
91
+ */
92
+ export async function createEmbedding (client, { text, model }) {
93
+ if (!text || !String(text).trim()) throw new Error('[embed-resource] text is required')
94
+ const resolvedModel = normalizeEmbedModel(model)
95
+
96
+ const response = await client.embeddings.create({
97
+ model: resolvedModel,
98
+ input: text,
99
+ })
100
+
101
+ const vector = response.data?.[0]?.embedding
102
+ if (!Array.isArray(vector) || !vector.length) {
103
+ throw new Error('[embed-resource] empty embedding returned')
104
+ }
105
+
106
+ return {
107
+ vector,
108
+ dimensions: vector.length,
109
+ inputTokens: response.usage?.prompt_tokens ?? 0,
110
+ }
111
+ }
@@ -0,0 +1,46 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
3
+ export default {
4
+ name: 'Embed resource',
5
+ id: '@ossy/media-tasks/schema/embed-resource',
6
+ categoryName: 'Media processing',
7
+ icon: 'cpu',
8
+ fields: [
9
+ {
10
+ name: 'model',
11
+ type: 'text',
12
+ description: 'Embedding model slug (e.g. openai/text-embedding-3-small)',
13
+ },
14
+ {
15
+ name: 'dimensions',
16
+ type: 'number',
17
+ description: 'Vector dimensionality',
18
+ },
19
+ {
20
+ name: 'textSource',
21
+ type: 'text',
22
+ description: 'Where the embedded text came from: input | markdown-body | file-bytes | vcd-description | run-prompt-completion',
23
+ },
24
+ {
25
+ name: 'textLength',
26
+ type: 'number',
27
+ description: 'Character count of the embedded text (after clamping)',
28
+ },
29
+ {
30
+ name: 'truncated',
31
+ type: 'boolean',
32
+ description: 'True when the text was truncated to the embedding character ceiling',
33
+ },
34
+ {
35
+ name: 'inputTokens',
36
+ type: 'number',
37
+ description: 'Tokens consumed by the embedding call',
38
+ },
39
+ {
40
+ name: 'vectorKey',
41
+ type: 'text',
42
+ description: 'Storage key for the raw embedding JSON artifact',
43
+ },
44
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
45
+ ],
46
+ }
@@ -0,0 +1,179 @@
1
+ import {
2
+ DEFAULT_EMBED_MODEL,
3
+ DEFAULT_EMBED_DIMENSIONS,
4
+ MAX_EMBED_CHARS,
5
+ clampEmbedText,
6
+ normalizeEmbedModel,
7
+ resolveEmbedText,
8
+ createEmbedding,
9
+ } from './embed-resource.js'
10
+ import { MARKDOWN_DOCUMENT_TYPE } from './run-prompt.js'
11
+
12
+ describe('embed-resource helpers', () => {
13
+ describe('normalizeEmbedModel', () => {
14
+ it('returns the model unchanged when valid', () => {
15
+ expect(normalizeEmbedModel('openai/text-embedding-3-small')).toBe('openai/text-embedding-3-small')
16
+ expect(normalizeEmbedModel('openai/text-embedding-3-large')).toBe('openai/text-embedding-3-large')
17
+ })
18
+ it('falls back to default for blank input', () => {
19
+ expect(normalizeEmbedModel('')).toBe(DEFAULT_EMBED_MODEL)
20
+ expect(normalizeEmbedModel(' ')).toBe(DEFAULT_EMBED_MODEL)
21
+ expect(normalizeEmbedModel(undefined)).toBe(DEFAULT_EMBED_MODEL)
22
+ expect(normalizeEmbedModel(null)).toBe(DEFAULT_EMBED_MODEL)
23
+ })
24
+ })
25
+
26
+ describe('clampEmbedText', () => {
27
+ it('does not truncate short text', () => {
28
+ expect(clampEmbedText('hello world')).toEqual({ text: 'hello world', truncated: false })
29
+ })
30
+ it('trims surrounding whitespace', () => {
31
+ expect(clampEmbedText(' hi ')).toEqual({ text: 'hi', truncated: false })
32
+ })
33
+ it('truncates to MAX_EMBED_CHARS', () => {
34
+ const long = 'x'.repeat(MAX_EMBED_CHARS + 100)
35
+ const result = clampEmbedText(long)
36
+ expect(result.truncated).toBe(true)
37
+ expect(result.text).toHaveLength(MAX_EMBED_CHARS)
38
+ })
39
+ it('handles non-string gracefully', () => {
40
+ expect(clampEmbedText(undefined)).toEqual({ text: '', truncated: false })
41
+ expect(clampEmbedText(null)).toEqual({ text: '', truncated: false })
42
+ })
43
+ })
44
+
45
+ describe('resolveEmbedText', () => {
46
+ it('prefers explicit text input', () => {
47
+ const resource = {
48
+ type: MARKDOWN_DOCUMENT_TYPE,
49
+ content: { body: 'md body' },
50
+ }
51
+ expect(resolveEmbedText({ inputText: ' from input ', resource })).toEqual({
52
+ text: 'from input',
53
+ source: 'input',
54
+ })
55
+ })
56
+
57
+ it('uses markdown body for markdown documents', () => {
58
+ const resource = {
59
+ type: MARKDOWN_DOCUMENT_TYPE,
60
+ content: { body: ' # Hello ' },
61
+ }
62
+ expect(resolveEmbedText({ resource })).toEqual({
63
+ text: '# Hello',
64
+ source: 'markdown-body',
65
+ })
66
+ })
67
+
68
+ it('uses file text when resource is not markdown', () => {
69
+ const resource = { type: '@ossy/platform/schema/file', content: { ContentType: 'text/plain' } }
70
+ expect(resolveEmbedText({ resource, fileText: ' file bytes ' })).toEqual({
71
+ text: 'file bytes',
72
+ source: 'file-bytes',
73
+ })
74
+ })
75
+
76
+ it('falls back to VCD description', () => {
77
+ const resource = {
78
+ type: '@ossy/platform/schema/file',
79
+ content: {
80
+ ContentType: 'image/jpeg',
81
+ taskArtifacts: {
82
+ 'visual-content-descriptors': {
83
+ descriptors: { body: { description: 'A sunny beach' } },
84
+ },
85
+ },
86
+ },
87
+ }
88
+ expect(resolveEmbedText({ resource })).toEqual({
89
+ text: 'A sunny beach',
90
+ source: 'vcd-description',
91
+ })
92
+ })
93
+
94
+ it('falls back to run-prompt completion', () => {
95
+ const resource = {
96
+ type: '@ossy/platform/schema/file',
97
+ content: {
98
+ ContentType: 'image/png',
99
+ taskArtifacts: {
100
+ 'run-prompt': {
101
+ completion: { body: { text: 'AI summary of content' } },
102
+ },
103
+ },
104
+ },
105
+ }
106
+ expect(resolveEmbedText({ resource })).toEqual({
107
+ text: 'AI summary of content',
108
+ source: 'run-prompt-completion',
109
+ })
110
+ })
111
+
112
+ it('returns source=none when nothing is found', () => {
113
+ expect(resolveEmbedText({})).toEqual({ text: '', source: 'none' })
114
+ expect(resolveEmbedText({ resource: {} })).toEqual({ text: '', source: 'none' })
115
+ })
116
+ })
117
+ })
118
+
119
+ describe('createEmbedding', () => {
120
+ const FAKE_VECTOR = Array.from({ length: DEFAULT_EMBED_DIMENSIONS }, (_, i) => i / DEFAULT_EMBED_DIMENSIONS)
121
+
122
+ it('calls embeddings.create with the right params and returns vector + metadata', async () => {
123
+ const calls = []
124
+ const client = {
125
+ embeddings: {
126
+ create: async (args) => {
127
+ calls.push(args)
128
+ return {
129
+ data: [{ embedding: FAKE_VECTOR }],
130
+ usage: { prompt_tokens: 42 },
131
+ }
132
+ },
133
+ },
134
+ }
135
+ const result = await createEmbedding(client, {
136
+ text: 'semantic knowledge base',
137
+ model: 'openai/text-embedding-3-small',
138
+ })
139
+ expect(calls[0]).toEqual({
140
+ model: 'openai/text-embedding-3-small',
141
+ input: 'semantic knowledge base',
142
+ })
143
+ expect(result.vector).toBe(FAKE_VECTOR)
144
+ expect(result.dimensions).toBe(DEFAULT_EMBED_DIMENSIONS)
145
+ expect(result.inputTokens).toBe(42)
146
+ })
147
+
148
+ it('uses the default model when none is provided', async () => {
149
+ const calls = []
150
+ const client = {
151
+ embeddings: {
152
+ create: async (args) => {
153
+ calls.push(args)
154
+ return {
155
+ data: [{ embedding: [0.1, 0.2, 0.3] }],
156
+ usage: { prompt_tokens: 3 },
157
+ }
158
+ },
159
+ },
160
+ }
161
+ await createEmbedding(client, { text: 'test' })
162
+ expect(calls[0].model).toBe(DEFAULT_EMBED_MODEL)
163
+ })
164
+
165
+ it('throws on empty text', async () => {
166
+ const client = { embeddings: { create: async () => ({}) } }
167
+ await expect(createEmbedding(client, { text: '' })).rejects.toThrow(/text is required/)
168
+ await expect(createEmbedding(client, { text: ' ' })).rejects.toThrow(/text is required/)
169
+ })
170
+
171
+ it('throws when the API returns an empty embedding', async () => {
172
+ const client = {
173
+ embeddings: {
174
+ create: async () => ({ data: [{ embedding: [] }], usage: { prompt_tokens: 1 } }),
175
+ },
176
+ }
177
+ await expect(createEmbedding(client, { text: 'test' })).rejects.toThrow(/empty embedding/)
178
+ })
179
+ })
@@ -0,0 +1,199 @@
1
+ import { createLogger } from '@ossy/observability'
2
+ import { StorageClient } from '@ossy/platform'
3
+ import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
+ import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
+ import { upsertEmbedding } from '@ossy/platform/vector/vector-store'
6
+ import { loadSourceBuffer } from './load-source-buffer.js'
7
+ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
8
+ import {
9
+ MARKDOWN_DOCUMENT_TYPE,
10
+ isTextContentType,
11
+ markdownDocumentBody,
12
+ } from './run-prompt.js'
13
+ import {
14
+ DEFAULT_EMBED_MODEL,
15
+ MAX_EMBED_CHARS,
16
+ clampEmbedText,
17
+ createEmbedding,
18
+ normalizeEmbedModel,
19
+ resolveEmbedText,
20
+ } from './embed-resource.js'
21
+
22
+ const log = createLogger('media-tasks')
23
+
24
+ const TASK_ID = '@ossy/media-tasks/tasks/embed-resource'
25
+ const SCHEMA_ID = '@ossy/media-tasks/schema/embed-resource'
26
+ const CONCEPT = 'embed-resource'
27
+ const VECTOR_ARTIFACT = 'vector'
28
+ const OUTPUT = {
29
+ name: 'embedding',
30
+ kind: 'location',
31
+ schemaId: SCHEMA_ID,
32
+ contentType: 'application/json',
33
+ }
34
+
35
+ export const metadata = {
36
+ id: TASK_ID,
37
+ triggers: [
38
+ {
39
+ kind: 'on_action',
40
+ action: '@ossy/media-tasks/actions/embed-resource',
41
+ },
42
+ {
43
+ type: MARKDOWN_DOCUMENT_TYPE,
44
+ event: 'Created',
45
+ },
46
+ {
47
+ type: '@ossy/platform/schema/file',
48
+ event: 'Created',
49
+ match: { 'content.ContentType': 'text/*' },
50
+ },
51
+ ],
52
+ outputs: [OUTPUT],
53
+ inputs: [
54
+ {
55
+ name: 'text',
56
+ type: 'text',
57
+ multiline: true,
58
+ sources: ['payload', 'config'],
59
+ description: 'Text to embed. When empty, uses the Markdown body, text file, VCD description, or run-prompt completion.',
60
+ },
61
+ {
62
+ name: 'model',
63
+ type: 'text',
64
+ default: DEFAULT_EMBED_MODEL,
65
+ sources: ['payload', 'config'],
66
+ description: `OpenRouter embedding model slug (default ${DEFAULT_EMBED_MODEL})`,
67
+ },
68
+ ],
69
+ configurable: true,
70
+ }
71
+
72
+ export async function run ({ event, payload, sdk, integrations, inputs }) {
73
+ const openrouter = integrations?.get?.('openrouter')
74
+ if (!openrouter) {
75
+ log.warn('[media-tasks/tasks/embed-resource] openrouter integration not connected')
76
+ throw Object.assign(new Error('OpenRouter integration not available'), { status: 503 })
77
+ }
78
+
79
+ const resourceId = event?.resourceId || payload?.resourceId || null
80
+ log.info(`[media-tasks/tasks/embed-resource] starting${resourceId ? ` for resource ${resourceId}` : ''}`)
81
+
82
+ let resource = null
83
+ let fileText = null
84
+ if (resourceId) {
85
+ resource = await sdk.invoke(GetResource, { resourceId })
86
+ fileText = await loadFileText(resource)
87
+ }
88
+
89
+ const { text: rawText, source: textSource } = resolveEmbedText({
90
+ inputText: inputs?.text,
91
+ resource,
92
+ fileText,
93
+ })
94
+
95
+ if (!rawText) {
96
+ throw Object.assign(
97
+ new Error('[media-tasks/tasks/embed-resource] no text found to embed (provide text input, or ensure resource has Markdown body, text content, VCD, or run-prompt output)'),
98
+ { status: 400 },
99
+ )
100
+ }
101
+
102
+ const { text, truncated } = clampEmbedText(rawText)
103
+ const model = normalizeEmbedModel(inputs?.model)
104
+
105
+ const { vector, dimensions, inputTokens } = await createEmbedding(openrouter, { text, model })
106
+ log.info(`[media-tasks/tasks/embed-resource] embedded ${text.length} chars → ${dimensions}d vector (${inputTokens} tokens, source: ${textSource})`)
107
+
108
+ // Always store in MongoDB vector store when we have a resource.
109
+ if (resource?.id) {
110
+ await upsertEmbedding({
111
+ resourceId: resource.id,
112
+ workspaceId: resource.belongsTo,
113
+ model,
114
+ dimensions,
115
+ vector,
116
+ textSource,
117
+ textLength: text.length,
118
+ })
119
+ log.info(`[media-tasks/tasks/embed-resource] upserted vector for resource ${resource.id}`)
120
+ }
121
+
122
+ if (!resource?.id) {
123
+ return {
124
+ output: OUTPUT.name,
125
+ model,
126
+ dimensions,
127
+ textSource,
128
+ textLength: text.length,
129
+ truncated,
130
+ inputTokens,
131
+ placedResourceIds: [],
132
+ }
133
+ }
134
+
135
+ const body = {
136
+ model,
137
+ dimensions,
138
+ textSource,
139
+ textLength: text.length,
140
+ truncated,
141
+ inputTokens,
142
+ vectorKey: taskArtifactObjectKey(resource.id, CONCEPT, VECTOR_ARTIFACT),
143
+ }
144
+
145
+ // Persist the raw vector as a JSON artifact for potential offline analysis.
146
+ const vectorKey = body.vectorKey
147
+ await StorageClient.save(vectorKey, Buffer.from(JSON.stringify({ vector }), 'utf8'))
148
+ log.info(`[media-tasks/tasks/embed-resource] saved vector artifact ${vectorKey}`)
149
+
150
+ const metaKey = taskArtifactObjectKey(resource.id, CONCEPT, OUTPUT.name)
151
+ await StorageClient.save(metaKey, Buffer.from(JSON.stringify(body), 'utf8'))
152
+ log.info(`[media-tasks/tasks/embed-resource] saved ${metaKey}`)
153
+
154
+ const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
155
+ const placed = await placeTaskArtifacts({
156
+ sdk,
157
+ sourceResource: resource,
158
+ taskId: TASK_ID,
159
+ output: OUTPUT,
160
+ body,
161
+ artifactKey: metaKey,
162
+ placementLocations,
163
+ })
164
+ if (placed.length) {
165
+ log.info(`[media-tasks/tasks/embed-resource] placed ${placed.length} resource(s)`)
166
+ } else {
167
+ log.info('[media-tasks/tasks/embed-resource] artifact only (no placement location configured)')
168
+ }
169
+
170
+ return {
171
+ output: OUTPUT.name,
172
+ key: metaKey,
173
+ vectorKey,
174
+ model,
175
+ dimensions,
176
+ textSource,
177
+ textLength: text.length,
178
+ truncated,
179
+ inputTokens,
180
+ placedResourceIds: placed.map((r) => r.id),
181
+ placedResourceId: placed[0]?.id ?? null,
182
+ }
183
+ }
184
+
185
+ /**
186
+ * @param {object | null | undefined} resource
187
+ * @returns {Promise<string | null>}
188
+ */
189
+ async function loadFileText (resource) {
190
+ if (!resource) return null
191
+ if (resource.type === MARKDOWN_DOCUMENT_TYPE) {
192
+ return markdownDocumentBody(resource) || null
193
+ }
194
+ const contentType = resource?.content?.ContentType || ''
195
+ if (!isTextContentType(contentType)) return null
196
+ const key = originalObjectKey(resource.id)
197
+ const buffer = await loadSourceBuffer(key)
198
+ return buffer ? buffer.toString('utf8') : null
199
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "@ossy/media-tasks/actions/convert-common-audio.label": "Convert audio format",
3
+ "@ossy/media-tasks/actions/convert-common-audio.description": "Convert audio between MP3, AAC, OGG, WAV, AIFF, and FLAC."
4
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/extract-colors',
3
+ access: 'workspace',
4
+ label: 'automation.task.extract-colors.title',
5
+ }
@@ -1,6 +1,6 @@
1
1
  import { createLogger } from '@ossy/observability'
2
2
  import { StorageClient } from '@ossy/platform'
3
- import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
3
+ import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
4
  import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
5
  import { extractColorsFromBuffer } from './extract-colors.js'
6
6
  import { loadSourceBuffer } from './load-source-buffer.js'
@@ -27,22 +27,28 @@ export const metadata = {
27
27
  event: 'Created',
28
28
  match: { 'content.ContentType': 'image/*' },
29
29
  },
30
+ {
31
+ kind: 'on_action',
32
+ action: '@ossy/media-tasks/actions/extract-colors',
33
+ },
30
34
  ],
31
35
  outputs: [OUTPUT],
32
36
  configurable: true,
33
37
  }
34
38
 
35
- export async function run ({ event, sdk }) {
39
+ export async function run ({ event, payload, sdk }) {
36
40
  const { default: sharp } = await import('sharp')
37
- const resourceId = event.resourceId
41
+ const resourceId = event?.resourceId || payload?.resourceId
42
+ if (!resourceId) {
43
+ throw new Error('[media-tasks/tasks/extract-colors] resourceId is required')
44
+ }
38
45
 
39
46
  log.info(`[media-tasks/tasks/extract-colors] starting for resource ${resourceId}`)
40
47
 
41
48
  const resource = await sdk.invoke(GetResource, { resourceId })
42
- const key = resource?.content?.Key
43
- if (!key) {
44
- throw new Error(`[media-tasks/tasks/extract-colors] missing storage key for ${resourceId}`)
45
- }
49
+ // content.Key is stripped by the API media layer (replaced with src URL);
50
+ // derive the storage key deterministically from the resource ID instead.
51
+ const key = originalObjectKey(resource.id)
46
52
 
47
53
  const sourceBuffer = await loadSourceBuffer(key)
48
54
  if (!sourceBuffer) {
@@ -10,7 +10,8 @@ const DEFAULT_APP_TITLE = 'Ossy'
10
10
 
11
11
  /**
12
12
  * Returns an OpenAI-compatible client pointed at OpenRouter.
13
- * Tasks receive this via `integrations.get('openrouter')`.
13
+ * Tasks receive this via `integrations.get('openrouter')` for chat completions
14
+ * and TTS (`audio.speech`).
14
15
  *
15
16
  * @param {{ env: NodeJS.ProcessEnv }} opts
16
17
  * @returns {import('openai').OpenAI}
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/resize-common-web',
3
+ access: 'workspace',
4
+ label: 'automation.task.resize-common-web.title',
5
+ }
@@ -1,6 +1,6 @@
1
1
  import { createLogger } from '@ossy/observability'
2
2
  import { StorageClient } from '@ossy/platform'
3
- import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
3
+ import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
4
  import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
5
  import { encodeBlurhashFromBuffer } from './encode-blurhash.js'
6
6
  import { loadSourceBuffer } from './load-source-buffer.js'
@@ -23,8 +23,8 @@ const Sizes = [
23
23
  { width: 48, height: 48, name: 'thumbnailLarge' },
24
24
  { width: 274, height: undefined, name: 'galleryMedium' },
25
25
  { width: 548, height: undefined, name: 'galleryLarge' },
26
- { width: 4, height: undefined, name: 'loader-square-blurred-before' },
27
- { width: 4, height: undefined, name: 'loader-square-blurred-after' },
26
+ // Matches ImageResource / detail maxWidth (960) so the browser downscales instead of upscales.
27
+ { width: 960, height: undefined, name: 'galleryXLarge' },
28
28
  ]
29
29
 
30
30
  export const metadata = {
@@ -35,22 +35,28 @@ export const metadata = {
35
35
  event: 'Created',
36
36
  match: { 'content.ContentType': 'image/*' },
37
37
  },
38
+ {
39
+ kind: 'on_action',
40
+ action: '@ossy/media-tasks/actions/resize-common-web',
41
+ },
38
42
  ],
39
43
  outputs: [OUTPUT],
40
44
  configurable: true,
41
45
  }
42
46
 
43
- export async function run ({ event, sdk }) {
47
+ export async function run ({ event, payload, sdk }) {
44
48
  const { default: sharp } = await import('sharp')
45
- const resourceId = event.resourceId
49
+ const resourceId = event?.resourceId || payload?.resourceId
50
+ if (!resourceId) {
51
+ throw new Error('[media-tasks/tasks/resize-common-web] resourceId is required')
52
+ }
46
53
 
47
54
  log.info(`[media-tasks/tasks/resize-common-web] starting for resource ${resourceId}`)
48
55
 
49
56
  const resource = await sdk.invoke(GetResource, { resourceId })
50
- const key = resource?.content?.Key
51
- if (!key) {
52
- throw new Error(`[media-tasks/tasks/resize-common-web] missing storage key for ${resourceId}`)
53
- }
57
+ // content.Key is stripped by the API media layer (replaced with src URL);
58
+ // derive the storage key deterministically from the resource ID instead.
59
+ const key = originalObjectKey(resource.id)
54
60
 
55
61
  const sourceBuffer = await loadSourceBuffer(key)
56
62
  if (!sourceBuffer) {
@@ -53,6 +53,12 @@ export const metadata = {
53
53
  type: MARKDOWN_DOCUMENT_TYPE,
54
54
  event: 'Created',
55
55
  },
56
+ // Ceiling for workspace on_action authoring / ADR 0003 primary-action parity.
57
+ // Storage → Tasks still omits this task until a prompt UI exists (required input).
58
+ {
59
+ kind: 'on_action',
60
+ action: '@ossy/media-tasks/actions/run-prompt',
61
+ },
56
62
  ],
57
63
  outputs: [OUTPUT],
58
64
  inputs: [
@@ -0,0 +1,4 @@
1
+ {
2
+ "@ossy/media-tasks/actions/convert-common-audio.label": "Konvertera ljudformat",
3
+ "@ossy/media-tasks/actions/convert-common-audio.description": "Konvertera ljud mellan MP3, AAC, OGG, WAV, AIFF och FLAC."
4
+ }