diff --git a/databox/indexer/config/config.json b/databox/indexer/config/config.json index 6530f3f36..699292890 100644 --- a/databox/indexer/config/config.json +++ b/databox/indexer/config/config.json @@ -56,6 +56,43 @@ "attributeDefinition": "idmp_attributeDefinition", "renditionDefinition": "idmp_renditionDefinition" }, + "renditions": { + "original": { + "from": "document", + "useAsOriginal": true, + "class": "document" + }, + "preview": { + "useAsPreview": true, + "class": "public", + "builders": { + "image": { + "from": "image:preview" + }, + "video": { + "from": "video:preview" + }, + "document": { + "from": "document:preview" + } + } + }, + "thumbnail": { + "useAsThumbnail": true, + "parent": "preview", + "builders": { + "image": { + "from": "image:thumbnail" + }, + "video": { + "from": "video:thumbnail" + }, + "document": { + "from": "document:thumbnail" + } + } + } + }, "databoxMapping": [ { "databox": "%env(PHRASEANET_DATABOX)%", diff --git a/databox/indexer/doc/conf_phraseanet.md b/databox/indexer/doc/conf_phraseanet.md index 613f875c9..4fde4ea91 100644 --- a/databox/indexer/doc/conf_phraseanet.md +++ b/databox/indexer/doc/conf_phraseanet.md @@ -231,3 +231,95 @@ To prevent twig to crash if a field doest not exists in a record (when trying to `getMetadata(...)` will return a "fake" empty metadata object. Same method applies for subdefs: `record.getSubdef('missingSubdef').permalink.url` will return null. + + +## `renditions` + +Allows to map Phraseanet subdef / (structures) to Phrasea renditions / (definitions). + +A Phraseanet subdef is identified by it **type** (image, video, audio, document, unknown) and its **name**. e.g. `image:thumbnail`. + +A Phrasea rendition-definition is declared by its **name** and **build settings** (sections image, video, ...). + +### `from` +The `from` setting maps the phrasea rendition-definition to the phraseanet subdef. The build settings will be generated from the phraseanet to match the subdef. + +It is possible to declare a rendition with no `from`: not imported from Phraseanet, but created in Phrasea. + +### `parent` +One can declare a `parent` relation between renditions, the parent rendition **must** be declared before the child. + + +The special `original` rendition has no build settings, so the `from` setting is top-level. Mostly it will be mapped to the Phraseanet special `document` subdef. + +### `useAsOriginal`, `useAsPreview`, `useAsThumbnail`, `class`, ... + +Common settings for all renditions. If not set, the value will be "guessed" from the subdef name / class. + +e.g. + +```json lines +... + "renditions": { + "original": { + "from": "document", + "useAsOriginal": true, + "class": "document" + }, + "preview": { + "useAsPreview": true, + "parent": "original", + "class": "public_preview", + "builders": { + "image": { + "from": "image:preview" + }, + "video": { + "from": "video:preview" + } + } + }, + "thumbnail": { + "useAsThumbnail": true, + "parent": "preview", + "builders": { + "image": { + "from": "image:thumbnail" + }, + "video": { + "from": "video:thumbnail" + } + } + } +/* ------------------- WIP ------------ + , + "pivot": { + "parent": "original", + "builders": { + "video": { + "build": { + "transformations": { + "module": "ffmpeg", + "enabled": true, + "options": { + "format": "video-mp4", + "audio_kilobitrate": 100, + "timeout": 7200, + "threads": 2, + "filters": { + "name": "resize", + "width": 1200, + "height": 1200, + "mode": "inset", + "enabled": true + } + } + } + } + } + } + } +*/ + } +... +``` diff --git a/databox/indexer/src/databox/client.ts b/databox/indexer/src/databox/client.ts index 637bfbd79..e02e49166 100644 --- a/databox/indexer/src/databox/client.ts +++ b/databox/indexer/src/databox/client.ts @@ -238,8 +238,9 @@ export class DataboxClient { return res.data['hydra:member']; } - async createRenditionDefinition(data: object): Promise { - await this.client.post(`/rendition-definitions`, data); + async createRenditionDefinition(data: object): Promise { + const res = await this.client.post(`/rendition-definitions`, data); + return res.data.id; } async flushWorkspace(workspaceId: string): Promise { diff --git a/databox/indexer/src/handlers/phraseanet/CPhraseanetRecord.ts b/databox/indexer/src/handlers/phraseanet/CPhraseanetRecord.ts index 80e3a090e..2944ef6e5 100644 --- a/databox/indexer/src/handlers/phraseanet/CPhraseanetRecord.ts +++ b/databox/indexer/src/handlers/phraseanet/CPhraseanetRecord.ts @@ -100,9 +100,11 @@ class CPhraseanetRecordBase { export class CPhraseanetRecord extends CPhraseanetRecordBase { record_id: string = ''; + phrasea_type: string = ''; constructor(r: PhraseanetRecord, client: PhraseanetClient) { super(r, client); this.record_id = r.record_id; + this.phrasea_type = r.phrasea_type; } } @@ -112,5 +114,6 @@ export class CPhraseanetStory extends CPhraseanetRecordBase { constructor(s: PhraseanetStory, client: PhraseanetClient) { super(s, client); this.story_id = s.story_id; + this.children = s.children.map(r => new CPhraseanetRecord(r, client)); } } diff --git a/databox/indexer/src/handlers/phraseanet/indexer.ts b/databox/indexer/src/handlers/phraseanet/indexer.ts index 8fd310d03..676140bdf 100644 --- a/databox/indexer/src/handlers/phraseanet/indexer.ts +++ b/databox/indexer/src/handlers/phraseanet/indexer.ts @@ -1,5 +1,11 @@ import {IndexIterator} from '../../indexers'; -import {ConfigDataboxMapping, FieldMap, PhraseanetConfig} from './types'; +import { + ConfigDataboxMapping, ConfigPhraseanetOriginal, ConfigPhraseanetSubdef, + FieldMap, +// FieldMaps, + PhraseanetConfig, PhraseanetDatabox, + PhraseanetSubdefStruct, +} from './types'; import {CPhraseanetRecord, CPhraseanetStory} from './CPhraseanetRecord'; import PhraseanetClient from './phraseanetClient'; import { @@ -13,6 +19,8 @@ import {getConfig, getStrict} from '../../configLoader'; import {escapeSlashes, splitPath} from '../../lib/pathUtils'; import {AttributeDefinition, Tag} from '../../databox/types'; import Twig from 'twig'; +import {Logger} from 'winston'; +import {DataboxClient} from '../../databox/client.ts'; export const phraseanetIndexer: IndexIterator = async function* (location, logger, databoxClient, options) { @@ -20,7 +28,7 @@ export const phraseanetIndexer: IndexIterator = return v.replace('/', '_'); }); - const client = new PhraseanetClient(location.options, logger); + const phraseanetClient = new PhraseanetClient(location.options, logger); const databoxMapping: ConfigDataboxMapping[] = getStrict( 'databoxMapping', @@ -41,20 +49,20 @@ export const phraseanetIndexer: IndexIterator = ]) { idempotencePrefixes[k] = getConfig( `idempotencePrefixes.${k}`, - client.getId() + '_', + phraseanetClient.getId() + '_', location.options ); } for (const dm of databoxMapping) { - const databox = await client.getDatabox(dm.databox); - if (databox === undefined) { + const phraseanetDatabox = await phraseanetClient.getDatabox(dm.databox); + if (phraseanetDatabox === undefined) { logger.info(`Unknown databox "${dm.databox}" (ignored)`); continue; } logger.info( - `Start indexing databox "${databox.name}" (#${databox.databox_id}) to workspace "${dm.workspaceSlug}"` + `Start indexing databox "${phraseanetDatabox.name}" (#${phraseanetDatabox.databox_id}) to workspace "${dm.workspaceSlug}"` ); // scan the conf.fieldMap to get a list of required locales @@ -97,223 +105,26 @@ export const phraseanetIndexer: IndexIterator = key: defaultPublicClass, }); - logger.info(`Fetching Meta structures`); - const metaStructure = await client.getMetaStruct( - databox.databox_id - ); - if (!dm.fieldMap) { - // import all fields from structure - for (const name in metaStructure) { - fieldMap.set(name, { - id: metaStructure[name].id, - position: 0, - type: - attributeTypesEquivalence[ - metaStructure[name].type - ] ?? DataboxAttributeType.Text, - multivalue: metaStructure[name].multivalue, - readonly: metaStructure[name].readonly, - translatable: false, - labels: metaStructure[name].labels, - values: [ - { - type: 'metadata', - value: name, - }, - ], - attributeDefinition: {} as AttributeDefinition, - }); - } - } - const attributeDefinitionIndex: Record< - string, - AttributeDefinition - > = {}; - let ufid = 0; // used to generate a unique id for fields declared in conf, but not existing in phraseanet - let position = 1; - for (const [name, fm] of fieldMap) { - fm.id = metaStructure[name] - ? metaStructure[name].id - : (--ufid).toString(); - fm.position = position++; - fm.multivalue = - fm.multivalue ?? - (metaStructure[name] - ? metaStructure[name].multivalue - : false); - fm.readonly = - fm.readonly ?? - (metaStructure[name] - ? metaStructure[name].readonly - : false); - fm.labels = - fm.labels ?? - (metaStructure[name] ? metaStructure[name].labels : {}); - fm.type = - fm.type ?? - (metaStructure[name] - ? attributeTypesEquivalence[metaStructure[name].type] - : DataboxAttributeType.Text); - for (const v of fm.values) { - if (v.locale !== undefined) { - fm.translatable = true; - } - - if (v.type === 'template') { - try { - v.twig = Twig.twig({data: v.value}); // compile once - } catch (e: any) { - throw new Error( - `Error compiling twig for field "${name}": ${e.message}` - ); - } - } - } - - if (!attributeDefinitionIndex[name]) { - const data = { - key: `${ - idempotencePrefixes['attributeDefinition'] - }_${name}_${fm.type}_${fm.multivalue ? '1' : '0'}`, - name: name, - position: fm.position, - editable: !fm.readonly, - multiple: fm.multivalue, - fieldType: - attributeTypesEquivalence[fm.type ?? ''] || fm.type, - workspace: `/workspaces/${workspaceId}`, - class: attrClassIndex[defaultPublicClass]['@id'], - labels: fm.labels, - translatable: fm.translatable, - }; - logger.info(`Creating "${name}" attribute definition`); - attributeDefinitionIndex[name] = - await databoxClient.createAttributeDefinition( - fm.id, - data - ); - } - fm.attributeDefinition = attributeDefinitionIndex[name]; - } - - logger.info(`Fetching status-bits`); - const tagIndex: TagIndex = {}; - const tagsIdByName: Record = {}; - for (const sb of await client.getStatusBitsStruct( - databox.databox_id - )) { - logger.info(`Creating "${sb.label_on}" tag`); - const key = - client.getId() + - '_' + - databox.databox_id.toString() + - '.sb' + - sb.bit; - const tag: Tag = await databoxClient.createTag(key, { - workspace: `/workspaces/${workspaceId}`, - name: sb.label_on, - }); - tagsIdByName[sb.label_on] = tag.id; - tagIndex[sb.bit] = '/tags/' + tagsIdByName[sb.label_on]; - } - - logger.info(`Fetching subdefs`); - const classIndex: Record = {}; - const renditionClasses = - await databoxClient.getRenditionClasses(workspaceId); - renditionClasses.forEach(rc => { - classIndex[rc.name] = rc.id; - }); - - const subdefs = await client.getSubdefsStruct(databox.databox_id); - for (const sd of subdefs) { - if (!classIndex[sd.class]) { - logger.info(`Creating rendition class "${sd.class}" `); - classIndex[sd.class] = - await databoxClient.createRenditionClass({ - name: sd.class, - workspace: `/workspaces/${workspaceId}`, - }); - } + logger.info(`Importing metadata structure`); + await importMetadataStructure(databoxClient, workspaceId, phraseanetDatabox.databox_id, phraseanetClient, dm, fieldMap, idempotencePrefixes['attributeDefinition'], attrClassIndex[defaultPublicClass]['@id'], logger); - logger.info( - `Creating rendition "${sd.name}" of class "${sd.class}" for type="${sd.type}"` - ); - await databoxClient.createRenditionDefinition({ - name: sd.name, - key: `${idempotencePrefixes['renditionDefinition']}${ - sd.name - }_${sd.type ?? ''}`, - class: `/rendition-classes/${classIndex[sd.class]}`, - useAsOriginal: sd.name === 'document', - useAsPreview: sd.name === 'preview', - useAsThumbnail: sd.name === 'thumbnail', - useAsThumbnailActive: sd.name === 'thumbnailgif', - priority: 0, - workspace: `/workspaces/${workspaceId}`, - labels: { - phraseanetDefinition: sd, - }, - }); - } + logger.info(`Importing status-bits structure`); + const tagIndex = await importStatusBitsStructure(databoxClient, workspaceId, phraseanetDatabox.databox_id, phraseanetClient, logger); - const sourceCollections: string[] = []; - if (dm.collections) { - for (const c of dm.collections.split(',')) { - const collection = databox.collections[c.trim()]; - if (collection == undefined) { - logger.info( - `Unknown collection "${c.trim()}" into databox "${ - databox.name - }" (#${databox.databox_id}) (ignored)` - ); - continue; - } - sourceCollections.push(collection.base_id.toString()); - } - if (sourceCollections.length === 0) { - logger.info( - `No collection found for "${dm.collections}" into databox "${databox.name}" (#${databox.databox_id}) (databox ignored)` - ); - } - } else { - for (const baseId of databox.baseIds) { - sourceCollections.push(baseId); - } - } + logger.info(`Importing subdefs structure`); + const subdefToRendition = await importSubdefsStructure(databoxClient, workspaceId, phraseanetDatabox.databox_id, phraseanetClient, dm, idempotencePrefixes['renditionDefinition'], logger); const collectionKeyPrefix = idempotencePrefixes['collection'] + - databox.databox_id.toString() + + phraseanetDatabox.databox_id + ':'; - const branch = splitPath(dm.recordsCollectionPath ?? ''); - await databoxClient.createCollectionTreeBranch( - workspaceId, - collectionKeyPrefix, - branch.map(k => ({ - key: k, - title: k, - })) - ); - logger.info(`Created records collection: "${branch.join('/')}"`); + logger.info(`Creating records collection(s)`); + const sourceCollections = await createRecordsCollections(databoxClient, workspaceId, phraseanetDatabox, dm, collectionKeyPrefix, logger); + + logger.info(`Creating stories collection`); + const storiesCollectionId = await createStoriesCollection(databoxClient, workspaceId, dm, collectionKeyPrefix, logger); - let storiesCollectionId: string | null = null; - if (dm.storiesCollectionPath !== undefined) { - const branch = splitPath(dm.storiesCollectionPath); - storiesCollectionId = - await databoxClient.createCollectionTreeBranch( - workspaceId, - collectionKeyPrefix, - branch.map(k => ({ - key: k, - title: k, - })) - ); - logger.info( - `Created stories collection: "${branch.join('/')}"` - ); - } const searchParams = { bases: sourceCollections, // if empty (no collections on config) : search all collections @@ -322,11 +133,11 @@ export const phraseanetIndexer: IndexIterator = const recordStories: Record = {}; // key: record_id ; values: story_id's if (storiesCollectionId !== null) { - logger.info(`Fetching stories`); + logger.info(`Importing stories`); let stories: CPhraseanetStory[] = []; let offset = 0; do { - stories = await client.searchStories( + stories = await phraseanetClient.searchStories( searchParams, offset, '' @@ -348,10 +159,10 @@ export const phraseanetIndexer: IndexIterator = } ); logger.info( - `Phraseanet story "${s.title}" (#${ + ` Phraseanet story "${s.title}" (#${ s.story_id }) from base "${ - databox.collections[s.base_id].name + phraseanetDatabox.collections[s.base_id].name }" (#${s.base_id}) ==> collection (#${storyCollId})` ); for (const rs of s.children) { @@ -368,11 +179,11 @@ export const phraseanetIndexer: IndexIterator = } while (stories.length > 0); } - logger.info(`Fetching records`); + logger.info(`Importing records`); let records: CPhraseanetRecord[]; let offset = 0; do { - records = await client.searchRecords( + records = await phraseanetClient.searchRecords( searchParams, offset, dm.searchQuery ?? '' @@ -382,7 +193,7 @@ export const phraseanetIndexer: IndexIterator = `Phraseanet record "${r.title}" (#${ r.record_id }) from base "${ - databox.collections[r.base_id].name + phraseanetDatabox.collections[r.base_id].name }" (#${r.base_id})` ); @@ -415,7 +226,7 @@ export const phraseanetIndexer: IndexIterator = const path = `${ dm.recordsCollectionPath ?? '' }/${escapeSlashes( - databox.collections[r.base_id].name + phraseanetDatabox.collections[r.base_id].name )}/${escapeSlashes(r.original_name)}`; yield createAsset( workspaceId, @@ -429,10 +240,698 @@ export const phraseanetIndexer: IndexIterator = r.record_id, fieldMap, tagIndex, - copyTo + copyTo, + subdefToRendition, + logger, ); } offset += records.length; } while (records.length > 0); } }; + +async function createRecordsCollections( + databoxClient: DataboxClient, + workspaceId: string, + phraseanetDatabox: PhraseanetDatabox, + dm: ConfigDataboxMapping, + collectionKeyPrefix: string, + logger: Logger +): Promise { + const sourceCollections: string[] = []; + if (dm.collections) { + for (const c of dm.collections.split(',')) { + const collection = phraseanetDatabox.collections[c.trim()]; + if (collection == undefined) { + logger.info( + `Unknown collection "${c.trim()}" into databox "${ + phraseanetDatabox.name + }" (#${phraseanetDatabox.databox_id}) (ignored)` + ); + continue; + } + sourceCollections.push(collection.base_id.toString()); + } + if (sourceCollections.length === 0) { + logger.info( + `No collection found for "${dm.collections}" into databox "${phraseanetDatabox.name}" (#${phraseanetDatabox.databox_id}) (databox ignored)` + ); + } + } else { + for (const baseId of phraseanetDatabox.baseIds) { + sourceCollections.push(baseId); + } + } + + const branch = splitPath(dm.recordsCollectionPath ?? ''); + await databoxClient.createCollectionTreeBranch( + workspaceId, + collectionKeyPrefix, + branch.map(k => ({ + key: k, + title: k, + })) + ); + logger.info(`Created records collection: "${branch.join('/')}"`); + + return sourceCollections; +} + +async function createStoriesCollection( + databoxClient: DataboxClient, + workspaceId: string, + dm: ConfigDataboxMapping, + collectionKeyPrefix: string, + logger: Logger +): Promise { + let storiesCollectionId: string | null = null; + if (dm.storiesCollectionPath !== undefined) { + const branch = splitPath(dm.storiesCollectionPath); + storiesCollectionId = + await databoxClient.createCollectionTreeBranch( + workspaceId, + collectionKeyPrefix, + branch.map(k => ({ + key: k, + title: k, + })) + ); + logger.info( + `Created stories collection: "${branch.join('/')}"` + ); + } + + return storiesCollectionId; +} + +async function importSubdefsStructure( + databoxClient: DataboxClient, + workspaceId: string, + phraseanetDataboxId: string, + phraseanetClient: PhraseanetClient, + dm: ConfigDataboxMapping, + idempotencePrefix: string, + logger: Logger +): Promise> { + const classIndex: Record = {}; + const renditionClasses = await databoxClient.getRenditionClasses(workspaceId); + renditionClasses.forEach(rc => { + classIndex[rc.name] = rc.id; + }); + + const subdefs = await phraseanetClient.getSubdefsStruct(phraseanetDataboxId); + const sdByName: Record; + class: string | null; + labels: Record; + }> = {}; + + if(dm.renditions === false) { + // special value: do not create rendition definitions + return {}; + } + + if(dm.renditions === undefined) { + // import all subdefs from phraseanet + dm['renditions'] = { + "original": { + "from": "document", + "useAsOriginal": true, + "class": "original" + } as ConfigPhraseanetOriginal + }; + + for(const sd of subdefs) { + if(!dm.renditions[sd.name]) { + dm.renditions[sd.name] = { + parent: null, + class: sd.class, + useAsOriginal: sd.name === 'document', + useAsPreview: sd.name === 'preview', + useAsThumbnail: sd.name === 'thumbnail', + useAsThumbnailActive: sd.name === 'thumbnailgif', + builders: {}, + } as ConfigPhraseanetSubdef; + } + (dm.renditions[sd.name] as ConfigPhraseanetSubdef).builders[sd.type] = { + from: `${sd.type}:${sd.name}` + }; + } + } + + const subdefToRendition = {} as Record; + + for(const [name, rendition] of Object.entries(dm.renditions)) { + if (!sdByName[name]) { + sdByName[name] = { + name: name, + parent: 'parent' in rendition ? rendition['parent'] : null, + useAsOriginal: rendition['useAsOriginal'] ?? false, + useAsPreview: rendition['useAsPreview'] ?? false, + useAsThumbnail: rendition['useAsThumbnail'] ?? false, + useAsThumbnailActive: rendition['useAsThumbnailActive'] ?? false, + types: {} as Record, + class: rendition['class'] ?? null, + labels: {}, + }; + } + if('from' in rendition && rendition['from'] === 'document') { + // phrnet original is not a subdef + continue; + } + for(const [family, settings] of Object.entries('builders' in rendition ? rendition['builders'] : [])) { + if('build' in settings && 'from' in settings) { + logger.error(` Rendition-definition "${name}" for family "${family}": Use "build" OR "from", not both. Rendition definition ignored`); + continue; + } + if('build' in settings) { + // hardcoded + } + if('from' in settings) { + // find the subdef with good name and family + const [sdFamily, sdName] = settings['from'].split(':'); + const sd = subdefs.find(sd => sd.name === sdName && sd.type === sdFamily); + if(!sd) { + logger.error(` Subdef "${settings['from']}" not found`); + continue; + } + if(sdByName[name].types[sd.type]) { + logger.error(` Build "${sd.type}" for rendition "${name}" already set`); + continue; + } + if(!subdefToRendition[settings['from']]) { + subdefToRendition[settings['from']] = []; + } + subdefToRendition[settings['from']].push(name); + sdByName[name].types[sd.type] = sd; + sdByName[name].labels = sd.labels; // todo: check conflicts + if(!rendition['class']) { + // use phrnet class + if (sdByName[name].class === null) { + sdByName[name].class = sd.class; + } + // sd of same name should have the same class + if (sdByName[name].class !== sd.class && sdByName[name].class !== 'mixed') { + logger.info(` Rendition "${name}" gets different class ("${sdByName[sd.name].class}" and "${sd.class}": "mixed" is used)`); + sdByName[name].class = 'mixed'; + } + } + } + } + } + + const renditionIdByName = {} as Record; + + for (const sdName in sdByName) { + const sd = sdByName[sdName]; + + if(!sd.class) { + logger.info(` Rendition definition "${sdName}" has neither "class" or phraseanet "from": using class "public"`); + sd.class = 'public'; + } + + if (!classIndex[sd.class]) { + logger.info(` Creating rendition class "${sd.class}" `); + classIndex[sd.class] = + await databoxClient.createRenditionClass({ + name: sd.class, + workspace: `/workspaces/${workspaceId}`, + public: true, + }); + } + + logger.info( + ` Creating rendition definition "${sd.name}" of class "${sd.class}"` + ); + + let jsConf: Record = {}; + for (const family in sd.types) { + switch (family) { + case 'image': + jsConf['image'] = translateImageSettings( + sd.types['image'] + ); + break; + case 'video': + jsConf['video'] = translateVideoSettings( + sd.types['video'] + ); + break; + case 'document': + jsConf['document'] = translateDocumentSettings( + sd.types['document'] + ); + break; + } + } + + + if(sd['parent'] && !renditionIdByName[sd['parent']]) { + logger.error(` Parent rendition definition "${sd['parent']}" for "${sd.name}" not found: no parent set. Check declaration order`); + sd['parent'] = null; + } + + renditionIdByName[sd.name] = await databoxClient.createRenditionDefinition({ + name: sd.name, + parent: sd['parent'] ? `/rendition-definitions/${renditionIdByName[sd['parent']]}` : null, + key: `${idempotencePrefix}${sd.name}`, + class: `/rendition-classes/${classIndex[sd.class]}`, + useAsOriginal: sd.useAsOriginal, + useAsPreview: sd.useAsPreview, + useAsThumbnail: sd.useAsThumbnail, + useAsThumbnailActive: sd.name === 'thumbnailgif', + priority: 0, + workspace: `/workspaces/${workspaceId}`, + labels: { + phraseanetDefinition: sd.labels, + }, + definition: jsToYaml(jsConf, 0).trim(), + }); + + } + + return subdefToRendition; +} + +async function importStatusBitsStructure( + databoxClient: DataboxClient, + workspaceId: string, + phraseanetDataboxId: string, + phraseanetClient: PhraseanetClient, + logger: Logger +): Promise { + const tagIndex: TagIndex = {}; + for (const sb of await phraseanetClient.getStatusBitsStruct(phraseanetDataboxId)) { + logger.info(` Creating "${sb.label_on}" tag`); + const key = + phraseanetClient.getId() + + '_' + + phraseanetDataboxId + + '.sb' + + sb.bit; + const tag: Tag = await databoxClient.createTag(key, { + workspace: `/workspaces/${workspaceId}`, + name: sb.label_on, + }); + tagIndex[sb.bit] = '/tags/' + tag.id; + } + + return tagIndex; +} + +async function importMetadataStructure( + databoxClient: DataboxClient, + workspaceId: string, + phraseanetDataboxId: string, + phraseanetClient: PhraseanetClient, + dm: ConfigDataboxMapping, + fieldMap: Map, + idempotencePrefix: string, + attrClass: string, + logger: Logger +): Promise { + const metaStructure = await phraseanetClient.getMetaStruct(phraseanetDataboxId); + if (!dm.fieldMap) { + // import all fields from structure + for (const name in metaStructure) { + fieldMap.set(name, { + id: metaStructure[name].id, + position: 0, + type: + attributeTypesEquivalence[ + metaStructure[name].type + ] ?? DataboxAttributeType.Text, + multivalue: metaStructure[name].multivalue, + readonly: metaStructure[name].readonly, + translatable: false, + labels: metaStructure[name].labels, + values: [ + { + type: 'metadata', + value: name, + }, + ], + attributeDefinition: {} as AttributeDefinition, + }); + } + } + const attributeDefinitionIndex: Record< + string, + AttributeDefinition + > = {}; + let ufid = 0; // used to generate a unique id for fields declared in conf, but not existing in phraseanet + let position = 1; + for (const [name, fm] of fieldMap) { + fm.id = metaStructure[name] + ? metaStructure[name].id + : (--ufid).toString(); + fm.position = position++; + fm.multivalue = + fm.multivalue ?? + (metaStructure[name] + ? metaStructure[name].multivalue + : false); + fm.readonly = + fm.readonly ?? + (metaStructure[name] + ? metaStructure[name].readonly + : false); + fm.labels = + fm.labels ?? + (metaStructure[name] ? metaStructure[name].labels : {}); + fm.type = + fm.type ?? + (metaStructure[name] + ? attributeTypesEquivalence[metaStructure[name].type] + : DataboxAttributeType.Text); + for (const v of fm.values) { + if (v.locale !== undefined) { + fm.translatable = true; + } + + if (v.type === 'template') { + try { + v.twig = Twig.twig({data: v.value}); // compile once + } catch (e: any) { + throw new Error( + `Error compiling twig for field "${name}": ${e.message}` + ); + } + } + } + + if (!attributeDefinitionIndex[name]) { + const data = { + key: `${idempotencePrefix}_${name}_${fm.type}_${fm.multivalue ? '1' : '0'}`, + name: name, + position: fm.position, + editable: !fm.readonly, + multiple: fm.multivalue, + fieldType: + attributeTypesEquivalence[fm.type ?? ''] || fm.type, + workspace: `/workspaces/${workspaceId}`, + class: attrClass, + labels: fm.labels, + translatable: fm.translatable, + }; + logger.info(` Creating "${name}" attribute definition`); + attributeDefinitionIndex[name] = + await databoxClient.createAttributeDefinition( + fm.id, + data + ); + } + fm.attributeDefinition = attributeDefinitionIndex[name]; + } +} + +function translateDocumentSettings(sd: PhraseanetSubdefStruct): object { + // too bad: phraseanet api does not provide the target "mediatype" (image, video, ...) + // so we guess from the presence of option "icodec" + if (sd.options['icodec']) { + return translateDocumentSettings_withIcodec(sd); + } + // here no icodec: pdf or flexpaper (flexpaper is not handled by phrasea, so import as pdf) + return translateDocumentSettings_toPdf(); +} + +function translateDocumentSettings_withIcodec(sd: PhraseanetSubdefStruct): object { + return { + transformations: [ + { + module: 'document_to_pdf' + }, + { + module: 'pdf_to_image', + options: { + size: [sd.options['size'], sd.options['size']], + resolution: sd.options['resolution'], + extension: sd.options['icodec'], + } + }, + ], + }; +} + +function translateDocumentSettings_toPdf(): object { + return { + transformations: [ + { + module: 'document_to_pdf' + }, + ], + }; +} + +function translateImageSettings(sd: PhraseanetSubdefStruct): object { + // todo: extension ? + const size = sd.options['size']; + + return { + transformations: [ + { + module: 'imagine', + options: { + filters: { + auto_rotate: {}, + background_fill: { + color: '#FFFFFF', + opacity: 100, + }, + thumbnail: { + size: [size, size], + mode: 'inset', + }, + }, + }, + }, + { + module: 'set_dpi', + options: { + dpi: sd.options['resolution'], + } + } + ], + }; +} + +function translateVideoSettings(sd: PhraseanetSubdefStruct): object { + // too bad: phraseanet api does not provide the target "mediatype" (image, video, ...) + // so we guess from the presence of option(s) "icodec", "vcodec", "acodec" + if (sd.options['vcodec']) { + // also have a acodec, so test first + return translateVideoSettings_withVcodec(sd); + } + if (sd.options['acodec']) { + // here no vcodec: pure audio + return translateVideoSettings_withAcodec(sd); + } + if (sd.options['icodec']) { + return translateVideoSettings_withIcodec(sd); + } + return {}; +} + +function translateVideoSettings_withVcodec(sd: PhraseanetSubdefStruct): object { + // todo : acodec, formats, ... + let format; + switch(sd.options['vcodec'] ?? '') { + case 'libx264': + format = 'video-mp4'; + break; + case 'libvpx': + format = 'video-webm'; + break; + case 'libtheora': + format = 'video-webm'; + break; + } + const size = sd.options['size'] ?? 100; + let ffmpegModuleOptions: any = { + format: format, + timeout: 7200, + filters: [ + { + name: 'resize', + width: size, + height: size, + mode: 'inset', + }, + ], + }; + // in phraseanet, "audiobitrate" is already in K ! + const audiokbrate = sd.options['audiobitrate'] ?? 0; + if (audiokbrate > 0) { + ffmpegModuleOptions['audio_kilobitrate'] = audiokbrate; + } + const audiosrate = sd.options['audiosamplerate'] ?? 0; + if (audiosrate > 0) { + ffmpegModuleOptions['#0'] = + `audio_samplerate: ${audiosrate} (not yet implemented in ffmpeg module)`; + } + + return { + transformations: [ + { + module: 'ffmpeg', + options: ffmpegModuleOptions, + }, + ], + }; +} + +function translateVideoSettings_withAcodec(sd: PhraseanetSubdefStruct): object { + let format = 'video-mp4'; + switch (sd.options['acodec'] ?? '') { + case 'pcm_s16le': + format = 'audio-wav'; + break; + case 'libmp3lame': + format = 'audio-mp3'; + break; + case 'flac': + format = 'audio-aac'; + break; + default: + throw new Error( + `Unsupported audio codec: ${sd.options['acodec']} for subdef video:${sd.name}` + ); + } + + let ffmpegModuleOptions: any = { + format: format, + timeout: 7200, + }; + // in phraseanet, "audiobitrate" is already in K ! + const audiokbrate = sd.options['audiobitrate'] ?? 0; + if (audiokbrate > 0) { + ffmpegModuleOptions['audio_kilobitrate'] = audiokbrate; + } + const audiosrate = sd.options['audiosamplerate'] ?? 0; + if (audiosrate > 0) { + ffmpegModuleOptions['#0'] = + `audio_samplerate: ${audiosrate} (not yet implemented in ffmpeg module)`; + } + + return { + transformations: [ + { + module: 'ffmpeg', + options: ffmpegModuleOptions, + }, + ], + }; +} + +function translateVideoSettings_withIcodec(sd: PhraseanetSubdefStruct): object { + if (sd.options['delay'] === undefined) { + // a static image + return translateVideoSettings_targetImageFrame(sd); + } else { + // a animated gif (ignore icodec, always use gif) + return translateVideoSettings_targetAnimatedGif(sd); + } +} + +function translateVideoSettings_targetImageFrame(sd: PhraseanetSubdefStruct): object { + let format; + switch (sd.options['icodec'] ?? '') { + case 'jpeg': + format = 'image-jpeg'; + break; + case 'png': + format = 'image-png'; + break; + case 'tiff': + format = 'image-tiff'; + break; + default: + throw new Error( + `Unsupported image codec: ${sd.options['icodec']} for subdef video:${sd.name}` + ); + } + const size = sd.options['size'] ?? 100; + + return { + transformations: [ + { + module: 'video_to_frame', + options: { + format: format, + start: 0, + }, + }, + { + module: 'imagine', + options: { + filters: [ + { + thumbnail: { + size: [size, size], + }, + }, + ], + }, + }, + ], + }; +} + +function translateVideoSettings_targetAnimatedGif(sd: PhraseanetSubdefStruct): object { + const size = sd.options['size'] ?? 100; + // fps from (msec)delay, with 2 decimals + const fps = Math.round(100000.0 / sd.options['delay']) / 100; + + return { + transformations: [ + { + module: 'video_to_animation', + options: { + 'format': 'animated-gif', + 'start': 0, + '#0': 'duration: 5', + 'fps': fps, + 'width': size, + 'height': size, + }, + }, + ], + }; +} + +function jsToYaml(a: any, depth: number): string { + let t = ''; + const tab = ' '.repeat(depth); + if (a instanceof Array) { + for (const k in a) { + t += `\n${tab}-${jsToYaml(a[k], depth + 1)}`; + } + } else if (typeof a === 'object') { + for (const k in a) { + if (k[0] === '#') { + t += `\n${tab}# ${a[k]}`; + } else { +// console.log("------- ", k, a[k]); + if((a[k] instanceof Array || typeof a[k] === 'object') && Object.keys(a[k]).length === 0) { + t += `\n${tab}${k}: ~`; + } else { + t += `\n${tab}${k}:${jsToYaml(a[k], depth + 1)}`; + } + } + } + } else { + if (typeof a === 'number') { + t += ` ${a}`; + } else { + t += ` '${a.toString().replace(/'/g, "\\'")}'`; + } + } + + return t; +} diff --git a/databox/indexer/src/handlers/phraseanet/shared.ts b/databox/indexer/src/handlers/phraseanet/shared.ts index 560a61d60..f46526920 100644 --- a/databox/indexer/src/handlers/phraseanet/shared.ts +++ b/databox/indexer/src/handlers/phraseanet/shared.ts @@ -1,18 +1,12 @@ import {Asset} from '../../indexers'; -import {FieldMap, PhraseanetSubdef} from './types'; +import {FieldMap} from './types'; import {CPhraseanetRecord} from './CPhraseanetRecord'; - +import {Logger} from 'winston'; import { AttributeClass, AttributeInput, - RenditionInput, } from '../../databox/types'; -const renditionDefinitionMapping: Record = { - document: 'original', -}; -const renditionDefinitionBlacklist = ['original']; - export type AttrDefinitionIndex = Record< string, { @@ -34,11 +28,10 @@ export async function createAsset( key: string, fieldMap: Map, tagIndex: TagIndex, - shortcutIntoCollections: {id: string; path: string}[] + shortcutIntoCollections: {id: string; path: string}[], + subdefToRendition: Record, + logger: Logger, ): Promise { - const document: PhraseanetSubdef | undefined = record.subdefs.find( - s => s.name === 'document' - ); const attributes: AttributeInput[] = []; @@ -102,37 +95,55 @@ export async function createAsset( } } + const renditions = []; + let originalSourceFile: { + url: string; + isPrivate: boolean; + importFile: boolean; + type: string; + }|null = null; + + for(const sd of record.subdefs ?? []) { + if(sd.name === 'document') { + originalSourceFile = { + url: sd.permalink.url, + isPrivate: false, + importFile: importFiles, + type: sd.mime_type, + } + logger.info(` "original": ${sd.permalink.url}`); + continue; + } + + const phrName = record.phrasea_type + ':' + sd.name; + + for(const name of subdefToRendition[phrName] ?? []) { + logger.info(` "${name}" (from "${sd.name}"): ${sd.permalink.url}`); + renditions.push({ + name: name, + sourceFile: { + url: sd.permalink.url, + isPrivate: false, + importFile: importFiles, + type: sd.mime_type, + }, + }); + } + } return { workspaceId: workspaceId, key: key, path: path, collectionKeyPrefix: collectionKeyPrefix, title: record.title, +// sourceFile: sourceFile, importFile: importFiles, - publicUrl: document?.permalink.url, + publicUrl: originalSourceFile?.url, isPrivate: false, attributes: attributes, tags: tags, generateRenditions: false, - renditions: (record.subdefs ?? []) - .map(s => { - const defName = renditionDefinitionMapping[s.name] || s.name; - - if (renditionDefinitionBlacklist.includes(defName)) { - return null; - } - - return { - name: defName, - sourceFile: { - url: s.permalink.url, - isPrivate: false, - importFile: importFiles, - type: s.mime_type, - }, - }; - }) - .filter(s => Boolean(s)) as RenditionInput[], + renditions: renditions, shortcutIntoCollections: shortcutIntoCollections, }; } diff --git a/databox/indexer/src/handlers/phraseanet/types.d.ts b/databox/indexer/src/handlers/phraseanet/types.d.ts index 48d69ca57..2f8089c54 100644 --- a/databox/indexer/src/handlers/phraseanet/types.d.ts +++ b/databox/indexer/src/handlers/phraseanet/types.d.ts @@ -21,6 +21,27 @@ export type FieldMap = { attributeDefinition: AttributeDefinition; }; +export type ConfigPhraseanetSubdefBase = { + useAsThumbnail?: boolean; + useAsPreview?: boolean; + useAsOriginal?: boolean; + useAsThumbnailActive?: boolean; + class: string; +}; + +export type ConfigPhraseanetOriginal = ConfigPhraseanetSubdefBase & { + from: string; +}; + +export type ConfigRenditionBuilder = { + from: string; +}; + +export type ConfigPhraseanetSubdef = ConfigPhraseanetSubdefBase & { + parent: string|null; + builders: Record; +}; + export type ConfigDataboxMapping = { databox: string; collections?: string; @@ -30,6 +51,7 @@ export type ConfigDataboxMapping = { copyTo: string; storiesCollectionPath: string; fieldMap: Map; + renditions: Record | false; }; export type PhraseanetConfig = { @@ -142,6 +164,8 @@ export type PhraseanetRecord = { title: string; original_name: string; mime_type: string; + phrasea_type: string; + type: string; created_on: string; updated_on: string; subdefs: PhraseanetSubdef[]; diff --git a/lib/php/rendition-factory-bundle/Resources/config/services.yaml b/lib/php/rendition-factory-bundle/Resources/config/services.yaml index b52b3d83b..15227b631 100644 --- a/lib/php/rendition-factory-bundle/Resources/config/services.yaml +++ b/lib/php/rendition-factory-bundle/Resources/config/services.yaml @@ -45,11 +45,27 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } + Alchemy\RenditionFactory\Transformer\Image\SetDpiTransformerModule: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } + # Output "formats" Alchemy\RenditionFactory\Transformer\Video\Format\JpegFormat: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\GifFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\Format\PngFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\Format\TiffFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\MkvFormat: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } @@ -70,6 +86,10 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\OgvFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\AnimatedGifFormat: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } @@ -94,6 +114,10 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\OgaFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation: ~ Imagine\Imagick\Imagine: ~ diff --git a/lib/php/rendition-factory/composer.json b/lib/php/rendition-factory/composer.json index 1c1b21442..751cd73c7 100644 --- a/lib/php/rendition-factory/composer.json +++ b/lib/php/rendition-factory/composer.json @@ -21,6 +21,24 @@ "test": "./vendor/bin/phpunit", "cs": "vendor/bin/php-cs-fixer fix" }, + "repositories": [ + { + "type": "git", + "url": "https://github.com/alchemy-fr/PHPExiftool" + }, + { + "type": "package", + "package": { + "name": "exiftool/exiftool", + "version": "12", + "source": { + "url": "https://github.com/exiftool/exiftool", + "type": "git", + "reference": "12.42" + } + } + } + ], "require": { "php": "^8.3", "ext-imagick": "*", @@ -31,7 +49,8 @@ "liip/imagine-bundle": "^2.13", "symfony/http-client": "^6.4.11", "php-ffmpeg/php-ffmpeg": "^1.2", - "spatie/pdf-to-image": "^3.1" + "spatie/pdf-to-image": "^3.1", + "alchemy/phpexiftool": "^4.0.1" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.17", diff --git a/lib/php/rendition-factory/src/Transformer/Image/SetDpiTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Image/SetDpiTransformerModule.php new file mode 100644 index 000000000..ac11edb68 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Image/SetDpiTransformerModule.php @@ -0,0 +1,88 @@ +buildConfiguration($treeBuilder->getRootNode()->children()); + + return new Documentation( + $treeBuilder, + <<
arrayNode('options') + ->children() + ->IntegerNode('dpi') + ->isRequired() + ->example('72') + ->end() + ->end() + ->end() + ; + // @formatter:on + } + + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface + { + if($inputFile->getFamily() !== FamilyEnum::Image) { + throw new \InvalidArgumentException('Input file must be an image'); + } + $dpi = $options['dpi']; + $this->logger->info(sprintf('Setting DPI to %s', $dpi)); + $writer = Writer::create( + new Exiftool($this->logger) + ); + $writer->write( + $inputFile->getPath(), + new MetadataBag([ +// new Metadata([ +// 'dpi' => $dpi, +// ]), + ]), + null, + [$dpi, $dpi] + ); + + return new OutputFile( + $inputFile->getPath(), + $inputFile->getType(), + FamilyEnum::Image, + false // TODO implement projection + ); + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php index f3a2c2396..79f215102 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -12,11 +12,14 @@ use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Filter\ResizeFilter; +use Alchemy\RenditionFactory\Transformer\Video\Format\AacFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface; use Alchemy\RenditionFactory\Transformer\Video\Format\MkvFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\Mp3Format; use Alchemy\RenditionFactory\Transformer\Video\Format\Mpeg4Format; use Alchemy\RenditionFactory\Transformer\Video\Format\MpegFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\OgaFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\OgvFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation; use Alchemy\RenditionFactory\Transformer\Video\Format\QuicktimeFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\WavFormat; @@ -52,13 +55,18 @@ public static function getName(): string private static function getSupportedOutputFormats(): array { return [ + // video MkvFormat::getFormat(), Mpeg4Format::getFormat(), MpegFormat::getFormat(), QuicktimeFormat::getFormat(), WebmFormat::getFormat(), + OgvFormat::getFormat(), + // audio + AacFormat::getFormat(), WavFormat::getFormat(), Mp3Format::getFormat(), + OgaFormat::getFormat(), ]; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/Format/AacFormat.php b/lib/php/rendition-factory/src/Transformer/Video/Format/AacFormat.php index acb738e29..d8878056f 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/Format/AacFormat.php +++ b/lib/php/rendition-factory/src/Transformer/Video/Format/AacFormat.php @@ -4,10 +4,11 @@ use Alchemy\RenditionFactory\DTO\FamilyEnum; use Alchemy\RenditionFactory\Transformer\Video\Format\Audio\Aac; +use FFMpeg\Format\AudioInterface; class AacFormat implements FormatInterface { - private Aac $format; + private AudioInterface $format; public function __construct() { @@ -34,7 +35,7 @@ public static function getFamily(): FamilyEnum return FamilyEnum::Audio; } - public function getFFMpegFormat(): Aac + public function getFFMpegFormat(): AudioInterface { return $this->format; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/Format/GifFormat.php b/lib/php/rendition-factory/src/Transformer/Video/Format/GifFormat.php new file mode 100644 index 000000000..7110841a6 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/Format/GifFormat.php @@ -0,0 +1,28 @@ +format; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/Format/OgaFormat.php b/lib/php/rendition-factory/src/Transformer/Video/Format/OgaFormat.php new file mode 100644 index 000000000..4108311d7 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/Format/OgaFormat.php @@ -0,0 +1,42 @@ +format = new Vorbis(); + } + + public static function getAllowedExtensions(): array + { + return ['oga', 'ogg']; + } + + public static function getMimeType(): string + { + return 'audio/ogg'; + } + + public static function getFormat(): string + { + return 'audio-ogg'; + } + + public static function getFamily(): FamilyEnum + { + return FamilyEnum::Audio; + } + + public function getFFMpegFormat(): AudioInterface + { + return $this->format; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/Format/OgvFormat.php b/lib/php/rendition-factory/src/Transformer/Video/Format/OgvFormat.php new file mode 100644 index 000000000..182dafc83 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/Format/OgvFormat.php @@ -0,0 +1,42 @@ +format = new Ogg(); + } + + public static function getAllowedExtensions(): array + { + return ['ogv']; + } + + public static function getMimeType(): string + { + return 'video/ogg'; + } + + public static function getFormat(): string + { + return 'video-ogg'; + } + + public static function getFamily(): FamilyEnum + { + return FamilyEnum::Video; + } + + public function getFFMpegFormat(): VideoInterface + { + return $this->format; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/Format/PngFormat.php b/lib/php/rendition-factory/src/Transformer/Video/Format/PngFormat.php new file mode 100644 index 000000000..39347dd88 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/Format/PngFormat.php @@ -0,0 +1,28 @@ +format; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php index 969fb8486..7b57c4a97 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php @@ -15,6 +15,7 @@ use Alchemy\RenditionFactory\Transformer\Video\Format\MkvFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\Mpeg4Format; use Alchemy\RenditionFactory\Transformer\Video\Format\MpegFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\OgvFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation; use Alchemy\RenditionFactory\Transformer\Video\Format\QuicktimeFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\WebmFormat; @@ -50,6 +51,7 @@ private static function getSupportedOutputFormats(): array MpegFormat::getFormat(), QuicktimeFormat::getFormat(), WebmFormat::getFormat(), + OgvFormat::getFormat(), ]; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php index bf965e8fd..fe00396ef 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php @@ -12,8 +12,11 @@ use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface; +use Alchemy\RenditionFactory\Transformer\Video\Format\GifFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\JpegFormat; use Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation; +use Alchemy\RenditionFactory\Transformer\Video\Format\PngFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\TiffFormat; use FFMpeg\Media\Video; use Symfony\Component\Config\Definition\Builder\NodeBuilder; use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; @@ -34,7 +37,12 @@ public static function getName(): string private static function getSupportedOutputFormats(): array { - return [JpegFormat::getFormat()]; + return [ + GifFormat::getFormat(), + JpegFormat::getFormat(), + PngFormat::getFormat(), + TiffFormat::getFormat(), + ]; } public function getDocumentation(): Documentation