Initial commit
This commit is contained in:
commit
0f91c3e941
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
**/__pycache__
|
27
backup.sh
Normal file
27
backup.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/bash
|
||||
shopt -s nocaseglob
|
||||
shopt -s dotglob
|
||||
|
||||
trap "exit" INT
|
||||
|
||||
read -r -s -p "Password: " PWD
|
||||
echo ""
|
||||
|
||||
host="192.168.20.2"
|
||||
user="oke"
|
||||
extra_args=("--info=progress2" "-h" "${@:1}")
|
||||
do_backup() (
|
||||
set -e
|
||||
echo "Waiting for '$host' to be up ..."
|
||||
until ping -c1 "$host" >/dev/null 2>&1; do :; done
|
||||
echo "... done waiting"
|
||||
|
||||
sshpass -p "$PWD" \
|
||||
rsync -av "${extra_args[@]}" --relative "$HOME/Pictures/DarktableLocal" "$user@$host":lenovo-darktable
|
||||
|
||||
sshpass -p "$PWD" \
|
||||
rsync -av "${extra_args[@]}" --relative "$HOME/.config/darktable" "$user@$host":lenovo-darktable
|
||||
|
||||
)
|
||||
|
||||
do_backup
|
187
check_videos.py
Normal file
187
check_videos.py
Normal file
@ -0,0 +1,187 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import datetime
|
||||
import glob
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import re
|
||||
|
||||
def get_time(fn):
|
||||
try:
|
||||
output = subprocess.check_output([
|
||||
"ffprobe", "-v", "quiet", fn, "-print_format", "json",
|
||||
"-show_entries",
|
||||
"stream=index,codec_type:stream_tags=creation_time:format_tags=creation_time"
|
||||
])
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("FFPROBE failed for ", fn)
|
||||
return ValueError()
|
||||
fmt = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
data = json.loads(output)
|
||||
if "streams" in data:
|
||||
for d in data["streams"]:
|
||||
if "tags" in d and "creation_time" in d["tags"]:
|
||||
return datetime.datetime.strptime(d["tags"]["creation_time"], fmt)
|
||||
if "format" in data and "tags" in data["format"] and "creation_time" in data["format"]["tags"]:
|
||||
return datetime.datetime.strptime(data["format"]["tags"]["creation_time"], fmt)
|
||||
# import sys
|
||||
# import zoneinfo
|
||||
# dt = datetime.datetime.fromtimestamp(
|
||||
# os.stat(fn).st_ctime, zoneinfo.ZoneInfo("UTC"))
|
||||
# dt = dt.replace(tzinfo=None)
|
||||
# return dt
|
||||
return ValueError()
|
||||
def get_time_image(fn):
|
||||
fmt = "%Y:%m:%d %H:%M:%S"
|
||||
return datetime.datetime.strptime(Image.open(fn)._getexif()[36867], fmt)
|
||||
|
||||
def get_new_fn(fn):
|
||||
base, ext = os.path.splitext(fn)
|
||||
directory, base_fn = os.path.split(base)
|
||||
ext = ext.lower()
|
||||
|
||||
image_files = list(
|
||||
filter(lambda fn: os.path.splitext(fn)[1].lower() in [".jpeg", ".jpg", ".png"],
|
||||
glob.iglob(glob.escape(base) + "*")))
|
||||
assert len(image_files) < 2
|
||||
fmt_folder = "%Y%m%d"
|
||||
fmt_base = "%Y%m%d_%H%M"
|
||||
if image_files:
|
||||
f = image_files[0]
|
||||
image_ext = os.path.splitext(f)[-1]
|
||||
image_dt = get_time_image(f)
|
||||
image_size = os.path.getsize(f)
|
||||
new_base = os.path.join(os.path.expanduser("~/Pictures/Darktable"), image_dt.strftime(fmt_folder), image_dt.strftime(fmt_base))
|
||||
|
||||
g = glob.escape(new_base) + "_*" + image_ext
|
||||
matches = [g for g in glob.glob(g) if image_size == os.path.getsize(g)]
|
||||
if len(matches) != 1:
|
||||
print()
|
||||
print("NO MATCH")
|
||||
print(image_files)
|
||||
print(matches)
|
||||
print(g)
|
||||
print(image_dt)
|
||||
|
||||
return None
|
||||
assert len(matches) == 1, fn
|
||||
nfn = os.path.splitext(matches[0])[0] + ext
|
||||
nfn = list(os.path.split(nfn))
|
||||
nfn[-1] = "." + nfn[-1]
|
||||
return os.path.join(*nfn)
|
||||
|
||||
time = get_time(fn)
|
||||
if isinstance(time, ValueError):
|
||||
print("NO TIME")
|
||||
return None
|
||||
new_base = os.path.join(os.path.expanduser("~/Pictures/Darktable"), time.strftime(fmt_folder), time.strftime(fmt_base))
|
||||
g = glob.glob(glob.escape(new_base) + "_*" + ext)
|
||||
if len(g) == 0:
|
||||
return new_base + "_0000" + ext
|
||||
n = 0
|
||||
s = os.path.getsize(fn)
|
||||
for f in g:
|
||||
if os.path.getsize(f) == s:
|
||||
return f
|
||||
p = r".*_(\d+)" + ext
|
||||
nn = re.match(p, f)
|
||||
n = max(n, int(nn.group(1)))
|
||||
n = new_base + "_%04d" % (n + 1) + ext
|
||||
return n
|
||||
|
||||
def main(dir, dry):
|
||||
from datetime import timedelta, datetime
|
||||
print(dir)
|
||||
offsets = {}
|
||||
for date in sorted(os.listdir(dir)):
|
||||
path = os.path.join(dir, date)
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
print(date)
|
||||
date = datetime.strptime(date, '%Y%m%d')
|
||||
|
||||
offset = None
|
||||
for fn in sorted(os.listdir(path)):
|
||||
|
||||
fn = os.path.join(path, fn)
|
||||
_, ext = os.path.splitext(fn)
|
||||
if ext.lower() not in ['.jpeg', '.jpg']:
|
||||
continue
|
||||
|
||||
import pprint
|
||||
from PIL import ExifTags
|
||||
import exifread
|
||||
import re
|
||||
|
||||
with open(fn, 'rb') as f:
|
||||
|
||||
tags = exifread.process_file(f)
|
||||
|
||||
try:
|
||||
model = str(tags['Image Model'])
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if str(model) != 'Pixel 6' or str(model).startswith("iPhone"):
|
||||
continue
|
||||
|
||||
offset_tags = ['EXIF OffsetTime', 'EXIF OffsetTimeOriginal', 'EXIF OffsetTimeDigitized', 'EXIF TimeZoneOffset']
|
||||
|
||||
for tag in offset_tags:
|
||||
try:
|
||||
offset_str = str(tags[tag])
|
||||
except KeyError:
|
||||
continue
|
||||
assert re.match(r"(\+|-)\d\d:\d\d", offset_str)
|
||||
offset = timedelta(hours=int(offset_str[1:3]), minutes=int(offset_str[4:6]))
|
||||
if offset_str[0] == '-':
|
||||
offset = -offset
|
||||
|
||||
break
|
||||
|
||||
break
|
||||
import pprint
|
||||
|
||||
if offset:
|
||||
offsets[date] = offset
|
||||
|
||||
for p, folders, files in os.walk(os.path.expanduser(dir)):
|
||||
for f in sorted(files):
|
||||
fn = os.path.join(p, f)
|
||||
|
||||
base, ext = os.path.splitext(f)
|
||||
if ext.lower() not in [".mp4", ".mov", ".mts"]:
|
||||
continue
|
||||
|
||||
time = get_time(fn)
|
||||
if isinstance(time, ValueError):
|
||||
continue
|
||||
|
||||
diff = sorted(offsets.keys(), key=lambda d: abs((d - time).total_seconds()))
|
||||
|
||||
# print(diff)
|
||||
# print(diff[0])
|
||||
# print(offsets[diff[0]])
|
||||
|
||||
print(time)
|
||||
print(time + offsets[diff[0]])
|
||||
print()
|
||||
continue
|
||||
nfn = get_new_fn(fn)
|
||||
if not nfn:
|
||||
print("SKIP: ", fn)
|
||||
continue
|
||||
assert nfn
|
||||
|
||||
print(os.path.basename(fn))
|
||||
print(os.path.basename(nfn))
|
||||
print()
|
||||
|
||||
|
||||
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry", action="store_true")
|
||||
options = parser.parse_args()
|
||||
main("/home/oke/Pictures/DarktableLocal", options.dry)
|
133
copy_videos.py
Normal file
133
copy_videos.py
Normal file
@ -0,0 +1,133 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import datetime
|
||||
import glob
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import re
|
||||
|
||||
def get_time(fn):
|
||||
try:
|
||||
output = subprocess.check_output([
|
||||
"ffprobe", "-v", "quiet", fn, "-print_format", "json",
|
||||
"-show_entries",
|
||||
"stream=index,codec_type:stream_tags=creation_time:format_tags=creation_time"
|
||||
])
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("FFPROBE failed for ", fn)
|
||||
return ValueError()
|
||||
fmt = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
data = json.loads(output)
|
||||
if "streams" in data:
|
||||
for d in data["streams"]:
|
||||
if "tags" in d and "creation_time" in d["tags"]:
|
||||
return datetime.datetime.strptime(d["tags"]["creation_time"], fmt)
|
||||
if "format" in data and "tags" in data["format"] and "creation_time" in data["format"]["tags"]:
|
||||
return datetime.datetime.strptime(data["format"]["tags"]["creation_time"], fmt)
|
||||
#import sys
|
||||
#import zoneinfo
|
||||
#dt = datetime.datetime.fromtimestamp(
|
||||
# os.stat(fn).st_ctime, zoneinfo.ZoneInfo("UTC"))
|
||||
#dt = dt.replace(tzinfo=None)
|
||||
#return dt
|
||||
return ValueError()
|
||||
def get_time_image(fn):
|
||||
fmt = "%Y:%m:%d %H:%M:%S"
|
||||
return datetime.datetime.strptime(Image.open(fn)._getexif()[36867], fmt)
|
||||
|
||||
def get_new_fn(fn):
|
||||
base, ext = os.path.splitext(fn)
|
||||
directory, base_fn = os.path.split(base)
|
||||
ext = ext.lower()
|
||||
|
||||
image_files = list(
|
||||
filter(lambda fn: os.path.splitext(fn)[1].lower() in [".jpeg", ".jpg", ".png"],
|
||||
glob.iglob(glob.escape(base) + "*")))
|
||||
assert len(image_files) < 2
|
||||
fmt_folder = "%Y%m%d"
|
||||
fmt_base = "%Y%m%d_%H%M"
|
||||
if image_files:
|
||||
f = image_files[0]
|
||||
image_ext = os.path.splitext(f)[-1]
|
||||
image_dt = get_time_image(f)
|
||||
image_size = os.path.getsize(f)
|
||||
new_base = os.path.join(os.path.expanduser("~/Pictures/Darktable"), image_dt.strftime(fmt_folder), image_dt.strftime(fmt_base))
|
||||
|
||||
g = glob.escape(new_base) + "_*" + image_ext
|
||||
matches = [g for g in glob.glob(g) if image_size == os.path.getsize(g)]
|
||||
if len(matches) != 1:
|
||||
print()
|
||||
print("NO MATCH")
|
||||
print(image_files)
|
||||
print(matches)
|
||||
print(g)
|
||||
print(image_dt)
|
||||
|
||||
return None
|
||||
assert len(matches) == 1, fn
|
||||
nfn = os.path.splitext(matches[0])[0] + ext
|
||||
nfn = list(os.path.split(nfn))
|
||||
nfn[-1] = "." + nfn[-1]
|
||||
return os.path.join(*nfn)
|
||||
|
||||
time = get_time(fn)
|
||||
if isinstance(time, ValueError):
|
||||
print("NO TIME")
|
||||
return None
|
||||
new_base = os.path.join(os.path.expanduser("~/Pictures/Darktable"), time.strftime(fmt_folder), time.strftime(fmt_base))
|
||||
g = glob.glob(glob.escape(new_base) + "_*" + ext)
|
||||
if len(g) == 0:
|
||||
return new_base + "_0000" + ext
|
||||
n = 0
|
||||
s = os.path.getsize(fn)
|
||||
for f in g:
|
||||
if os.path.getsize(f) == s:
|
||||
return f
|
||||
p = r".*_(\d+)" + ext
|
||||
nn = re.match(p, f)
|
||||
n = max(n, int(nn.group(1)))
|
||||
n = new_base + "_%04d" % (n + 1) + ext
|
||||
return n
|
||||
|
||||
def main(dir, dry):
|
||||
print(dir)
|
||||
for p, folders, files in os.walk(os.path.expanduser(dir)):
|
||||
for f in sorted(files):
|
||||
base, ext = os.path.splitext(f)
|
||||
|
||||
ext = ext.lower()
|
||||
if ext not in [".mp4", ".mov", ".mts"]:
|
||||
if ext not in [".jpg", ".jpeg", ".png"]:
|
||||
pass # print("Not handled: ", os.path.join(p, f))
|
||||
continue
|
||||
fn = os.path.join(p, f)
|
||||
nfn = get_new_fn(fn)
|
||||
if not nfn:
|
||||
print("SKIP: ", fn)
|
||||
continue
|
||||
assert nfn
|
||||
|
||||
if not os.path.exists(nfn):
|
||||
if not os.path.exists(os.path.dirname(nfn)):
|
||||
os.makedirs(os.path.dirname(nfn))
|
||||
print(fn, nfn)
|
||||
if not dry:
|
||||
shutil.copy(fn, nfn)
|
||||
else:
|
||||
print(fn, nfn, "EXIST")
|
||||
|
||||
|
||||
|
||||
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry", action="store_true")
|
||||
options = parser.parse_args()
|
||||
main("/run/media/oke/3634-3063", options.dry)
|
||||
main("/run/media/oke/3138-3162", options.dry)
|
||||
main("/run/media/oke/01D5-9878/DCIM/", options.dry)
|
||||
main("/run/media/oke/E5B5-DBF0/DCIM", options.dry)
|
||||
main("/run/media/oke/disk/", options.dry)
|
||||
main("~/Nextcloud/InstantUpload", options.dry)
|
||||
main("/home/oke/Desktop/360/", options.dry)
|
241
dt_auto_group.py
Normal file
241
dt_auto_group.py
Normal file
@ -0,0 +1,241 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
|
||||
def is_raw(image):
|
||||
return image.filename.split(".")[-1].lower() not in ["jpeg", "jpg", "png"]
|
||||
|
||||
def consume_group_pixel_6(images):
|
||||
# print("NEXT: ", images[0])
|
||||
i = 1
|
||||
for i in range(1, len(images)):
|
||||
# print(images[i], images[i].datetime_taken, images[0].datetime_taken, images[i].datetime_taken - images[0].datetime_taken)
|
||||
if images[i].datetime_taken - images[0].datetime_taken > timedelta(milliseconds=600):
|
||||
break
|
||||
else:
|
||||
i = len(images)
|
||||
# print(i)
|
||||
if i == 1:
|
||||
# print(images[:1])
|
||||
return images[:1], images[1:]
|
||||
|
||||
def priority(image):
|
||||
return - (
|
||||
image.iso - images[0].iso +
|
||||
image.aperture - images[0].aperture +
|
||||
image.exposure - images[0].exposure
|
||||
)
|
||||
|
||||
sorted_images = sorted(filter(lambda image: is_raw(image) != is_raw(images[0]), images[1:i]), key=priority)
|
||||
|
||||
if len(sorted_images) == 0:
|
||||
# print(images[:1])
|
||||
return images[:1], images[1:]
|
||||
|
||||
group = [images[0], sorted_images[0]]
|
||||
|
||||
images = images[1:]
|
||||
images.remove(sorted_images[0])
|
||||
# print(group)
|
||||
return group, images
|
||||
|
||||
|
||||
def consume_group_burst(images, delta=None):
|
||||
if delta is None:
|
||||
delta = timedelta(seconds=1)
|
||||
i = -1
|
||||
for i in range(len(images)):
|
||||
# print(i, images[0], images[0].datetime_taken, images[i], images[i].datetime_taken, (images[i].datetime_taken - images[0].datetime_taken))
|
||||
if (images[i].datetime_taken - images[0].datetime_taken) > delta:
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return images[:i], images[i:]
|
||||
|
||||
def consume_group_exposure_bracketing(images):
|
||||
seen_exposures = set([])
|
||||
seen_raw_exposures = set([])
|
||||
|
||||
def trace_exposure(image):
|
||||
bias = int(image.exposure_bias * 100)
|
||||
info((image, image.datetime_taken, bias))
|
||||
if is_raw(image):
|
||||
is_new = bias not in seen_raw_exposures
|
||||
if not is_new:
|
||||
info(seen_raw_exposures, seen_exposures, bias)
|
||||
seen_raw_exposures.add(bias)
|
||||
else:
|
||||
is_new = bias not in seen_exposures
|
||||
if not is_new:
|
||||
info(seen_raw_exposures, seen_exposures, bias)
|
||||
seen_exposures.add(bias)
|
||||
return is_new
|
||||
|
||||
info(images[0])
|
||||
trace_exposure(images[0])
|
||||
for i in range(1, len(images)):
|
||||
if images[i].datetime_taken - images[i - 1].datetime_taken > timedelta(seconds=1):
|
||||
info("::: Out of time")
|
||||
break
|
||||
|
||||
if not trace_exposure(images[i]):
|
||||
info("::: Exposure seen")
|
||||
break
|
||||
info(images[i])
|
||||
|
||||
else:
|
||||
i = len(images)
|
||||
|
||||
return images[:i], images[i:]
|
||||
|
||||
|
||||
def select_leader(images, prefer_raw):
|
||||
|
||||
def leader_preference(image):
|
||||
return (
|
||||
image.flag(ImageFlags.REJECTED),
|
||||
-image.stars,
|
||||
-votes[image],
|
||||
abs(image.exposure_bias),
|
||||
(-int(is_raw(image))) if prefer_raw else int(is_raw(image)),
|
||||
image.datetime_taken
|
||||
)
|
||||
|
||||
votes = {}
|
||||
for image in images:
|
||||
if image not in votes:
|
||||
votes[image] = 0
|
||||
if image.group in images:
|
||||
if image.group not in votes:
|
||||
votes[image.group] = 0
|
||||
votes[image.group] += 1
|
||||
|
||||
leader = min(images, key=leader_preference)
|
||||
|
||||
return leader
|
||||
|
||||
def info(*args):
|
||||
pass
|
||||
# print(*args)
|
||||
|
||||
def validate_group(images, prefer_raw):
|
||||
will_degrade = False
|
||||
has_changes = False
|
||||
|
||||
leader = select_leader(images, prefer_raw)
|
||||
|
||||
for image in images:
|
||||
if image.group != leader:
|
||||
if image.group == image and not image.flag(ImageFlags.REJECTED):
|
||||
# if image was group leader and was not rejected, changing the
|
||||
# group leader would degrate image
|
||||
will_degrade = True
|
||||
print("Below change would degrate dataset: MANUAL ATTENTION NEEDED.")
|
||||
print("Change group leader of {}: {} => {}".format(image, image.group, leader))
|
||||
image.group = leader
|
||||
has_changes = True
|
||||
|
||||
if has_changes:
|
||||
stars = max(map(lambda image: image.stars, images))
|
||||
rejected = min(map(lambda image: image.flag(ImageFlags.REJECTED), images))
|
||||
for image in images:
|
||||
if image.flag(ImageFlags.REJECTED) != rejected:
|
||||
print("Change rejected status of {}: {} => {}".format(image, image.flag(ImageFlags.REJECTED), rejected))
|
||||
image.set_flag(ImageFlags.REJECTED, rejected)
|
||||
if image.stars != stars:
|
||||
print("Change stars of {}: {} => {}".format(image, image.stars, stars))
|
||||
image.stars = stars
|
||||
print()
|
||||
return has_changes, will_degrade
|
||||
|
||||
|
||||
def main(date_from, force, dry_run):
|
||||
|
||||
images = set([])
|
||||
for r in FilmRoll.filter():
|
||||
try:
|
||||
if datetime.strptime(os.path.split(r.folder)[-1], "%Y%m%d").date() >= date_from:
|
||||
# print(r)
|
||||
for image in Image.filter(film=r):
|
||||
images.add(image)
|
||||
else:
|
||||
pass
|
||||
# print("not", r)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
print("Ignoring", r)
|
||||
|
||||
images = sorted(filter(lambda image: image.datetime_taken, images), key=lambda image: (image.datetime_taken, -is_raw(image), image.filename))
|
||||
|
||||
by_model = {}
|
||||
for image in images:
|
||||
key = (image.maker, image.model)
|
||||
if key not in by_model:
|
||||
by_model[key] = []
|
||||
by_model[key].append(image)
|
||||
|
||||
n = 0
|
||||
for model, images in by_model.items():
|
||||
|
||||
images = images[:]
|
||||
prefer_raw = True
|
||||
while len(images) > 0:
|
||||
if model[1].name == "DSC-RX100":
|
||||
group, images = consume_group_exposure_bracketing(images)
|
||||
elif model[1].name == "ZV-1" or model[1].name == "ILCE-6700":
|
||||
original_images = images
|
||||
group, images = consume_group_exposure_bracketing(original_images)
|
||||
group_burst, images_burst = consume_group_burst(original_images)
|
||||
if len(group_burst) > len(group):
|
||||
group, images = group_burst, images_burst
|
||||
elif model[1].name == "Pixel 6":
|
||||
group, images = consume_group_pixel_6(images)
|
||||
prefer_raw = False
|
||||
|
||||
elif (
|
||||
model[1].name == "HERO9 Black" or
|
||||
model[1].name == "HERO11 Black"
|
||||
):
|
||||
group, images = consume_group_burst(images, timedelta(seconds=3))
|
||||
prefer_raw = False
|
||||
elif (
|
||||
model[1].name == "HERO3+ Black Edition" or
|
||||
model[1].name == "iPhone 12 mini" or
|
||||
model[1].name == "iPhone 15 Pro Max"
|
||||
):
|
||||
group, images = images[:1], images[1:]
|
||||
else:
|
||||
if model[1].name != '':
|
||||
for image in images:
|
||||
print(image)
|
||||
print("Unkown model {} - {}".format(*model))
|
||||
break
|
||||
|
||||
has_changes, will_degrade = validate_group(group, prefer_raw)
|
||||
if has_changes:
|
||||
n += 1
|
||||
|
||||
if not dry_run and (not will_degrade or force):
|
||||
for image in group:
|
||||
print("Saving {}".format(image))
|
||||
image.save()
|
||||
|
||||
print("incorrect", n)
|
||||
|
||||
import argparse
|
||||
if __name__ == '__main__':
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--save", action="store_true")
|
||||
parser.add_argument("--from", dest="from_date", default=None)
|
||||
|
||||
options = parser.parse_args()
|
||||
|
||||
if options.from_date is None:
|
||||
from_date = datetime.now().date()
|
||||
else:
|
||||
from_date = datetime.strptime(options.from_date, "%Y%m%d").date()
|
||||
main(from_date, options.force, not options.save)
|
51
dt_cleanup.py
Normal file
51
dt_cleanup.py
Normal file
@ -0,0 +1,51 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
|
||||
import peewee as pw
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
base_path = "/home/oke/Pictures/Darktable"
|
||||
|
||||
subprocess.check_call(["mountpoint", "/home/oke/Pictures/DarktableRemote"])
|
||||
real_folders = [
|
||||
"/home/oke/Pictures/DarktableLocal",
|
||||
"/home/oke/Pictures/DarktableRemote",
|
||||
]
|
||||
|
||||
query: list[Image] = Image.filter()
|
||||
pw.prefetch(query, FilmRoll)
|
||||
images = list(query)
|
||||
import itertools
|
||||
|
||||
|
||||
required_files = list(itertools.chain(*[
|
||||
[os.path.join(image.film.folder, image.filename),
|
||||
os.path.join(image.film.folder,
|
||||
os.path.splitext(image.filename)[0] +
|
||||
(("_%02d" % image.version) if image.version != 0 else "") +
|
||||
os.path.splitext(image.filename)[1]) + ".xmp"
|
||||
]
|
||||
for image in images]))
|
||||
|
||||
allowed_files = list(itertools.chain(*[
|
||||
[os.path.join(image.film.folder, "." + os.path.splitext(image.filename)[0] + ".mov")]
|
||||
for image in images])) + required_files
|
||||
|
||||
|
||||
|
||||
for fn in allowed_files:
|
||||
assert fn.startswith(base_path)
|
||||
|
||||
for folder in real_folders:
|
||||
real_files = set([os.path.join(path, file) for path, folders, files in os.walk(folder) for file in files if file[-3:] not in ["mov", "mp4", "mts", "ata"]])
|
||||
mapped_allowed = [fn.replace(base_path, folder) for fn in allowed_files]
|
||||
|
||||
print(":::: Files to remove:")
|
||||
for fn in sorted(real_files.difference(set(mapped_allowed))):
|
||||
print(fn)
|
||||
|
||||
if folder == "/home/oke/Pictures/DarktableRemote":
|
||||
print(":::: Missing files:")
|
||||
mapped_required = [fn.replace(base_path, folder) for fn in required_files]
|
||||
for fn in sorted(set(mapped_required).difference(set(real_files))):
|
||||
print(fn)
|
100
dt_fix_file_structure.py
Normal file
100
dt_fix_file_structure.py
Normal file
@ -0,0 +1,100 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
|
||||
from datetime import datetime
|
||||
import peewee as pw
|
||||
import os
|
||||
from pprint import pprint
|
||||
import subprocess
|
||||
|
||||
base_path = "/home/oke/Pictures/Darktable"
|
||||
real_folders = [
|
||||
"/home/oke/Pictures/DarktableRemote",
|
||||
"/home/oke/Pictures/DarktableLocal",
|
||||
]
|
||||
|
||||
subprocess.check_call(["mountpoint", "/home/oke/Pictures/DarktableRemote"])
|
||||
|
||||
query = Image.filter()
|
||||
pw.prefetch(query, FilmRoll)
|
||||
dry = True
|
||||
|
||||
move_files = []
|
||||
|
||||
|
||||
d_root = "/home/oke/Pictures/Darktable"
|
||||
d_local = "/home/oke/Pictures/DarktableLocal"
|
||||
d_remote = "/home/oke/Pictures/DarktableRemote"
|
||||
|
||||
|
||||
def me_and_related_dt(fn, new_folder, new_basename):
|
||||
directory, filename = os.path.split(fn)
|
||||
basename, ext = os.path.splitext(filename)
|
||||
yield fn, os.path.join(new_folder, new_basename + ext)
|
||||
yield os.path.join(directory, "." + basename + ".mov"), os.path.join(new_folder, "." + new_basename + ".mov"),
|
||||
yield fn + ".xmp", os.path.join(new_folder, new_basename + ext + ".xmp")
|
||||
|
||||
|
||||
def me_and_related_potential(fn, new_folder, new_basename):
|
||||
for local_fn, local_fn_related in me_and_related_dt(fn, new_folder, new_basename):
|
||||
assert local_fn.startswith(d_root)
|
||||
yield local_fn.replace(d_root, d_local), local_fn_related.replace(d_root, d_local)
|
||||
yield local_fn.replace(d_root, d_remote), local_fn_related.replace(d_root, d_remote)
|
||||
|
||||
def me_and_related(fn, new_folder, new_basename):
|
||||
for fn, fn_related in me_and_related_potential(fn, new_folder, new_basename):
|
||||
if os.path.exists(fn):
|
||||
assert not os.path.exists(fn_related), fn + "=>" + fn_related
|
||||
yield fn, fn_related
|
||||
|
||||
changes = []
|
||||
|
||||
for image in query:
|
||||
try:
|
||||
n = 0
|
||||
dt_fn = datetime.strptime(image.filename[:13],"%Y%m%d_%H%M")
|
||||
if image.datetime_taken:
|
||||
dt_internal = datetime(image.datetime_taken.year, image.datetime_taken.month, image.datetime_taken.day, image.datetime_taken.hour, image.datetime_taken.minute)
|
||||
expected_folder = os.path.join(base_path, dt_internal.strftime("%Y%m%d"))
|
||||
if dt_fn != dt_internal:
|
||||
d = ((dt_internal - dt_fn).total_seconds() / 3600)
|
||||
dt_formatted = image.datetime_taken.strftime("%Y%m%d_%H%M")
|
||||
new_film_roll = image.film
|
||||
if expected_folder != image.film.folder:
|
||||
new_film_roll = FilmRoll.get(folder=expected_folder)
|
||||
assert new_film_roll
|
||||
|
||||
new_fn = dt_formatted + image.filename[13:]
|
||||
new_basename, _ = os.path.splitext(new_fn)
|
||||
|
||||
changes.append((
|
||||
image, new_film_roll, new_fn,
|
||||
list(me_and_related(os.path.join(image.film.folder, image.filename), new_film_roll.folder, new_basename))
|
||||
))
|
||||
|
||||
print("%s: %.2fH" % (image.filename, d))
|
||||
for src, dst in changes[-1][-1]:
|
||||
print("\t%s => %s" % (src, dst))
|
||||
except Exception as e:
|
||||
print("ERROR:", e)
|
||||
|
||||
|
||||
|
||||
reply = input("Type YES to remove all these files. This is not reversible... : ")
|
||||
|
||||
if reply != 'YES':
|
||||
print("You did not type 'YES'. Not doing anything!")
|
||||
exit(-1)
|
||||
|
||||
for image, film_roll, new_fn, move in changes:
|
||||
print(image.filename, "=>", new_fn)
|
||||
|
||||
for src, dst in move:
|
||||
assert not os.path.exists(dst)
|
||||
|
||||
for src, dst in move:
|
||||
print("Moving %s => %s" % (src, dst))
|
||||
os.rename(src, dst)
|
||||
print("Saving image into sql")
|
||||
image.film = film_roll
|
||||
image.filename = new_fn
|
||||
image.save()
|
114
dt_remove_secondary copy.py
Normal file
114
dt_remove_secondary copy.py
Normal file
@ -0,0 +1,114 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
|
||||
from datetime import datetime
|
||||
import peewee as pw
|
||||
import os
|
||||
import shutil
|
||||
|
||||
remove_three_stars_before = datetime(2024, 4, 1)
|
||||
remove_secondary_before = datetime(2024, 6, 15)
|
||||
remove_rejected_before = datetime(2024, 6, 15)
|
||||
|
||||
query = Image.filter()
|
||||
pw.prefetch(query, FilmRoll)
|
||||
|
||||
def is_older_than(image, cmp):
|
||||
return (image.datetime_taken or image.datetime_imported) < cmp
|
||||
|
||||
def should_remove(image):
|
||||
return (
|
||||
(is_older_than(image, remove_three_stars_before) and image.stars < 3) or
|
||||
(image.flag(ImageFlags.REJECTED) and is_older_than(image, remove_rejected_before)) or
|
||||
(image.group_id != image.id and is_older_than(image, remove_secondary_before))
|
||||
)
|
||||
|
||||
file_usages = {}
|
||||
for x in query:
|
||||
path = os.path.join(x.film.folder, x.filename)
|
||||
if path not in file_usages:
|
||||
file_usages[path] = 0
|
||||
if not should_remove(x):
|
||||
file_usages[path] += 1
|
||||
|
||||
n_total = 0
|
||||
n_not_needed = 0
|
||||
needs_removal = []
|
||||
extra_files_to_remove = []
|
||||
bytes_to_remove = 0
|
||||
|
||||
d_root = "/home/oke/Pictures/Darktable"
|
||||
d_local = "/home/oke/Pictures/DarktableLocal"
|
||||
d_remote = "/home/oke/Pictures/DarktableRemote"
|
||||
|
||||
def potential_extras(fn):
|
||||
directory, filename = os.path.split(fn)
|
||||
basename, _ = os.path.splitext(filename)
|
||||
|
||||
iphone_movie = os.path.join(directory, "." + basename + ".mov")
|
||||
yield iphone_movie
|
||||
|
||||
needs_fetching = []
|
||||
|
||||
for file, usages in file_usages.items():
|
||||
if not file.startswith(d_root):
|
||||
print("SKIPPNG", file)
|
||||
continue
|
||||
assert file.startswith(d_root)
|
||||
remote_file = file.replace(d_root, d_remote)
|
||||
local_file = file.replace(d_root, d_local)
|
||||
|
||||
if usages > 0:
|
||||
if not os.path.exists(local_file):
|
||||
needs_fetching.append((remote_file, local_file))
|
||||
else:
|
||||
n_not_needed += 1
|
||||
if os.path.exists(local_file):
|
||||
needs_removal.append(local_file)
|
||||
bytes_to_remove += os.stat(local_file).st_size
|
||||
|
||||
for extra_fn in potential_extras(local_file):
|
||||
if os.path.exists(extra_fn):
|
||||
extra_files_to_remove.append(extra_fn)
|
||||
bytes_to_remove += os.stat(extra_fn).st_size
|
||||
|
||||
files_to_remove = list(sorted(needs_removal + extra_files_to_remove))
|
||||
|
||||
needs_fetching = sorted(needs_fetching)
|
||||
import pprint
|
||||
print("Files to fetch: ")
|
||||
pprint.pprint(needs_fetching)
|
||||
print("Extra files to remove:")
|
||||
pprint.pprint(extra_files_to_remove)
|
||||
print("First files to remove:")
|
||||
pprint.pprint(needs_removal[:10])
|
||||
print("Last files to remove:")
|
||||
pprint.pprint(needs_removal[-10:])
|
||||
|
||||
with open("remove.txt", "w") as f:
|
||||
pprint.pprint(sorted(set(needs_removal).union(extra_files_to_remove)), f)
|
||||
|
||||
print("Not needed: %6d / %6d (%3d%%)" % (n_not_needed, len(file_usages), 100 * n_not_needed / len(file_usages)))
|
||||
print()
|
||||
print("Not yet deleted: %6d / %6d (%3d%%)" % (len(needs_removal), n_not_needed, 100 * len(needs_removal) / n_not_needed))
|
||||
print("Bytes to free: %5.2fGB" % (bytes_to_remove / 1024. / 1024 / 1024))
|
||||
print("Total files to remove: %d" % len(files_to_remove))
|
||||
print()
|
||||
print("Total files to fetch: %3d" % len(needs_fetching))
|
||||
print()
|
||||
|
||||
reply = input("Type YES to remove all these files. This is not reversible... : ")
|
||||
|
||||
if reply == 'YES':
|
||||
|
||||
for fn in files_to_remove:
|
||||
assert fn.startswith(d_local)
|
||||
os.remove(fn)
|
||||
|
||||
for f, t in needs_fetching:
|
||||
assert os.path.exists(f), "Backup corruped, file not found: " + f
|
||||
assert not os.path.exists(t)
|
||||
print(f, t)
|
||||
shutil.copy(f, t)
|
||||
|
||||
else:
|
||||
print("You did not type 'YES'. Not doing anything!")
|
34
dt_render.py
Normal file
34
dt_render.py
Normal file
@ -0,0 +1,34 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
|
||||
from datetime import datetime
|
||||
import peewee as pw
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
query = Image.filter()
|
||||
|
||||
output_folder = "output"
|
||||
for image in query:
|
||||
assert isinstance(image, Image)
|
||||
if image.stars >= 3 and (not image.group or image.group == image):
|
||||
|
||||
print(image)
|
||||
|
||||
image_path = os.path.join(image.film.folder, image.filename)
|
||||
|
||||
output_path = os.path.join(output_folder, os.path.splitext(image.filename)[0] + ".jpg")
|
||||
|
||||
|
||||
if os.path.exists(output_path):
|
||||
continue
|
||||
command = [
|
||||
'darktable-cli',
|
||||
'--width', '1920',
|
||||
'--hq', 'true',
|
||||
image_path,
|
||||
output_path
|
||||
]
|
||||
|
||||
import subprocess
|
||||
subprocess.call(command)
|
52
export.py
Normal file
52
export.py
Normal file
@ -0,0 +1,52 @@
|
||||
from pytable.models import Color, Image, ImageFlags, FilmRoll, ColorLabel, TaggedImages
|
||||
|
||||
from datetime import datetime
|
||||
import peewee as pw
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple, Optional, Generic, TypeVar, Iterable
|
||||
|
||||
|
||||
export_base = "/home/oke/Nextcloud/Blog/"
|
||||
query = (
|
||||
Image.filter()
|
||||
.join(ColorLabel)
|
||||
.where(ColorLabel.color == Color.BLUE)
|
||||
.where(Image.datetime_taken > datetime(2024, 6, 14, 22))
|
||||
)
|
||||
pw.prefetch(query, FilmRoll)
|
||||
|
||||
def is_blog(image: Image):
|
||||
return (
|
||||
(image.group == image or image.group == None) and
|
||||
not image.flag(ImageFlags.REJECTED)
|
||||
)
|
||||
|
||||
images = list(filter(is_blog, query))
|
||||
for image in sorted(images, key=lambda i: i.datetime_taken or i.datetime_imported):
|
||||
assert isinstance(image, Image)
|
||||
fn = os.path.join(image.film.folder, image.filename)
|
||||
version = "" if image.version == 0 else ("_%02d" % image.version)
|
||||
base, ext = os.path.splitext(image.filename)
|
||||
xmp = os.path.join(image.film.folder, base + version + ext + ".xmp")
|
||||
out_fn = os.path.join(export_base, base + version + ".jpg")
|
||||
|
||||
if os.path.exists(out_fn):
|
||||
if not image.datetime_changed or datetime.fromtimestamp(os.stat(out_fn).st_ctime) > image.datetime_changed:
|
||||
continue
|
||||
os.remove(out_fn)
|
||||
subprocess.call([
|
||||
'darktable-cli',
|
||||
fn,
|
||||
xmp,
|
||||
'--width', '1920',
|
||||
'--out-ext', 'jpg',
|
||||
'--upscale', 'false',
|
||||
'--hq', 'true',
|
||||
out_fn
|
||||
])
|
||||
|
||||
print(len(images))
|
||||
|
17
pytable/database.py
Normal file
17
pytable/database.py
Normal file
@ -0,0 +1,17 @@
|
||||
import peewee as pw
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
if sys.platform == "linux":
|
||||
db_library = pw.SqliteDatabase(
|
||||
os.path.expanduser('~/.config/darktable/library.db'))
|
||||
db_data = pw.SqliteDatabase(
|
||||
os.path.expanduser('~/.config/darktable/data.db'))
|
||||
else:
|
||||
db = pw.SqliteDatabase(None)
|
||||
db_data = pw.SqliteDatabase(None)
|
||||
|
||||
def open_sqlite_db(sqlite_fn):
|
||||
global db
|
||||
db = pw.init(sqlite_fn)
|
69
pytable/fields.py
Normal file
69
pytable/fields.py
Normal file
@ -0,0 +1,69 @@
|
||||
import peewee as pw
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
|
||||
class DarktableTimestampField(pw.TimestampField):
|
||||
"""
|
||||
Darktable not always stores timestamps as UNIX timestamps with origin of
|
||||
1970-01-01, but sometimes 0001-01-01. This timestamp field lets the user
|
||||
specifiy what origin to use for the timestamp.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, origin=datetime.datetime(1970, 1, 1), **kwargs) -> None:
|
||||
kwargs['resolution'] = 10**6
|
||||
self.epoch_diff = (datetime.datetime(1970, 1, 1) - origin).total_seconds()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def python_value(self, value):
|
||||
if value is None or value == -1:
|
||||
return None
|
||||
return super().python_value(value - self.epoch_diff * self.resolution)
|
||||
|
||||
def db_value(self, value):
|
||||
if value is None or value == -1:
|
||||
return -1
|
||||
|
||||
if isinstance(value, datetime.datetime):
|
||||
pass
|
||||
elif isinstance(value, datetime.date):
|
||||
value = datetime.datetime(value.year, value.month, value.day)
|
||||
else:
|
||||
raise ValueError()
|
||||
|
||||
return super().db_value(value) + self.epoch_diff * self.resolution
|
||||
|
||||
class ModuleOrderListField(pw.CharField):
|
||||
|
||||
def db_value(self, value):
|
||||
print("db_value")
|
||||
value = super().db_value(value)
|
||||
if hasattr(value, "__item__"):
|
||||
new_value = ",".join([str(v) for v in itertools.chain(*value)])
|
||||
print(value, new_value)
|
||||
return new_value
|
||||
print(value)
|
||||
return value
|
||||
|
||||
def python_value(self, value):
|
||||
value = super().python_value(value)
|
||||
if isinstance(value, str):
|
||||
values = value.split(",")
|
||||
assert len(values) % 2 == 0
|
||||
new_value = [
|
||||
(values[i * 2], int(values[i * 2 + 1]))
|
||||
for i in range(int(len(values) / 2))]
|
||||
return new_value
|
||||
return value
|
||||
|
||||
class EnumField(pw.IntegerField):
|
||||
|
||||
def __init__(self, enum_type, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.enum_type = enum_type
|
||||
|
||||
def db_value(self, value):
|
||||
return value.value
|
||||
|
||||
def python_value(self, value):
|
||||
return self.enum_type(super().python_value(value))
|
304
pytable/models.py
Normal file
304
pytable/models.py
Normal file
@ -0,0 +1,304 @@
|
||||
from .database import db_library, db_data
|
||||
from .fields import DarktableTimestampField, ModuleOrderListField, EnumField
|
||||
from .types import ImageFlags, v30_jpg_order, v30_order, IOPOrderType, legacy_order, Color
|
||||
from .modules import DT_MODULES, Module
|
||||
|
||||
from typing import List
|
||||
import peewee as pw
|
||||
|
||||
import datetime
|
||||
|
||||
class FilmRoll(pw.Model):
|
||||
"""
|
||||
A darktable filmroll.
|
||||
"""
|
||||
access_timestamp: datetime.datetime = DarktableTimestampField() # type: ignore
|
||||
folder: str = pw.CharField(1024) # type: ignore
|
||||
|
||||
def __str__(self):
|
||||
return self.folder
|
||||
|
||||
class Meta:
|
||||
db_table = 'film_rolls'
|
||||
database = db_library
|
||||
|
||||
class Maker(pw.Model):
|
||||
|
||||
name: str = pw.CharField() # type: ignore
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
db_table = 'makers'
|
||||
database = db_library
|
||||
|
||||
class Model(pw.Model):
|
||||
|
||||
name: str = pw.CharField() # type: ignore
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
db_table = 'models'
|
||||
database = db_library
|
||||
|
||||
class Lens(pw.Model):
|
||||
|
||||
name: str = pw.CharField() # type: ignore
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
db_table = 'lens'
|
||||
database = db_library
|
||||
|
||||
class Camera(pw.Model):
|
||||
|
||||
name: str = pw.CharField() # type: ignore
|
||||
|
||||
class Meta:
|
||||
db_table = 'cameras'
|
||||
database = db_library
|
||||
|
||||
class Image(pw.Model):
|
||||
"""
|
||||
A darktable image.
|
||||
"""
|
||||
id: int = pw.IntegerField(primary_key=True) # type: ignore
|
||||
group = pw.ForeignKeyField('self')
|
||||
film = pw.ForeignKeyField(FilmRoll)
|
||||
|
||||
# Image width in pixels
|
||||
width: int = pw.IntegerField() # type: ignore
|
||||
# Image height in pixels
|
||||
height: int = pw.IntegerField() # type: ignore
|
||||
|
||||
# The filename of the image
|
||||
filename: str = pw.CharField() # type: ignore
|
||||
|
||||
# The maker of the camera used to capture this picture
|
||||
maker = pw.ForeignKeyField(Maker)
|
||||
# The model of the camera used to capture this picture
|
||||
model = pw.ForeignKeyField(Model)
|
||||
# The lens used to capture this picture
|
||||
lens = pw.ForeignKeyField(Lens)
|
||||
# The camera used to capture this picture
|
||||
camera = pw.ForeignKeyField(Camera)
|
||||
|
||||
# The exposure used to capture this picture
|
||||
exposure = pw.FloatField()
|
||||
# The aperture used to capture this picture
|
||||
aperture = pw.FloatField()
|
||||
# The iso used to capture this picture
|
||||
iso = pw.FloatField()
|
||||
# The focal length used to capture this picture
|
||||
focal_length = pw.FloatField()
|
||||
# The focus distance used to capture this picture
|
||||
focus_distance = pw.FloatField()
|
||||
|
||||
# The date this picture was taken
|
||||
datetime_taken: datetime.datetime = DarktableTimestampField(utc=True, origin=datetime.datetime(1, 1, 1)) # type: ignore
|
||||
|
||||
# The flags attached to this picture. To read individual flags, see 'flag()' function
|
||||
flags: int = pw.IntegerField() # type: ignore
|
||||
|
||||
output_width: int = pw.IntegerField() # type: ignore
|
||||
output_height: int = pw.IntegerField() # type: ignore
|
||||
crop = pw.DoubleField()
|
||||
|
||||
raw_parameters: int = pw.IntegerField() # type: ignore
|
||||
# raw_denoise_threshold = pw.DoubleField()
|
||||
# raw_auto_bright_threshold = pw.DoubleField()
|
||||
raw_black: int = pw.IntegerField() # type: ignore
|
||||
raw_maximum: int = pw.IntegerField() # type: ignore
|
||||
|
||||
# license: str = pw.CharField() # type: ignore
|
||||
|
||||
# sha1sum = pw.FixedCharField(40)
|
||||
|
||||
orientation: int = pw.IntegerField() # type: ignore
|
||||
|
||||
# histogram = pw.BlobField()
|
||||
# lightmap = pw.BlobField()
|
||||
|
||||
longitude = pw.DoubleField()
|
||||
latitude = pw.DoubleField()
|
||||
altitude = pw.DoubleField()
|
||||
|
||||
color_matrix = pw.BlobField()
|
||||
|
||||
colorspace: int = pw.IntegerField() # type: ignore
|
||||
version: int = pw.IntegerField() # type: ignore
|
||||
max_version: int = pw.IntegerField() # type: ignore
|
||||
write_timestamp: datetime.datetime = DarktableTimestampField() # type: ignore
|
||||
history_end: int = pw.IntegerField() # type: ignore
|
||||
position: int = pw.IntegerField() # type: ignore
|
||||
|
||||
aspect_ratio = pw.DoubleField()
|
||||
exposure_bias = pw.DoubleField()
|
||||
|
||||
datetime_imported: datetime.datetime = DarktableTimestampField(origin=datetime.datetime(1, 1, 1), column_name="import_timestamp") # type: ignore
|
||||
datetime_changed: datetime.datetime = DarktableTimestampField(origin=datetime.datetime(1, 1, 1), column_name="change_timestamp") # type: ignore
|
||||
datetime_exported: datetime.datetime = DarktableTimestampField(origin=datetime.datetime(1, 1, 1), column_name="export_timestamp") # type: ignore
|
||||
datetime_printed: datetime.datetime = DarktableTimestampField(origin=datetime.datetime(1, 1, 1), column_name="print_timestamp") # type: ignore
|
||||
datetime_thumb: datetime.datetime = DarktableTimestampField(origin=datetime.datetime(1, 1, 1), column_name="thumb_timestamp") # type: ignore
|
||||
thumb_maxmip: int = pw.IntegerField() # type: ignore
|
||||
|
||||
def flag(self, flag):
|
||||
return bool(self.flags & flag.value)
|
||||
|
||||
def set_flag(self, flag, value):
|
||||
b = -1
|
||||
assert flag
|
||||
flag = flag.value
|
||||
while flag:
|
||||
b += 1
|
||||
flag = flag >> 1
|
||||
self.flags = (self.flags & ~flag) | (value & flag)
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def stars(self):
|
||||
return self.flags & 0x7
|
||||
|
||||
@stars.setter
|
||||
def stars(self, value):
|
||||
self.flags = (self.flags & ~0x7) | (value & 0x7)
|
||||
|
||||
def __str__(self):
|
||||
return self.filename
|
||||
|
||||
history: pw.BackrefAccessor
|
||||
module_order: pw.BackrefAccessor
|
||||
|
||||
def get_ordered_active_modules(self):
|
||||
|
||||
# nextline: type: ignore
|
||||
active_history = (self.history
|
||||
.order_by(HistoryEntry.num.asc()) # type: ignore
|
||||
.where(HistoryEntry.num < self.history_end))
|
||||
|
||||
if len(active_history) == 0:
|
||||
return []
|
||||
|
||||
module_orders = self.module_order.select()
|
||||
module_order = module_orders[0]
|
||||
iop_list = None
|
||||
if module_order.version == IOPOrderType.CUSTOM:
|
||||
iop_list = module_order.iop_list
|
||||
elif module_order.version == IOPOrderType.V30:
|
||||
iop_list = [(v, 0) for v in v30_order]
|
||||
elif module_order.version == IOPOrderType.V30_JPG:
|
||||
iop_list = [(v, 0) for v in v30_jpg_order]
|
||||
elif module_order.version == IOPOrderType.LEGACY:
|
||||
iop_list = [(v, 0) for v in legacy_order]
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
# Override if iop_list exists for the case that
|
||||
# 'module_order.version != CUSTOM'... Is this a bug in dt?
|
||||
if module_order.iop_list:
|
||||
iop_list = module_order.iop_list
|
||||
|
||||
iop_list = {iop_list[i]: i for i in range(len(iop_list))}
|
||||
|
||||
active_modules = {}
|
||||
for he in active_history:
|
||||
if he.enabled:
|
||||
active_modules[(he.module_name, he.instance)] = he.module
|
||||
elif (he.module_name, he.instance) in active_modules:
|
||||
del active_modules[(he.module_name, he.instance)]
|
||||
|
||||
active_modules = sorted(active_modules.items(), key=lambda kv: iop_list[kv[0]])
|
||||
return list(map(lambda kv: kv[1], active_modules))
|
||||
|
||||
class Meta:
|
||||
db_table = 'images'
|
||||
database = db_library # This model uses the "people.db" database``
|
||||
|
||||
class ColorLabel(pw.Model):
|
||||
|
||||
image: Image = pw.ForeignKeyField(Image, column_name='imgid') # type: ignore
|
||||
color: Color = EnumField(Color) # type: ignore
|
||||
|
||||
class Meta:
|
||||
primary_key = False
|
||||
db_table = 'color_labels'
|
||||
database = db_library
|
||||
|
||||
class HistoryEntry(pw.Model):
|
||||
"""
|
||||
A darktable filmroll.
|
||||
"""
|
||||
image: Image = pw.ForeignKeyField(Image, column_name="imgid", backref="history") # type: ignore
|
||||
num: int = pw.IntegerField() # type: ignore
|
||||
|
||||
module_name: str = pw.CharField(256, column_name="operation") # type: ignore
|
||||
version: int = pw.IntegerField(column_name="module") # type: ignore
|
||||
params: bytes = pw.BlobField(column_name="op_params") # type: ignore
|
||||
|
||||
enabled: bool = pw.BooleanField() # type: ignore
|
||||
|
||||
blendop_version: int = pw.IntegerField() # type: ignore
|
||||
blendop_params: bytes = pw.BlobField() # type: ignore
|
||||
|
||||
instance: int = pw.IntegerField(column_name="multi_priority") # type: ignore
|
||||
name: str = pw.CharField(256, column_name="multi_name") # type: ignore
|
||||
name_hand_edited: int = pw.IntegerField(column_name="multi_name_hand_edited") # type: ignore
|
||||
|
||||
@property
|
||||
def module(self):
|
||||
for cls in DT_MODULES:
|
||||
if cls.NAME == self.module_name and cls.VERSION == self.version:
|
||||
return cls(self.instance, self.params)
|
||||
return Module(self.instance, self.params, module_name=self.module_name)
|
||||
|
||||
def __str__(self):
|
||||
return "%3d - %sv%d-%s" % (
|
||||
self.num, self.module_name, self.version, self.instance)
|
||||
|
||||
class Meta:
|
||||
primary_key = False
|
||||
db_table = 'history'
|
||||
database = db_library
|
||||
|
||||
|
||||
class ModuleOrderEntry(pw.Model):
|
||||
"""
|
||||
A darktable filmroll.
|
||||
"""
|
||||
image: Image = pw.ForeignKeyField(Image, column_name="imgid", backref="module_order") # type: ignore
|
||||
version: IOPOrderType = EnumField(IOPOrderType) # type: ignore
|
||||
iop_list = ModuleOrderListField()
|
||||
|
||||
def __str__(self):
|
||||
return str((self.version, self.iop_list))
|
||||
|
||||
class Meta:
|
||||
primary_key = False
|
||||
db_table = 'module_order'
|
||||
database = db_library
|
||||
|
||||
class Tag(pw.Model):
|
||||
|
||||
name: str = pw.CharField() # type: ignore
|
||||
synonyms: str = pw.CharField() # type: ignore
|
||||
flags: int = pw.IntegerField() # type: ignore
|
||||
|
||||
class Meta:
|
||||
db_table = 'tags'
|
||||
database = db_data
|
||||
|
||||
class TaggedImages(pw.Model):
|
||||
|
||||
image: Image = pw.ForeignKeyField(Image, backref='tags', column_name='imgid') # type: ignore
|
||||
tag: Tag = pw.ForeignKeyField(Tag, backref='images', column_name='tagid') # type: ignore
|
||||
|
||||
class Meta:
|
||||
primary_key = False
|
||||
db_table = 'tagged_images'
|
||||
database = db_library
|
420
pytable/modules.py
Normal file
420
pytable/modules.py
Normal file
@ -0,0 +1,420 @@
|
||||
from .types import Adaptation, ColorIntent, ColorSpacesColorProfile, \
|
||||
Illuminant, IlluminantLED, ImageOrientation
|
||||
|
||||
import struct
|
||||
import enum
|
||||
|
||||
DT_MODULES = []
|
||||
|
||||
|
||||
def decode_bytes(bytes):
|
||||
return bytes.decode().rstrip("\0")
|
||||
|
||||
|
||||
class ModuleMeta(type):
|
||||
|
||||
def __new__(cls, name, bases, dct):
|
||||
new_cls = super().__new__(cls, name, bases, dct)
|
||||
if name != "Module":
|
||||
DT_MODULES.append(new_cls)
|
||||
return new_cls
|
||||
|
||||
|
||||
class Module(metaclass=ModuleMeta):
|
||||
|
||||
NAME = ""
|
||||
VERSION = -1
|
||||
PARAMS_NAMES = ()
|
||||
PARAMS_FORMAT = ""
|
||||
PARAMS_TYPES = ()
|
||||
|
||||
def __init__(self, instance, raw_params, module_name=None) -> None:
|
||||
self.module_name = module_name or self.__class__.__name__
|
||||
self.instance = instance
|
||||
self.params = self.parse_params(raw_params)
|
||||
|
||||
def parse_params(self, raw_params):
|
||||
if not self.PARAMS_FORMAT:
|
||||
return {}
|
||||
values = struct.unpack(self.PARAMS_FORMAT, raw_params)
|
||||
params = {}
|
||||
pts = list(self.PARAMS_TYPES) + [None] * (len(self.PARAMS_NAMES) - len(self.PARAMS_TYPES))
|
||||
for name, type in zip(self.PARAMS_NAMES, pts):
|
||||
n = 1
|
||||
if "*" in name:
|
||||
name, n = name.split("*")
|
||||
n = int(n)
|
||||
params[name] = values[:n]
|
||||
assert len(params[name]) == n
|
||||
if type:
|
||||
params[name] = [type(v) for v in params[name]]
|
||||
if n == 1:
|
||||
params[name] = params[name][0]
|
||||
values = values[n:]
|
||||
assert not values, values
|
||||
return params
|
||||
|
||||
def __str__(self):
|
||||
import pprint
|
||||
return "%s-%d: %s" % (self.module_name, self.instance, pprint.pformat(
|
||||
self.params, indent=0, compact=True, width=10000, depth=1
|
||||
))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s-%d" % (self.module_name, self.instance)
|
||||
|
||||
class ColorBalanceRGBSaturation(enum.Enum):
|
||||
JZAZBZ = 0
|
||||
DTUCS = 1
|
||||
|
||||
|
||||
class ColorBalanceRGB(Module):
|
||||
NAME = "colorbalancergb"
|
||||
VERSION = 5
|
||||
PARAMS_NAMES = ("shadows_Y", "shadows_C", "shadows_H", "midtones_Y",
|
||||
"midtones_C", "midtones_H", "highlights_Y", "highlights_C",
|
||||
"highlights_H", "global_Y", "global_C", "global_H",
|
||||
"shadows_weight", "white_fulcrum", "highlights_weight",
|
||||
"chroma_shadows", "chroma_highlights", "chroma_global",
|
||||
"chroma_midtones", "saturation_global",
|
||||
"saturation_highlights", "saturation_midtones",
|
||||
"saturation_shadows", "hue_angle", "brilliance_global",
|
||||
"brilliance_highlights", "brilliance_midtones",
|
||||
"brilliance_shadows", "mask_grey_fulcrum", "vibrance",
|
||||
"grey_fulcrum", "contrast", "saturation_formula")
|
||||
|
||||
PARAMS_FORMAT = "32fi"
|
||||
PARAMS_TYPES = [None] * 32 + [ColorBalanceRGBSaturation]
|
||||
|
||||
|
||||
class ColorCalibrationV2(Module):
|
||||
|
||||
NAME = "channelmixerrgb"
|
||||
VERSION = 3
|
||||
PARAMS_NAMES = ("red*4", "green*4", "blue*4", "saturation*4",
|
||||
"lightness*4", "grey*4", "normalize_red",
|
||||
"normalize_green", "normalize_blue",
|
||||
"normalize_saturation", "normalize_lightness",
|
||||
"normalize_grey", "illuminant", "illum_fluo",
|
||||
"illum_led", "adaptation", "x", "y", "temperature",
|
||||
"gamut", "clip", "version")
|
||||
PARAMS_FORMAT = "24f6i4iffffii"
|
||||
PARAMS_TYPES = ([None] * 6 + [bool] * 6 +
|
||||
[Illuminant, None, IlluminantLED, Adaptation,
|
||||
None, None, None, None, bool])
|
||||
|
||||
|
||||
class ColorInputProfileNormalize(enum.Enum):
|
||||
OFF = 0
|
||||
SRGB = 1
|
||||
ADOBE_RGB = 2
|
||||
LINEAR_REC709_RGB = 3
|
||||
LINEAR_REC2020_RGB = 4
|
||||
|
||||
|
||||
class ColorInputProfileV7(Module):
|
||||
|
||||
NAME = "colorin"
|
||||
VERSION = 7
|
||||
PARAMS_NAMES = (
|
||||
"type", "filename", "intent", "normalize", "blue_mapping", "type_work",
|
||||
"filename_work")
|
||||
PARAMS_FORMAT = "i512siiii512s"
|
||||
PARAMS_TYPES = (ColorSpacesColorProfile, decode_bytes, ColorIntent,
|
||||
ColorInputProfileNormalize, bool, ColorSpacesColorProfile,
|
||||
decode_bytes)
|
||||
|
||||
|
||||
class ColorOutputProfileV5(Module):
|
||||
|
||||
NAME = "colorout"
|
||||
VERSION = 5
|
||||
PARAMS_NAMES = (
|
||||
"type", "filename", "intent")
|
||||
PARAMS_FORMAT = "i512si"
|
||||
PARAMS_TYPES = (ColorSpacesColorProfile, decode_bytes, ColorIntent)
|
||||
|
||||
|
||||
class ColorZonesChannel(enum.Enum):
|
||||
lightness = 0
|
||||
chroma = 1
|
||||
hue = 2
|
||||
|
||||
class ColorZonesV5(Module):
|
||||
NAME = "colorzones"
|
||||
VERSION = 5
|
||||
PARAMS_NAMES = ("channel", "curve*120", "curve_num_nodes*3",
|
||||
"curve_type*3", "strength", "mode", "spline_version")
|
||||
|
||||
PARAMS_FORMAT = "i120f6ifii"
|
||||
|
||||
def parse_params(self, raw_params):
|
||||
params = super().parse_params(raw_params)
|
||||
curve = params["curve"]
|
||||
curve = [
|
||||
[(curve[channel * 20 * 2 + node * 2],
|
||||
curve[channel * 20 * 2 + node * 2 + 1]) for node in range(20)]
|
||||
for channel in range(3)
|
||||
]
|
||||
curve[0] = curve[0][:params["curve_num_nodes"][0]]
|
||||
curve[1] = curve[1][:params["curve_num_nodes"][1]]
|
||||
curve[2] = curve[2][:params["curve_num_nodes"][2]]
|
||||
params["curve"] = curve
|
||||
return params
|
||||
|
||||
|
||||
class DemosaicMethod(enum.Enum):
|
||||
PPG = 0
|
||||
AMAZE = 1
|
||||
VNG4 = 2
|
||||
RCD = 5
|
||||
LMMSE = 6
|
||||
RCD_VNG = 2048 | 5
|
||||
AMAZE_VNG = 2048 | 6
|
||||
PASSTHROUGH_MONOCHROME = 3
|
||||
PASSTHROUGH_COLOR = 4
|
||||
VNG = 1024 | 0
|
||||
MARKESTEIJN = 1024 | 1
|
||||
MARKESTEIJN_3 = 1024 | 2
|
||||
FCD = 1024 | 4
|
||||
MARKEST3_VNG = 2048 | 1024 | 2
|
||||
PASSTHR_MONOX = 1024 | 3
|
||||
PASSTHR_COLORX = 1024 | 5
|
||||
|
||||
|
||||
class DemosaicGreenEQ(enum.Enum):
|
||||
NO = 0
|
||||
LOCAL = 1
|
||||
FULL = 2
|
||||
BOTH = 3
|
||||
|
||||
|
||||
class DemosaicQualFlags(enum.Enum):
|
||||
DEFAULT = 0
|
||||
FULL_SCALE = 1
|
||||
ONLY_VNG_LINEAR = 2
|
||||
|
||||
|
||||
class DemosaicSmooth(enum.Enum):
|
||||
OFF = 0
|
||||
ONCE = 1
|
||||
TWICE = 2
|
||||
THREE_TIMES = 3
|
||||
FOUR_TIMES = 4
|
||||
FIVE_TIMES = 5
|
||||
|
||||
|
||||
class DemosaicLMMSE(enum.Enum):
|
||||
BASIC = 0
|
||||
MEDIAN = 1
|
||||
MEDIANX3 = 2
|
||||
REFINE_AND_MEDIANS = 3
|
||||
REFINEx2_AND_MEDIANS = 4
|
||||
|
||||
|
||||
class DemosaicV4(Module):
|
||||
|
||||
NAME = "demosaic"
|
||||
VERSION = 4
|
||||
PARAMS_NAMES = (
|
||||
"green_eq", "median_thrs", "color_smoothing", "demosaicing_method",
|
||||
"lmmse_refine", "dual_thrs")
|
||||
PARAMS_FORMAT = "ifiiif"
|
||||
PARAMS_TYPES = (DemosaicGreenEQ, None, DemosaicSmooth, DemosaicMethod, DemosaicLMMSE)
|
||||
|
||||
|
||||
class DenoiseProfiledWaveletMode(enum.Enum):
|
||||
RGB = 0
|
||||
Y0U0V0 = 1
|
||||
|
||||
class DenoiseProfiled(Module):
|
||||
NAME = "denoiseprofile"
|
||||
VERSION = 11
|
||||
PARAMS_NAMES = ("radius", "nbhood", "strength", "shadows",
|
||||
"bias", "scattering", "central_pixel_weight",
|
||||
"overshooting", "a*3", "b*3", "mode", "x*42", "y*42",
|
||||
"wb_adaptive_anscombe", "fix_anscombe_and_nlmeans_norm",
|
||||
"use_new_vst", "wavelet_color_mode")
|
||||
|
||||
PARAMS_FORMAT = "8f6fi42f42fiiii"
|
||||
PARAMS_TYPES = [None] * 16 + [DenoiseProfiledWaveletMode]
|
||||
|
||||
def parse_params(self, raw_params):
|
||||
params = super().parse_params(raw_params)
|
||||
params["x"] = [[params["x"][profile * 7 + band] for band in range(7)] for profile in range(6)]
|
||||
params["y"] = [[params["y"][profile * 7 + band] for band in range(7)] for profile in range(6)]
|
||||
return params
|
||||
|
||||
|
||||
class DiffuseOrSharpenV2(Module):
|
||||
|
||||
NAME = "diffuse"
|
||||
VERSION = 2
|
||||
PARAMS_NAMES = (
|
||||
"iterations", "sharpness", "radius", "regularization",
|
||||
"variance_threshold", "anisotropy_first", "anisotropy_second",
|
||||
"anisotropy_third", "anisotropy_fourth", "threshold",
|
||||
"first", "second", "third", "fourth", "radius_center")
|
||||
PARAMS_FORMAT = "ififffffffffffi"
|
||||
|
||||
|
||||
class DisplayEncodingV1(Module):
|
||||
|
||||
NAME = "gamma"
|
||||
VERSION = 1
|
||||
PARAMS_NAMES = ("gamma", "linear")
|
||||
PARAMS_FORMAT = "ff"
|
||||
|
||||
|
||||
class ExposureMode(enum.Enum):
|
||||
MANUAL = 0
|
||||
DEFLICKER = 1
|
||||
|
||||
|
||||
class ExposureV6(Module):
|
||||
|
||||
NAME = "exposure"
|
||||
VERSION = 6
|
||||
PARAMS_NAMES = ("mode", "black", "exposure", "deflicker_percentile",
|
||||
"deflicker_target_level", "compensate_exposure_bias")
|
||||
PARAMS_FORMAT = "iffffi"
|
||||
PARAMS_TYPES = (ExposureMode, None, None, None, None, bool)
|
||||
|
||||
|
||||
class FilmicRGBMethod(enum.Enum):
|
||||
NONE = 0
|
||||
MAX_RGB = 1
|
||||
LUMINANCE = 2
|
||||
POWER_NORM = 3
|
||||
EUCLIDEAN_NORM_V1 = 4
|
||||
EUCLIDEAN_NORM_V2 = 5
|
||||
|
||||
|
||||
class FilmicRGBCurve(enum.Enum):
|
||||
POLY_4 = 0
|
||||
POLY_3 = 1
|
||||
RATIONAL = 2
|
||||
|
||||
|
||||
class FilmicRGBReconstruction(enum.Enum):
|
||||
RECONSTRUCT_RGB = 0
|
||||
RECONSTRUCT_RATIOS = 1
|
||||
|
||||
|
||||
class FilmicRGBNoiseDistribution(enum.Enum):
|
||||
UNIFORM = 0
|
||||
GAUSSIAN = 1
|
||||
POISSONIAN = 2
|
||||
|
||||
|
||||
class FilmicRGB(Module):
|
||||
|
||||
NAME = "filmicrgb"
|
||||
VERSION = 6
|
||||
PARAMS_NAMES = ("grey_point_source", "black_point_source",
|
||||
"white_point_source", "reconstruct_threshold",
|
||||
"reconstruct_feather", "reconstruct_bloom_vs_details",
|
||||
"reconstruct_grey_vs_color",
|
||||
"reconstruct_structure_vs_texture", "security_factor",
|
||||
"grey_point_target", "black_point_target",
|
||||
"white_point_target", "output_power", "latitude",
|
||||
"contrast", "saturation", "balance", "noise_level",
|
||||
"preserve_color", "version", "auto_hardness", "custom_grey",
|
||||
"high_quality_reconstruction", "noise_distribution",
|
||||
"shadows", "highlights", "compensate_icc_black",
|
||||
"spline_version", "enable_highlight_reconstruction")
|
||||
PARAMS_FORMAT = "18f11i"
|
||||
PARAMS_TYPES = [None] * 18 + [
|
||||
FilmicRGBMethod, None, bool, bool, None, FilmicRGBNoiseDistribution,
|
||||
FilmicRGBCurve, FilmicRGBCurve, bool, None, bool]
|
||||
|
||||
|
||||
class HighlightsMode(enum.Enum):
|
||||
OPPOSED = 5
|
||||
LCH = 1
|
||||
CLIP = 0
|
||||
SEGMENTS = 4
|
||||
LAPLACIAN = 3
|
||||
INPAINT = 2
|
||||
|
||||
class HighlightsAtrousWaveletsScales(enum.Enum):
|
||||
PX2 = 0
|
||||
PX4 = 1
|
||||
PX8 = 2
|
||||
PX16 = 3
|
||||
PX32 = 4
|
||||
PX64 = 5
|
||||
PX128 = 6
|
||||
PX256 = 7
|
||||
PX512 = 8
|
||||
PX1024 = 9
|
||||
PX2048 = 10
|
||||
PX4096 = 11
|
||||
|
||||
class HighlightsRecoveryMode(enum.Enum):
|
||||
MODE_OFF = 0
|
||||
MODE_ADAPT = 5
|
||||
MODE_ADAPTF = 6
|
||||
MODE_SMALL = 1
|
||||
MODE_LARGE = 2
|
||||
MODE_SMALLF = 3
|
||||
MODE_LARGEF = 4
|
||||
|
||||
|
||||
class HighlightsV4(Module):
|
||||
|
||||
NAME = "highlights"
|
||||
VERSION = 4
|
||||
PARAMS_NAMES = ("mode", "blendL", "blendC", "strength", "clip",
|
||||
"noise_level", "iterations", "scales", "candidating",
|
||||
"combine", "recovery", "solid_color")
|
||||
PARAMS_FORMAT = "ifffffiiffif"
|
||||
PARAMS_TYPES = (HighlightsMode, None, None, None, None, None, None,
|
||||
HighlightsAtrousWaveletsScales, None, None,
|
||||
HighlightsRecoveryMode)
|
||||
|
||||
|
||||
class LocalContrastMode(enum.Enum):
|
||||
BILATERAL = 0
|
||||
LOCAL_LAPLACIAN = 1
|
||||
|
||||
class LocalContrastV3(Module):
|
||||
NAME = "bilat"
|
||||
VERSION = 3
|
||||
PARAMS_NAMES = ("mode", "sigma_r", "sigma_s", "detail", "midtone")
|
||||
PARAMS_FORMAT = "i4f"
|
||||
PARAMS_TYPES = (LocalContrastMode,)
|
||||
|
||||
|
||||
class OrientationV2(Module):
|
||||
NAME = "flip"
|
||||
VERSION = 2
|
||||
PARAMS_NAMES = ("orientation",)
|
||||
PARAMS_FORMAT = "i"
|
||||
PARAMS_TYPES = (ImageOrientation,)
|
||||
|
||||
|
||||
class SharpenV1(Module):
|
||||
NAME = "sharpen"
|
||||
VERSION = 1
|
||||
PARAMS_NAMES = ("radius", "amount", "threshold")
|
||||
PARAMS_FORMAT = "3f"
|
||||
|
||||
|
||||
class RawBlackWhitePointV2(Module):
|
||||
|
||||
NAME = "rawprepare"
|
||||
VERSION = 2
|
||||
PARAMS_NAMES = (
|
||||
"left", "top", "right", "bottom", "raw_black_level_separate0",
|
||||
"raw_black_level_separate1", "raw_black_level_separate2",
|
||||
"raw_black_level_separate3", "raw_white_point", "flat_field")
|
||||
PARAMS_FORMAT = "iiiiHHHHHi"
|
||||
|
||||
|
||||
class WhiteBalance(Module):
|
||||
|
||||
NAME = "temperature"
|
||||
VERSION = 3
|
||||
PARAMS_NAMES = ("red", "green", "blue", "g2")
|
||||
PARAMS_FORMAT = "ffff"
|
202
pytable/types.py
Normal file
202
pytable/types.py
Normal file
@ -0,0 +1,202 @@
|
||||
import enum
|
||||
|
||||
|
||||
class Adaptation(enum.Enum):
|
||||
LINEAR_BRADFORD = 0
|
||||
CAT16 = 1
|
||||
FULL_BRADFORD = 2
|
||||
XYZ = 3
|
||||
RGB = 4
|
||||
|
||||
|
||||
class ColorSpacesColorProfile(enum.Enum):
|
||||
NONE = -1
|
||||
FILE = 0
|
||||
SRGB = 1
|
||||
ADOBERGB = 2
|
||||
LIN_REC709 = 3
|
||||
LIN_REC2020 = 4
|
||||
XYZ = 5
|
||||
LAB = 6
|
||||
INFRARED = 7
|
||||
DISPLAY = 8
|
||||
EMBEDDED_ICC = 9
|
||||
EMBEDDED_MATRIX = 10
|
||||
STANDARD_MATRIX = 11
|
||||
ENHANCED_MATRIX = 12
|
||||
VENDOR_MATRIX = 13
|
||||
ALTERNATE_MATRIX = 14
|
||||
BRG = 15
|
||||
EXPORT = 16
|
||||
SOFTPROOF = 17
|
||||
WORK = 18
|
||||
DISPLAY2 = 19
|
||||
REC709 = 20
|
||||
PROPHOTO_RGB = 21
|
||||
PQ_REC2020 = 22
|
||||
HLG_REC2020 = 23
|
||||
PQ_P3 = 24
|
||||
HLG_P3 = 25
|
||||
LAST = 26
|
||||
|
||||
|
||||
class ColorIntent(enum.Enum):
|
||||
PERCEPTUAL = 0
|
||||
RELATIVE_COLORIMETRIC = 1
|
||||
SATURATION = 2
|
||||
ABSOLUTE_COLORIMETRIC = 3
|
||||
|
||||
|
||||
class Illuminant(enum.Enum):
|
||||
PIPE = 0
|
||||
A = 1
|
||||
D = 2
|
||||
E = 3
|
||||
F = 4
|
||||
LED = 5
|
||||
BB = 6
|
||||
CUSTOM = 7
|
||||
DETECT_SURFACES = 8
|
||||
DETECT_EDGES = 9
|
||||
CAMERA = 10
|
||||
|
||||
|
||||
class IlluminantLED(enum.Enum):
|
||||
LED_B1 = 0
|
||||
LED_B2 = 1
|
||||
LED_B3 = 2
|
||||
LED_B4 = 3
|
||||
LED_B5 = 4
|
||||
LED_BH1 = 5
|
||||
LED_RGB1= 6
|
||||
LED_V1 = 7
|
||||
LED_V2 = 8
|
||||
|
||||
|
||||
class IOPOrderType(enum.Enum):
|
||||
CUSTOM = 0
|
||||
LEGACY = 1
|
||||
V30 = 2
|
||||
V30_JPG = 3
|
||||
|
||||
class Color(enum.Enum):
|
||||
RED = 0
|
||||
YELLOW = 1
|
||||
GREEN = 2
|
||||
BLUE = 3
|
||||
PURPLE = 4
|
||||
|
||||
class ImageFlags(enum.Enum):
|
||||
"""
|
||||
Flags that are stored in Image::flags. Extract flags with 'image.flag(flag)'
|
||||
|
||||
Extracted from:
|
||||
https: """
|
||||
# Image is rejected
|
||||
REJECTED = 8
|
||||
THUMBNAIL_DEPRECATED = 16
|
||||
# set during import if the image is low-dynamic range, i.e. doesn't
|
||||
# need demosaic, wb, highlight clipping etc.
|
||||
LDR = 32,
|
||||
# set during import if the image is raw data, i.e. it needs demosaicing.
|
||||
RAW = 64
|
||||
# set during import if images is a high-dynamic range image..
|
||||
HDR = 128
|
||||
# set when marked for deletion
|
||||
REMOVE = 256
|
||||
# set when auto-applying presets have been applied to this image.
|
||||
AUTO_PRESETS_APPLIED = 512
|
||||
# legacy flag. is set for all new images. i hate to waste a bit on this :(
|
||||
NO_LEGACY_PRESETS = 1024
|
||||
# local copy status
|
||||
LOCAL_COPY = 2048
|
||||
# image has an associated .txt file for overlay
|
||||
HAS_TXT = 4096
|
||||
# image has an associated wav file
|
||||
HAS_WAV = 8192
|
||||
# image is a bayer pattern with 4 colors (e.g., CYGM or RGBE)
|
||||
BAYER = 16384
|
||||
# image was detected as monochrome
|
||||
MONOCHROME = 32768
|
||||
# DNG image has exif tags which are not cached in the database but
|
||||
# must be read and stored in t when the image is loaded.
|
||||
HAS_ADDITIONAL_EXIF_TAGS = 65536
|
||||
# image is an sraw
|
||||
S_RAW = 1 << 17
|
||||
# image has a monochrome preview tested
|
||||
MONOCHROME_PREVIEW = 1 << 18
|
||||
# image has been set to monochrome via demosaic module
|
||||
MONOCHROME_BAYER = 1 << 19
|
||||
# image has a flag set to use the monochrome workflow in the modules supporting it
|
||||
MONOCHROME_WORKFLOW = 1 << 20
|
||||
|
||||
class ImageOrientation(enum.Enum):
|
||||
NULL = -1
|
||||
NONE = 0
|
||||
FLIP_Y = 1 << 0
|
||||
FLIP_X = 1 << 1
|
||||
SWAP_XY = 1 << 2
|
||||
FLIP_HORIZONTALLY = FLIP_X
|
||||
FLIP_VERTICALLY = FLIP_Y
|
||||
ROTATE_180_DEG = FLIP_Y | FLIP_X
|
||||
TRANSPOSE = SWAP_XY
|
||||
ROTATE_CCW_90_DEG = FLIP_X | SWAP_XY
|
||||
ROTATE_CW_90_DEG = FLIP_Y | SWAP_XY
|
||||
TRANSVERSE = FLIP_Y | FLIP_X | SWAP_XY
|
||||
|
||||
|
||||
legacy_order = (
|
||||
"rawprepare", "invert", "temperature", "highlights", "cacorrect",
|
||||
"hotpixels", "rawdenoise", "demosaic", "mask_manager", "denoiseprofile",
|
||||
"tonemap", "exposure", "spots", "retouch", "lens", "cacorrectrgb", "ashift",
|
||||
"liquify", "rotatepixels", "scalepixels", "flip", "clipping", "toneequal",
|
||||
"crop", "graduatednd", "basecurve", "bilateral", "profile_gamma",
|
||||
"hazeremoval", "colorin", "channelmixerrgb", "diffuse", "censorize",
|
||||
"negadoctor", "blurs", "basicadj", "colorreconstruct", "colorchecker",
|
||||
"defringe", "equalizer", "vibrance", "colorbalance", "colorbalancergb",
|
||||
"colorize", "colortransfer", "colormapping", "bloom", "nlmeans",
|
||||
"globaltonemap", "shadhi", "atrous", "bilat", "colorzones", "lowlight",
|
||||
"monochrome", "sigmoid", "filmic", "filmicrgb", "colisa", "zonesystem",
|
||||
"tonecurve", "levels", "rgblevels", "rgbcurve", "relight", "colorcorrection",
|
||||
"sharpen", "lowpass", "highpass", "grain", "lut3d", "colorcontrast",
|
||||
"colorout", "channelmixer", "soften", "vignette", "splittoning", "velvia",
|
||||
"clahe", "finalscale", "overexposed", "rawoverexposed", "dither", "borders",
|
||||
"watermark", "gamma"
|
||||
)
|
||||
|
||||
v30_order = (
|
||||
"rawprepare", "invert", "temperature", "highlights", "cacorrect", "hotpixels",
|
||||
"rawdenoise", "demosaic", "denoiseprofile", "bilateral", "rotatepixels",
|
||||
"scalepixels", "lens", "cacorrectrgb","hazeremoval", "ashift", "flip",
|
||||
"clipping", "liquify", "spots", "retouch", "exposure", "mask_manager",
|
||||
"tonemap", "toneequal","crop","graduatednd", "profile_gamma", "equalizer",
|
||||
"colorin", "channelmixerrgb", "diffuse", "censorize", "negadoctor","blurs",
|
||||
"nlmeans","colorchecker","defringe","atrous", "lowpass", "highpass", "sharpen",
|
||||
"colortransfer","colormapping", "channelmixer","basicadj", "colorbalance",
|
||||
"colorbalancergb", "rgbcurve", "rgblevels", "basecurve","filmic", "sigmoid",
|
||||
"filmicrgb", "lut3d", "colisa", "tonecurve", "levels", "shadhi",
|
||||
"zonesystem", "globaltonemap", "relight", "bilat","colorcorrection",
|
||||
"colorcontrast", "velvia", "vibrance", "colorzones", "bloom", "colorize",
|
||||
"lowlight", "monochrome", "grain", "soften", "splittoning", "vignette",
|
||||
"colorreconstruct", "colorout", "clahe", "finalscale", "overexposed",
|
||||
"rawoverexposed", "dither", "borders", "watermark", "gamma"
|
||||
)
|
||||
|
||||
v30_jpg_order = (
|
||||
"rawprepare", "invert", "temperature", "highlights", "cacorrect", "hotpixels",
|
||||
"rawdenoise", "demosaic", "colorin", "denoiseprofile", "bilateral",
|
||||
"rotatepixels", "scalepixels", "lens", "cacorrectrgb", "hazeremoval",
|
||||
"ashift", "flip", "clipping", "liquify", "spots", "retouch", "exposure",
|
||||
"mask_manager", "tonemap", "toneequal", "crop", "graduatednd",
|
||||
"profile_gamma", "equalizer", "channelmixerrgb", "diffuse", "censorize",
|
||||
"negadoctor", "blurs", "nlmeans", "colorchecker","defringe","atrous",
|
||||
"lowpass", "highpass", "sharpen", "colortransfer","colormapping",
|
||||
"channelmixer","basicadj", "colorbalance", "colorbalancergb", "rgbcurve",
|
||||
"rgblevels", "basecurve","filmic", "sigmoid", "filmicrgb", "lut3d", "colisa",
|
||||
"tonecurve", "levels", "shadhi", "zonesystem", "globaltonemap", "relight",
|
||||
"bilat","colorcorrection","colorcontrast", "velvia", "vibrance",
|
||||
"colorzones", "bloom", "colorize", "lowlight", "monochrome", "grain",
|
||||
"soften", "splittoning", "vignette", "colorreconstruct","colorout", "clahe",
|
||||
"finalscale", "overexposed", "rawoverexposed", "dither", "borders",
|
||||
"watermark", "gamma"
|
||||
)
|
297
synchronise.py
Normal file
297
synchronise.py
Normal file
@ -0,0 +1,297 @@
|
||||
from pytable.models import Image, ImageFlags, FilmRoll
|
||||
|
||||
from datetime import datetime
|
||||
import peewee as pw
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple, Optional, Generic, TypeVar, Iterable
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
Range = Tuple[Optional[T], Optional[T]]
|
||||
|
||||
class Filter:
|
||||
|
||||
|
||||
def __call__(self, image: Image):
|
||||
return self.matches(image)
|
||||
|
||||
def matches(self, image: Image) -> bool:
|
||||
...
|
||||
|
||||
@dataclass
|
||||
class LocalRegion(Filter):
|
||||
|
||||
date_range: Range[datetime] = (None, None)
|
||||
stars_range: Range[int] = (None, None)
|
||||
|
||||
def matches(self, image: Image) -> bool:
|
||||
|
||||
stars = image.stars if not image.flag(ImageFlags.REJECTED) else -1
|
||||
if self.stars_range[0] is not None and stars < self.stars_range[0]:
|
||||
return False
|
||||
if self.stars_range[1] is not None and stars > self.stars_range[1]:
|
||||
return False
|
||||
|
||||
dt = (image.datetime_taken or image.datetime_imported)
|
||||
if self.date_range[0] is not None and dt < self.date_range[0]:
|
||||
return False
|
||||
if self.date_range[1] is not None and dt > self.date_range[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@dataclass
|
||||
class Or(Filter):
|
||||
|
||||
filters: Iterable[Filter]
|
||||
|
||||
def matches(self, image: Image) -> bool:
|
||||
return any(f(image) for f in self.filters)
|
||||
|
||||
@dataclass
|
||||
class And(Filter):
|
||||
|
||||
filters: Iterable[Filter]
|
||||
|
||||
def matches(self, image: Image) -> bool:
|
||||
return all(f(image) for f in self.filters)
|
||||
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.filter = Or([
|
||||
LocalRegion((datetime(2024, 6, 14), None), (0, None)),
|
||||
LocalRegion((datetime(2024, 6, 22, 14), datetime(2024, 6, 22, 14, 24)))
|
||||
])
|
||||
|
||||
self.d_root = "/home/oke/Pictures/Darktable"
|
||||
self.d_local = "/home/oke/Pictures/DarktableLocal"
|
||||
self.d_remote = "/home/oke/Pictures/DarktableRemote"
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _is_older_than(image: Image, date: datetime):
|
||||
return (image.datetime_taken or image.datetime_imported) < date
|
||||
|
||||
def should_not_be_local(self, image: Image):
|
||||
return not self.filter(image)
|
||||
|
||||
def should_be_local(self, image: Image):
|
||||
return self.filter(image)
|
||||
|
||||
def image_local_folder(self, image: Image):
|
||||
folder = image.film.folder
|
||||
if not folder.startswith(self.d_root):
|
||||
raise NotImplementedError()
|
||||
assert folder.startswith(self.d_root)
|
||||
return folder.replace(self.d_root, self.d_local)
|
||||
|
||||
def image_local_image(self, image: Image) -> str:
|
||||
return os.path.join(self.image_local_folder(image), image.filename)
|
||||
|
||||
def image_local_image_xmp(self, image: Image) -> str:
|
||||
base, ext = os.path.splitext(image.filename)
|
||||
version = ("_%02d" % image.version) if image.version != 0 else ""
|
||||
xmp_filename = base + version + ext + '.xmp'
|
||||
return os.path.join(self.image_local_folder(image), xmp_filename)
|
||||
|
||||
def image_local_iphone_movie(self, image: Image) -> str:
|
||||
base, _ = os.path.splitext(image.filename)
|
||||
return os.path.join(self.image_local_folder(image), "." + base + ".mov")
|
||||
|
||||
def potential_extras(self, image):
|
||||
yield self.image_local_iphone_movie(image)
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
class SyncReq(Enum):
|
||||
EXISTS = 0
|
||||
REQUIRED_SOFT = 1
|
||||
REQUIRED = 2
|
||||
|
||||
@staticmethod
|
||||
def max(a: "SyncReq", b: "SyncReq"):
|
||||
if a.value < b.value:
|
||||
return b
|
||||
return a
|
||||
@staticmethod
|
||||
def required(v: 'SyncReq'):
|
||||
return v == SyncReq.REQUIRED or v == SyncReq.REQUIRED_SOFT
|
||||
|
||||
@staticmethod
|
||||
def make(required, optional):
|
||||
if required:
|
||||
if optional:
|
||||
return SyncReq.REQUIRED_SOFT
|
||||
return SyncReq.REQUIRED
|
||||
return SyncReq.EXISTS
|
||||
|
||||
@dataclass
|
||||
class FileInfo:
|
||||
required: bool = False
|
||||
optional: bool = False
|
||||
|
||||
class SyncManager:
|
||||
|
||||
def __init__(self, config) -> None:
|
||||
self._config = config
|
||||
self._files: Dict[str, SyncReq] = {}
|
||||
|
||||
def register_file(self, fn: str, requirement: SyncReq):
|
||||
if fn in self._files:
|
||||
requirement = SyncReq.max(self._files[fn], requirement)
|
||||
self._files[fn] = requirement
|
||||
|
||||
def register_image(self, image: Image):
|
||||
required = self._config.should_be_local(image)
|
||||
self.register_file(
|
||||
self._config.image_local_image(image),
|
||||
SyncReq.make(required, False)
|
||||
)
|
||||
self.register_file(
|
||||
self._config.image_local_image_xmp(image),
|
||||
SyncReq.make(required, False)
|
||||
)
|
||||
|
||||
for extra in self._config.potential_extras(image):
|
||||
self.register_file(
|
||||
extra,
|
||||
SyncReq.make(required, True)
|
||||
)
|
||||
|
||||
def partition(self):
|
||||
result = {k: [] for k in SyncReq}
|
||||
for file, req in self._files.items():
|
||||
result[req].append(file)
|
||||
return result
|
||||
|
||||
config = Config()
|
||||
|
||||
sync_manager = SyncManager(config)
|
||||
|
||||
query = Image.filter()
|
||||
pw.prefetch(query, FilmRoll)
|
||||
for image in query:
|
||||
sync_manager.register_image(image)
|
||||
|
||||
|
||||
actions = sync_manager.partition()
|
||||
|
||||
for key in actions:
|
||||
actions[key] = list(sorted(map(
|
||||
lambda fn: os.path.relpath(fn, config.d_local) + "\n",
|
||||
actions[key]
|
||||
)))
|
||||
|
||||
with open("required.txt", "w") as f:
|
||||
f.writelines(actions[SyncReq.REQUIRED])
|
||||
import subprocess
|
||||
user = "oke"
|
||||
host = "192.168.20.2"
|
||||
|
||||
args = [
|
||||
"rsync",
|
||||
# "--dry-run",
|
||||
"--info=progress",
|
||||
"--ignore-existing",
|
||||
"-av",
|
||||
"--files-from",
|
||||
"required.txt",
|
||||
f"{user}@{host}:lenovo-darktable{config.d_local}",
|
||||
os.path.abspath(config.d_local)
|
||||
]
|
||||
import shlex
|
||||
print(shlex.join(args))
|
||||
subprocess.call(args)
|
||||
|
||||
with open("required_optional.txt", "w") as f:
|
||||
f.writelines(actions[SyncReq.REQUIRED_SOFT])
|
||||
|
||||
with open("remove.txt", "w") as f:
|
||||
f.writelines(actions[SyncReq.EXISTS])
|
||||
|
||||
exit()
|
||||
for fn in actions[SyncReq.REQUIRED_SOFT]:
|
||||
if not os.path.exists(fn):
|
||||
print("FETCH opt: ", fn)
|
||||
|
||||
for fn in sync_manager.files_to_fetch_required():
|
||||
print("FETCH: ", fn)
|
||||
|
||||
for fn in sync_manager.files_to_remove():
|
||||
print("RM: ", fn)
|
||||
n_total = 0
|
||||
n_not_needed = 0
|
||||
needs_removal = []
|
||||
extra_files_to_remove = []
|
||||
bytes_to_remove = 0
|
||||
|
||||
needs_fetching = []
|
||||
|
||||
for file, usages in file_usages.items():
|
||||
if not file.startswith(d_root):
|
||||
print("SKIPPNG", file)
|
||||
continue
|
||||
assert file.startswith(d_root)
|
||||
remote_file = file.replace(d_root, d_remote)
|
||||
local_file = file.replace(d_root, d_local)
|
||||
|
||||
if usages > 0:
|
||||
if not os.path.exists(local_file):
|
||||
needs_fetching.append((remote_file, local_file))
|
||||
else:
|
||||
n_not_needed += 1
|
||||
if os.path.exists(local_file):
|
||||
needs_removal.append(local_file)
|
||||
bytes_to_remove += os.stat(local_file).st_size
|
||||
|
||||
for extra_fn in potential_extras(local_file):
|
||||
if os.path.exists(extra_fn):
|
||||
extra_files_to_remove.append(extra_fn)
|
||||
bytes_to_remove += os.stat(extra_fn).st_size
|
||||
|
||||
files_to_remove = list(sorted(needs_removal + extra_files_to_remove))
|
||||
|
||||
needs_fetching = sorted(needs_fetching)
|
||||
import pprint
|
||||
print("Files to fetch: ")
|
||||
pprint.pprint(needs_fetching)
|
||||
print("Extra files to remove:")
|
||||
pprint.pprint(extra_files_to_remove)
|
||||
print("First files to remove:")
|
||||
pprint.pprint(needs_removal[:10])
|
||||
print("Last files to remove:")
|
||||
pprint.pprint(needs_removal[-10:])
|
||||
|
||||
with open("remove.txt", "w") as f:
|
||||
pprint.pprint(sorted(set(needs_removal).union(extra_files_to_remove)), f)
|
||||
|
||||
print("Not needed: %6d / %6d (%3d%%)" % (n_not_needed, len(file_usages), 100 * n_not_needed / len(file_usages)))
|
||||
print()
|
||||
print("Not yet deleted: %6d / %6d (%3d%%)" % (len(needs_removal), n_not_needed, 100 * len(needs_removal) / n_not_needed))
|
||||
print("Bytes to free: %5.2fGB" % (bytes_to_remove / 1024. / 1024 / 1024))
|
||||
print("Total files to remove: %d" % len(files_to_remove))
|
||||
print()
|
||||
print("Total files to fetch: %3d" % len(needs_fetching))
|
||||
print()
|
||||
|
||||
reply = input("Type YES to remove all these files. This is not reversible... : ")
|
||||
|
||||
if reply == 'YES':
|
||||
|
||||
for fn in files_to_remove:
|
||||
assert fn.startswith(d_local)
|
||||
os.remove(fn)
|
||||
|
||||
for f, t in needs_fetching:
|
||||
assert os.path.exists(f), "Backup corruped, file not found: " + f
|
||||
assert not os.path.exists(t)
|
||||
print(f, t)
|
||||
shutil.copy(f, t)
|
||||
|
||||
else:
|
||||
print("You did not type 'YES'. Not doing anything!")
|
278
vmgr/actions.py
Normal file
278
vmgr/actions.py
Normal file
@ -0,0 +1,278 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Callable, Any, Optional
|
||||
import functools
|
||||
import inspect
|
||||
import sys
|
||||
import os
|
||||
import datetime
|
||||
import traceback
|
||||
|
||||
from video import VideoModel, Video, BASE_DIR
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
TRASH_DT_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
||||
TRASH = "/home/oke/.local/share/Trash"
|
||||
TRASH_INFO = os.path.join(TRASH, "info")
|
||||
TRASH_FILES = os.path.join(TRASH, "files")
|
||||
|
||||
import json
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar('T')
|
||||
V = TypeVar('V')
|
||||
|
||||
class ActionException(Exception):
|
||||
|
||||
def __init__(self, message, *args: object) -> None:
|
||||
self.message = message
|
||||
super().__init__(*args)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Exception: '{self.message}'>"
|
||||
|
||||
class ActionFailed(ActionException):
|
||||
...
|
||||
|
||||
class ActionIncomplete(ActionException):
|
||||
...
|
||||
|
||||
import uuid
|
||||
class UndoableAction(Generic[T]):
|
||||
|
||||
ID = "OverrideMe"
|
||||
|
||||
def __init__(self, args: T, group_leader=None, uid=None):
|
||||
self.args: T = args
|
||||
self.uuid = uid
|
||||
if self.uuid is None:
|
||||
self.uuid = group_leader.uuid if group_leader else uuid.uuid4().hex
|
||||
|
||||
def undo_action(self) -> "UndoableAction[Any]": ...
|
||||
|
||||
def run(self, model: VideoModel) -> Optional[bool]: ...
|
||||
|
||||
|
||||
class RestoreFromTrash(UndoableAction[str]):
|
||||
|
||||
def undo_action(self) -> UndoableAction[Any]:
|
||||
return Trash(self.args)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Restoring {self.args}"
|
||||
|
||||
@staticmethod
|
||||
def trashed_files():
|
||||
for fn in os.listdir(TRASH_INFO):
|
||||
if fn.endswith('.trashinfo'):
|
||||
with open(os.path.join(TRASH_INFO, fn), 'r') as f:
|
||||
lines_iter = iter(f)
|
||||
if next(lines_iter) != '[Trash Info]\n':
|
||||
print("Invalid trashinfo file: ", fn)
|
||||
continue
|
||||
path = None
|
||||
date = None
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if path is None and line.startswith('Path='):
|
||||
path = line[5:]
|
||||
if date is None and line.startswith('DeletionDate='):
|
||||
date = datetime.datetime.strptime(line[13:], TRASH_DT_FORMAT)
|
||||
if path is None or date is None:
|
||||
print("Invalid trashinfo file: ", fn)
|
||||
continue
|
||||
trash_file = os.path.join(TRASH_FILES, fn[:-10])
|
||||
if not os.path.exists(trash_file):
|
||||
print("Trashed file does not exist: ", trash_file)
|
||||
continue
|
||||
yield (date, trash_file, path)
|
||||
|
||||
|
||||
def run(self, model):
|
||||
file = self.args
|
||||
matches = sorted(((date, trash_file) for (date, trash_file, path) in self.trashed_files() if path == self.args), reverse=True)
|
||||
if len(matches) == 0:
|
||||
raise ActionFailed("Could not find trashed file")
|
||||
try:
|
||||
os.rename(matches[0][1], file)
|
||||
except Exception as e:
|
||||
traceback.print_exception(e)
|
||||
raise ActionFailed("Could not move file to original destination...")
|
||||
try:
|
||||
os.remove(os.path.join(TRASH_INFO, os.path.basename(file) + ".trashinfo"))
|
||||
except Exception as e:
|
||||
traceback.print_exception(e)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class Trash(UndoableAction[str]):
|
||||
|
||||
def undo_action(self) -> UndoableAction[Any]:
|
||||
return RestoreFromTrash(self.args)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Trashing {self.args}"
|
||||
|
||||
def run(self, model: VideoModel):
|
||||
file = os.path.abspath(os.path.expanduser(self.args))
|
||||
i = 0
|
||||
suffix = ""
|
||||
while True:
|
||||
info_fn = os.path.join(TRASH_INFO, os.path.basename(file) + suffix + ".trashinfo")
|
||||
try:
|
||||
with open(info_fn, 'x') as f:
|
||||
f.write(
|
||||
"[Trash Info]\n"
|
||||
f"Path={file}\n"
|
||||
f"DeletionDate={datetime.datetime.now().strftime(TRASH_DT_FORMAT)}\n"
|
||||
)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
suffix = f"_{i}"
|
||||
continue
|
||||
|
||||
trash_fn = os.path.join(TRASH_FILES, os.path.basename(file) + suffix)
|
||||
if os.path.exists(trash_fn):
|
||||
os.remove(info_fn)
|
||||
i += 1
|
||||
suffix = f"_{i}"
|
||||
continue
|
||||
os.rename(file, trash_fn)
|
||||
index, video = model.find_video(Video(self.args))
|
||||
if index:
|
||||
model.remove(index)
|
||||
break
|
||||
|
||||
class SetVideoStars(UndoableAction[Tuple[str, bool, int, bool, int]]):
|
||||
|
||||
def run(self, model: VideoModel):
|
||||
file, old_reject, old_rating, new_reject, new_rating = self.args
|
||||
index, video = model.find_video(Video(file))
|
||||
video.metadata.rejected = new_reject
|
||||
video.metadata.rating = new_rating
|
||||
video.save_metadata()
|
||||
if index:
|
||||
model.dataChanged.emit(index, index)
|
||||
|
||||
def undo_action(self) -> UndoableAction[Any]:
|
||||
file, old_reject, old_rating, new_reject, new_rating = self.args
|
||||
return SetVideoStars((file, new_reject, new_rating, old_reject, old_rating))
|
||||
|
||||
def __str__(self):
|
||||
file, old_reject, old_rating, new_reject, new_rating = self.args
|
||||
return f"{file}: rejected status ({old_reject} -> {new_reject}) and rating ({old_rating} -> {new_rating})"
|
||||
|
||||
class UndoableActionList:
|
||||
|
||||
def __init__(self, fn: str) -> None:
|
||||
self._data: List[UndoableAction] = []
|
||||
self._fn: str = fn
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
with open(self._fn, "a") as f:
|
||||
pass
|
||||
|
||||
self.f = open(self._fn, 'r+')
|
||||
|
||||
for line in self.f:
|
||||
ID, uuid, args = json.loads(line.strip())
|
||||
|
||||
revert_fns = [obj for name, obj in inspect.getmembers(sys.modules[__name__])
|
||||
if inspect.isclass(obj) and name == ID]
|
||||
assert len(revert_fns) == 1
|
||||
self._data.append(revert_fns[0](args, uid=uuid))
|
||||
self.f.seek(0, os.SEEK_END)
|
||||
|
||||
def append(self, action: UndoableAction):
|
||||
json.dump((action.__class__.__name__, action.uuid, action.args), self.f)
|
||||
self.f.write("\n")
|
||||
self.f.flush()
|
||||
self._data.append(action)
|
||||
|
||||
def pop(self, uuid=None):
|
||||
if not self._data:
|
||||
return None
|
||||
if uuid and self._data[-1].uuid != uuid:
|
||||
return None
|
||||
action = self._data.pop()
|
||||
self._remove_line()
|
||||
return action
|
||||
|
||||
def clear(self):
|
||||
self._data = []
|
||||
self.f.seek(0, os.SEEK_SET)
|
||||
self.f.truncate()
|
||||
self.f.flush()
|
||||
|
||||
def _remove_line(self):
|
||||
pos = self.f.tell() - 1
|
||||
|
||||
if pos == -1:
|
||||
return
|
||||
|
||||
while pos > 0 and self.f.read(1) != "\n":
|
||||
pos -= 1
|
||||
self.f.seek(pos, os.SEEK_SET)
|
||||
if pos != 0:
|
||||
pos += 1
|
||||
self.f.seek(pos, os.SEEK_SET)
|
||||
self.f.truncate()
|
||||
self.f.flush()
|
||||
|
||||
class HistoryManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.history = UndoableActionList(os.path.join(BASE_DIR, "vmgr.history"))
|
||||
self.future = UndoableActionList(os.path.join(BASE_DIR, "vmgr.redo"))
|
||||
|
||||
def apply(self, model, *actions: UndoableAction):
|
||||
should_reload = False
|
||||
self.future.clear()
|
||||
for action in actions:
|
||||
print(action)
|
||||
should_reload = action.run(model) or should_reload
|
||||
self.history.append(action)
|
||||
if should_reload:
|
||||
model.reload()
|
||||
else:
|
||||
model.update()
|
||||
|
||||
@staticmethod
|
||||
def move(stack_1: UndoableActionList, stack_2, model, undo):
|
||||
action: Optional[UndoableAction] = stack_1.pop()
|
||||
should_reload = False
|
||||
while action is not None:
|
||||
try:
|
||||
if undo:
|
||||
print(action.undo_action())
|
||||
should_reload = action.undo_action().run(model) or should_reload
|
||||
else:
|
||||
print(action)
|
||||
should_reload = action.run(model) or should_reload
|
||||
except:
|
||||
stack_1.append(action)
|
||||
raise
|
||||
stack_2.append(action)
|
||||
action = stack_1.pop(action and action.uuid)
|
||||
return should_reload
|
||||
|
||||
def undo(self, model):
|
||||
should_reload = self.move(self.history, self.future, model, True)
|
||||
if should_reload:
|
||||
model.reload()
|
||||
else:
|
||||
model.update()
|
||||
|
||||
def redo(self, model: VideoModel):
|
||||
should_reload = self.move(self.future, self.history, model, False)
|
||||
if should_reload:
|
||||
model.reload()
|
||||
else:
|
||||
model.update()
|
||||
|
||||
|
||||
|
445
vmgr/main.py
Normal file
445
vmgr/main.py
Normal file
@ -0,0 +1,445 @@
|
||||
import vlc
|
||||
import sys
|
||||
import os.path
|
||||
import vlc
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
import subprocess
|
||||
from video import VideoModel
|
||||
from superqt import QLabeledRangeSlider
|
||||
from actions import UndoableAction, HistoryManager
|
||||
from typing import List
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
history = HistoryManager()
|
||||
|
||||
class Player(QtWidgets.QMainWindow):
|
||||
"""A simple Media Player using VLC and Qt
|
||||
"""
|
||||
def __init__(self, master=None):
|
||||
QtWidgets.QMainWindow.__init__(self, master)
|
||||
self.setWindowTitle("Media Player")
|
||||
|
||||
# creating a basic vlc instance
|
||||
self.instance = vlc.Instance()
|
||||
# creating an empty vlc media player
|
||||
self.mediaplayer = self.instance.media_player_new()
|
||||
self.createUI()
|
||||
self.isPaused = False
|
||||
|
||||
|
||||
def createUI(self):
|
||||
"""Set up the user interface, signals & slots
|
||||
"""
|
||||
self.widget = QtWidgets.QWidget(self)
|
||||
self.setCentralWidget(self.widget)
|
||||
|
||||
# In this widget, the video will be drawn
|
||||
if sys.platform == "darwin": # for MacOS
|
||||
self.videoframe = QtWidgets.QMacCocoaViewContainer(0)
|
||||
else:
|
||||
self.videoframe = QtWidgets.QFrame()
|
||||
self.palette = self.videoframe.palette()
|
||||
self.palette.setColor (QtGui.QPalette.Window,
|
||||
QtGui.QColor(0,0,0))
|
||||
self.videoframe.setPalette(self.palette)
|
||||
self.videoframe.setAutoFillBackground(True)
|
||||
|
||||
self.positionslider = QtWidgets.QSlider(QtCore.Qt.Horizontal, self)
|
||||
self.positionslider.setToolTip("Position")
|
||||
self.positionslider.setMaximum(1000)
|
||||
|
||||
def mousePressEvent(event):
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
val = self.pixelPosToRangeValue(event.pos())
|
||||
self.setValue(val)
|
||||
|
||||
self.positionslider.sliderMoved.connect(self.setPosition)
|
||||
self.positionslider.sliderPressed.connect(lambda *args: print(args))
|
||||
|
||||
|
||||
self.hbuttonbox = QtWidgets.QHBoxLayout()
|
||||
self.playbutton = QtWidgets.QPushButton("Play")
|
||||
self.hbuttonbox.addWidget(self.playbutton)
|
||||
self.playbutton.clicked.connect(self.PlayPause)
|
||||
|
||||
self.hbuttonbox.addStretch(1)
|
||||
self.setVolume(100)
|
||||
|
||||
self.videoplayerlayout = QtWidgets.QVBoxLayout()
|
||||
self.videoplayerlayout.addWidget(self.videoframe)
|
||||
self.videoplayerlayout.addWidget(self.positionslider)
|
||||
self.videoplayerlayout.addLayout(self.hbuttonbox)
|
||||
|
||||
|
||||
self.videoplayerlayoutWidget = QtWidgets.QWidget();
|
||||
self.videoplayerlayoutWidget.setLayout(self.videoplayerlayout);
|
||||
|
||||
|
||||
|
||||
import dataclasses
|
||||
def move_current_frame(d_frames):
|
||||
before = self.mediaplayer.get_time()
|
||||
|
||||
mspf = int(1000 // (self.mediaplayer.get_fps() or 25))
|
||||
d_time_ms = d_frames * mspf
|
||||
target = self.mediaplayer.get_time() + d_time_ms
|
||||
result = self.mediaplayer.set_time(target)
|
||||
|
||||
print(before, target, result, self.mediaplayer.get_time())
|
||||
|
||||
|
||||
def pp():
|
||||
self.PlayPause()
|
||||
print(self.mediaplayer.get_time())
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Space"), self)
|
||||
self.shortcut.activated.connect(pp)
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Right"), self)
|
||||
self.shortcut.activated.connect(lambda : move_current_frame(1))
|
||||
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Left"), self)
|
||||
self.shortcut.activated.connect(lambda : move_current_frame(-1))
|
||||
|
||||
|
||||
def export_frame():
|
||||
print("XX")
|
||||
t = self.mediaplayer.get_time()
|
||||
print(t)
|
||||
ms = t % 1000
|
||||
t = t // 1000
|
||||
s = t % 60
|
||||
t = t // 60
|
||||
m = t % 60
|
||||
t = t // 60
|
||||
h = t % 60
|
||||
t = t // 60
|
||||
t_str = f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||
print("Exporting: ", self.filename, t_str)
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
def apply(filename, t_str):
|
||||
base_dir = os.path.expanduser(os.path.join("~", "Pictures", "video_exports"))
|
||||
|
||||
print("W", filename, t_str)
|
||||
if not os.path.exists(base_dir):
|
||||
os.makedirs(base_dir)
|
||||
print("W", filename, t_str)
|
||||
begin = datetime.now()
|
||||
base_name = os.path.splitext(os.path.basename(filename))[0]
|
||||
|
||||
print("W", filename, t_str)
|
||||
print(base_name[:13])
|
||||
frame_datetime = datetime.strptime(base_name[:13], "%Y%m%d_%H%M") + timedelta(hours=h, minutes=m, seconds=s, milliseconds=ms)
|
||||
print(base_name[:13])
|
||||
frame_datetime_str = frame_datetime.strftime("%Y:%m:%d_%H:%M:%S")
|
||||
print(base_name[:13])
|
||||
|
||||
print("W", filename, t_str)
|
||||
out = os.path.join(base_dir,
|
||||
base_name +
|
||||
f"{h:02d}{m:02d}{s:02d}{ms:03d}.png")
|
||||
print(out)
|
||||
subprocess.call([
|
||||
"ffmpeg",
|
||||
"-ss", t_str,
|
||||
"-i", filename,
|
||||
"-vframes", "1",
|
||||
"-c:v", "png",
|
||||
out
|
||||
])
|
||||
subprocess.call([
|
||||
"exiftool",
|
||||
f"-CreateDate={frame_datetime_str}",
|
||||
f"-DateTimeOriginal={frame_datetime_str}",
|
||||
f"-PNG:CreationTime={frame_datetime_str}",
|
||||
"-overwrite_original",
|
||||
out
|
||||
])
|
||||
print("Duration: ", datetime.now() - begin)
|
||||
from video import thumbnailer
|
||||
|
||||
thumbnailer.pool.apply_async(apply, args=(self.filename, t_str))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+e"), self)
|
||||
self.shortcut.activated.connect(lambda : export_frame())
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("f"), self)
|
||||
self.shortcut.activated.connect(lambda : self.mediaplayer.toggle_fullscreen())
|
||||
|
||||
def slowmo():
|
||||
if self.mediaplayer.get_rate() < 0.7:
|
||||
print("rate", 1)
|
||||
self.mediaplayer.set_rate(1)
|
||||
else:
|
||||
print("rate", .5)
|
||||
self.mediaplayer.set_rate(0.5)
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("s"), self)
|
||||
self.shortcut.activated.connect(slowmo)
|
||||
|
||||
def change_size(diff):
|
||||
self.icon_size += diff
|
||||
print(self.icon_size < 64)
|
||||
if self.icon_size < 64:
|
||||
self.icon_size = 64
|
||||
if self.icon_size > 256:
|
||||
self.icon_size = 256
|
||||
print(self.icon_size)
|
||||
self.list_view.setIconSize(QtCore.QSize(self.icon_size, self.icon_size))
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("+"), self)
|
||||
self.shortcut.activated.connect(lambda : change_size(64))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("-"), self)
|
||||
self.shortcut.activated.connect(lambda : change_size(-64))
|
||||
from typing import Optional
|
||||
def undo():
|
||||
history.undo(self.model)
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+z"), self)
|
||||
self.shortcut.activated.connect(lambda : undo())
|
||||
def redo():
|
||||
history.redo(self.model)
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+Shift+z"), self)
|
||||
self.shortcut.activated.connect(lambda : redo())
|
||||
|
||||
|
||||
def set_rating(value):
|
||||
selected = self.list_view.selectedIndexes()
|
||||
|
||||
stars = None
|
||||
actions = []
|
||||
for i, index in enumerate(sorted(selected, key=lambda r: -r.row())):
|
||||
video = self.model.video_files[index.row()]
|
||||
from actions import SetVideoStars
|
||||
stars = SetVideoStars((
|
||||
video.fn,
|
||||
video.metadata.rejected,
|
||||
video.metadata.rating,
|
||||
False if value >= 0 else not video.metadata.rejected,
|
||||
value if value >= 0 else video.metadata.rating
|
||||
), stars)
|
||||
stars.run(self.model)
|
||||
actions.append(stars)
|
||||
history.apply(self.model, *actions)
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("0"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(0))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("1"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(1))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("2"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(2))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("3"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(3))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("4"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(4))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("5"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(5))
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("r"), self)
|
||||
self.shortcut.activated.connect(lambda : set_rating(-1))
|
||||
|
||||
def link():
|
||||
directory = QtWidgets.QFileDialog.getExistingDirectory(
|
||||
self, "Open File", os.path.expanduser('~'))
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Not a directory",
|
||||
"The selected item was not a directory. "
|
||||
"You need to select an empty folder. ")
|
||||
return
|
||||
|
||||
if os.listdir(directory):
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Non-empty directory selected",
|
||||
"The selected directory is not empty. "
|
||||
"You need to select an empty folder. ")
|
||||
return
|
||||
|
||||
print(directory)
|
||||
failure = False
|
||||
for index in self.list_view.selectedIndexes():
|
||||
video = self.model.video_files[index.row()]
|
||||
failure = failure or (
|
||||
0 != subprocess.call([
|
||||
"ln",
|
||||
"-s",
|
||||
video.fn,
|
||||
os.path.join(directory, os.path.basename(video.fn)),
|
||||
])
|
||||
)
|
||||
if failure:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
"Linkage failure",
|
||||
"Some files could not be linked.")
|
||||
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+L"), self)
|
||||
self.shortcut.activated.connect(link)
|
||||
|
||||
def delete():
|
||||
from actions import Trash
|
||||
selected = self.list_view.selectedIndexes()
|
||||
response = QtWidgets.QMessageBox.question(self, "Delete Items", "Do you really want to delete %d items?" % len(selected), defaultButton=QtWidgets.QMessageBox.No)
|
||||
if response == QtWidgets.QMessageBox.Yes:
|
||||
actions = []
|
||||
for i, index in enumerate(sorted(selected, key=lambda r: -r.row())):
|
||||
video = self.model.video_files[index.row()]
|
||||
action = Trash(video.fn, actions[-1] if len(actions) else None)
|
||||
actions.append(action)
|
||||
history.apply(self.model, *actions)
|
||||
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Del"), self)
|
||||
self.shortcut.activated.connect(lambda : delete())
|
||||
|
||||
self.shortcut = QtWidgets.QShortcut(QtGui.QKeySequence("F5"), self)
|
||||
self.shortcut.activated.connect(lambda : self.model.reload())
|
||||
|
||||
self.model = VideoModel()
|
||||
|
||||
def open_file(index):
|
||||
self.OpenFile(self.model.video_files[index.row()].fn)
|
||||
|
||||
self.list_view = QtWidgets.QListView()
|
||||
self.list_view.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.list_view.doubleClicked.connect(open_file)
|
||||
self.list_view.setViewMode(QtWidgets.QListView.IconMode)
|
||||
self.list_view.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
self.list_view.setFlow(QtWidgets.QListView.LeftToRight)
|
||||
self.list_view.setMovement(QtWidgets.QListView.Snap)
|
||||
self.list_view.setModel(self.model)
|
||||
# self.list_view.setGridSize(QtCore.QSize(256 + 10, 256 + 10))
|
||||
self.icon_size = 256
|
||||
self.list_view.setIconSize(QtCore.QSize(self.icon_size, self.icon_size))
|
||||
self.list_view.setWordWrap(True)
|
||||
|
||||
self.hboxlayout = QtWidgets.QSplitter()
|
||||
self.hboxlayout.addWidget(self.list_view)
|
||||
self.hboxlayout.addWidget(self.videoplayerlayoutWidget)
|
||||
|
||||
self.filter_slider = QLabeledRangeSlider(QtCore.Qt.Horizontal)
|
||||
self.filter_slider.setRange(0, 6)
|
||||
self.filter_slider.setValue((0, 6))
|
||||
self.filter_slider.setMaximum(6)
|
||||
self.filter_slider.setMinimum(-1)
|
||||
|
||||
def set_rating_filter(v):
|
||||
self.model.filter_rating = v
|
||||
self.filter_slider.sliderMoved.connect(set_rating_filter)
|
||||
self.parentLayout = QtWidgets.QVBoxLayout()
|
||||
self.parentLayout.addWidget(self.filter_slider, 0)
|
||||
self.parentLayout.addWidget(self.hboxlayout, 2)
|
||||
self.widget.setLayout(self.parentLayout)
|
||||
|
||||
exit = QtWidgets.QAction("&Exit", self)
|
||||
exit.triggered.connect(sys.exit)
|
||||
menubar = self.menuBar()
|
||||
filemenu = menubar.addMenu("&File")
|
||||
filemenu.addAction(exit)
|
||||
|
||||
self.timer = QtCore.QTimer(self)
|
||||
self.timer.setInterval(200)
|
||||
self.timer.timeout.connect(self.updateUI)
|
||||
|
||||
def PlayPause(self):
|
||||
"""Toggle play/pause status
|
||||
"""
|
||||
if self.mediaplayer.is_playing():
|
||||
self.mediaplayer.pause()
|
||||
self.playbutton.setText("Play")
|
||||
self.isPaused = True
|
||||
else:
|
||||
if self.mediaplayer.play() == -1:
|
||||
self.OpenFile()
|
||||
return
|
||||
self.mediaplayer.play()
|
||||
self.playbutton.setText("Pause")
|
||||
self.timer.start()
|
||||
self.isPaused = False
|
||||
|
||||
def Stop(self):
|
||||
"""Stop player
|
||||
"""
|
||||
self.mediaplayer.stop()
|
||||
self.playbutton.setText("Play")
|
||||
|
||||
def OpenFile(self, filename=None):
|
||||
"""Open a media file in a MediaPlayer
|
||||
"""
|
||||
if filename is None:
|
||||
return
|
||||
filename = QtWidgets.QFileDialog.getOpenFileName(self, "Open File", os.path.expanduser('~'))
|
||||
if not filename:
|
||||
return
|
||||
|
||||
# create the media
|
||||
if sys.version < '3':
|
||||
filename = unicode(filename)
|
||||
self.filename = filename
|
||||
self.media = self.instance.media_new(filename)
|
||||
# put the media in the media player
|
||||
self.mediaplayer.set_media(self.media)
|
||||
|
||||
# parse the metadata of the file
|
||||
self.media.parse()
|
||||
# set the title of the track as window title
|
||||
self.setWindowTitle(self.media.get_meta(0))
|
||||
|
||||
# the media player has to be 'connected' to the QFrame
|
||||
# (otherwise a video would be displayed in it's own window)
|
||||
# this is platform specific!
|
||||
# you have to give the id of the QFrame (or similar object) to
|
||||
# vlc, different platforms have different functions for this
|
||||
if sys.platform.startswith('linux'): # for Linux using the X Server
|
||||
self.mediaplayer.set_xwindow(int(self.videoframe.winId()))
|
||||
elif sys.platform == "win32": # for Windows
|
||||
self.mediaplayer.set_hwnd(self.videoframe.winId())
|
||||
elif sys.platform == "darwin": # for MacOS
|
||||
self.mediaplayer.set_nsobject(self.videoframe.winId())
|
||||
self.PlayPause()
|
||||
|
||||
def setVolume(self, Volume):
|
||||
"""Set the volume
|
||||
"""
|
||||
self.mediaplayer.audio_set_volume(Volume)
|
||||
|
||||
def setPosition(self, position):
|
||||
"""Set the position
|
||||
"""
|
||||
# setting the position to where the slider was dragged
|
||||
self.mediaplayer.set_position(position / 1000.0)
|
||||
# the vlc MediaPlayer needs a float value between 0 and 1, Qt
|
||||
# uses integer variables, so you need a factor; the higher the
|
||||
# factor, the more precise are the results
|
||||
# (1000 should be enough)
|
||||
|
||||
def updateUI(self):
|
||||
"""updates the user interface"""
|
||||
# setting the slider to the desired position
|
||||
self.positionslider.setValue(int(self.mediaplayer.get_position() * 1000))
|
||||
|
||||
if not self.mediaplayer.is_playing():
|
||||
# no need to call this function if nothing is played
|
||||
self.timer.stop()
|
||||
if not self.isPaused:
|
||||
# after the video finished, the play button stills shows
|
||||
# "Pause", not the desired behavior of a media player
|
||||
# this will fix it
|
||||
self.Stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
|
||||
|
||||
player = Player()
|
||||
player.show()
|
||||
player.resize(640, 480)
|
||||
if sys.argv[1:]:
|
||||
player.OpenFile(sys.argv[1])
|
||||
sys.exit(app.exec_())
|
74
vmgr/thumbnails.py
Normal file
74
vmgr/thumbnails.py
Normal file
@ -0,0 +1,74 @@
|
||||
import os
|
||||
import multiprocessing
|
||||
import hashlib
|
||||
import subprocess
|
||||
from PyQt5 import QtGui
|
||||
import multiprocessing.pool
|
||||
from typing import Dict, Optional
|
||||
|
||||
class CachedThumbnailGenerator:
|
||||
|
||||
def __init__(self, cache_folder) -> None:
|
||||
self.cache_folder = cache_folder
|
||||
self.memory_cache: Dict[str, Optional[QtGui.QIcon]] = {}
|
||||
self.pool = multiprocessing.pool.ThreadPool(1)
|
||||
|
||||
def create_thumbnail(self, video_file, callback=None, error_callback=None):
|
||||
self.pool.apply_async(
|
||||
self._get_thumbnail_file, (video_file,),
|
||||
callback=callback, error_callback=error_callback)
|
||||
|
||||
def get_thumbnail(self, video_file, callback=None, error_callback=None):
|
||||
cache_key = video_file
|
||||
|
||||
if cache_key not in self.memory_cache:
|
||||
self.memory_cache[cache_key] = None
|
||||
|
||||
def inner_callback(thumbnail_file):
|
||||
if not thumbnail_file:
|
||||
return
|
||||
image = QtGui.QImage(thumbnail_file)
|
||||
pixmap = QtGui.QPixmap.fromImage(image)
|
||||
self.memory_cache[cache_key] = QtGui.QIcon(pixmap)
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
self.create_thumbnail(
|
||||
video_file, inner_callback, error_callback)
|
||||
|
||||
if cache_key in self.memory_cache and self.memory_cache[cache_key]:
|
||||
return self.memory_cache[cache_key]
|
||||
return None
|
||||
|
||||
def _cache_file(self, video_file):
|
||||
abs_path = os.path.abspath(os.path.expanduser(video_file)) # + str(sec)
|
||||
digest = hashlib.md5(abs_path.encode()).hexdigest()
|
||||
return os.path.join(self.cache_folder, digest[:1], digest[1:2], digest + ".jpg")
|
||||
|
||||
def _load_cache(self, video_file):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _generate_thumbnail(video_fn, thumbnail_fn):
|
||||
subprocess.check_call([
|
||||
'ffmpeg', '-i',
|
||||
video_fn,
|
||||
"-threads", "1",
|
||||
'-ss', '00:00:00.000',
|
||||
'-c:v', 'mjpeg',
|
||||
'-vframes', '1',
|
||||
'-frames:v', '1',
|
||||
'-filter:v', 'scale=300:300:force_original_aspect_ratio=increase,crop=300:300',
|
||||
# '-f', 'image2pipe',
|
||||
# '-'
|
||||
thumbnail_fn
|
||||
])
|
||||
|
||||
def _get_thumbnail_file(self, video_file):
|
||||
cache_file = self._cache_file(video_file)
|
||||
if not os.path.exists(cache_file):
|
||||
if not os.path.exists(os.path.dirname(cache_file)):
|
||||
os.makedirs(os.path.dirname(cache_file))
|
||||
self._generate_thumbnail(video_file, cache_file)
|
||||
assert os.path.exists(cache_file)
|
||||
return cache_file
|
252
vmgr/video.py
Normal file
252
vmgr/video.py
Normal file
@ -0,0 +1,252 @@
|
||||
import os
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
import itertools
|
||||
from typing import Any, List, Dict
|
||||
from thumbnails import CachedThumbnailGenerator
|
||||
|
||||
from typing import Tuple, Optional
|
||||
import yaml
|
||||
|
||||
BASE_DIR = "/home/oke/Pictures/DarktableLocal/"
|
||||
CACHE_FOLDER = os.path.expanduser("~/.cache/video_manager")
|
||||
|
||||
if not os.path.exists(CACHE_FOLDER):
|
||||
os.makedirs(CACHE_FOLDER)
|
||||
|
||||
thumbnailer = CachedThumbnailGenerator(CACHE_FOLDER)
|
||||
|
||||
|
||||
class Metadata:
|
||||
|
||||
def __init__(self, rating=0, rejected=False):
|
||||
assert type(rating) == int
|
||||
self.rating = rating
|
||||
self.rejected = rejected
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, fn):
|
||||
with open(fn) as f:
|
||||
data = yaml.load(f, yaml.SafeLoader)
|
||||
return Metadata(**data)
|
||||
|
||||
def save(self, fn):
|
||||
with open(fn, "w") as f:
|
||||
yaml.dump(self.__dict__, f)
|
||||
|
||||
class Video:
|
||||
|
||||
def __init__(self, fn: str) -> None:
|
||||
self.fn = fn
|
||||
self._metadata = None
|
||||
|
||||
def __repr__(self):
|
||||
return os.path.basename(self.fn)
|
||||
|
||||
@property
|
||||
def metadata(self):
|
||||
if not self._metadata:
|
||||
if os.path.exists(self.metadata_file()):
|
||||
self._metadata = Metadata.from_file(self.metadata_file())
|
||||
else:
|
||||
self._metadata = Metadata()
|
||||
return self._metadata
|
||||
|
||||
def metadata_file(self):
|
||||
return self.fn + '.metadata'
|
||||
|
||||
def save_metadata(self):
|
||||
self.metadata.save(self.metadata_file())
|
||||
|
||||
def load_thumbnail(self, thumbnailer, callback=None, error_callback=None):
|
||||
thumbnailer.create_thumbnail(self.fn, callback, error_callback)
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.fn > other.fn
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.fn == other.fn
|
||||
|
||||
class VideoModel(QtCore.QAbstractListModel):
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._filter_rating = (0, 6)
|
||||
self.show_up_to = 10
|
||||
|
||||
self.video_obj_cache = {}
|
||||
self.all_files: List[Video] = self.collect_files()
|
||||
self.video_files: List[Video] = self.filter_files(self.all_files)
|
||||
|
||||
|
||||
pixmap = QtGui.QPixmap(256, 256)
|
||||
pixmap.fill(QtCore.Qt.transparent)
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
painter.setBrush(QtGui.QColor("red"))
|
||||
painter.drawEllipse(pixmap.rect())
|
||||
painter.end()
|
||||
self.default_icon = QtGui.QIcon(pixmap)
|
||||
|
||||
def collect_files(self, base_dir=BASE_DIR):
|
||||
def _():
|
||||
for film_roll in os.listdir(base_dir):
|
||||
roll_dir = os.path.join(base_dir, film_roll)
|
||||
if os.path.isdir(roll_dir):
|
||||
for fn in os.listdir(roll_dir):
|
||||
if fn[0] != '.' and os.path.splitext(fn)[-1].lower() in [".mp4", ".mov", ".avi"]:
|
||||
path = os.path.join(roll_dir, fn)
|
||||
if path not in self.video_obj_cache:
|
||||
self.video_obj_cache[path] = Video(path)
|
||||
yield self.video_obj_cache[path]
|
||||
return sorted(_())
|
||||
|
||||
@property
|
||||
def filter_rating(self):
|
||||
return self._filter_rating
|
||||
|
||||
@filter_rating.setter
|
||||
def filter_rating(self, value):
|
||||
self._filter_rating = value
|
||||
self.update()
|
||||
|
||||
def filter_files(self, videos: List[Video]):
|
||||
def flt(video: Video):
|
||||
minimum, maximum = self._filter_rating
|
||||
rating = -1 if video.metadata.rejected else video.metadata.rating
|
||||
return rating >= minimum and rating < maximum
|
||||
return list(filter(flt, videos))[:self.show_up_to]
|
||||
|
||||
|
||||
def find_video(self, video: Video)-> Tuple[Optional[QtCore.QModelIndex], Video]:
|
||||
try:
|
||||
index = self.all_files.index(video)
|
||||
video = self.all_files[index]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
index = self.video_files.index(video)
|
||||
index = self.index(index, 0)
|
||||
except ValueError:
|
||||
index = None
|
||||
return index, video
|
||||
|
||||
|
||||
|
||||
def update(self):
|
||||
i = 0
|
||||
parent = QtCore.QModelIndex()
|
||||
before = list(self.video_files)
|
||||
action_list = []
|
||||
after = self.filter_files(self.all_files)
|
||||
|
||||
while i < len(after) and i < len(before):
|
||||
if before[i] == after[i]:
|
||||
i += 1
|
||||
elif before[i] < after[i]:
|
||||
action_list.append(('r', i))
|
||||
before.pop(i)
|
||||
else:
|
||||
action_list.append(('a', i, after[i]))
|
||||
before.insert(i, after[i])
|
||||
|
||||
j = i
|
||||
while j < len(before):
|
||||
action_list.append(('r', j))
|
||||
before.pop(j)
|
||||
|
||||
for j in range(i, len(after)):
|
||||
action_list.append(('a', j, after[j]))
|
||||
before.insert(j, after[j])
|
||||
# assert before == after
|
||||
|
||||
if len(action_list) == 0:
|
||||
return
|
||||
def work_batch():
|
||||
batch = action_list[b:e]
|
||||
if batch[0][0] == 'r':
|
||||
self.beginRemoveRows(parent, batch[0][1], batch[0][1] + len(batch) - 1)
|
||||
del self.video_files[batch[0][1]:batch[0][1] + len(batch)]
|
||||
self.endRemoveRows()
|
||||
else:
|
||||
self.beginInsertRows(parent, batch[0][1], batch[0][1] + len(batch) - 1)
|
||||
self.video_files = (
|
||||
self.video_files[0:batch[0][1]] +
|
||||
list(map(lambda a: a[2], batch)) +
|
||||
self.video_files[batch[0][1]:]
|
||||
)
|
||||
self.endInsertRows()
|
||||
|
||||
b = 0
|
||||
e = 1
|
||||
for e in range(1, len(action_list)):
|
||||
if (
|
||||
action_list[e][0] == 'r' and
|
||||
action_list[e - 1][0] == 'r' and
|
||||
action_list[e][1] == action_list[e - 1][1]
|
||||
):
|
||||
continue
|
||||
if (
|
||||
action_list[e][0] == 'a' and
|
||||
action_list[e - 1][0] == 'a' and
|
||||
action_list[e][1] == action_list[e - 1][1] + 1
|
||||
):
|
||||
continue
|
||||
work_batch()
|
||||
b = e
|
||||
e = len(action_list)
|
||||
work_batch()
|
||||
assert len(self.video_files) == len(after)
|
||||
# assert self.video_files == after
|
||||
|
||||
def reload(self):
|
||||
self.all_files = self.collect_files()
|
||||
self.update()
|
||||
|
||||
def invalidateFilter(self, index=None, index_to=None):
|
||||
self.update()
|
||||
|
||||
def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
|
||||
return self.show_up_to < len(self.all_files)
|
||||
|
||||
def fetchMore(self, parent: QtCore.QModelIndex) -> None:
|
||||
self.show_up_to = min(self.show_up_to + 5, len(self.all_files))
|
||||
self.update()
|
||||
|
||||
def remove(self, index):
|
||||
self.all_files.remove(self.video_files[index.row()])
|
||||
self.update()
|
||||
|
||||
def data(self, index: QtCore.QModelIndex, role: int=0) -> Any:
|
||||
video = self.video_files[index.row()]
|
||||
if role == 0:
|
||||
size = 0
|
||||
try:
|
||||
size = os.stat(video.fn).st_size / 1024. / 1024. / 1024.
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return '%s %s %.2f' % (
|
||||
os.path.basename(video.fn),
|
||||
('★' * video.metadata.rating + '☆' * (5 - video.metadata.rating))
|
||||
if not video.metadata.rejected else
|
||||
'xxxxx'
|
||||
,
|
||||
size
|
||||
)
|
||||
if role == 1:
|
||||
def callback():
|
||||
self.dataChanged.emit(index, index)
|
||||
return (
|
||||
thumbnailer.get_thumbnail(video.fn, callback)
|
||||
or self.default_icon
|
||||
)
|
||||
|
||||
|
||||
def rowCount(self, index=None):
|
||||
return len(self.video_files)
|
||||
|
||||
def columnCount(self, index):
|
||||
return 1
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user