@ossy/media-tasks 3.15.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.
- package/README.md +37 -24
- package/package.json +4 -4
- package/src/extract-colors.js +71 -0
- package/src/extract-colors.schema.js +43 -0
- package/src/extract-colors.spec.js +44 -0
- package/src/extract-colors.task.js +79 -0
- package/src/load-source-buffer.js +17 -0
- package/src/openrouter.integration.js +30 -0
- package/src/openrouter.integration.spec.js +37 -0
- package/src/resize-common-web.schema.js +38 -5
- package/src/resize-common-web.task.js +59 -31
- package/src/resolve-configured-placement.js +44 -0
- package/src/resolve-configured-placement.spec.js +58 -0
- package/src/run-prompt.action.js +5 -0
- package/src/run-prompt.js +123 -0
- package/src/run-prompt.schema.js +48 -0
- package/src/run-prompt.spec.js +117 -0
- package/src/run-prompt.task.js +187 -0
- package/src/visual-content-descriptors.js +70 -0
- package/src/visual-content-descriptors.schema.js +38 -5
- package/src/visual-content-descriptors.spec.js +105 -0
- package/src/visual-content-descriptors.task.js +91 -13
- package/src/openai.integration.js +0 -74
package/README.md
CHANGED
|
@@ -6,27 +6,32 @@ Tasks in this package are registered with `TaskService.RegisterTask()` at API st
|
|
|
6
6
|
|
|
7
7
|
## Tasks
|
|
8
8
|
|
|
9
|
-
| Task id |
|
|
10
|
-
|
|
11
|
-
| `@ossy/media-tasks/tasks/resize-common-web` |
|
|
12
|
-
| `@ossy/media-tasks/tasks/
|
|
9
|
+
| Task id | Definition trigger (ceiling) | Description |
|
|
10
|
+
|---------|------------------------------|-------------|
|
|
11
|
+
| `@ossy/media-tasks/tasks/resize-common-web` | file `Created` + `image/*` | Common web sizes + BlurHash → storage keys under the source id |
|
|
12
|
+
| `@ossy/media-tasks/tasks/extract-colors` | file `Created` + `image/*` | Dominant color + palette JSON → storage |
|
|
13
|
+
| `@ossy/media-tasks/tasks/visual-content-descriptors` | file `Created` + `image/*` / `video/*` | OpenRouter vision → title/description/tags/alt JSON → storage. Input `model` (config or payload); default `openai/gpt-4o-mini`, overridable per trigger. |
|
|
14
|
+
| `@ossy/media-tasks/tasks/run-prompt` | file `Created` + `text/*` / `image/*` / `application/json`, or `@ossy/resources/schema/markdown` `Created` | OpenRouter chat completion. Inputs `prompt` (required), `model` (default `openai/gpt-4o-mini`), optional `temperature`. File bytes or the Markdown document `body` are the context. |
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
All four set **`configurable: true`**. Workspaces arm them on `/automation` with their own `triggers[]` (within the ceiling above) and optional placement folders. Until armed, they do not run.
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
## Task outputs
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
1. **Artifact** — storage key `{resourceId}:task-{concept}-{outputName}`; read via `GET /r/:id/tasks/:concept/:name`
|
|
21
|
+
2. **Placement** — `placeTaskArtifacts` creates a document **only when** `outputs[].prefix` or workspace `placementLocations` are set (no default folder)
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
| Task | Output | Placed schema |
|
|
24
|
+
|------|--------|---------------|
|
|
25
|
+
| extract-colors | `palette` | `@ossy/media-tasks/schema/extract-colors` |
|
|
26
|
+
| resize-common-web | `sizes` (+ binary keys) | `@ossy/media-tasks/schema/resize-common-web` (JSON meta placed; binaries via URL) |
|
|
27
|
+
| visual-content-descriptors | `descriptors` | `@ossy/media-tasks/schema/visual-content-descriptors` |
|
|
28
|
+
| run-prompt | `completion` | `@ossy/media-tasks/schema/run-prompt` |
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|--------|----|
|
|
26
|
-
| Resize Common Web | `@ossy/media-tasks/schema/resize-common-web` |
|
|
27
|
-
| Visual Content Descriptors | `@ossy/media-tasks/schema/visual-content-descriptors` |
|
|
32
|
+
## Image placeholders (BlurHash)
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
`resize-common-web` stores BlurHash + dimensions on the **`sizes`** JSON artifact. Consumers use `resizeImageMeta` / `resizeArtifactSrc` from `@ossy/resources` after media URL attach.
|
|
30
35
|
|
|
31
36
|
## Task contract
|
|
32
37
|
|
|
@@ -36,21 +41,29 @@ Each `*.task.js` file exports:
|
|
|
36
41
|
export const metadata = {
|
|
37
42
|
id: '@ossy/media-tasks/tasks/my-task',
|
|
38
43
|
triggers: [{ type, event, match?, location? }],
|
|
44
|
+
inputs: [{ name: 'model', type: 'text', default: 'openai/gpt-4o-mini', sources: ['payload', 'config'] }],
|
|
45
|
+
outputs: [{ name, kind: 'location', schemaId, contentType }],
|
|
46
|
+
configurable: true,
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
export async function run ({ event, sdk }) { ... }
|
|
49
|
+
export async function run ({ event, sdk, inputs }) { ... }
|
|
42
50
|
```
|
|
43
51
|
|
|
44
|
-
- `
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
`configurable: true` opts the task into Automation UI workspace triggers, input defaults, per-trigger overrides, and output placement. Omit it for system tasks. Definition `triggers` are the capability ceiling; workspaces author their own `triggers[]`. Each trigger’s `config` overrides instance `config` for that match only. `run` reads the resolved values from `inputs`.
|
|
53
|
+
|
|
54
|
+
## Integrations
|
|
55
|
+
|
|
56
|
+
| Id | Credentials | Used by |
|
|
57
|
+
|----|-------------|---------|
|
|
58
|
+
| `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors` and `run-prompt` via `integrations.get('openrouter')` |
|
|
59
|
+
|
|
60
|
+
OpenRouter is OpenAI-compatible (`baseURL` `https://openrouter.ai/api/v1`). The VCD `model` input defaults to `openai/gpt-4o-mini`. A workspace can set another slug as the instance default, and a different slug on a single trigger (folder or MIME). An invoke payload still wins.
|
|
61
|
+
|
|
62
|
+
`run-prompt` sends the resolved prompt as the user message. A matching file is appended as context (text/JSON excerpt, or an image data URL). A Markdown document (`@ossy/resources/schema/markdown`) sends `content.body` instead. The completion is stored at `{resourceId}:task-run-prompt-completion` and placed only when the workspace set a folder. **Run now** invokes `@ossy/media-tasks/actions/run-prompt` with the editor values; without a resource id the completion is returned on the task run and not stored.
|
|
50
63
|
|
|
51
64
|
## Dependencies
|
|
52
65
|
|
|
53
|
-
- **`sharp`** (peer) — image resizing + metadata
|
|
66
|
+
- **`sharp`** (peer) — image resizing + metadata
|
|
54
67
|
- **`blurhash`** — encode compact placeholders after resize
|
|
55
|
-
- **`openai`** (peer) —
|
|
56
|
-
- **`@ossy/platform`** — `StorageClient
|
|
68
|
+
- **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter
|
|
69
|
+
- **`@ossy/platform`** — `StorageClient` for reading/writing file bytes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/media-tasks",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Changestream-triggered media processing tasks (image resize, AI descriptors)",
|
|
3
|
+
"version": "3.16.0",
|
|
4
|
+
"description": "Changestream-triggered media processing tasks (image resize, AI descriptors, prompts)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@ossy/observability": "^3.0.9",
|
|
20
|
-
"@ossy/resources": "^3.
|
|
20
|
+
"@ossy/resources": "^3.16.0",
|
|
21
21
|
"blurhash": "^2.0.5"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
],
|
|
35
35
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
36
36
|
"license": "MIT",
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "1872fb1467bf656284aae35881c34960c3860807"
|
|
38
38
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {number} r
|
|
3
|
+
* @param {number} g
|
|
4
|
+
* @param {number} b
|
|
5
|
+
*/
|
|
6
|
+
function toHex (r, g, b) {
|
|
7
|
+
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Expand a 4-bit channel (0–15) to 0–255. */
|
|
11
|
+
function expandNibble (nibble) {
|
|
12
|
+
return nibble * 17
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract a small palette from an image buffer via sharp (resize + 4-bit RGB buckets).
|
|
17
|
+
*
|
|
18
|
+
* @param {import('sharp')} sharp
|
|
19
|
+
* @param {Buffer} sourceBuffer
|
|
20
|
+
* @param {{ maxColors?: number, sampleSize?: number }} [options]
|
|
21
|
+
* @returns {Promise<{ dominant: string | null, colors: Array<{ hex: string, ratio: number }> }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function extractColorsFromBuffer (sharp, sourceBuffer, options = {}) {
|
|
24
|
+
const maxColors = options.maxColors ?? 5
|
|
25
|
+
const sampleSize = options.sampleSize ?? 64
|
|
26
|
+
|
|
27
|
+
const { data, info } = await sharp(sourceBuffer)
|
|
28
|
+
.ensureAlpha()
|
|
29
|
+
.resize(sampleSize, sampleSize, { fit: 'inside' })
|
|
30
|
+
.raw()
|
|
31
|
+
.toBuffer({ resolveWithObject: true })
|
|
32
|
+
|
|
33
|
+
const channels = info.channels
|
|
34
|
+
/** @type {Map<number, number>} */
|
|
35
|
+
const buckets = new Map()
|
|
36
|
+
let visible = 0
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < data.length; i += channels) {
|
|
39
|
+
const a = channels === 4 ? data[i + 3] : 255
|
|
40
|
+
if (a < 128) continue
|
|
41
|
+
visible += 1
|
|
42
|
+
const r = data[i] >> 4
|
|
43
|
+
const g = data[i + 1] >> 4
|
|
44
|
+
const b = data[i + 2] >> 4
|
|
45
|
+
const key = (r << 8) | (g << 4) | b
|
|
46
|
+
buckets.set(key, (buckets.get(key) || 0) + 1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (visible === 0) {
|
|
50
|
+
return { dominant: null, colors: [] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ranked = [...buckets.entries()]
|
|
54
|
+
.sort((a, b) => b[1] - a[1])
|
|
55
|
+
.slice(0, maxColors)
|
|
56
|
+
|
|
57
|
+
const colors = ranked.map(([key, count]) => {
|
|
58
|
+
const r = expandNibble((key >> 8) & 0xf)
|
|
59
|
+
const g = expandNibble((key >> 4) & 0xf)
|
|
60
|
+
const b = expandNibble(key & 0xf)
|
|
61
|
+
return {
|
|
62
|
+
hex: toHex(r, g, b),
|
|
63
|
+
ratio: Math.round((count / visible) * 1000) / 1000,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
dominant: colors[0]?.hex ?? null,
|
|
69
|
+
colors,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: 'Extract Colors',
|
|
3
|
+
id: '@ossy/media-tasks/schema/extract-colors',
|
|
4
|
+
categoryName: 'Media processing',
|
|
5
|
+
icon: 'color-picker',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
name: 'dominant',
|
|
9
|
+
type: 'text',
|
|
10
|
+
description: 'Dominant hex color',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'colors',
|
|
14
|
+
type: 'textarea',
|
|
15
|
+
description: 'Palette entries as JSON ({ hex, ratio }[])',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'derivedFrom',
|
|
19
|
+
type: 'text',
|
|
20
|
+
description: 'Source resource id this output was derived from',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'taskId',
|
|
24
|
+
type: 'text',
|
|
25
|
+
description: 'Task that produced this resource',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'artifactName',
|
|
29
|
+
type: 'text',
|
|
30
|
+
description: 'Named task output',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'artifactKey',
|
|
34
|
+
type: 'text',
|
|
35
|
+
description: 'Storage key for the artifact bytes/JSON',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'artifactHref',
|
|
39
|
+
type: 'text',
|
|
40
|
+
description: 'Stable /r/…/tasks/… read path',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
2
|
+
import { extractColorsFromBuffer } from './extract-colors.js'
|
|
3
|
+
|
|
4
|
+
describe('extractColorsFromBuffer', () => {
|
|
5
|
+
it('returns the dominant color for a solid image', async () => {
|
|
6
|
+
const sourceBuffer = await sharp({
|
|
7
|
+
create: {
|
|
8
|
+
width: 40,
|
|
9
|
+
height: 40,
|
|
10
|
+
channels: 3,
|
|
11
|
+
background: { r: 20, g: 120, b: 200 },
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
.png()
|
|
15
|
+
.toBuffer()
|
|
16
|
+
|
|
17
|
+
const result = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
18
|
+
|
|
19
|
+
expect(result.dominant).toMatch(/^#[0-9a-f]{6}$/)
|
|
20
|
+
expect(result.colors.length).toBeGreaterThanOrEqual(1)
|
|
21
|
+
expect(result.colors[0].hex).toBe(result.dominant)
|
|
22
|
+
expect(result.colors[0].ratio).toBeGreaterThan(0.9)
|
|
23
|
+
|
|
24
|
+
// 20→17, 120→119, 200→204 after 4-bit quantization (*17)
|
|
25
|
+
expect(result.dominant).toBe('#1177cc')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('ignores fully transparent pixels', async () => {
|
|
29
|
+
const sourceBuffer = await sharp({
|
|
30
|
+
create: {
|
|
31
|
+
width: 16,
|
|
32
|
+
height: 16,
|
|
33
|
+
channels: 4,
|
|
34
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.png()
|
|
38
|
+
.toBuffer()
|
|
39
|
+
|
|
40
|
+
const result = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
41
|
+
expect(result.dominant).toBeNull()
|
|
42
|
+
expect(result.colors).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { extractColorsFromBuffer } from './extract-colors.js'
|
|
6
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
7
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
8
|
+
|
|
9
|
+
const log = createLogger('media-tasks')
|
|
10
|
+
|
|
11
|
+
const TASK_ID = '@ossy/media-tasks/tasks/extract-colors'
|
|
12
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/extract-colors'
|
|
13
|
+
const CONCEPT = 'extract-colors'
|
|
14
|
+
const OUTPUT = {
|
|
15
|
+
name: 'palette',
|
|
16
|
+
kind: 'location',
|
|
17
|
+
schemaId: SCHEMA_ID,
|
|
18
|
+
contentType: 'application/json',
|
|
19
|
+
// prefix omitted — place only when workspace config supplies a location
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const metadata = {
|
|
23
|
+
id: TASK_ID,
|
|
24
|
+
triggers: [
|
|
25
|
+
{
|
|
26
|
+
type: '@ossy/platform/schema/file',
|
|
27
|
+
event: 'Created',
|
|
28
|
+
match: { 'content.ContentType': 'image/*' },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
outputs: [OUTPUT],
|
|
32
|
+
configurable: true,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function run ({ event, sdk }) {
|
|
36
|
+
const { default: sharp } = await import('sharp')
|
|
37
|
+
const resourceId = event.resourceId
|
|
38
|
+
|
|
39
|
+
log.info(`[media-tasks/tasks/extract-colors] starting for resource ${resourceId}`)
|
|
40
|
+
|
|
41
|
+
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
|
+
}
|
|
46
|
+
|
|
47
|
+
const sourceBuffer = await loadSourceBuffer(key)
|
|
48
|
+
if (!sourceBuffer) {
|
|
49
|
+
throw new Error(`[media-tasks/tasks/extract-colors] missing file bytes for ${resourceId}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const palette = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
53
|
+
const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
|
|
54
|
+
await StorageClient.save(objectKey, Buffer.from(JSON.stringify(palette), 'utf8'))
|
|
55
|
+
log.info(`[media-tasks/tasks/extract-colors] saved ${objectKey} dominant=${palette.dominant}`)
|
|
56
|
+
|
|
57
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
58
|
+
const placed = await placeTaskArtifacts({
|
|
59
|
+
sdk,
|
|
60
|
+
sourceResource: resource,
|
|
61
|
+
taskId: TASK_ID,
|
|
62
|
+
output: OUTPUT,
|
|
63
|
+
body: palette,
|
|
64
|
+
artifactKey: objectKey,
|
|
65
|
+
placementLocations,
|
|
66
|
+
})
|
|
67
|
+
if (placed.length) {
|
|
68
|
+
log.info(`[media-tasks/tasks/extract-colors] placed ${placed.length} resource(s): ${placed.map((r) => r.id).join(', ')}`)
|
|
69
|
+
} else {
|
|
70
|
+
log.info(`[media-tasks/tasks/extract-colors] artifact only (no placement location configured)`)
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
output: OUTPUT.name,
|
|
74
|
+
key: objectKey,
|
|
75
|
+
placedResourceIds: placed.map((r) => r.id),
|
|
76
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
77
|
+
dominant: palette.dominant,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StorageClient } from '@ossy/platform'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Created fires before the client finishes PUT; wait briefly for blob bytes.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} key
|
|
7
|
+
* @param {{ attempts?: number, delayMs?: number }} [options]
|
|
8
|
+
* @returns {Promise<Buffer | null>}
|
|
9
|
+
*/
|
|
10
|
+
export async function loadSourceBuffer (key, { attempts = 20, delayMs = 250 } = {}) {
|
|
11
|
+
for (let i = 0; i < attempts; i++) {
|
|
12
|
+
const { buffer, exists } = await StorageClient.load(key)
|
|
13
|
+
if (exists && buffer) return buffer
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import OpenAiSDK from 'openai'
|
|
2
|
+
|
|
3
|
+
export const id = 'openrouter'
|
|
4
|
+
|
|
5
|
+
export const credentials = ['OPENROUTER_API_KEY']
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1'
|
|
8
|
+
const DEFAULT_SITE_URL = 'https://ossy.se'
|
|
9
|
+
const DEFAULT_APP_TITLE = 'Ossy'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns an OpenAI-compatible client pointed at OpenRouter.
|
|
13
|
+
* Tasks receive this via `integrations.get('openrouter')`.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
16
|
+
* @returns {import('openai').OpenAI}
|
|
17
|
+
*/
|
|
18
|
+
export async function connect ({ env }) {
|
|
19
|
+
const siteUrl = env.OPENROUTER_SITE_URL || env.WEB_CLIENT_DOMAIN || DEFAULT_SITE_URL
|
|
20
|
+
const appTitle = env.OPENROUTER_APP_TITLE || DEFAULT_APP_TITLE
|
|
21
|
+
|
|
22
|
+
return new OpenAiSDK({
|
|
23
|
+
apiKey: env.OPENROUTER_API_KEY,
|
|
24
|
+
baseURL: env.OPENROUTER_BASE_URL || DEFAULT_BASE_URL,
|
|
25
|
+
defaultHeaders: {
|
|
26
|
+
'HTTP-Referer': siteUrl,
|
|
27
|
+
'X-OpenRouter-Title': appTitle,
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connect, credentials, id } from './openrouter.integration.js'
|
|
2
|
+
|
|
3
|
+
describe('openrouter.integration', () => {
|
|
4
|
+
it('exports id and required credentials', () => {
|
|
5
|
+
expect(id).toBe('openrouter')
|
|
6
|
+
expect(credentials).toEqual(['OPENROUTER_API_KEY'])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('connects an OpenAI-compatible client to OpenRouter', async () => {
|
|
10
|
+
const client = await connect({
|
|
11
|
+
env: {
|
|
12
|
+
OPENROUTER_API_KEY: 'test-key',
|
|
13
|
+
WEB_CLIENT_DOMAIN: 'https://example.test',
|
|
14
|
+
OPENROUTER_APP_TITLE: 'Ossy Test',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(client.apiKey).toBe('test-key')
|
|
19
|
+
expect(client.baseURL).toBe('https://openrouter.ai/api/v1')
|
|
20
|
+
expect(client._options.defaultHeaders['HTTP-Referer']).toBe('https://example.test')
|
|
21
|
+
expect(client._options.defaultHeaders['X-OpenRouter-Title']).toBe('Ossy Test')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('allows overriding base URL and site attribution', async () => {
|
|
25
|
+
const client = await connect({
|
|
26
|
+
env: {
|
|
27
|
+
OPENROUTER_API_KEY: 'k',
|
|
28
|
+
OPENROUTER_BASE_URL: 'https://openrouter.example/v1',
|
|
29
|
+
OPENROUTER_SITE_URL: 'https://custom.site',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(client.baseURL).toBe('https://openrouter.example/v1')
|
|
34
|
+
expect(client._options.defaultHeaders['HTTP-Referer']).toBe('https://custom.site')
|
|
35
|
+
expect(client._options.defaultHeaders['X-OpenRouter-Title']).toBe('Ossy')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -2,19 +2,52 @@ export default {
|
|
|
2
2
|
name: 'Resize Common Web',
|
|
3
3
|
id: '@ossy/media-tasks/schema/resize-common-web',
|
|
4
4
|
categoryName: 'Media processing',
|
|
5
|
-
icon: '
|
|
5
|
+
icon: 'screen-shot',
|
|
6
6
|
fields: [
|
|
7
7
|
{
|
|
8
|
-
name: '
|
|
8
|
+
name: 'blurhash',
|
|
9
9
|
type: 'text',
|
|
10
|
+
description: 'BlurHash placeholder string',
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
|
-
name: '
|
|
13
|
-
type: '
|
|
13
|
+
name: 'width',
|
|
14
|
+
type: 'number',
|
|
15
|
+
description: 'Natural image width',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'height',
|
|
19
|
+
type: 'number',
|
|
20
|
+
description: 'Natural image height',
|
|
14
21
|
},
|
|
15
22
|
{
|
|
16
|
-
name: '
|
|
23
|
+
name: 'artifacts',
|
|
17
24
|
type: 'textarea',
|
|
25
|
+
description: 'Named derivative artifacts (thumbnail/gallery sizes)',
|
|
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
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { createLogger } from '@ossy/observability'
|
|
2
2
|
import { StorageClient } from '@ossy/platform'
|
|
3
|
-
import {
|
|
3
|
+
import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
|
+
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
4
5
|
import { encodeBlurhashFromBuffer } from './encode-blurhash.js'
|
|
6
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
7
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
5
8
|
|
|
6
9
|
const log = createLogger('media-tasks')
|
|
7
10
|
|
|
11
|
+
const TASK_ID = '@ossy/media-tasks/tasks/resize-common-web'
|
|
12
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/resize-common-web'
|
|
13
|
+
const CONCEPT = 'resize-common-web'
|
|
14
|
+
const OUTPUT = {
|
|
15
|
+
name: 'sizes',
|
|
16
|
+
kind: 'location',
|
|
17
|
+
schemaId: SCHEMA_ID,
|
|
18
|
+
contentType: 'application/json',
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
const Sizes = [
|
|
9
22
|
{ width: 24, height: 24, name: 'thumbnailSmall' },
|
|
10
23
|
{ width: 48, height: 48, name: 'thumbnailLarge' },
|
|
@@ -14,18 +27,8 @@ const Sizes = [
|
|
|
14
27
|
{ width: 4, height: undefined, name: 'loader-square-blurred-after' },
|
|
15
28
|
]
|
|
16
29
|
|
|
17
|
-
/** Created fires before the client finishes PUT; wait briefly for blob bytes. */
|
|
18
|
-
async function loadSourceBuffer (key, { attempts = 20, delayMs = 250 } = {}) {
|
|
19
|
-
for (let i = 0; i < attempts; i++) {
|
|
20
|
-
const { buffer, exists } = await StorageClient.load(key)
|
|
21
|
-
if (exists && buffer) return buffer
|
|
22
|
-
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
23
|
-
}
|
|
24
|
-
return null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
30
|
export const metadata = {
|
|
28
|
-
id:
|
|
31
|
+
id: TASK_ID,
|
|
29
32
|
triggers: [
|
|
30
33
|
{
|
|
31
34
|
type: '@ossy/platform/schema/file',
|
|
@@ -33,6 +36,8 @@ export const metadata = {
|
|
|
33
36
|
match: { 'content.ContentType': 'image/*' },
|
|
34
37
|
},
|
|
35
38
|
],
|
|
39
|
+
outputs: [OUTPUT],
|
|
40
|
+
configurable: true,
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export async function run ({ event, sdk }) {
|
|
@@ -52,31 +57,54 @@ export async function run ({ event, sdk }) {
|
|
|
52
57
|
throw new Error(`[media-tasks/tasks/resize-common-web] missing file bytes for ${resourceId}`)
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
const mime = resource.content?.ContentType ?? 'image/jpeg'
|
|
61
|
+
/** @type {Record<string, { key: string, contentType: string, width?: number, height?: number }>} */
|
|
62
|
+
const artifacts = {}
|
|
63
|
+
|
|
55
64
|
log.info(`[media-tasks/tasks/resize-common-web] Resizing ${resourceId}`)
|
|
56
65
|
|
|
57
66
|
for (const { width, height, name } of Sizes) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
67
|
+
const resized = sharp(sourceBuffer).resize(width, height)
|
|
68
|
+
const { data, info } = await resized.toBuffer({ resolveWithObject: true })
|
|
69
|
+
const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, name)
|
|
70
|
+
|
|
71
|
+
log.info(`[media-tasks/tasks/resize-common-web] Saving ${name} for ${resourceId}`)
|
|
72
|
+
await StorageClient.save(objectKey, data)
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
throw error
|
|
74
|
+
artifacts[name] = {
|
|
75
|
+
key: objectKey,
|
|
76
|
+
contentType: mime,
|
|
77
|
+
...(info.width != null ? { width: info.width } : {}),
|
|
78
|
+
...(info.height != null ? { height: info.height } : {}),
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
const { blurhash, width, height } = await encodeBlurhashFromBuffer(sharp, sourceBuffer)
|
|
83
|
+
const sizesBody = { artifacts, blurhash, width, height }
|
|
84
|
+
const sizesKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
|
|
85
|
+
await StorageClient.save(sizesKey, Buffer.from(JSON.stringify(sizesBody), 'utf8'))
|
|
86
|
+
log.info(`[media-tasks/tasks/resize-common-web] saved ${sizesKey}`)
|
|
87
|
+
|
|
88
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
89
|
+
const placed = await placeTaskArtifacts({
|
|
90
|
+
sdk,
|
|
91
|
+
sourceResource: resource,
|
|
92
|
+
taskId: TASK_ID,
|
|
93
|
+
output: OUTPUT,
|
|
94
|
+
body: sizesBody,
|
|
95
|
+
artifactKey: sizesKey,
|
|
96
|
+
placementLocations,
|
|
97
|
+
})
|
|
98
|
+
if (placed.length) {
|
|
99
|
+
log.info(`[media-tasks/tasks/resize-common-web] placed ${placed.length} resource(s)`)
|
|
100
|
+
} else {
|
|
101
|
+
log.info(`[media-tasks/tasks/resize-common-web] artifact only (no placement location configured)`)
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
output: OUTPUT.name,
|
|
105
|
+
key: sizesKey,
|
|
106
|
+
placedResourceIds: placed.map((r) => r.id),
|
|
107
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
108
|
+
artifactCount: Object.keys(artifacts).length,
|
|
81
109
|
}
|
|
82
110
|
}
|