Skip to content

Commit 32ea467

Browse files
taylorli2000petrjasek
authored andcommitted
add xmp metadata parsing for videos (#4882)
1 parent 44c00f2 commit 32ea467

14 files changed

Lines changed: 294 additions & 30 deletions

fixtures/empty_metadata.jpg

76.8 KB
Loading

fixtures/empty_metadata.mov

1.81 KB
Binary file not shown.

fixtures/metadata.jpg

203 KB
Loading

fixtures/metadata.mov

14.5 KB
Binary file not shown.

karma.conf.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ module.exports = function(config) {
4848
files: [
4949
'scripts/tests.ts',
5050
'scripts/**/*.html',
51+
{
52+
pattern: 'fixtures/**/*',
53+
watched: false,
54+
included: false,
55+
}
5156
],
5257

5358
ngHtml2JsPreprocessor: {

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"main": "scripts/index.js",
3030
"types": "scripts/core/superdesk-api.d.ts",
3131
"dependencies": {
32-
"@metadata/exif": "github:superdesk/exif#431066d",
3332
"@popperjs/core": "2.10.2",
3433
"@sourcefabric/common": "0.0.69",
3534
"@sourcefabric/date-fns-tz": "^1.2.1",
@@ -44,6 +43,7 @@
4443
"@types/react-dom": "16.8.0",
4544
"@types/react-redux": "7.1.9",
4645
"@typescript-eslint/parser": "5.57.0",
46+
"@uswriting/exiftool": "1.0.5",
4747
"angular": "1.6.9",
4848
"angular-contenteditable": "0.3.9",
4949
"angular-dynamic-locale": "0.1.32",

scripts/apps/archive/constants.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import {IPTCMetadata, XMPMetadata} from 'superdesk-api';
2+
import {getInvertObject} from 'utils/object';
3+
14
export enum ITEM_STATE {
25
/**
36
* Item created in user workspace.
@@ -114,3 +117,53 @@ export const CANCELED_STATES = KILLED_STATES.concat([ITEM_STATE.SPIKED]);
114117
* KILLED | RECALLED | UNPUBLISHED | SPIKED | SCHEDULED
115118
*/
116119
export const READONLY_STATES = CANCELED_STATES.concat([ITEM_STATE.SCHEDULED]);
120+
121+
export const IPTC_XMP_TAGS = {
122+
'IPTC:Destination': 'XMP:Destination',
123+
'IPTC:ServiceIdentifier': 'XMP:ServiceIdentifier',
124+
'IPTC:ProductID': 'XMP:ProductID',
125+
'IPTC:DateSent': 'XMP:DateSent',
126+
'IPTC:TimeSent': 'XMP:TimeSent',
127+
'IPTC:ObjectName': 'XMP:Title',
128+
'IPTC:EditStatus': 'XMP:EditStatus',
129+
'IPTC:Urgency': 'XMP:Urgency',
130+
'IPTC:SubjectReference': 'XMP:SubjectCode',
131+
'IPTC:Category': 'XMP:Category',
132+
'IPTC:SupplementalCategories': 'XMP:SupplementalCategories',
133+
'IPTC:Keywords': 'XMP:Subject',
134+
'IPTC:ContentLocationCode': 'XMP:LocationCode',
135+
'IPTC:ContentLocationName': 'XMP:LocationName',
136+
'IPTC:ReleaseDate': 'XMP:ReleaseDate',
137+
'IPTC:ReleaseTime': 'XMP:ReleaseTime',
138+
'IPTC:ExpirationDate': 'XMP:ExpirationDate',
139+
'IPTC:ExpirationTime': 'XMP:ExpirationTime',
140+
'IPTC:SpecialInstructions': 'XMP:Instructions',
141+
'IPTC:DateCreated': 'XMP:DateCreated',
142+
'IPTC:TimeCreated': 'XMP:DateCreated',
143+
'IPTC:By-line': 'XMP:Creator',
144+
'IPTC:By-lineTitle': 'XMP:AuthorsPosition',
145+
'IPTC:City': 'XMP:City',
146+
'IPTC:Sub-location': 'XMP:Location',
147+
'IPTC:Province-State': 'XMP:State',
148+
'IPTC:Country-PrimaryLocationCode': 'XMP:CountryCode',
149+
'IPTC:Country-PrimaryLocationName': 'XMP:Country',
150+
'IPTC:OriginalTransmissionReference': 'XMP:TransmissionReference',
151+
'IPTC:Headline': 'XMP:Headline',
152+
'IPTC:Credit': 'XMP:Credit',
153+
'IPTC:Source': 'XMP:Source',
154+
'IPTC:CopyrightNotice': 'XMP:Rights',
155+
'IPTC:Contact': 'XMP:CreatorContactInfo',
156+
'IPTC:Caption-Abstract': 'XMP:Description',
157+
'IPTC:Writer-Editor': 'XMP:CaptionWriter',
158+
'IPTC:LanguageIdentifier': 'XMP:Language',
159+
} as const satisfies Record<`IPTC:${keyof IPTCMetadata}`, `XMP:${XMPMetadata}`>;
160+
export const XMP_IPTC_TAGS = getInvertObject(IPTC_XMP_TAGS);
161+
162+
export const EXIFTOOL_ARGS = {
163+
COMPOSITE: ['-use MWG', '-mwg:all'],
164+
IPTC: '-iptc:all',
165+
JSON: '-j',
166+
showDuplicates: '-a',
167+
showGroupNames: '-G',
168+
XMP: '-xmp:all',
169+
} as const;

scripts/apps/archive/controllers/UploadController.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,22 @@ import _ from 'lodash';
22
import {getDataUrl} from 'core/upload/image-preview-directive';
33
import {gettext} from 'core/utils';
44
import {isEmpty, pickBy} from 'lodash';
5-
import {handleBinaryFile} from '@metadata/exif';
65
import {extensions} from 'appConfig';
76
import {IPTCMetadata, IUser, IArticle} from 'superdesk-api';
87
import {appConfig} from 'appConfig';
98
import {fileUploadErrorModal} from './file-upload-error-modal';
109
import {showModal} from '@superdesk/common';
1110
import {sdApi} from 'api';
11+
import {getMetadata} from 'apps/archive/parse-metadata';
1212

1313
const isNotEmptyString = (value: any) => value != null && value !== '';
1414

1515
/* eslint-disable complexity */
1616

17-
function getExifData(file: File): Promise<IPTCMetadata> {
18-
return new Promise((resolve, reject) => {
19-
const reader = new FileReader();
20-
21-
reader.onloadend = () => {
22-
try {
23-
const exif: { iptcdata: IPTCMetadata } = handleBinaryFile(reader.result);
24-
25-
resolve(exif.iptcdata);
26-
} catch (error) {
27-
console.error(error);
28-
reject(error);
29-
}
30-
};
31-
32-
reader.onerror = reject;
33-
reader.readAsArrayBuffer(file);
34-
});
35-
}
36-
37-
function mapIPTCExtensions(metadata: IPTCMetadata, user: IUser, parent?: IArticle): Promise<Partial<IArticle>> {
17+
function mapIPTCExtensions(
18+
metadata: Partial<IPTCMetadata>,
19+
user: IUser, parent?: IArticle,
20+
): Promise<Partial<IArticle>> {
3821
const meta: Partial<IPTCMetadata> = Object.assign({
3922
'By-line': user.byline,
4023
}, pickBy(metadata, isNotEmptyString));
@@ -361,10 +344,10 @@ export function UploadController(
361344
? Promise.resolve()
362345
: Promise.all(acceptedFiles.map(
363346
({file, getThumbnail}) =>
364-
getExifData(file)
347+
getMetadata(file)
365348
.then(
366349
(fileMeta) => mapIPTCExtensions(fileMeta, $scope.currentUser, $scope.parent),
367-
() => ({}), // proceed with upload on exif parsing error
350+
(): Partial<IArticle> => ({}), // proceed with upload on exif parsing error
368351
)
369352
.then((meta) => {
370353
const item = initFile(file, meta, getPseudoId());
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {parseMetadata} from '@uswriting/exiftool/cjs';
2+
import {IContentProfileType} from 'apps/workspace/content/controllers/ContentProfilesController';
3+
import {IPTCMetadata} from 'superdesk-api';
4+
import {getObjectEntries} from 'utils/object';
5+
import {EXIFTOOL_ARGS, XMP_IPTC_TAGS} from './constants';
6+
7+
type RawMetadata = {
8+
data: Array<Record<string, unknown>>;
9+
contentType: IContentProfileType.picture | IContentProfileType.video;
10+
};
11+
12+
type ExiftoolOptions = Parameters<typeof parseMetadata>[1];
13+
14+
const getMetadata = (f: File, options?: ExiftoolOptions) =>
15+
(f.type.startsWith('video/')
16+
? getVideoMetadata(f, options)
17+
: getPictureMetadata(f, options)
18+
).then(processMetadata, (): Partial<IPTCMetadata> => ({}));
19+
20+
const getVideoMetadata = (
21+
f: File,
22+
options?: ExiftoolOptions,
23+
): Promise<RawMetadata> =>
24+
parseMetadata<any>(f, {
25+
args: [EXIFTOOL_ARGS.showGroupNames, EXIFTOOL_ARGS.JSON, EXIFTOOL_ARGS.XMP],
26+
transform: (d) => JSON.parse(d),
27+
...options,
28+
}).then((r) => ({...r, contentType: IContentProfileType.video}));
29+
30+
const getPictureMetadata = (
31+
f: File,
32+
options?: ExiftoolOptions,
33+
): Promise<RawMetadata> =>
34+
parseMetadata<any>(f, {
35+
args: [
36+
EXIFTOOL_ARGS.showGroupNames,
37+
EXIFTOOL_ARGS.JSON,
38+
EXIFTOOL_ARGS.XMP,
39+
EXIFTOOL_ARGS.showDuplicates,
40+
EXIFTOOL_ARGS.IPTC,
41+
...EXIFTOOL_ARGS.COMPOSITE,
42+
],
43+
transform: (d) => JSON.parse(d),
44+
...options,
45+
}).then((r) => ({...r, contentType: IContentProfileType.picture}));
46+
47+
const processMetadata = (metadata: RawMetadata): Partial<IPTCMetadata> => {
48+
const data = metadata.data?.[0] ?? {};
49+
50+
if (metadata.contentType === IContentProfileType.video)
51+
return stripGroupNames(mapXMPtoIPTC(data));
52+
return stripGroupNames(data);
53+
};
54+
55+
const mapXMPtoIPTC = (metadata: RawMetadata['data'][number]) =>
56+
getObjectEntries(metadata).reduce<RawMetadata['data'][number]>(
57+
(acc, [k, v]) => {
58+
acc[XMP_IPTC_TAGS[k] ?? k] = v;
59+
return acc;
60+
},
61+
{},
62+
);
63+
64+
const stripGroupNames = (metadata: RawMetadata['data'][number]) =>
65+
(({IPTC, XMP, Composite}) => ({...IPTC, ...XMP, ...Composite}))(
66+
getObjectEntries(metadata).reduce<
67+
Record<string, Partial<Record<string, unknown>>>
68+
>(
69+
(a, [k, v]) => {
70+
const [group, tag] = k.split(':');
71+
72+
if (!group || !tag) return a;
73+
a[group][tag] = v;
74+
return a;
75+
},
76+
{IPTC: {}, XMP: {}, Composite: {}},
77+
),
78+
);
79+
80+
export {getMetadata, getPictureMetadata, getVideoMetadata};

0 commit comments

Comments
 (0)