Skip to content

Commit d01f62a

Browse files
committed
feat: add ELAN (EAF) transcription display for media and annotation files
Parse and render ELAN Annotation Format files with full support for alignable and reference annotations, multiple tiers, and document metadata. When viewing audio/video files with hasAnnotation relationships, the transcription is displayed below the player with time-aligned highlighting and click-to-seek. Direct .eaf file viewing shows a structured transcription with metadata header instead of raw XML.
1 parent d8f804b commit d01f62a

File tree

6 files changed

+524
-12
lines changed

6 files changed

+524
-12
lines changed

src/components/ElasticField.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const derived = computed(() => {
6262
name = String(field[0]);
6363
} else {
6464
url = testURL(field['@id']);
65-
name = Array.isArray(field.name) ? field.name[0] : field.name;
65+
name = Array.isArray(field.name) ? field.name[0] : field.name || field['@id'];
6666
description = Array.isArray(field.description) ? field.description[0] : field.description;
6767
6868
if (title === 'contentLocation') {

src/components/FileResolve.vue

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
<script setup lang="ts">
2-
import { inject, ref } from 'vue';
2+
import { inject, onMounted, ref } from 'vue';
33
import AccessHelper from '@/components/AccessHelper.vue';
44
import CSVWidget from '@/components/widgets/CSVWidget.vue';
5+
import EafTranscriptionWidget from '@/components/widgets/EafTranscriptionWidget.vue';
56
import PDFWidget from '@/components/widgets/PDFWidget.vue';
67
import PlainTextWidget from '@/components/widgets/PlainTextWidget.vue';
7-
import type { ApiService, EntityType, RoCrate } from '@/services/api';
8+
import type { AnnotationRef, ApiService, EntityType, RoCrate } from '@/services/api';
89
910
const api = inject<ApiService>('api');
1011
if (!api) {
1112
throw new Error('API instance not provided');
1213
}
1314
14-
const { entity, metadata } = defineProps<{
15+
const {
16+
entity,
17+
metadata,
18+
annotations = [],
19+
} = defineProps<{
1520
entity: EntityType;
1621
metadata: RoCrate['hasPart'][number] & { license?: RoCrate['license'] };
22+
annotations?: AnnotationRef[];
1723
}>();
1824
1925
const data = ref();
2026
const streamUrl = ref('');
27+
const annotationUrls = ref<string[]>([]);
28+
const currentTime = ref<number>(0);
29+
const mediaRef = ref<HTMLAudioElement | HTMLVideoElement | null>(null);
2130
2231
const resolveFile = async () => {
2332
if (entity.entityType !== 'http://schema.org/MediaObject') {
@@ -27,6 +36,30 @@ const resolveFile = async () => {
2736
streamUrl.value = (await api.getFileUrl(entity.fileId, metadata.filename, false)) || '';
2837
};
2938
39+
const resolveAnnotations = async () => {
40+
if (annotations.length === 0) {
41+
return;
42+
}
43+
44+
const results = await Promise.all(
45+
annotations.map(async (ann) => {
46+
const result = await api.getEntity(ann['@id']);
47+
if ('error' in result) {
48+
return null;
49+
}
50+
51+
const annEntity = result.entity;
52+
if (annEntity.entityType !== 'http://schema.org/MediaObject') {
53+
return null;
54+
}
55+
56+
const filename = ann.filename || annEntity.name;
57+
return api.getFileUrl(annEntity.fileId, filename, false);
58+
}),
59+
);
60+
annotationUrls.value = results.filter((url): url is string => !!url);
61+
};
62+
3063
const handleDownload = async () => {
3164
if (entity.entityType !== 'http://schema.org/MediaObject') {
3265
return;
@@ -38,15 +71,36 @@ const handleDownload = async () => {
3871
}
3972
};
4073
74+
const handleTimeUpdate = (event: Event) => {
75+
const el = event.target as HTMLMediaElement;
76+
currentTime.value = el.currentTime;
77+
};
78+
79+
const handleSeek = (seconds: number) => {
80+
if (mediaRef.value) {
81+
mediaRef.value.currentTime = seconds;
82+
}
83+
};
84+
4185
const extension = metadata.filename.split('.').pop() || '';
4286
const encodingFormat = [metadata.encodingFormat].flat();
4387
const plainEncodingFormats = encodingFormat.filter((ef) => typeof ef === 'string');
4488
const isCsv = plainEncodingFormats.some((ef) => ef.endsWith('csv')) || extension === 'csv';
89+
const isEaf = extension === 'eaf';
4590
const isTxt =
46-
plainEncodingFormats.some((ef) => ef.startsWith('text')) || ['txt', 'eaf', 'html', 'xml', 'flab'].includes(extension);
91+
!isEaf &&
92+
(plainEncodingFormats.some((ef) => ef.startsWith('text')) || ['txt', 'html', 'xml', 'flab'].includes(extension));
4793
const isPdf = plainEncodingFormats.some((ef) => ef.endsWith('pdf')) || extension === 'pdf';
94+
const isAudio = encodingFormat.some((f) => f?.startsWith('audio'));
95+
const isVideo = encodingFormat.some((f) => f?.startsWith('video'));
4896
4997
resolveFile();
98+
99+
onMounted(() => {
100+
if ((isAudio || isVideo) && annotations.length > 0) {
101+
resolveAnnotations();
102+
}
103+
});
50104
</script>
51105

52106
<template>
@@ -65,22 +119,32 @@ resolveFile();
65119
<CSVWidget :src="streamUrl" />
66120
</div>
67121

122+
<div v-else-if="isEaf" class="p-4">
123+
<EafTranscriptionWidget :src="streamUrl" v-if="streamUrl" show-header />
124+
</div>
125+
68126
<div v-else-if="isTxt" class="p-4 wrap-break-word">
69127
<PlainTextWidget :src="streamUrl" v-if="streamUrl" />
70128
</div>
71129

72-
<div v-else-if="encodingFormat.some((f) => f?.startsWith('audio'))" class="flex justify-center">
73-
<audio controls v-if="streamUrl">
130+
<div v-else-if="isAudio" class="flex flex-col items-center">
131+
<audio ref="mediaRef" controls v-if="streamUrl" @timeupdate="handleTimeUpdate">
74132
<source :src="streamUrl" :type="encodingFormat.find((f) => f.startsWith('audio'))">
75133
Your browser does not support the audio element.
76134
</audio>
135+
<div v-for="(url, index) in annotationUrls" :key="index" class="w-full mt-4">
136+
<EafTranscriptionWidget :src="url" :current-time="currentTime" @seek="handleSeek" />
137+
</div>
77138
</div>
78139

79-
<div v-else-if="encodingFormat?.some((f) => f?.startsWith('video'))" class="flex justify-center">
80-
<video controls v-if="streamUrl">
140+
<div v-else-if="isVideo" class="flex flex-col items-center">
141+
<video ref="mediaRef" controls v-if="streamUrl" @timeupdate="handleTimeUpdate">
81142
<source :src="streamUrl" :type="encodingFormat.find((f) => f.startsWith('video'))">
82143
Your browser does not support the video element.
83144
</video>
145+
<div v-for="(url, index) in annotationUrls" :key="index" class="w-full mt-4">
146+
<EafTranscriptionWidget :src="url" :current-time="currentTime" @seek="handleSeek" />
147+
</div>
84148
</div>
85149

86150
<div v-else-if="encodingFormat?.some((f) => f?.startsWith('image'))" class="flex justify-center">
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<script setup lang="ts">
2+
import { computed, nextTick, ref, watch } from 'vue';
3+
import { type EafDocument, parseEaf } from '@/composables/useEafParser';
4+
5+
const props = defineProps<{
6+
src: string;
7+
currentTime?: number;
8+
showHeader?: boolean;
9+
}>();
10+
11+
const emit = defineEmits<{
12+
seek: [seconds: number];
13+
}>();
14+
15+
const eafDoc = ref<EafDocument>();
16+
const selectedTierIds = ref<string[]>([]);
17+
const loading = ref(true);
18+
19+
const tiers = computed(() => eafDoc.value?.tiers.filter((t) => t.annotations.length > 0) ?? []);
20+
21+
type MergedRow = {
22+
startMs: number;
23+
endMs: number;
24+
texts: { tierId: string; value: string }[];
25+
};
26+
27+
const selectedTiers = computed(() => tiers.value.filter((t) => selectedTierIds.value.includes(t.tierId)));
28+
29+
const mergedRows = computed<MergedRow[]>(() => {
30+
const groups = new Map<string, MergedRow>();
31+
32+
for (const tier of selectedTiers.value) {
33+
for (const ann of tier.annotations) {
34+
const key = `${ann.startMs}-${ann.endMs}`;
35+
36+
let group = groups.get(key);
37+
if (!group) {
38+
group = { startMs: ann.startMs, endMs: ann.endMs, texts: [] };
39+
groups.set(key, group);
40+
}
41+
42+
group.texts.push({ tierId: tier.tierId, value: ann.value });
43+
}
44+
}
45+
46+
return Array.from(groups.values()).sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
47+
});
48+
49+
const showTierLabels = computed(() => selectedTierIds.value.length > 1);
50+
51+
const languageLabelMap = computed(() => {
52+
const map = new Map<string, string>();
53+
if (!eafDoc.value) {
54+
return map;
55+
}
56+
for (const lang of eafDoc.value.languages) {
57+
map.set(lang.langId, lang.langLabel || lang.langId);
58+
}
59+
return map;
60+
});
61+
62+
const fetchAndParse = async (url: string) => {
63+
loading.value = true;
64+
65+
try {
66+
const response = await fetch(url);
67+
if (!response.ok) {
68+
return;
69+
}
70+
71+
const xml = await response.text();
72+
eafDoc.value = parseEaf(xml);
73+
const nonEmpty = tiers.value;
74+
if (nonEmpty.length > 0) {
75+
selectedTierIds.value = [nonEmpty[0].tierId];
76+
}
77+
} finally {
78+
loading.value = false;
79+
}
80+
};
81+
82+
watch(
83+
() => props.src,
84+
(url) => {
85+
if (url) {
86+
fetchAndParse(url);
87+
}
88+
},
89+
{ immediate: true },
90+
);
91+
92+
const currentTimeMs = computed(() => (props.currentTime ?? -1) * 1000);
93+
94+
const formatTime = (ms: number): string => {
95+
const totalSeconds = Math.floor(ms / 1000);
96+
const minutes = Math.floor(totalSeconds / 60);
97+
const seconds = totalSeconds % 60;
98+
const millis = Math.floor((ms % 1000) / 10);
99+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(2, '0')}`;
100+
};
101+
102+
const isActive = (startMs: number, endMs: number): boolean => {
103+
if (props.currentTime === undefined) {
104+
return false;
105+
}
106+
107+
return currentTimeMs.value >= startMs && currentTimeMs.value < endMs;
108+
};
109+
110+
const tableRef = ref<InstanceType<typeof import('element-plus')['ElTable']>>();
111+
112+
const activeRowIndex = computed(() => {
113+
if (props.currentTime === undefined) {
114+
return -1;
115+
}
116+
117+
return mergedRows.value.findIndex((r) => isActive(r.startMs, r.endMs));
118+
});
119+
120+
watch(activeRowIndex, (index) => {
121+
if (index < 0) {
122+
return;
123+
}
124+
125+
nextTick(() => {
126+
const tableEl = tableRef.value?.$el as HTMLElement | undefined;
127+
if (!tableEl) {
128+
return;
129+
}
130+
131+
// Element Plus uses el-scrollbar inside the body wrapper
132+
const scrollViewport = tableEl.querySelector('.el-table__body-wrapper .el-scrollbar__wrap') as HTMLElement | null;
133+
const rows = tableEl.querySelectorAll('.el-table__body tbody tr');
134+
if (!scrollViewport || !rows?.[index]) {
135+
return;
136+
}
137+
138+
const row = rows[index] as HTMLElement;
139+
const viewportHeight = scrollViewport.clientHeight;
140+
const rowTop = row.offsetTop;
141+
const rowBottom = rowTop + row.offsetHeight;
142+
const scrollTop = scrollViewport.scrollTop;
143+
const scrollBottom = scrollTop + viewportHeight;
144+
145+
// If row is outside visible area, scroll to centre it
146+
if (rowBottom > scrollBottom || rowTop < scrollTop) {
147+
scrollViewport.scrollTo({
148+
top: rowTop - viewportHeight / 2,
149+
behavior: 'smooth',
150+
});
151+
}
152+
});
153+
});
154+
155+
const handleRowClick = (row: MergedRow) => {
156+
emit('seek', row.startMs / 1000);
157+
};
158+
159+
const tableRowClassName = ({ rowIndex }: { row: MergedRow; rowIndex: number }) => {
160+
return rowIndex === activeRowIndex.value ? 'eaf-active-row' : '';
161+
};
162+
</script>
163+
164+
<template>
165+
<div v-if="loading" class="p-4 text-center">
166+
<el-skeleton :rows="5" animated />
167+
</div>
168+
<div v-else-if="tiers.length > 0" class="w-full">
169+
<div v-if="props.showHeader && eafDoc" class="mb-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600">
170+
<div v-if="eafDoc.author"><span class="font-semibold">Author:</span> {{ eafDoc.author }}</div>
171+
<div v-if="eafDoc.date"><span class="font-semibold">Date:</span> {{ new
172+
Date(eafDoc.date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) }}</div>
173+
<div v-if="eafDoc.version"><span class="font-semibold">Version:</span> {{ eafDoc.version }}</div>
174+
<div v-if="eafDoc.format"><span class="font-semibold">Format:</span> {{ eafDoc.format }}</div>
175+
<div v-if="eafDoc.languages.length">
176+
<span class="font-semibold">Languages:</span>
177+
{{eafDoc.languages.map((l) => l.langLabel || l.langId).join(', ')}}
178+
</div>
179+
</div>
180+
181+
<div class="flex" style="height: 400px;">
182+
<div v-if="tiers.length > 1" class="pr-4 shrink-0 overflow-y-auto">
183+
<h4 class="text-lg font-semibold mb-2">Tiers ({{ tiers.length }})</h4>
184+
<el-checkbox :model-value="selectedTierIds.length === tiers.length"
185+
:indeterminate="selectedTierIds.length > 0 && selectedTierIds.length < tiers.length" class="mb-2"
186+
@change="(val: boolean) => selectedTierIds = val ? tiers.map((t) => t.tierId) : []">
187+
Select all
188+
</el-checkbox>
189+
<el-checkbox-group v-model="selectedTierIds" class="flex flex-col gap-2">
190+
<div v-for="tier in tiers" :key="tier.tierId">
191+
<el-checkbox :label="tier.tierId" :value="tier.tierId" />
192+
<div class="ml-6 text-xs text-gray-500">
193+
<div v-if="tier.annotations.length">Start: {{ formatTime(tier.annotations[0].startMs) }}</div>
194+
<div v-if="tier.participant">{{ tier.participant }}</div>
195+
<div v-if="tier.annotator">Annotator: {{ tier.annotator }}</div>
196+
<div v-if="tier.langRef && languageLabelMap.get(tier.langRef)">{{ languageLabelMap.get(tier.langRef) }}
197+
</div>
198+
</div>
199+
</div>
200+
</el-checkbox-group>
201+
</div>
202+
203+
<div class="flex-1 min-w-0">
204+
<el-table ref="tableRef" :data="mergedRows" :row-class-name="tableRowClassName" @row-click="handleRowClick"
205+
class="cursor-pointer" height="100%">
206+
<el-table-column label="Start" width="120">
207+
<template #default="{ row }">{{ formatTime(row.startMs) }}</template>
208+
</el-table-column>
209+
<el-table-column label="End" width="120">
210+
<template #default="{ row }">{{ formatTime(row.endMs) }}</template>
211+
</el-table-column>
212+
<el-table-column label="Text">
213+
<template #default="{ row }">
214+
<div v-for="(text, i) in row.texts" :key="i">
215+
<span v-if="showTierLabels" class="text-xs text-gray-400 mr-1">{{ text.tierId }}:</span>
216+
<span>{{ text.value }}</span>
217+
</div>
218+
</template>
219+
</el-table-column>
220+
</el-table>
221+
</div>
222+
</div>
223+
</div>
224+
</template>
225+
226+
<style>
227+
.eaf-active-row {
228+
--el-table-tr-bg-color: var(--el-color-primary-light-8);
229+
font-weight: bold;
230+
}
231+
</style>

0 commit comments

Comments
 (0)