Skip to content

Commit

Permalink
Merge pull request #72 from thekid/feature/metadata
Browse files Browse the repository at this point in the history
Show lens model and creation date from EXIF / XMP segments
  • Loading branch information
thekid authored Jan 2, 2025
2 parents ed4b889 + d6e2424 commit ae00cee
Show file tree
Hide file tree
Showing 13 changed files with 132 additions and 67 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

"require": {
"xp-framework/compiler": "^9.3",
"xp-framework/imaging": "^11.0",
"xp-framework/imaging": "^11.1",
"xp-framework/command": "^12.0",
"xp-framework/networking": "^10.4",
"xp-forge/marshalling": "^2.4",
Expand Down
12 changes: 9 additions & 3 deletions src/main/handlebars/layout.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@
left: 0;
font-size: .9rem;
display: grid;
grid-template-columns: repeat(6, max-content);
grid-template-columns: repeat(8, max-content);
gap: 1px;
justify-content: center;
pointer-events: none;
Expand Down Expand Up @@ -761,8 +761,12 @@
border-radius: 0;
}
.meta div:nth-child(2) {
grid-column: span 3;
.meta div:nth-child(1) {
grid-column: span 2;
}
.meta div:nth-child(4) {
grid-column: span 4;
}
.meta div {
Expand Down Expand Up @@ -811,8 +815,10 @@
<div class="image">
<img alt="Lightbox" width="100%">
<div class="meta">
<div><output name="datetime"></output></div>
<div><output name="make"></output></div>
<div><output name="model"></output></div>
<div><output name="lensmodel"></output></div>
<div>ISO <output name="isospeedratings"></output></div>
<div><output name="focallength"></output> mm</div>
<div><output name="aperturefnumber"></output></div>
Expand Down
31 changes: 19 additions & 12 deletions src/main/js/lightbox.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
class Lightbox {

#meta($meta, dataset) {
if ('' !== (dataset.make ?? '')) {
$meta.querySelectorAll('output').forEach($o => $o.value = dataset[$o.name]);
$meta.style.visibility = 'visible';
} else {
$meta.style.visibility = 'hidden';
}
}

/** Opens the given lightbox, loading the image and filling in meta data */
#open($target, $link, offset) {
const $full = $target.querySelector('img');
const $img = $link.querySelector('img');

// Use opening image...
$full.src = $img.src;
$target.dataset.offset = offset;
$target.showModal();

// Overlay meta data if present
const $meta = $target.querySelector('.meta');
if ('' !== ($img.dataset.make ?? '')) {
$meta.querySelectorAll('output').forEach($o => $o.value = $img.dataset[$o.name]);
$meta.style.visibility = 'visible';
} else {
$meta.style.visibility = 'hidden';
}

// ...then replace by larger version
this.#meta($target.querySelector('.meta'), $img.dataset);
$target.dataset.offset = offset;
$full.src = $link.href;
}

#navigate($target, $link, offset) {
this.#meta($target.querySelector('.meta'), $link.querySelector('img').dataset);
$target.dataset.offset = offset;
$target.querySelector('img').src = $link.href;
}

/** Attach all of the given elements to open the lightbox specified by the given DOM element */
attach(selector, $target) {
$target.addEventListener('click', e => {
Expand All @@ -42,7 +49,7 @@ class Lightbox {

e.stopPropagation();
if (offset >= 0 && offset < selector.length) {
this.#open($target, selector.item(offset), offset);
this.#navigate($target, selector.item(offset), offset);
}
});

Expand Down Expand Up @@ -72,7 +79,7 @@ class Lightbox {

e.stopPropagation();
if (offset >= 0 && offset < selector.length) {
this.#open($target, selector.item(offset), offset);
this.#navigate($target, selector.item(offset), offset);
}
});

Expand Down
5 changes: 4 additions & 1 deletion src/main/php/de/thekid/dialog/import/Cover.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
/** Imports the cover image */
class Cover extends Source {

/** Returns this source's name */
public function name(): string { return '@cover'; }

public function entryFrom(Description $description): array<string, mixed> {
$date= $description->meta['date'];
return [
Expand All @@ -15,7 +18,7 @@ public function entryFrom(Description $description): array<string, mixed> {
'title' => $description->meta['title'],
'keywords' => $description->meta['keywords'] ?? [],
'content' => $description->content,
'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date)->getTimeZone())],
'locations' => [...$description->locations(($date instanceof Date ? $date : new Date($date))->getTimeZone())],
'is' => ['cover' => true],
];
}
Expand Down
22 changes: 2 additions & 20 deletions src/main/php/de/thekid/dialog/import/LocalDirectory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

use Generator;
use de\thekid\dialog\processing\{Files, Images, Videos, ResizeTo};
use io\{File, Folder};
use lang\{Throwable, IllegalArgumentException};
use lang\Throwable;
use util\cmd\{Command, Arg};
use util\log\Logging;
use webservices\rest\{Endpoint, RestUpload};
Expand All @@ -19,29 +18,12 @@
* - cover.md: The image to use for the cover page
*/
class LocalDirectory extends Command {
private static $implementations= [
'content.md' => new Content(...),
'journey.md' => new Journey(...),
'cover.md' => new Cover(...),
];
private $source, $api;

/** Sets origin folder, e.g. `./imports/album` */
#[Arg(position: 0)]
public function from(string $origin): void {
foreach (self::$implementations as $source => $implementation) {
$file= new File($origin, $source);
if (!$file->exists()) continue;

$this->source= $implementation(new Folder($origin), $file);
return;
}

throw new IllegalArgumentException(sprintf(
'Cannot locate any of [%s] in %s',
implode(', ', array_keys(self::$implementations)),
$origin
));
$this->source= Source::in($origin);
}

/** Sets API url, e.g. `http://user:pass@localhost:8080/api` */
Expand Down
2 changes: 1 addition & 1 deletion src/main/php/de/thekid/dialog/import/LookupWeather.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function execute(Endpoint $api) {
// Infer date range from first and last images
$dates= [];
foreach ($this->images as $image) {
$dates[]= new Date(strtr($image['meta']['dateTime'], ['+00:00' => '']), $tz);
$dates[]= new Date($image['meta']['dateTime'], $tz);
}
usort($dates, fn($a, $b) => $b->compareTo($a));
$first= current($dates);
Expand Down
40 changes: 28 additions & 12 deletions src/main/php/de/thekid/dialog/import/Source.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ public function __construct(
$this->name= $this->origin->dirname;
}

/** Creates a source from a given origin folder */
public static function in(string|Folder $origin): self {
static $implementations= [
'content.md' => new Content(...),
'journey.md' => new Journey(...),
'cover.md' => new Cover(...),
];

foreach ($implementations as $source => $new) {
$file= new File($origin, $source);
if ($file->exists()) return $new($origin instanceof Folder ? $origin : new Folder($origin), $file);
}

throw new IllegalArgumentException(sprintf(
'Cannot locate any of [%s] in %s',
implode(', ', array_keys($implementations)),
$origin
));
}

/** Returns this source's name */
public function name(): string { return $this->name; }

Expand All @@ -29,26 +49,22 @@ public function parent(): ?string { return strstr($this->name(), '/', true) ?: n
/** Sets a parent for this source */
public function nestedIn(string $parent): self { $this->name= $parent.'/'.$this->name; return $this; }

/** Returns this source's origin */
public function origin(): Folder { return $this->origin; }

/** Yields all the media files in this source */
protected function mediaIn(Files $files): iterable {
static $processed= '/^(thumb|preview|full|video|screen)-/';

$images= [];
foreach ($this->entry['images'] ?? [] as $image) {
$images[$image['name']]= $image;
}

foreach ($this->origin->entries() as $path) {
$name= $path->name();
if ($path->isFile() && !preg_match($processed, $name) && ($processing= $files->processing($name))) {
$file= $path->asFile();
$name= $file->filename;

if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) {
yield new UploadMedia($this->entry['slug'], $file, $processing);
}
unset($images[$name]);
foreach ($files->in($this->origin) as $file => $processing) {
$name= $file->filename;
if (!isset($images[$name]) || $file->lastModified() > $images[$name]['modified']) {
yield new UploadMedia($this->entry['slug'], $file, $processing);
}
unset($images[$name]);
}

foreach ($images as $rest) {
Expand Down
31 changes: 29 additions & 2 deletions src/main/php/de/thekid/dialog/processing/Files.php
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
<?php namespace de\thekid\dialog\processing;

use io\Folder;

/** @test de.thekid.dialog.unittest.FilesTest */
class Files {
private $patterns= [];
private $processed= null;

/** Maps file extensions to a processing instance */
public function matching(array<string> $extensions, Processing $processing): self {
$this->patterns['/('.implode('|', array_map(preg_quote(...), $extensions)).')$/i']= $processing;
$this->processed= null;
return $this;
}

/** Returns a (cached) pattern to match all processed files */
public function processed(): string {
if (null !== $this->processed) return $this->processed;
$prefixes= [];
foreach ($this->patterns as $processing) {
foreach ($processing->prefixes() as $prefix) {
$prefixes[$prefix]= true;
}
}
return $this->processed= '/^('.implode('|', array_keys($prefixes)).')-/';
}

/** Returns processing instance based on filename, or NULL */
public function processing(string $filename): ?Processing {
foreach ($this->patterns as $pattern => $processing) {
if (preg_match($pattern, $filename)) return $processing;
if (!preg_match($this->processed(), $filename)) {
foreach ($this->patterns as $pattern => $processing) {
if (preg_match($pattern, $filename)) return $processing;
}
}
return null;
}

/** Yields files and their associated processing in a given folder */
public function in(Folder $origin): iterable {
foreach ($origin->entries() as $path) {
if ($path->isFile() && ($processing= $this->processing($path->name()))) {
yield $path->asFile() => $processing;
}
}
}
}
14 changes: 12 additions & 2 deletions src/main/php/de/thekid/dialog/processing/Images.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<?php namespace de\thekid\dialog\processing;

use img\io\MetaDataReader;
use img\io\{MetaDataReader, XMPSegment};
use io\File;

class Images extends Processing {
private const RDF= 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
private $meta= new MetaDataReader();

public function kind(): string { return 'image'; }
Expand All @@ -24,16 +25,25 @@ public function meta(File $source): array<string, mixed> {
$r+= [
'width' => $exif->width,
'height' => $exif->height,
'dateTime' => $exif->dateTime?->toString('c', self::$UTC) ?? gmdate('c'),
'dateTime' => $exif->dateTime?->toString(self::DATEFORMAT),
'make' => $exif->make,
'model' => $exif->model,
'lensModel' => $exif->lensModel,
'apertureFNumber' => $exif->apertureFNumber,
'exposureTime' => $exif->exposureTime,
'isoSpeedRatings' => $exif->isoSpeedRatings,
'focalLength' => $exif->focalLength ? $this->toRounded($exif->focalLength, precision: 1) : null,
'flashUsed' => $exif->flashUsed(),
];
}

// Merge in XMP segment
if ($xmp= $meta?->segmentsOf(XMPSegment::class)) {
foreach ($xmp[0]->document()->getElementsByTagNameNS(self::RDF, 'Description')[0]->attributes as $attr) {
$r[lcfirst($attr->name)]= $attr->value;
}
}
$r['lensModel']??= $r['lens'] ?? '(Unknown Lens)';
return $r;
} finally {
$source->close();
Expand Down
5 changes: 4 additions & 1 deletion src/main/php/de/thekid/dialog/processing/Processing.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
use util\TimeZone;

abstract class Processing {
protected static $UTC= new TimeZone('UTC');
protected const DATEFORMAT= 'd.m.Y H:i';
protected $targets= [];

/** Returns processing kind */
public abstract function kind(): string;

/** Returns prefixes used by the targets */
public function prefixes(): array<string> { return array_keys($this->targets); }

/**
* Adds a conversion target with a given prefix and conversion target.
* Fluent interface.
Expand Down
12 changes: 8 additions & 4 deletions src/main/php/de/thekid/dialog/processing/Videos.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ class Videos extends Processing {

public function __construct(private string $executable= 'ffmpeg') { }

/** Returns processing kind */
public function kind(): string { return 'video'; }

/** Returns prefixes used by the targets */
public function prefixes(): array<string> { return [...parent::prefixes(), 'video', 'screen']; }

/** Executes a given external command and returns its exit code */
private function execute(string $command, array<string> $args): void {
$p= new Process($command, $args, null, null, [STDIN, STDOUT, STDERR]);
Expand All @@ -27,7 +31,7 @@ private function execute(string $command, array<string> $args): void {
}

public function meta(File $source): array<string, mixed> {
static $MAP= [
static $mdta= [
'mdta:com.apple.quicktime.make' => 'make',
'mdta:com.apple.quicktime.model' => 'model',
'mdta:com.android.manufacturer' => 'make',
Expand All @@ -49,21 +53,21 @@ public function meta(File $source): array<string, mixed> {
// Normalize meta data from iOS and Android devices
$r= [];
foreach ($meta as $key => $value) {
if ($mapped= $MAP[$key] ?? null) {
if ($mapped= $mdta[$key] ?? null) {
$r[$mapped]= $value[0];
}
}

// Prefer original creation date from iOS, converting it to local time
if ($date= $meta['mdta:com.apple.quicktime.creationdate'][0] ?? null) {
$r['dateTime']= new Date(preg_replace('/[+-][0-9]{4}$/', '', $date))->toString('c', self::$UTC);
$r['dateTime']= new Date(preg_replace('/[+-][0-9]{4}$/', '', $date))->toString(self::DATEFORMAT);
}

// Aggregate information from movie header: Duration and creation time
// Time info is the number of seconds since 1904-01-01 00:00:00 UTC
if (isset($meta['mvhd'])) {
$r['duration']= round($meta['mvhd']['duration'] / $meta['mvhd']['scale'], 3);
$r['dateTime']??= new Date($meta['mvhd']['created'] - 2082844800)->toString('c', self::$UTC);
$r['dateTime']??= new Date($meta['mvhd']['created'] - 2082844800)->toString(self::DATEFORMAT);
}

return $r;
Expand Down
Loading

0 comments on commit ae00cee

Please sign in to comment.