@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.
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 | Trigger | Description |
10
- |---------|---------|-------------|
11
- | `@ossy/media-tasks/tasks/resize-common-web` | `@ossy/platform/schema/file` `Created` (`content.ContentType: image/*`) | Generates common web thumbnail and gallery sizes using `sharp`, then patches `content.blurhash` / `content.width` / `content.height` |
12
- | `@ossy/media-tasks/tasks/visual-content-descriptors` | `@ossy/platform/schema/file` `Created` (`image/*`, `video/*`) | Uses GPT-4o to generate title, description, tags, and alt text |
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
- ## Image placeholders (BlurHash)
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
- After derivatives upload, `resize-common-web` encodes a [BlurHash](https://blurha.sh/) string and natural pixel size onto the file resource via `@ossy/resources/actions/patch-content` (ADR 0008 `Patched` deep merge — does not replace `sizes`).
18
+ ## Task outputs
17
19
 
18
- Consumers (`Image` / `ImageCard`) prefer `content.blurhash` for an instant local placeholder, and still fall back to `sizes['loader-square-blurred-after']`. `width` / `height` set CSS `aspect-ratio` to reduce layout shift.
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
- ## Schemas
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
- Task outcome schemas live in `src/*.schema.js` and are auto-discovered at build time:
30
+ See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
23
31
 
24
- | Schema | Id |
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
- These describe optional task-run documents; handlers are implemented in the matching `*.task.js` files.
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
- - `metadata.id` — canonical task id (`@ossy/<package>/tasks/…`)
45
- - `triggers[].type` — resource schema id to watch (e.g. `@ossy/platform/schema/file`)
46
- - `triggers[].event` — lifecycle event name (`Created`, `Patched`, …)
47
- - `triggers[].match` — optional field matcher on `event.payload` (e.g. `{ 'content.ContentType': 'image/*' }`)
48
- - `event` — the raw eventstore `fullDocument` (`resourceId`, `type`, `event`, `payload`, …)
49
- - `sdk` — platform SDK instance (passed from API startup; `null` during local dev if unconfigured)
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 in `resize-common-web`
66
+ - **`sharp`** (peer) — image resizing + metadata
54
67
  - **`blurhash`** — encode compact placeholders after resize
55
- - **`openai`** (peer) — GPT-4o vision in `visual-content-descriptors`
56
- - **`@ossy/platform`** — `StorageClient.load` for reading uploaded file bytes (provided at runtime by the platform server)
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.14.0",
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.14.0",
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": "539cc1b14a57e9f6472a89648939e21821dc204d"
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: 'attribution',
5
+ icon: 'screen-shot',
6
6
  fields: [
7
7
  {
8
- name: 'resourceId',
8
+ name: 'blurhash',
9
9
  type: 'text',
10
+ description: 'BlurHash placeholder string',
10
11
  },
11
12
  {
12
- name: 'status',
13
- type: 'text',
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: 'result',
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 { GetResource, PatchResourceContent } from '@ossy/resources'
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: '@ossy/media-tasks/tasks/resize-common-web',
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 resizedBuffer = await sharp(sourceBuffer).resize(width, height).toBuffer()
59
- const mime = resource.content?.ContentType ?? 'image/jpeg'
60
- const file = new File([new Uint8Array(resizedBuffer)], name, { type: mime })
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
- log.info(`[media-tasks/tasks/resize-common-web] Uploading ${name} for ${resourceId}`)
63
- try {
64
- await sdk.resources.uploadNamedVersion({ id: resourceId, name, file })
65
- } catch (error) {
66
- log.error(`[media-tasks/tasks/resize-common-web] Error uploading ${name} for ${resourceId}`, undefined, error)
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
- try {
72
- const { blurhash, width, height } = await encodeBlurhashFromBuffer(sharp, sourceBuffer)
73
- log.info(`[media-tasks/tasks/resize-common-web] Patching blurhash for ${resourceId}`)
74
- await sdk.invoke(PatchResourceContent, {
75
- id: resourceId,
76
- content: { blurhash, width, height },
77
- })
78
- } catch (error) {
79
- log.error(`[media-tasks/tasks/resize-common-web] Error encoding blurhash for ${resourceId}`, undefined, error)
80
- throw error
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
  }