@ossy/media-tasks 3.14.0 → 3.16.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.
@@ -2,19 +2,52 @@ export default {
2
2
  name: 'Visual Content Descriptors',
3
3
  id: '@ossy/media-tasks/schema/visual-content-descriptors',
4
4
  categoryName: 'Media processing',
5
- icon: 'attribution',
5
+ icon: 'details-more',
6
6
  fields: [
7
7
  {
8
- name: 'resourceId',
8
+ name: 'title',
9
9
  type: 'text',
10
+ description: 'Suggested resource name',
10
11
  },
11
12
  {
12
- name: 'status',
13
- type: 'text',
13
+ name: 'description',
14
+ type: 'textarea',
15
+ description: 'Suggested SEO description',
14
16
  },
15
17
  {
16
- name: 'result',
18
+ name: 'tags',
17
19
  type: 'textarea',
20
+ description: 'Suggested tags',
21
+ },
22
+ {
23
+ name: 'alt',
24
+ type: 'text',
25
+ description: 'Suggested alt text',
26
+ },
27
+ {
28
+ name: 'derivedFrom',
29
+ type: 'text',
30
+ description: 'Source resource id this output was derived from',
31
+ },
32
+ {
33
+ name: 'taskId',
34
+ type: 'text',
35
+ description: 'Task that produced this resource',
36
+ },
37
+ {
38
+ name: 'artifactName',
39
+ type: 'text',
40
+ description: 'Named task output',
41
+ },
42
+ {
43
+ name: 'artifactKey',
44
+ type: 'text',
45
+ description: 'Storage key for the artifact bytes/JSON',
46
+ },
47
+ {
48
+ name: 'artifactHref',
49
+ type: 'text',
50
+ description: 'Stable /r/…/tasks/… read path',
18
51
  },
19
52
  ],
20
53
  }
@@ -0,0 +1,105 @@
1
+ import {
2
+ DEFAULT_VCD_MODEL,
3
+ getVisualContentDescriptors,
4
+ toImageDataUrl,
5
+ } from './visual-content-descriptors.js'
6
+
7
+ describe('toImageDataUrl', () => {
8
+ it('encodes buffer as a data URL with mime type', () => {
9
+ const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47])
10
+ expect(toImageDataUrl(buf, 'image/png')).toBe(
11
+ `data:image/png;base64,${buf.toString('base64')}`,
12
+ )
13
+ })
14
+
15
+ it('rejects empty buffers', () => {
16
+ expect(() => toImageDataUrl(Buffer.alloc(0))).toThrow(/image buffer/)
17
+ })
18
+ })
19
+
20
+ describe('getVisualContentDescriptors', () => {
21
+ it('calls chat.completions once with json_object and OpenRouter model slug', async () => {
22
+ const calls = []
23
+ const client = {
24
+ chat: {
25
+ completions: {
26
+ create: async (args) => {
27
+ calls.push(args)
28
+ return {
29
+ choices: [{
30
+ message: {
31
+ content: JSON.stringify({
32
+ title: 'Sunset',
33
+ description: 'Orange sky over water',
34
+ tags: ['sunset', 'nature'],
35
+ alt: 'Sunset over water',
36
+ }),
37
+ },
38
+ }],
39
+ }
40
+ },
41
+ },
42
+ },
43
+ }
44
+
45
+ const dataUrl = 'data:image/jpeg;base64,abc'
46
+ await expect(getVisualContentDescriptors(client, dataUrl))
47
+ .resolves.toEqual({
48
+ title: 'Sunset',
49
+ description: 'Orange sky over water',
50
+ tags: ['sunset', 'nature'],
51
+ alt: 'Sunset over water',
52
+ })
53
+
54
+ expect(calls).toHaveLength(1)
55
+ expect(calls[0]).toMatchObject({
56
+ model: DEFAULT_VCD_MODEL,
57
+ response_format: { type: 'json_object' },
58
+ messages: [
59
+ { role: 'system', content: expect.any(String) },
60
+ {
61
+ role: 'user',
62
+ content: [{ type: 'image_url', image_url: { url: dataUrl } }],
63
+ },
64
+ ],
65
+ })
66
+ })
67
+
68
+ it('normalizes missing fields and non-string tags', async () => {
69
+ const client = {
70
+ chat: {
71
+ completions: {
72
+ create: async () => ({
73
+ choices: [{ message: { content: '{"title":1,"tags":["ok",2]}' } }],
74
+ }),
75
+ },
76
+ },
77
+ }
78
+ await expect(getVisualContentDescriptors(client, 'data:image/png;base64,x'))
79
+ .resolves.toEqual({
80
+ title: null,
81
+ description: null,
82
+ tags: ['ok'],
83
+ alt: null,
84
+ })
85
+ })
86
+
87
+ it('rejects missing client or imageUrl', async () => {
88
+ await expect(getVisualContentDescriptors(null, 'data:image/png;base64,x'))
89
+ .rejects.toThrow(/openrouter client/)
90
+ await expect(getVisualContentDescriptors({ chat: { completions: { create: async () => ({}) } } }, ''))
91
+ .rejects.toThrow(/imageUrl/)
92
+ })
93
+
94
+ it('rejects non-JSON model content', async () => {
95
+ const client = {
96
+ chat: {
97
+ completions: {
98
+ create: async () => ({ choices: [{ message: { content: 'not-json' } }] }),
99
+ },
100
+ },
101
+ }
102
+ await expect(getVisualContentDescriptors(client, 'data:image/png;base64,x'))
103
+ .rejects.toThrow(/non-JSON/)
104
+ })
105
+ })
@@ -1,8 +1,29 @@
1
- import { GetResource, UpdateResourceName, UpdateResourceContent } from '@ossy/resources'
2
- import { OpenAi } from './openai.integration.js'
1
+ import { createLogger } from '@ossy/observability'
2
+ import { StorageClient } from '@ossy/platform'
3
+ import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
+ import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
+ import { loadSourceBuffer } from './load-source-buffer.js'
6
+ import {
7
+ DEFAULT_VCD_MODEL,
8
+ getVisualContentDescriptors,
9
+ toImageDataUrl,
10
+ } from './visual-content-descriptors.js'
11
+ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
12
+
13
+ const log = createLogger('media-tasks')
14
+
15
+ const TASK_ID = '@ossy/media-tasks/tasks/visual-content-descriptors'
16
+ const SCHEMA_ID = '@ossy/media-tasks/schema/visual-content-descriptors'
17
+ const CONCEPT = 'visual-content-descriptors'
18
+ const OUTPUT = {
19
+ name: 'descriptors',
20
+ kind: 'location',
21
+ schemaId: SCHEMA_ID,
22
+ contentType: 'application/json',
23
+ }
3
24
 
4
25
  export const metadata = {
5
- id: '@ossy/media-tasks/tasks/visual-content-descriptors',
26
+ id: TASK_ID,
6
27
  triggers: [
7
28
  {
8
29
  type: '@ossy/platform/schema/file',
@@ -15,20 +36,77 @@ export const metadata = {
15
36
  match: { 'content.ContentType': 'video/*' },
16
37
  },
17
38
  ],
39
+ outputs: [OUTPUT],
40
+ inputs: [
41
+ {
42
+ name: 'model',
43
+ type: 'text',
44
+ default: DEFAULT_VCD_MODEL,
45
+ sources: ['payload', 'config'],
46
+ description: 'OpenRouter model slug',
47
+ },
48
+ ],
49
+ configurable: true,
18
50
  }
19
51
 
20
- export async function run ({ event, sdk }) {
52
+ export async function run ({ event, sdk, integrations, inputs }) {
21
53
  const resourceId = event.resourceId
22
54
 
55
+ log.info(`[media-tasks/tasks/visual-content-descriptors] starting for resource ${resourceId}`)
56
+
57
+ const openrouter = integrations?.get?.('openrouter')
58
+ if (!openrouter) {
59
+ log.warn('[media-tasks/tasks/visual-content-descriptors] openrouter integration not connected')
60
+ throw Object.assign(new Error('OpenRouter integration not available'), { status: 503 })
61
+ }
62
+
23
63
  const resource = await sdk.invoke(GetResource, { resourceId })
24
- const visualContentDescriptors = await OpenAi.getVisualContentDescriptors(resource.content.src)
25
-
26
- await sdk.invoke(UpdateResourceName, { id: resource.id, name: visualContentDescriptors.title })
27
- await sdk.invoke(UpdateResourceContent, {
28
- id: resource.id,
29
- content: {
30
- ...resource.content,
31
- ...visualContentDescriptors,
32
- },
64
+ const key = resource?.content?.Key
65
+ if (!key) {
66
+ throw new Error(`[media-tasks/tasks/visual-content-descriptors] missing storage key for ${resourceId}`)
67
+ }
68
+
69
+ const contentType = resource?.content?.ContentType || 'application/octet-stream'
70
+ if (!String(contentType).startsWith('image/')) {
71
+ throw new Error(
72
+ `[media-tasks/tasks/visual-content-descriptors] unsupported ContentType ${contentType} for ${resourceId}`,
73
+ )
74
+ }
75
+
76
+ const sourceBuffer = await loadSourceBuffer(key)
77
+ if (!sourceBuffer) {
78
+ throw new Error(`[media-tasks/tasks/visual-content-descriptors] missing file bytes for ${resourceId}`)
79
+ }
80
+
81
+ // Inline bytes — OpenRouter cannot fetch localhost / auth-gated storage URLs.
82
+ const imageUrl = toImageDataUrl(sourceBuffer, contentType)
83
+ const body = await getVisualContentDescriptors(openrouter, imageUrl, {
84
+ model: inputs?.model,
85
+ })
86
+ const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
87
+ await StorageClient.save(objectKey, Buffer.from(JSON.stringify(body), 'utf8'))
88
+ log.info(`[media-tasks/tasks/visual-content-descriptors] saved ${objectKey}`)
89
+
90
+ const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
91
+ const placed = await placeTaskArtifacts({
92
+ sdk,
93
+ sourceResource: resource,
94
+ taskId: TASK_ID,
95
+ output: OUTPUT,
96
+ body,
97
+ artifactKey: objectKey,
98
+ placementLocations,
33
99
  })
100
+ if (placed.length) {
101
+ log.info(`[media-tasks/tasks/visual-content-descriptors] placed ${placed.length} resource(s)`)
102
+ } else {
103
+ log.info(`[media-tasks/tasks/visual-content-descriptors] artifact only (no placement location configured)`)
104
+ }
105
+ return {
106
+ output: OUTPUT.name,
107
+ key: objectKey,
108
+ descriptors: body,
109
+ placedResourceIds: placed.map((r) => r.id),
110
+ placedResourceId: placed[0]?.id ?? null,
111
+ }
34
112
  }
@@ -1,74 +0,0 @@
1
- import OpenAiSDK from 'openai'
2
-
3
- export const id = 'openai'
4
-
5
- export const credentials = ['OPENAI_API_KEY']
6
-
7
- /**
8
- * Returns an OpenAI client configured from env vars.
9
- * Tasks receive this via `integrations.get('openai')`.
10
- *
11
- * @param {{ env: NodeJS.ProcessEnv }} opts
12
- * @returns {import('openai').OpenAI}
13
- */
14
- export async function connect({ env }) {
15
- return new OpenAiSDK({
16
- apiKey: env.OPENAI_API_KEY,
17
- organization: env.OPENAI_ORGANIZATION,
18
- })
19
- }
20
-
21
- let _openai = null
22
- function getOpenAi() {
23
- if (!_openai) _openai = new OpenAiSDK({
24
- apiKey: process.env.OPENAI_API_KEY,
25
- organization: process.env.OPENAI_ORGANIZATION,
26
- })
27
- return _openai
28
- }
29
-
30
- export class OpenAi {
31
- static async getVisualContentDescriptors(imageSrc) {
32
- const systemPrompt = `
33
- You will be provided with images, and your task is to create a json object including the following properties:
34
- -title: Short attention grabbing title that describes the image
35
- -description: A short text describing the image, suitable for SEO purposes
36
- -tags: an array of tags describing the image that will be used for categorization and SEO purposes
37
- -alt: A short text that describes the image, suitable for alt text
38
- `
39
-
40
- const response = await getOpenAi().chat.completions.create({
41
- model: 'gpt-4o-mini',
42
- max_tokens: 4000,
43
- messages: [
44
- { role: 'system', content: systemPrompt },
45
- { role: 'user', content: [{ type: 'image_url', image_url: { url: imageSrc } }] },
46
- ],
47
- })
48
-
49
- const parsedResponse = await OpenAi.ensureJSONResponse(response)
50
- const visualDescriptors = JSON.parse(
51
- parsedResponse?.choices?.[0]?.message?.content ?? '{}'
52
- )
53
- return visualDescriptors
54
- }
55
-
56
- static ensureJSONResponse(response) {
57
- const systemPrompt = `
58
- You will be recieving a text string that contains a json object.
59
- Your task is to parse the text string and convert the response into a json object.
60
- `
61
-
62
- const textString = response?.choices?.[0]?.message?.content
63
-
64
- return getOpenAi().chat.completions.create({
65
- model: 'gpt-4-turbo-preview',
66
- max_tokens: 4000,
67
- response_format: { type: 'json_object' },
68
- messages: [
69
- { role: 'system', content: systemPrompt },
70
- { role: 'user', content: textString ?? '' },
71
- ],
72
- })
73
- }
74
- }