@ossy/media-tasks 3.18.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,142 @@
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 {
6
+ contentTypeForCommonAudioFormat,
7
+ convertCommonAudioBuffer,
8
+ isAlreadyCommonAudioTargetFormat,
9
+ isAudioContentType,
10
+ normalizeCommonAudioFormat,
11
+ normalizeCommonAudioQuality,
12
+ } from './convert-common-audio.js'
13
+ import { loadSourceBuffer } from './load-source-buffer.js'
14
+ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
15
+
16
+ const log = createLogger('media-tasks')
17
+
18
+ const TASK_ID = '@ossy/media-tasks/tasks/convert-common-audio'
19
+ const SCHEMA_ID = '@ossy/media-tasks/schema/convert-common-audio'
20
+ const CONCEPT = 'convert-common-audio'
21
+ const AUDIO_ARTIFACT = 'audio'
22
+ const OUTPUT = {
23
+ name: 'converted',
24
+ kind: 'location',
25
+ schemaId: SCHEMA_ID,
26
+ contentType: 'application/json',
27
+ }
28
+
29
+ export const metadata = {
30
+ id: TASK_ID,
31
+ triggers: [
32
+ {
33
+ type: '@ossy/platform/schema/file',
34
+ event: 'Created',
35
+ match: { 'content.ContentType': 'audio/*' },
36
+ },
37
+ {
38
+ kind: 'on_action',
39
+ action: '@ossy/media-tasks/actions/convert-common-audio',
40
+ },
41
+ ],
42
+ outputs: [OUTPUT],
43
+ inputs: [
44
+ {
45
+ name: 'format',
46
+ type: 'text',
47
+ default: 'mp3',
48
+ sources: ['payload', 'config'],
49
+ options: ['mp3', 'aac', 'ogg', 'wav', 'aiff', 'flac'],
50
+ description: 'Target format: mp3 (default), aac, ogg, wav, aiff, or flac',
51
+ },
52
+ {
53
+ name: 'quality',
54
+ type: 'number',
55
+ default: 85,
56
+ sources: ['payload', 'config'],
57
+ description: 'Encode quality 1–100 for lossy formats (mp3/aac/ogg); ignored for lossless (wav/aiff/flac)',
58
+ },
59
+ ],
60
+ configurable: true,
61
+ }
62
+
63
+ export async function run ({ event, payload, sdk, inputs }) {
64
+ const resourceId = event?.resourceId || payload?.resourceId
65
+ if (!resourceId) {
66
+ throw new Error('[media-tasks/tasks/convert-common-audio] resourceId is required')
67
+ }
68
+
69
+ const format = normalizeCommonAudioFormat(inputs?.format)
70
+ const quality = normalizeCommonAudioQuality(inputs?.quality)
71
+
72
+ log.info(`[media-tasks/tasks/convert-common-audio] starting for resource ${resourceId} → ${format}`)
73
+
74
+ const resource = await sdk.invoke(GetResource, { resourceId })
75
+ // content.Key is stripped by the API media layer; derive from resource id.
76
+ const key = originalObjectKey(resource.id)
77
+
78
+ const sourceContentType = resource?.content?.ContentType || 'application/octet-stream'
79
+
80
+ if (!isAudioContentType(sourceContentType)) {
81
+ throw new Error(
82
+ `[media-tasks/tasks/convert-common-audio] unsupported ContentType ${sourceContentType} for ${resourceId}`,
83
+ )
84
+ }
85
+
86
+ if (isAlreadyCommonAudioTargetFormat(sourceContentType, format)) {
87
+ log.info(
88
+ `[media-tasks/tasks/convert-common-audio] skip ${resourceId}: already ${contentTypeForCommonAudioFormat(format)}`,
89
+ )
90
+ return { skipped: true, reason: 'already-target-format', format, sourceContentType }
91
+ }
92
+
93
+ const sourceBuffer = await loadSourceBuffer(key)
94
+ if (!sourceBuffer) {
95
+ throw new Error(`[media-tasks/tasks/convert-common-audio] missing file bytes for ${resourceId}`)
96
+ }
97
+
98
+ const converted = await convertCommonAudioBuffer(sourceBuffer, { format, quality })
99
+ const audioKey = taskArtifactObjectKey(resourceId, CONCEPT, AUDIO_ARTIFACT)
100
+ await StorageClient.save(audioKey, converted.buffer)
101
+ log.info(`[media-tasks/tasks/convert-common-audio] saved audio ${audioKey} (${converted.size} bytes)`)
102
+
103
+ const body = {
104
+ format: converted.format,
105
+ contentType: converted.contentType,
106
+ sourceContentType,
107
+ quality,
108
+ size: converted.size,
109
+ audioKey,
110
+ }
111
+
112
+ const metaKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
113
+ await StorageClient.save(metaKey, Buffer.from(JSON.stringify(body), 'utf8'))
114
+ log.info(`[media-tasks/tasks/convert-common-audio] saved ${metaKey}`)
115
+
116
+ const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
117
+ const placed = await placeTaskArtifacts({
118
+ sdk,
119
+ sourceResource: resource,
120
+ taskId: TASK_ID,
121
+ output: OUTPUT,
122
+ body,
123
+ artifactKey: metaKey,
124
+ placementLocations,
125
+ })
126
+ if (placed.length) {
127
+ log.info(`[media-tasks/tasks/convert-common-audio] placed ${placed.length} resource(s)`)
128
+ } else {
129
+ log.info('[media-tasks/tasks/convert-common-audio] artifact only (no placement location configured)')
130
+ }
131
+
132
+ return {
133
+ output: OUTPUT.name,
134
+ key: metaKey,
135
+ audioKey,
136
+ format: converted.format,
137
+ contentType: converted.contentType,
138
+ quality,
139
+ placedResourceIds: placed.map((r) => r.id),
140
+ placedResourceId: placed[0]?.id ?? null,
141
+ }
142
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/convert-common-image',
3
+ access: 'workspace',
4
+ label: 'automation.task.convert-common-image.title',
5
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * First-slice image format converter (#896).
3
+ *
4
+ * Scope: convert camera-phone / awkward uploads (HEIC/HEIF, TIFF, BMP, AVIF)
5
+ * and the configured target formats themselves (JPEG/PNG/WebP → another target,
6
+ * e.g. JPEG→WebP) via sharp. Already-at-target sources are skipped.
7
+ * Manual invoke still accepts any image/*. Document conversions (DOCX→PDF, etc.)
8
+ * are out of scope — they need a different engine.
9
+ */
10
+
11
+ /** @type {ReadonlyArray<string>} */
12
+ export const CONVERT_SOURCE_MIME_TYPES = Object.freeze([
13
+ 'image/heic',
14
+ 'image/heif',
15
+ 'image/tiff',
16
+ 'image/tif',
17
+ 'image/bmp',
18
+ 'image/x-ms-bmp',
19
+ 'image/x-bmp',
20
+ 'image/avif',
21
+ // Target formats are also valid inputs so folder-watch can convert e.g. JPEG→WebP.
22
+ 'image/jpeg',
23
+ 'image/jpg',
24
+ 'image/pjpeg',
25
+ 'image/png',
26
+ 'image/webp',
27
+ ])
28
+
29
+ /** @type {ReadonlyArray<string>} */
30
+ export const CONVERT_TARGET_FORMATS = Object.freeze(['jpeg', 'png', 'webp'])
31
+
32
+ /** @type {Readonly<Record<string, string>>} */
33
+ export const CONVERT_FORMAT_CONTENT_TYPES = Object.freeze({
34
+ jpeg: 'image/jpeg',
35
+ jpg: 'image/jpeg',
36
+ png: 'image/png',
37
+ webp: 'image/webp',
38
+ })
39
+
40
+ const DEFAULT_FORMAT = 'jpeg'
41
+ const DEFAULT_QUALITY = 85
42
+
43
+ /**
44
+ * @param {unknown} value
45
+ * @returns {string}
46
+ */
47
+ export function normalizeConvertFormat (value) {
48
+ const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
49
+ if (raw === 'jpg') return 'jpeg'
50
+ if (CONVERT_TARGET_FORMATS.includes(raw)) return raw
51
+ return DEFAULT_FORMAT
52
+ }
53
+
54
+ /**
55
+ * @param {unknown} value
56
+ * @returns {number}
57
+ */
58
+ export function normalizeConvertQuality (value) {
59
+ const n = typeof value === 'number' ? value : Number(String(value ?? '').trim())
60
+ if (!Number.isFinite(n)) return DEFAULT_QUALITY
61
+ return Math.min(100, Math.max(1, Math.round(n)))
62
+ }
63
+
64
+ /**
65
+ * @param {unknown} contentType
66
+ * @returns {string}
67
+ */
68
+ export function normalizeContentType (contentType) {
69
+ return String(contentType || '')
70
+ .split(';')[0]
71
+ .trim()
72
+ .toLowerCase()
73
+ }
74
+
75
+ /**
76
+ * True when upload MIME is in the automatic conversion set.
77
+ *
78
+ * @param {unknown} contentType
79
+ * @returns {boolean}
80
+ */
81
+ export function isConvertSourceMime (contentType) {
82
+ return CONVERT_SOURCE_MIME_TYPES.includes(normalizeContentType(contentType))
83
+ }
84
+
85
+ /**
86
+ * @param {string} format
87
+ * @returns {string}
88
+ */
89
+ export function contentTypeForFormat (format) {
90
+ const normalized = normalizeConvertFormat(format)
91
+ return CONVERT_FORMAT_CONTENT_TYPES[normalized] || CONVERT_FORMAT_CONTENT_TYPES.jpeg
92
+ }
93
+
94
+ /**
95
+ * Skip when the source is already the requested MIME (no-op conversion).
96
+ *
97
+ * @param {unknown} sourceContentType
98
+ * @param {string} targetFormat
99
+ * @returns {boolean}
100
+ */
101
+ export function isAlreadyTargetFormat (sourceContentType, targetFormat) {
102
+ return normalizeContentType(sourceContentType) === contentTypeForFormat(targetFormat)
103
+ }
104
+
105
+ /**
106
+ * Convert an image buffer to the target format via sharp.
107
+ *
108
+ * @param {import('sharp')} sharp
109
+ * @param {Buffer} sourceBuffer
110
+ * @param {{ format?: string, quality?: number }} [options]
111
+ * @returns {Promise<{
112
+ * buffer: Buffer,
113
+ * format: string,
114
+ * contentType: string,
115
+ * width?: number,
116
+ * height?: number,
117
+ * size: number,
118
+ * }>}
119
+ */
120
+ export async function convertImageBuffer (sharp, sourceBuffer, options = {}) {
121
+ const format = normalizeConvertFormat(options.format)
122
+ const quality = normalizeConvertQuality(options.quality)
123
+
124
+ let pipeline = sharp(sourceBuffer, { failOn: 'none' }).rotate()
125
+ if (format === 'jpeg') {
126
+ // Flatten transparency onto white — JPEG has no alpha.
127
+ pipeline = pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }).jpeg({ quality, mozjpeg: true })
128
+ } else if (format === 'png') {
129
+ pipeline = pipeline.png({ compressionLevel: 9 })
130
+ } else if (format === 'webp') {
131
+ pipeline = pipeline.webp({ quality })
132
+ }
133
+
134
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true })
135
+ return {
136
+ buffer: data,
137
+ format,
138
+ contentType: contentTypeForFormat(format),
139
+ ...(info.width != null ? { width: info.width } : {}),
140
+ ...(info.height != null ? { height: info.height } : {}),
141
+ size: data.length,
142
+ }
143
+ }
@@ -0,0 +1,46 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
3
+ export default {
4
+ name: 'Convert common image',
5
+ id: '@ossy/media-tasks/schema/convert-common-image',
6
+ categoryName: 'Media processing',
7
+ icon: 'swap',
8
+ fields: [
9
+ {
10
+ name: 'format',
11
+ type: 'text',
12
+ description: 'Output format (jpeg, png, or webp)',
13
+ },
14
+ {
15
+ name: 'contentType',
16
+ type: 'text',
17
+ description: 'MIME type of the converted image bytes',
18
+ },
19
+ {
20
+ name: 'sourceContentType',
21
+ type: 'text',
22
+ description: 'MIME type of the source upload',
23
+ },
24
+ {
25
+ name: 'width',
26
+ type: 'number',
27
+ description: 'Converted image width in pixels',
28
+ },
29
+ {
30
+ name: 'height',
31
+ type: 'number',
32
+ description: 'Converted image height in pixels',
33
+ },
34
+ {
35
+ name: 'size',
36
+ type: 'number',
37
+ description: 'Converted image byte length',
38
+ },
39
+ {
40
+ name: 'imageKey',
41
+ type: 'text',
42
+ description: 'Storage key for the converted image binary artifact',
43
+ },
44
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
45
+ ],
46
+ }
@@ -0,0 +1,101 @@
1
+ import sharp from 'sharp'
2
+ import {
3
+ CONVERT_SOURCE_MIME_TYPES,
4
+ contentTypeForFormat,
5
+ convertImageBuffer,
6
+ isAlreadyTargetFormat,
7
+ isConvertSourceMime,
8
+ normalizeConvertFormat,
9
+ normalizeConvertQuality,
10
+ } from './convert-common-image.js'
11
+
12
+ describe('convert-common-image helpers', () => {
13
+ it('normalizes format and quality', () => {
14
+ expect(normalizeConvertFormat('JPG')).toBe('jpeg')
15
+ expect(normalizeConvertFormat('webp')).toBe('webp')
16
+ expect(normalizeConvertFormat('nope')).toBe('jpeg')
17
+ expect(normalizeConvertQuality(72.4)).toBe(72)
18
+ expect(normalizeConvertQuality(0)).toBe(1)
19
+ expect(normalizeConvertQuality(200)).toBe(100)
20
+ expect(normalizeConvertQuality('bad')).toBe(85)
21
+ })
22
+
23
+ it('recognizes scoped upload MIME types including target formats as inputs', () => {
24
+ expect(isConvertSourceMime('image/heic')).toBe(true)
25
+ expect(isConvertSourceMime('image/tiff; charset=binary')).toBe(true)
26
+ expect(isConvertSourceMime('image/jpeg')).toBe(true)
27
+ expect(isConvertSourceMime('image/png')).toBe(true)
28
+ expect(isConvertSourceMime('image/webp')).toBe(true)
29
+ expect(isConvertSourceMime('image/gif')).toBe(false)
30
+ expect(CONVERT_SOURCE_MIME_TYPES).toContain('image/avif')
31
+ })
32
+
33
+ it('detects already-converted sources', () => {
34
+ expect(isAlreadyTargetFormat('image/jpeg', 'jpeg')).toBe(true)
35
+ expect(isAlreadyTargetFormat('image/jpeg', 'webp')).toBe(false)
36
+ expect(isAlreadyTargetFormat('image/png', 'jpeg')).toBe(false)
37
+ expect(contentTypeForFormat('webp')).toBe('image/webp')
38
+ })
39
+ })
40
+
41
+ describe('convertImageBuffer', () => {
42
+ it('converts PNG bytes to JPEG', async () => {
43
+ const sourceBuffer = await sharp({
44
+ create: {
45
+ width: 12,
46
+ height: 8,
47
+ channels: 3,
48
+ background: { r: 10, g: 20, b: 30 },
49
+ },
50
+ })
51
+ .png()
52
+ .toBuffer()
53
+
54
+ const result = await convertImageBuffer(sharp, sourceBuffer, { format: 'jpeg', quality: 80 })
55
+ expect(result.format).toBe('jpeg')
56
+ expect(result.contentType).toBe('image/jpeg')
57
+ expect(result.width).toBe(12)
58
+ expect(result.height).toBe(8)
59
+ expect(result.size).toBeGreaterThan(0)
60
+
61
+ const meta = await sharp(result.buffer).metadata()
62
+ expect(meta.format).toBe('jpeg')
63
+ })
64
+
65
+ it('converts to webp when requested', async () => {
66
+ const sourceBuffer = await sharp({
67
+ create: {
68
+ width: 8,
69
+ height: 8,
70
+ channels: 3,
71
+ background: { r: 200, g: 100, b: 50 },
72
+ },
73
+ })
74
+ .png()
75
+ .toBuffer()
76
+
77
+ const result = await convertImageBuffer(sharp, sourceBuffer, { format: 'webp' })
78
+ expect(result.contentType).toBe('image/webp')
79
+ const meta = await sharp(result.buffer).metadata()
80
+ expect(meta.format).toBe('webp')
81
+ })
82
+
83
+ it('converts JPEG bytes to WebP (target formats are valid inputs)', async () => {
84
+ const sourceBuffer = await sharp({
85
+ create: {
86
+ width: 10,
87
+ height: 6,
88
+ channels: 3,
89
+ background: { r: 40, g: 80, b: 120 },
90
+ },
91
+ })
92
+ .jpeg({ quality: 90 })
93
+ .toBuffer()
94
+
95
+ const result = await convertImageBuffer(sharp, sourceBuffer, { format: 'webp', quality: 80 })
96
+ expect(result.format).toBe('webp')
97
+ expect(result.contentType).toBe('image/webp')
98
+ const meta = await sharp(result.buffer).metadata()
99
+ expect(meta.format).toBe('webp')
100
+ })
101
+ })
@@ -0,0 +1,155 @@
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 {
6
+ contentTypeForFormat,
7
+ convertImageBuffer,
8
+ isAlreadyTargetFormat,
9
+ isConvertSourceMime,
10
+ normalizeConvertFormat,
11
+ normalizeConvertQuality,
12
+ } from './convert-common-image.js'
13
+ import { loadSourceBuffer } from './load-source-buffer.js'
14
+ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
15
+
16
+ const log = createLogger('media-tasks')
17
+
18
+ const TASK_ID = '@ossy/media-tasks/tasks/convert-common-image'
19
+ const SCHEMA_ID = '@ossy/media-tasks/schema/convert-common-image'
20
+ const CONCEPT = 'convert-common-image'
21
+ const IMAGE_ARTIFACT = 'image'
22
+ const OUTPUT = {
23
+ name: 'converted',
24
+ kind: 'location',
25
+ schemaId: SCHEMA_ID,
26
+ contentType: 'application/json',
27
+ }
28
+
29
+ export const metadata = {
30
+ id: TASK_ID,
31
+ triggers: [
32
+ // Ceiling is all images so Storage → Tasks can offer manual convert.
33
+ // Upload auto-run covers CONVERT_SOURCE_MIME_TYPES (awkward formats + jpeg/png/webp).
34
+ {
35
+ type: '@ossy/platform/schema/file',
36
+ event: 'Created',
37
+ match: { 'content.ContentType': 'image/*' },
38
+ },
39
+ {
40
+ kind: 'on_action',
41
+ action: '@ossy/media-tasks/actions/convert-common-image',
42
+ },
43
+ ],
44
+ outputs: [OUTPUT],
45
+ inputs: [
46
+ {
47
+ name: 'format',
48
+ type: 'text',
49
+ default: 'jpeg',
50
+ sources: ['payload', 'config'],
51
+ options: ['jpeg', 'png', 'webp'],
52
+ description: 'Target format: jpeg, png, or webp (default jpeg)',
53
+ },
54
+ {
55
+ name: 'quality',
56
+ type: 'number',
57
+ default: 85,
58
+ sources: ['payload', 'config'],
59
+ description: 'Encode quality 1–100 for jpeg/webp (ignored for png)',
60
+ },
61
+ ],
62
+ configurable: true,
63
+ }
64
+
65
+ export async function run ({ event, payload, sdk, inputs }) {
66
+ const { default: sharp } = await import('sharp')
67
+ const resourceId = event?.resourceId || payload?.resourceId
68
+ if (!resourceId) {
69
+ throw new Error('[media-tasks/tasks/convert-common-image] resourceId is required')
70
+ }
71
+
72
+ const format = normalizeConvertFormat(inputs?.format)
73
+ const quality = normalizeConvertQuality(inputs?.quality)
74
+
75
+ log.info(`[media-tasks/tasks/convert-common-image] starting for resource ${resourceId} → ${format}`)
76
+
77
+ const resource = await sdk.invoke(GetResource, { resourceId })
78
+ // content.Key is stripped by the API media layer (replaced with src URL);
79
+ // derive the storage key deterministically from the resource ID instead.
80
+ const key = originalObjectKey(resource.id)
81
+
82
+ const sourceContentType = resource?.content?.ContentType || 'application/octet-stream'
83
+ const triggeredByUpload = Boolean(event?.resourceId && !payload?.resourceId)
84
+
85
+ // Upload path only auto-converts the scoped MIME set; manual invoke accepts any image.
86
+ if (triggeredByUpload && !isConvertSourceMime(sourceContentType)) {
87
+ log.info(
88
+ `[media-tasks/tasks/convert-common-image] skip ${resourceId}: ContentType ${sourceContentType} not in convert set`,
89
+ )
90
+ return { skipped: true, reason: 'unsupported-source-mime', sourceContentType }
91
+ }
92
+
93
+ if (!String(sourceContentType).toLowerCase().startsWith('image/')) {
94
+ throw new Error(
95
+ `[media-tasks/tasks/convert-common-image] unsupported ContentType ${sourceContentType} for ${resourceId}`,
96
+ )
97
+ }
98
+
99
+ if (isAlreadyTargetFormat(sourceContentType, format)) {
100
+ log.info(
101
+ `[media-tasks/tasks/convert-common-image] skip ${resourceId}: already ${contentTypeForFormat(format)}`,
102
+ )
103
+ return { skipped: true, reason: 'already-target-format', format, sourceContentType }
104
+ }
105
+
106
+ const sourceBuffer = await loadSourceBuffer(key)
107
+ if (!sourceBuffer) {
108
+ throw new Error(`[media-tasks/tasks/convert-common-image] missing file bytes for ${resourceId}`)
109
+ }
110
+
111
+ const converted = await convertImageBuffer(sharp, sourceBuffer, { format, quality })
112
+ const imageKey = taskArtifactObjectKey(resourceId, CONCEPT, IMAGE_ARTIFACT)
113
+ await StorageClient.save(imageKey, converted.buffer)
114
+ log.info(`[media-tasks/tasks/convert-common-image] saved image ${imageKey} (${converted.size} bytes)`)
115
+
116
+ const body = {
117
+ format: converted.format,
118
+ contentType: converted.contentType,
119
+ sourceContentType,
120
+ ...(converted.width != null ? { width: converted.width } : {}),
121
+ ...(converted.height != null ? { height: converted.height } : {}),
122
+ size: converted.size,
123
+ imageKey,
124
+ }
125
+
126
+ const metaKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
127
+ await StorageClient.save(metaKey, Buffer.from(JSON.stringify(body), 'utf8'))
128
+ log.info(`[media-tasks/tasks/convert-common-image] saved ${metaKey}`)
129
+
130
+ const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
131
+ const placed = await placeTaskArtifacts({
132
+ sdk,
133
+ sourceResource: resource,
134
+ taskId: TASK_ID,
135
+ output: OUTPUT,
136
+ body,
137
+ artifactKey: metaKey,
138
+ placementLocations,
139
+ })
140
+ if (placed.length) {
141
+ log.info(`[media-tasks/tasks/convert-common-image] placed ${placed.length} resource(s)`)
142
+ } else {
143
+ log.info('[media-tasks/tasks/convert-common-image] artifact only (no placement location configured)')
144
+ }
145
+
146
+ return {
147
+ output: OUTPUT.name,
148
+ key: metaKey,
149
+ imageKey,
150
+ format: converted.format,
151
+ contentType: converted.contentType,
152
+ placedResourceIds: placed.map((r) => r.id),
153
+ placedResourceId: placed[0]?.id ?? null,
154
+ }
155
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/embed-resource',
3
+ access: 'workspace',
4
+ label: 'automation.task.embed-resource.title',
5
+ }