ffmpegのインストール
ダウンロードして展開してパスを通します。
色々なところで紹介されているので、詳しいことは割愛。
動画ファイルを変換する関数を作成
こんな感じになると思います。
import os,subprocess
def resize_and_save_movie(in_path, out_path):
strcmd='ffmpeg -i "' + in_path + '"' #入力ファイルと、
strcmd+=' "' + out_path + '"' #出力ファイルを引数に指定。他にしたければこの間で
result = subprocess.run(strcmd,stderr=subprocess.STDOUT, stdout=subprocess.PIPE, encoding='utf-8',text=True,check=True)
sr=os.stat(path=in_path) #変換元ファイルのステータスを取得して、
os.utime(path=out_path, times=(sr.st_atime, sr.st_mtime)) #変換先ファイルの最終アクセス時刻と、最終更新時刻を引き継ぐ
後は、os.walkなりで繰り返し呼び出せば、アラ簡単。
(in_path
に指定するファイルの絞り込みや、out_path
に指定するファイル名の変更処理などは自前でご用意ください)
以下は、ただの日記です。
今回やってみたこと
- python から ffmpeg を使って、動画の一括変換
- 前回作ったサムネイル作成器を使って、写真整理の原本フォルダの軽量版コピーを作成。
- 前回作ったギャラリーの機能充実(主にモバイル端末上での操作性向上)
前回の記事に乗せたツールもシコシコと改善されていますが、前回記事の中身を差し替えるのではなく、新しい記事として書くことにしました。(なので、前回記事に張り付けてあるツールはちょっと古いまま放置です。)
取り組みに至った経緯
前回までの営みによって、過去の写真や動画を一元管理して、スイスイと見て回ることができるようになりました。
・・・外付けHDDが繋がっている母艦でならね!
便利に見られるようにはなったけれど、簡単に見ることができるようにはなっていない、という状態です。
撮りためた写真を、我が子に見せてあげたいじゃないですか。
嫁さんがスマホで子供に写真とかを見せて盛り上がってるじゃないですか。
「お父さんの写真は全部パソコンに取り込んであるんだよ。」としか言えなくて、悔しいじゃないですか。
端的に言ってストレスです。
気づけば、写真整理の原本フォルダは超デカくなっていました。
とても母艦以外の端末で気軽に見ようという感じではありません。
一部を NAS に突っ込んだりしてみましたが、重くてまるで使い物になりません。
ましてや全写真・動画データを NAS にコピーしようとか、どうせコピー中に何度もI/Oエラーとか起こってヒーヒー言うことになる未来が視えてきてしまいます。うまくコピーできたとしても使い物にならないのに!
というわけで、スマホやタブレットでも気軽に見たいなー、という気がしてきたので、軽量版コピーを作成することにしました。
どれくらい縮まるのか
結論から先に言うと軽量版コピーは、原本フォルダの十分の一以下のサイズになりました!
ファイル種類 | 原本サイズ | 縮小版コピー | 圧縮率 |
---|---|---|---|
jpeg画像 | 400GB超 | 31GB | 7.8% |
各種rawデータ | 200GB超 | 0 | - |
各種動画形式 | 350GB位 | 64GB | 18.3% |
合計 | 1TB弱 | 95GB | 9.5% |
改めて 1TB はデカい。ちょっと気軽に扱えるデータ量じゃないです。ローカルでならともかく、ネットワーク越しに気軽にクリックでカチカチと弄んではいけません。翌朝の成功をお祈りしながら、夜間掛け逃げバッチ処理にでも食わせる類の代物です。
まぁ、95GB でも軽量版という名乗りとしてどうなんだという気もしますが。(不要な写真を消せばまだまだガッツリ減るとは思うのですが、めんどくさいのであまり断捨離が進んでいません)
rawデータは軽量版コピーにはそっくり不要なので、実際のところは、750GB → 95GBで、実質一割強といったところでしょうか。…十分の一以下はちょっと言い過ぎですね。
静止画
静止画は、横幅 2560 を指定しました。(WQHD の横幅と同じ大きさにしてみました)
webp形式でクオリティは 75 を指定しましたが、画質はまぁそこそこ。スマホやタブレットで見る程度なら十分。
これだけでサイズが撮って出し jpeg の十分の一以下になる。ありがたい事です。
動画
動画は、720p に変換しました。スマホやタブレットで見るなら(以下略。
元の動画形式は色々ありますが、全体的に見たら2割以下のサイズになっているので、ffmpeg 様々です。
もともと一部の動画ファイル(昔のデジカメが吐いたaviとか、ハンディカムが吐くmtsとか)が、ブラウザ内の video タグでは再生できませんでしたが、mp4 に変換することで全てブラウザ上で再生できるようになりました。
子供は動画でウッヒョー!と大喜びするので、副作用というより必須作用ですな。
原本の動画ファイルに対して変換かけるのはなんか怖いのでやりませんが。
今回、作成・修正したツール
- 動画サイズ変更 (new)
- サムネ作成 (update)
- ギャラリー作成 (update)
の3本です。
動画リサイズだけが今回の新作で、他二つは前の記事のツールの機能改善版です。
実際にはこれ以前に、さらに以前の記事で作った、写真や動画ファイルを決まった形のフォルダ構成に整頓するツールでフォルダを育てている状態が前提ですが、そちらのツールは今回なにも触ることがなかったので再掲いたしません。
動画サイズ変換器
この記事を書き始めたときには、本記事の目玉になるような気がしていたのですが、解像度以外には特に何も指定せずにおまかせ変換してもらっているだけなので、ffmpeg 自身に関してこの記事で述べることがあまりなかったです。
特にわからないこともなく、普通に subprocess.run で呼び出しながらフォルダ構成を保った別パスに向かって変換結果を出力しただけでした。全動画を確認したわけではありませんが、今のところ何の問題も起こっていません。・・・正直、拍子抜けです。
一応、大まかな機能説明としては、以下の通りです。
- 入力元に指定されたフォルダから、動画ファイル(拡張子".mp4", ".mov", ".avi", ".mts")を対象に、以下の処理を行う。
- 出力先パスを求める。
引数で指定した出力先に、引数で指定したプレフィスクをつけて、拡張子はmp4。 - 入力パスと出力パスを元に変換する。
引数で指定した解像度で変換しようとしてエラーが出たら、縦横方向の指定を変えてリトライ。
- 出力先パスを求める。
- 出力先に指定されたフォルダから、動画ファイルのサムネイル(~.動画拡張子.webp)を対象に、以下の処理を行う
- 縮小コピー側の動画は拡張子が mp4 に変わっている場合があるので、動画サムネの拡張子も追従するように。
- 元ファイルがなくなっているサムネは削除する。
同じ意味合いの値を、引数で渡してみたり、ベタ書きで書いてしまっていたりが混在してしまっています。バッチファイルから決まった引数で呼ばれていればいいですが、そうでない場合は多分まともに動きません。単一のプログラムとして独立しきれてなくて、いかにもサンデープログラミングの産物という感じではないでしょうか。。
以下、py本体です。batからの呼び出しについては後述。
movie_resize.py
import os,sys
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor
import subprocess
import shutil #move
import time
start_thread=0
def resize_and_save_movie(input_path, output_path):
global start_thread
while start_thread ==0 :
time.sleep(1)
try:
if not os.path.exists(os.path.dirname(output_path)): #保存先フォルダが存在しない場合は、
os.makedirs(os.path.dirname(output_path)) #フォルダを作成
except(OSError):
pass
try:
strcmd='ffmpeg -i "' + input_path + '" -vf scale=-1:' + str(Resolution) + ' "' + output_path + '"'
result = subprocess.run(strcmd,stderr=subprocess.STDOUT, stdout=subprocess.PIPE, encoding='utf-8',text=True,check=True)
sr=os.stat(path=input_path)
os.utime(path=output_path, times=(sr.st_atime, sr.st_mtime))
return True, f"Movie {input_path} resized and saved to {output_path}"
except Exception as e:
#print (f"Error processing \n{input_path}\n{output_path}\n{e}\nRetry!")
os.remove(output_path) #失敗したファイルを削除
try:
strcmd='ffmpeg -i "' + input_path + '" -vf scale=' + str(Resolution) + ':-1 "' + output_path + '"'
result = subprocess.run(strcmd,stderr=subprocess.STDOUT, stdout=subprocess.PIPE, encoding='utf-8',text=True,check=True)
sr=os.stat(path=input_path)
os.utime(path=output_path, times=(sr.st_atime, sr.st_mtime))
return True, f"Movie {input_path} resized and saved to {output_path}"
except Exception as e:
return False, f"Error processing \n{input_path}\n{output_path}\n{e}"
# 元動画→縮小動画作成
def process_movies_in_directory():
with ThreadPoolExecutor(max_workers=5) as executor:
#with ThreadPoolExecutor() as executor:
futures = []
for root, dirs, files in os.walk(target_directory):
for file in files:
#対象フォルダの処理中なので、出力先フォルダが中に存在しても無視。(出力先を対象に別途実施。)
if root.startswith(output_directory):
pass
#対象フォルダ(元データ)中の、thumbnailを無視する
elif 'thumbnail' in root:
pass
#動画ファイルを対象。
if file.lower().endswith((".mp4", ".mov", ".avi", ".mts")):
if file.lower().endswith(".mp4"):
newfilename = prefix + file
else:
#mp4以外はmp4に変換する。
filename,exe = os.path.splitext(file)
newfilename = prefix + filename + ".mp4"
dir_path_out = root.replace(target_directory,output_directory) #縮小版の格納パス、フルパス
file_path_out = os.path.join(dir_path_out, newfilename)
if not os.path.exists(file_path_out): #存在しない場合のみ作成対象。
file_path_org = os.path.join(root, file)
futures.append(executor.submit(resize_and_save_movie, file_path_org, file_path_out))
print('\r'+str(len(futures)) ,end='')
#print(f"ORIGINAL:{file_path_org}")
#print(f"RESIZE :{file_path_out}")
#else: #縮小版の日付がズレた時用。存在するなら日時を合わせる。
# file_path_org = os.path.join(root, file)
# sr=os.stat(path=file_path_org)
# os.utime(path=file_path_out, times=(sr.st_atime, sr.st_mtime))
successful_count = 0
error_count = 0
#上のループが時間がかかると、進捗バーが出るまでに時間がかかる上に、出たときにはだいぶ進んでしまっているので、
#対象ファイルの収集が終わるまでは、スレッドは止めておくようにする。(めっちゃ作ることになるが。。)
global start_thread
start_thread=1
for future in tqdm(futures, desc="Processing movies", total=len(futures)):
success, message = future.result()
if success:
successful_count += 1
else:
error_count += 1
tqdm.write(message)
tqdm.write(f"Processing complete. {successful_count} movies processed successfully, {error_count} errors.")
# 縮小動画のうち、元動画が存在しないものを削除
def process_resized_movies_in_directory():
for root, dirs, files in os.walk(output_directory):
dir_path_org = root.replace(output_directory,target_directory) #元ファイルの格納パス、フルパス
for file in files:
if 'thumbnail' in root:
if file.startswith("tmb_"):
#縮小動画作成先フォルダにthumbnailフォルダが存在し、
#avi,mov,mtsのサムネイル画像があった場合、mp4サムネファイル名にする
# ※縮小動画はmp4に統一するため、元動画の形式とは異なる。
tmb_name=''
if file.lower().endswith(".avi.webp"):
tmb_name = file.replace(".avi.webp",".mp4.webp")
tmb_name = file.replace(".AVI.webp",".mp4.webp")
if file.lower().endswith(".mov.webp"):
tmb_name = file.replace(".mov.webp",".mp4.webp")
tmb_name = file.replace(".MOV.webp",".mp4.webp")
if file.lower().endswith(".mts.webp"):
tmb_name = file.replace(".mts.webp",".mp4.webp")
tmb_name = file.replace(".MTS.webp",".mp4.webp")
if tmb_name: #置換が必要。
if not os.path.exists(os.path.join(root,tmb_name)): #置換後のファイル名が存在しない場合だけ
#リネームしてしまうと、毎回サムネ作成で再処理対象になってしまうので残しておく
shutil.copy(os.path.join(root,file), os.path.join(root,tmb_name)) #コピー
# thumbnailフォルダ以外
elif file.startswith(prefix) and file.lower().endswith((".mp4")):
file_path_org = os.path.join(dir_path_org,file.removeprefix(prefix).removesuffix(".mp4").removesuffix(".MP4"))
flgDel=True
if os.path.exists(file_path_org+".mp4"):
flgDel=False
if os.path.exists(file_path_org+".avi"):
flgDel=False
if os.path.exists(file_path_org+".mov"):
flgDel=False
if os.path.exists(file_path_org+".mts"):
flgDel=False
if flgDel: #元ファイルが存在しない場合は、
#delfilename=os.path.join(root,file)
#print(f"ORIGINAL:{file_path_org}")
#print(f"DELETE :{delfilename}")
os.remove(os.path.join(root,file)) #縮小版を削除
#デフォルト値
prefix = 'resize_'
out_dir = 'resize'
Resolution=720
# 処理対象のディレクトリパスを取得
if len(sys.argv) < 2:
print('対象フォルダを指定してください。')
sys.exit()
#対象フォルダ
target_directory = sys.argv[1]
if not os.path.isdir(target_directory):
print(f'"{target_directory}"はフォルダではありません。')
print('対象フォルダには有効なパスを指定してください。')
sys.exit()
#ファイル接頭辞
if len(sys.argv) >= 3:
prefix = sys.argv[2]
#出力先フォルダ
if len(sys.argv) >= 4:
out_dir = sys.argv[3]
#解像度
if len(sys.argv) >= 5:
if sys.argv[4].isdecimal():
Resolution=int(sys.argv[4])
else:
print('解像度は数値で指定してください。')
sys.exit()
if len(sys.argv) >= 6:
print('引数の数が不正です')
sys.exit()
output_directory = os.path.abspath(os.path.join(target_directory,out_dir))
print(f'対象フォルダ :{target_directory}')
print(f'ファイル名接頭辞:{prefix}')
print(f'出力先 :{output_directory}')
print(f'解像度 :{Resolution}')
# 元動画→縮小動画作成
process_movies_in_directory()
# 縮小動画のうち、元動画が存在しないものを削除
process_resized_movies_in_directory()
サムネ作成器
前回作ったものの改善版。
サムネ作成器、兼、縮小版コピー作成機になります。
変更点は以下の通りです。
- 縮小コピー画像にも exif を保持するようにした。
(後処理でギャラリー作成に食わせるため) - 引数で色々指定できるようにした。
- サムネイルの解像度(横幅)
- ファイル名のプレフィックス
- 出力先パス
- 動画ファイルを対象にするかしないか
thumbnail_generator.py
import os,sys
from PIL import Image, ExifTags
#import pyexiv2
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor
import cv2 #動画のサムネ作成
import tempfile #cv2が日本語パスに対応できないので
import shutil #move
import time
start_thread=0
def resize_and_save_image(input_path, output_path):
global start_thread
while start_thread ==0 :
time.sleep(1)
try:
with Image.open(input_path) as img:
# 画像の向きを調整(Exif情報を保持)
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
try:
exif_dict = dict(img._getexif().items())
if exif_dict[orientation] == 3:
img = img.rotate(180, expand=True)
elif exif_dict[orientation] == 6:
img = img.rotate(270, expand=True)
elif exif_dict[orientation] == 8:
img = img.rotate(90, expand=True)
except (AttributeError, KeyError, IndexError):
# 画像にExif情報がない場合や、Orientationが存在しない場合は無視
pass
# 画像の縦横サイズを取得
width, height = img.size
# 横に並べるので、横幅が指定pixelになるように縮小する。
new_height = int(height * (max_size / width))
img.thumbnail((max_size, new_height), Image.LANCZOS)
try:
if not os.path.exists(os.path.dirname(output_path)): #保存先フォルダが存在しない場合は、
os.makedirs(os.path.dirname(output_path)) #フォルダを作成
except(OSError):
pass
# 画像を保存
exif = img.info.get('exif')
if exif != None:
#print(f"{output_path}")
img.save(output_path, format="WEBP", quality=quality, exif=exif)
else:
img.save(output_path, format="WEBP", quality=quality)
return True, f"Image {input_path} resized and saved to {output_path}"
except Exception as e:
return False, f"Error processing {input_path}/{output_path}: {e}"
def resize_and_save_move(input_path, output_path):
global start_thread
while start_thread ==0 :
time.sleep(1)
try:
cap = cv2.VideoCapture(input_path)
if not cap.isOpened():
return False, f"Error processing {input_path}/{output_path}: VideoCapture open"
_, img = cap.read()
height = img.shape[0]
width = img.shape[1]
# リサイズ
newWidth = max_size
newHeight = int(height * newWidth / width)
img = cv2.resize(img, (newWidth, newHeight))
outputDir= os.path.dirname(output_path)
try:
if not os.path.exists(outputDir): #保存先フォルダが存在しない場合は、
os.makedirs(outputDir) #フォルダを作成
except(OSError):
pass
with tempfile.TemporaryDirectory() as tmpDir: #一時フォルダに、
tmpFile=os.path.join(tmpDir,"tmp.webp")
cv2.imwrite(tmpFile, img, [int(cv2.IMWRITE_WEBP_QUALITY), quality]) # 画像を保存
shutil.move(tmpFile, output_path) #本来の出力先へ移動
return True, f"Image {input_path} resized and saved to {output_path}"
except Exception as e:
return False, f"Error processing {input_path}/{output_path}: {e}"
def process_images_in_directory(directory_path):
tmb_root = os.path.abspath(os.path.join(directory_path,thumbnail_dir))
#with ThreadPoolExecutor(max_workers=5) as executor:
with ThreadPoolExecutor() as executor:
futures = []
for root, dirs, files in os.walk(directory_path):
for file in files:
#実ファイルがなくなっているサムネの削除(「tmb_~.webp」しか存在しないハズ。あとはギャラリー用のhtmlとか?)
#サムネフォルダが対象フォルダ内に存在する場合しか働かない。
if root.startswith(tmb_root):
if file.startswith(prefix_thumbnail) and file.endswith(".webp"):
dir_path_img = root.replace(tmb_root,directory_path) #元画像の格納パス、フルパス
file_path_img = os.path.join(dir_path_img,file.removeprefix(prefix_thumbnail).removesuffix(".webp"))
if not os.path.exists(file_path_img): #元画像が存在しない場合は、
os.remove(os.path.join(root,file)) #サムネイルを削除
#原本を対象に縮小版を作成中、原本のthumbnailを無視する
elif 'thumbnail' in root:
pass
#実ファイル側
else:
# サムネ作成対象(画像ファイル&動画ファイル)
#if file.lower().endswith((".jpg", ".jpeg", ".png", ".mp4", ".mov", ".avi", ".mts")):
if file.lower().endswith((".jpg", ".jpeg", ".png", ".mp4", ".mov", ".avi", ".mts", ".webp")):
dir_path_tmb = root.replace(directory_path,tmb_root) #サムネの格納パス、フルパス
file_path_tmb = os.path.join(dir_path_tmb, prefix_thumbnail+file+".webp")
if not os.path.exists(file_path_tmb): #存在しない場合のみ作成対象。
file_path_img = os.path.join(root, file)
if file.lower().endswith((".jpg", ".jpeg", ".png")):
#画像ファイル
futures.append(executor.submit(resize_and_save_image, file_path_img, file_path_tmb))
elif movie_flg ==1:
#動画ファイル
futures.append(executor.submit(resize_and_save_move, file_path_img, file_path_tmb))
print('\r'+str(len(futures)) ,end='')
successful_count = 0
error_count = 0
#上のループが時間がかかると、進捗バーが出るまでに時間がかかる上に、出たときにはだいぶ進んでしまっているので、
#対象ファイルの収集が終わるまでは、スレッドは止めておくようにする。(めっちゃ作ることになるが。。)
global start_thread
start_thread=1
for future in tqdm(futures, desc="Processing images", total=len(futures)):
success, message = future.result()
if success:
successful_count += 1
else:
error_count += 1
tqdm.write(message)
tqdm.write(f"Processing complete. {successful_count} images processed successfully, {error_count} errors.")
#デフォルト値
prefix_thumbnail = 'tmb_'
thumbnail_dir = 'thumbnail'
max_size=640
quality=75
metadata_flg=0 #デフォルトはExifは捨てる
movie_flg=1 #デフォルトでは動画も対象
# 処理対象のディレクトリパスを取得
if len(sys.argv) < 2:
print('対象フォルダを指定してください。')
sys.exit()
#対象フォルダ
target_directory = sys.argv[1]
if not os.path.isdir(target_directory):
print(f'"{target_directory}"はフォルダではありません。')
print('対象フォルダを指定してください。')
sys.exit()
#サムネ接頭辞
if len(sys.argv) >= 3:
prefix_thumbnail = sys.argv[2]
#出力先フォルダ
if len(sys.argv) >= 4:
thumbnail_dir = sys.argv[3]
#横幅
if len(sys.argv) >= 5:
if sys.argv[4].isdecimal():
max_size=int(sys.argv[4])
else:
print('横幅を数値で指定してください。')
sys.exit()
#品質
if len(sys.argv) >= 6:
if sys.argv[5].isdecimal():
quality=int(sys.argv[5])
else:
print('品質を数値で指定してください。')
sys.exit()
#動画処理フラグ
if len(sys.argv) >= 7:
if sys.argv[6].isdecimal():
movie_flg=int(sys.argv[6])
else:
print('動画を対象にするかどうか、0 or 1 で指定してください。')
sys.exit()
if len(sys.argv) >= 8:
print('引数の数が不正です')
sys.exit()
print(f'対象フォルダ :{target_directory}')
print(f'ファイル名接頭辞:{prefix_thumbnail}')
print(f'出力先 :{thumbnail_dir}')
print(f'横幅 :{max_size}')
print(f'品質 :{quality}')
if (movie_flg == 1):
print(f'動画ファイル :対象')
else:
print(f'動画ファイル :対象外')
# 処理を実行
process_images_in_directory(target_directory)
ギャラリー作成器
前回作ったものの改善版。
機能一覧を書いて、新しい部分に(new)とかつけようと思ったのですが、ほぼ変更点しかありませんでした。(元々の機能がほぼ何もなかったともいう。)
JavaScriptの部分の変更点がほとんどなので、ざっと変更点として触れるだけにします。
変更点は以下の通り。漏れはあります(断言)
- 一覧表示時
- 画面が縦長の場合は、日付一覧を非表示にした。
- 右側の日付一覧から yyyy を削除した。
- 日付一覧の上下に、前年の年末と、翌年の年初へ飛ぶリンクをつけた。
- 上下左右カーソルの代わりに
hjkl
でも同じように振る舞うようにしてみた。 - Ctrl+ホイールやピンチ操作、
o
,i
キーで、サムネの列数を変えられるようにした。 - カーソル移動時に、動画ファイルにもフォーカスを当てるようにした。
動画の上でEnterなどを押すと再生開始します。(画像が1行揃っていない時の上下カーソルのフォーカスの動きが変なのは相変わらず) - 日付・時刻で降順にした。(スマホだと最近の写真が上に来るので倣いました。最新画像を見るのにわざわざ一番下までスイスイしなくていいのは理にかなってますね)
- ホバーで出していたExif情報を、タッチの長押しでも出るようにしました。スマホじゃホバーしようがないので。
- 手持ちのモデルに関しては、35mm換算が入っていない場合、計算してそれっぽい値を出すようにしました。コンデジの素の焦点距離ではピンとこないので。
-
m
キーで全ての動画のミュートをトグルできるようにした。
- 画像表示時
- 表示状態を切り替えるキー追加。
s
:全画面fit、d
:短辺fitと全画面fit、f
:原寸大と全画面fitの切り替え(元はEnterかクリックで順次切り替えできるだけだった) - ピンチズームが画像中央部分しか拡大できなかったので、ピンチ操作にあわせてズームされるようにした。ついでにピンチ中の2本指スクロールにも対応した。
- ズーム時にはスワイプで慣性スクロールするようにした。
- Ctrl+ホイールでもズームできるようにした。
- マウスのホイールで前後の画像に切り替わるようにしてみた。(元々は、非ズーム時に左右カーソルや左右スワイプで写真が切り替わるくらいだった)
- 下スワイプや、画面サイズ以下へのピンチインでサムネ一覧に戻るようにした。(元々の閉じる操作は、ダブルクリック, ESC, BackSpace くらいだったハズ)
- 画像を長押しタッチでもExif情報が表示されるようにした。キーボード操作だと
e
で出る。 - ダブルタップで全画面に収まる状態と、短辺フィットをトグルするようにした。
-
a
でスライドショー開始。q
で加速。z
で減速。それ以外のキーではスライドショー停止。 - トリプルタップでスライドショー。左右スワイプでスピード調整(右加速、左減速)。シングルタップで停止。
- 表示状態を切り替えるキー追加。
python実装部分で記憶に残っている修正は、撮影時刻の秒の小数点以下をExifからとるのに意外に手間取ったとこくらいですかね。Exif に subsecTimeOriginal が入ってなくて同一秒数内の連射だけ昇順になっちゃったりとか(ソート第2キーとしてファイル名にも降順指定して解決)、subsecTimeOriginalが、0前詰め文字列で出てくるのに気付かずpythonの%f(6桁マイクロ秒)に仕立て直すときに、例えば"06"を"600000"にしてしまい、変な並びになってしまったりとか。
スライドショーは、全画面フィット状態で開始するとフェードしながら画像を切り替えます。短辺フィット状態でスライドショー開始すると、長辺側を右→左、または、上→下方向へゆっくりスクロールするようにしてあります。
その年の最後の画像の次には、同年の最初の画像に戻ります。(年単位のhtmlの中でループします)
さすがにここまでの行数になると js と css は別ファイルに切り出しました。
jsをちょっと修正しただけで、ギャラリー作成を動かしなおす…なんてことはやってられないので。
gallery_generator.py
import os,sys
from PIL import Image
import piexif
from datetime import datetime
from jinja2 import Template
from tqdm import tqdm
from pprint import pprint
def get_images_in_folder(folder_path):
image_list = []
for root, dirs, files in os.walk(folder_path):
for file in files:
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.mp4', '.mov', '.avi' , '.mts' , '.webp')) and not file.startswith('tmb_'):
image_path = os.path.join(root, file)
image_list.append(image_path)
return image_list
# HTMLテンプレート
template_str = r"""
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery</title>
<link rel="stylesheet" href="..\css\styles.css">
<dialog id="exifTip">
<p class="exifText"></p>
<button type="button" class="buttonOk">OK</button>
</dialog>
</head>
<body>
<div id="fullscreen-viewer">
<img src='' id='vimg'>
</div>
<div id="gallery">
{% set ns = namespace(imgIdx=1) %}
{% for date in unique_dates|sort(reverse=true) %}
<div class="date" id="{{ date }}">
<div class="date-header" ">{{ groupname_by_date[date] }}</div>
{% for image in images_by_date[date] %}
<div class="thumbnail" >
{% if image.type=="image" %}
<img tabindex="0" src="{{ image.trpath }}" loading="lazy" alt="{{ image.name }}" title="{{ image.tip }}" onclick="t({{ ns.imgIdx }});" id={{ ns.imgIdx }} orgImg="{{image.orpath}}">
{% elif image.type=="move" %}
<video tabindex="0" src="{{ image.orpath }}" controls disablepictureinpicture preload="none" poster="{{ image.trpath }}" id={{ ns.imgIdx }}>{{ image.name }}"</video>
{% endif %}
</div>
{% set ns.imgIdx = ns.imgIdx + 1 %}
{% endfor %}
</div>
{% endfor %}
<div id="early"></div>
</div>
<div id="dateindex">
<div class="date-list-header">
<a href="..\{{nYear}}\gallery.html#early">{{nYear}}</a>
</div>
<div class="date-list">
{% for date in unique_dates|sort(reverse=true) %} {# 日付を昇順でソート #}
<a href="#{{ date }}">{{ date }}</a>
{% endfor %}
</div>
<div class="date-list-footer">
<a href="..\{{pYear}}\gallery.html">{{pYear}}</a>
</div>
</div>
<script type="text/javascript" src="..\js\script.js"></script>
</body>
</html>
"""
#def getSS(a,b):
def getSS(exposureTime):
if exposureTime == None:
return '-sec'
a=exposureTime[0]
b=exposureTime[1]
c,d = a,b
if a != 1:
c = 1
d = round(b / a);
return str(c) + '/' + str(d) + 'sec'
#数値に変換できるかどうか確認。
def isflote(s):
try:
float(s)
except ValueError:
return False
else:
return True
# フォルダのパス
if len(sys.argv) != 3:
print('対象フォルダと、サムネネイル相対パスを指定してください。')
sys.exit()
folder_path = sys.argv[1]
if not os.path.isdir(folder_path):
print(f'"{folder_path}"はフォルダではありません。')
print('対象フォルダを指定してください。')
sys.exit()
thumbnail_path = os.path.abspath(os.path.join(folder_path ,sys.argv[2]))
if not os.path.isdir(thumbnail_path):
print(f'"{thumbnail_path}"はフォルダではありません。')
print('サムネイルフォルダを指定してください。')
sys.exit()
print(folder_path)
print(thumbnail_path)
# フォルダ内の画像を取得
image_list = get_images_in_folder(folder_path)
# 画像収集結果から、撮影日時、サムネパス、画像パス、ファイル名を取得
images_with_date = []
for image_path in tqdm(image_list, desc="Processing images", unit="image"): # tqdmを使用してプログレスバーを表示
image_dir=os.path.dirname(image_path)
image_file=os.path.basename(image_path)
folder_name = os.path.basename(os.path.relpath(image_dir+r'\..', folder_path))
try:
folder_date=datetime.strptime(folder_name[0:8],'%Y%m%d')
except Exception as err:
#print('フォルダ名形式エラー:'+ image_path)
#print('再試行')
folder_name = os.path.basename(os.path.relpath(image_dir, folder_path))
try:
folder_date=datetime.strptime(folder_name[0:8],'%Y%m%d')
except Exception as err:
#print('フォルダから日付を取得できませんでした。処理を中断します。')
print('フォルダ名形式エラー:'+ image_path)
sys.exit()
#print('日付(' + folder_date.strftime('%Y/%m/%d') + ')を取得しました。処理を継続します。')
strDate='/'.join([str(folder_date.year), str(folder_date.month), str(folder_date.day)])
strYoubi='日月火水木金土'
iYoubi = int(folder_date.strftime('%w'))
folder_name = strDate + '(' + strYoubi[iYoubi:iYoubi+1]+') '+ folder_name[9:]
try:
#動画の場合
if image_path.lower().endswith(('.mp4', '.mov', '.avi', '.mts')):
filetype='move'
#時刻=ファイルの更新日時
date_obj=datetime.fromtimestamp(os.path.getmtime(image_path))
#動画にはツールチップ無し
tipinfo = ""
#動画ファイルの相対パス
img_rpath = os.path.relpath(image_path,folder_path)
#静止画の場合
else:
filetype='image'
pexif = piexif.load(image_path) #SSを分数で取得するため。。縦横はPILでとるので二本立て。
with Image.open(image_path) as img:
width,height= img.size
exif = img.getexif()
exif_dict = img._getexif()
#時刻=撮影時刻
#date_str = exif_dict.get(36867, # ExifのDateTimeOriginalのタグが存在しない場合は、
# datetime.fromtimestamp(os.path.getmtime(image_path)).strftime('%Y:%m:%d %H:%M:%S') #更新日時
#)
date_str = exif_dict.get(36867,'-') # 撮影時刻(DateTimeOriginal)
if date_str == '-':
datetime.fromtimestamp(os.path.getmtime(image_path)).strftime('%Y:%m:%d %H:%M:%S.%f') #更新日時(%fは6桁マイクロ秒)
else:
subsec=exif_dict.get(37521,'0') #exifから小数点以下(SubSecTimeOriginal)を取得
date_str += '.' + "{:<06s}".format(subsec) #マイクロ秒の形式(左寄せ0埋め6桁)に編集。
date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S.%f')
date_str = date_str.replace(':','/',2)[:-3] #日付区切りを/にして、小数点3桁(ミリ秒単位)まで残す。
#img の title に設定してツールチップを表示
##################################################
#ファイル名
#撮影日付 時刻
#縦 x 横(画素数MP) ファイルサイズKB
#焦点距離mm(35mm換算) F値 シャッター速度 ISO感度
#メーカー名 モデル名
##################################################
mekerModel= exif.get(271,'unknown').rstrip('\x00') + ' - ' + exif.get(272,'unknown').rstrip('\x00')
#焦点距離
mm = str(exif_dict.get(37386,'-'))
mm35 = str(exif_dict.get(41989,'-'))
#35mm換算値がない場合、センサーサイズから求める
if mm35 =='-' and isflote(mm):
if mekerModel == 'Canon - Canon PowerShot G7 X Mark III': # 1型 8.8(W)-36.8mm(T) [24(W)-100mm(T)]
mm35 = float(mm)*2.73
elif mekerModel == 'Canon - Canon PowerShot G9 X Mark II': # 1型 10.2(W)-30.6mm(T) [28(W)- 84mm(T)]
mm35 = float(mm)*2.75
elif mekerModel == 'Canon - Canon PowerShot SX740 HS': # 1/2.3型
mm35 = float(mm)*5.58
elif mekerModel == 'FUJIFILM - FinePix REAL 3D W1': # 1/2.3型
mm35 = float(mm)*5.58
elif mekerModel == 'Canon - Canon PowerShot ZOOM': # 1/3型 13.8(W)-55.5(T) [100(W)-400mm(T)]
mm35 = int(float(mm)*7.215) # 1/2.3型CMOSセンサーの中央部分を使用、だそうで。100-400になるようにint。
elif mekerModel == 'SONY - HDR-CX680': # 1/5.8型 1.9-57.0mm [26.8-804.0mm(16:9時), 32.8-984.0mm(4:3時)]
mm35 = float(mm)*14.10526315789474 # 16:9撮影時は左記の「14.~」でよい。
# 4:3撮影時だとちょっと違う(17.26315789473684)けど、大体16:9なので無視。。
else:
mm35 = 0 #わからないやつは0に。(最初から0が出てくるのもあるし)
else:
mm35=float(mm35) #ちゃんと値が出てきた場合も、↓でroundするのでfloatに。
mmFssISO = mm + 'mm(' + str(round(mm35,1)) + 'mm) '
mmFssISO += 'f/' + str(exif_dict.get(33437,'-')) + ' '
mmFssISO += getSS(pexif['Exif'].get(33434)) + ' '
mmFssISO += 'ISO' + str(exif_dict.get(34855,'-'))
tip_list=[
image_file, #ファイル名
date_str, #撮影日時
#縦x横 (画素数)
str(height) + ' x ' + str(width) + ' (' + str(round(height*width / 1000000,2)) + 'MP)'
#( size exifの256,257はNone!?
+ ' ' + '{:,}'.format(round(os.path.getsize(image_path) / 1024)) + 'KB',
mmFssISO, #●mm f/● ●/●s ●iso
mekerModel #メーカー モデル
]
tipinfo = '\n'.join(tip_list)
#画像ファイルの相対パス
img_rpath = os.path.relpath(image_path,folder_path)
#サムネの相対パスを取得(動画/静止画共通)
thum_dir=image_dir.replace(folder_path,thumbnail_path)
if image_file.endswith('.webp'):
#元からwebpのものは、アルバム用(というか、縮小しただけ)として、alb_が付いているものと想定。
#今のところ、カメラからwebpが出てくることはないが、そのうち引数とかで上手いことするようにしたい。
#thum_file=image_file.replace('alb_','tmb_')
#やっぱりアルバムからさらにサムネを作ることもあるので、こうしておく。
#→アルバム用に縮小したものからさらに縮小してサムネを作ると、滅茶苦茶ガビガビになるのでやめる。
#thum_file='tmb_' + image_file + '.webp'
#元ファイルから、サムネ作成時にtmb_alb_*.webpとして作っておく。拡張子はダブらない
thum_file='tmb_' + image_file
else:
thum_file='tmb_' + image_file + '.webp'
thum_path=os.path.join(thum_dir,thum_file)
thum_rpath=os.path.relpath(thum_path,folder_path)
images_with_date.append({
'type': filetype, #タイプ
'date': date_obj, #時刻
'tip' : tipinfo, #ツールチップ
'trpath': thum_rpath, #サムネの相対パス
'orpath': img_rpath, #実画像の相対パス
'name': os.path.basename(image_path), #ファイル名
'dirname': folder_name
})
except (AttributeError, ValueError, OSError):
pass
except Exception as other:
print('\n')
print(other)
print(image_path)
sys.exit()
# 撮影日時でソート
#images_with_date.sort(key=lambda x: x['date'])
images_with_date.sort(key=lambda x: (x['date'],x['name']),reverse=True)
# 重複なしの日付リストを作成
unique_dates = list(set(date.strftime('%m/%d') for date in [image['date'] for image in images_with_date]))
# 日付ごとに画像をグループ化するディクショナリ
images_by_date = {date: [] for date in unique_dates}
for image in images_with_date:
images_by_date[image['date'].strftime('%m/%d')].append(image)
# 日付ごとのグループ名のディクショナリ
groupname_by_date = {date: [] for date in unique_dates}
for image in images_with_date:
#print(f'{image['date'].strftime('%Y-%m-%d')}:{image['dirname']}')
#print(groupname_by_date[image['date'].strftime('%Y-%m-%d')])
if not groupname_by_date[image['date'].strftime('%m/%d')]:
if image['dirname'] != '':
groupname_by_date[image['date'].strftime('%m/%d')] = image['dirname']
#tYear=int(images_with_date[0]['date'].strftime('%Y'))
tYear=int(images_with_date[len(images_with_date)-1]['date'].strftime('%Y'))
nYear=tYear+1
pYear=tYear-1
# HTMLテンプレートに渡すデータを作成
template_data = {
'unique_dates': unique_dates,
'images_by_date': images_by_date,
'groupname_by_date': groupname_by_date,
'nYear':nYear,
'pYear':pYear,
}
# テンプレートをレンダリング
template = Template(template_str)
html_output = template.render(**template_data)
# HTMLファイルに保存
output_path = os.path.join(folder_path,'gallery.html')
with open(output_path, 'w', encoding='utf-8') as html_file:
html_file.write(html_output)
print(f"Gallery HTML file generated at: {output_path}")
script.js
// vi: se ts=4 sw=4 sts=4 :
//画面構成要素
const viewer = document.getElementById('fullscreen-viewer');
const vimg = document.getElementById('vimg');
const gallery = document.getElementById('gallery');
const dateList = document.querySelector('.date-list');
//ダイアログ
const exifTipDialog= document.querySelector('#exifTip');
const exifTipText = exifTip.querySelector(".exifText")
const exifTipButton = exifTip.querySelector(".buttonOk")
//ダイアログのイベント
exifTipButton.addEventListener('click',function(e) {
exifTipDialog.close();
})
//変数
let imgIdx=1; //表示している画像、または、サムネ一覧のフォーカス位置のid
let hiddenImgIdx = 1; //画面の向きや、サムネ列数変更時にフォーカス位置が飛んでいかないように保持するid
let fitSts = 1; //画面表示状態(1:全画面fit , 2:画面幅fit , 3:原寸大)
let repeatGuard; //キーリピート等による連続切り替え抑止用
//定数
const switchInterval = 100; //連続切り替え抑止(ms)
//ロード時
window.onload = function(){
setFocus(document.getElementById(imgIdx)); //最初の要素を選択
highlightCurrentDate(); //日付リストを強調表示
switchDirection(); //縦横の向きに合わせて日付リスト表示有無切り替え
if(location.hash != ""){
hashJump(location.hash.substring(1));
}
}
let hashJumpTimer //指定されたアンカーまでスクロールを繰り返すタイマー
let prevScrollPos //前回スクロール位置
function hashJump(hash){
const e = document.getElementById(hash); //ハッシュの要素まで、
e.scrollIntoView({block: "nearest", inline: "nearest"}); //スクロールする。
forcusTracking(); //現在の画面上の要素のIDをhiddenImgIdxに取得
if(0<hiddenImgIdx){
imgIdx = hiddenImgIdx;
setFocus(document.getElementById(imgIdx));
}
//1秒間、スクロール位置が変わらなくなるまで繰り返す。
if( prevScrollPos != gallery.scrollHeight){
hashJumpTimer = setTimeout(hashJump,1000,hash);
//console.log(hash + ' ' + prevScrollPos);
}
prevScrollPos = gallery.scrollHeight;
}
//明示的に操作したときは、繰り返しを停止。
gallery.addEventListener('keydown', function(e) {
clearTimeout(hashJumpTimer);
})
gallery.addEventListener('mousedown', function(e) {
clearTimeout(hashJumpTimer);
})
gallery.addEventListener('touchstart', function(e) {
clearTimeout(hashJumpTimer);
})
gallery.addEventListener('wheel',function(e){
clearTimeout(hashJumpTimer);
})
//画面回転時(orientationchange ではなく resize で判定実施)
window.addEventListener("resize", switchDirection);
function switchDirection() {
if( viewer.style.display !== 'block'){
if (window.innerHeight < window.innerWidth) { //横長
dateListVisible(1);
}else{ //縦長
dateListVisible(0);
}
change_tmb_size(0);
imgIdx = hiddenImgIdx;
setFocus(document.getElementById(imgIdx),true,true); //フォーカスを移す
}
};
//日付リスト表示有無切り替え
function dateListVisible(s){
const dateListH = document.querySelector('.date-list-header');
const dateListF = document.querySelector('.date-list-footer');
if (s === 1){
dateList.style.display='block';
dateListH.style.display='block';
dateListF.style.display='block';
gallery.style.margin= `0 ${dateList.offsetWidth}px 0 0`;
}else{
dateList.style.display='none';
dateListH.style.display='none';
dateListF.style.display='none';
gallery.style.margin= `0 0 0 0`;
}
}
//ブラウザ全画面表示の切り替え
function fullScreenMode(s){
em=document.documentElement;
if(s==1){
//全画面表示
if (em.requestFullscreen) {
em.requestFullscreen();
} else if (em.mozRequestFullScreen) { // Firefox
em.mozRequestFullScreen();
} else if (em.webkitRequestFullscreen) { // Chrome, Safari, Opera
em.webkitRequestFullscreen();
} else if (em.msRequestFullscreen) { // IE/Edge
em.msRequestFullscreen();
}
} else{
//全画面解除
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) { // Firefox
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) { // Chrome, Safari, Opera
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) { // IE/Edge
document.msExitFullscreen();
}
}
}
//サムネ/ビューワ切り替え
function viewSwitch(s){
if(s==0){
//サムネ一覧を表示
//fullScreenMode(0);
viewer.style.display='none'; //ビューワを消す
dateindex.style.display='inline'; //日付リストと
gallery.style.display='inline'; //サムネイルを表示
hiddenImgIdx = imgIdx;
change_tmb_size(0); //縦横の向きが変わってるかもしれんで、サムネの幅を再描画
switchDirection(); //縦横の向きに合わせて日付リスト表示有無切り替え
}else{
fullScreenMode(1); //ブラウザをフルスクリーンに
viewer.style.display='block'; //ビューワを表示
currentScale = 1;
vimg.style.transform = `scale(${currentScale})`;
fitFull(); //画面にフィットして表示
gallery.style.display='none';
dateindex.style.display='none';
vimg.style.opacity = 1;
}
}
//1:全画面fit
function fitFull(){
//vimg.style.position = 'static'; //スクロール時やスライド時にサイズが正しく判定されるように。
vimg.style.width=`100%`;
vimg.style.height=`100%`;
fitSts=1;
//viewer.style.overflow='hidden';
viewer.style.overflow='auto';
viewer.style.cursor='zoom-in';
zoomRatio=100;
currentScale = 1;
}
//2:短辺fit
function fitWide(){
const r1 = vimg.naturalWidth/ vimg.naturalHeight; //画像の縦横比
const r2 = viewer.offsetWidth / viewer.offsetHeight; //画面の縦横比
vimg.style.position = 'static'; //スクロール時やスライド時にサイズが正しく判定されるように。
//画像よりも画面の方が、より横長(左右にはみ出す量より、上下にはみ出す量の方が多い)の場合
if ( r1 < r2 ){
vimg.style.width=`100%`; //左右は収める。(短辺)
vimg.style.height='auto'; //上下はスクロール
//viewer.style.overflow='hidden auto';
//console.log('上下スクロール')
}else{ //逆
vimg.style.width='auto';
vimg.style.height=`100%`;
//viewer.style.overflow='auto hidden';
//console.log('左右スクロール')
}
fitSts=2;
if (vimg.naturalWidth < viewer.offsetWidth
&& vimg.naturalHeight < viewer.offsetHeight) {
viewer.style.cursor='zoom-in';
}else{
viewer.style.cursor='none';
}
zoomRatio=100;
currentScale = 1;
scrollImg();
}
//3:原寸大
function fitOrg(){
vimg.style.position = 'static';
vimg.style.width=`${vimg.naturalWidth}px`;
vimg.style.height=`${vimg.naturalHeight}px`;
fitSts=3;
viewer.style.overflow='auto';
viewer.style.cursor='none';
zoomRatio=100;
currentScale = 1;
scrollImg();
}
function fitInit(){
if(fitSts == 1){
fitFull();
}else if(fitSts == 2){
fitWide();
}else if(fitSts == 3){
fitOrg();
}
}
//fit切り替え(未指定は順繰り)
function fitSwitdh(s){
if (s === undefined) {
//3:原寸大→1:全画面
if (fitSts == 3 ){
fitFull();
//1:全画面→2:短辺fit
}else if (fitSts == 1 ){
fitWide();
//2:短辺fit→状況による
}else if (fitSts == 2 ){
if (vimg.naturalWidth < viewer.offsetWidth
&& vimg.naturalHeight < viewer.offsetHeight) {
//画面からはみ出すことがない画像だったら、fitに戻す。
fitFull();
}else{
fitOrg();
}
}
//全画面
}else if (s == 1){
fitFull();
//短辺fitと全画面の切り替え
}else if (s == 2){
if (fitSts == 2){
fitFull();
}else{
fitWide();
}
//原寸大と全画面の切り替え
}else if (s == 3){
if (fitSts == 3){
fitFull();
}else{
fitOrg();
}
}
}
// サムネクリック(htmlから呼び出し)
function t(i){
imgIdx=i;
hiddenImgIdx=i; //フルスクリーンへのresizeイベントでスクロール位置のidで上書きされてしまわないように。
ei=document.getElementById(imgIdx);
setFocus(ei);
tmbSrc = ei.getAttribute('src'); //取り急ぎ出せる画像を出す
orgSrc = ei.getAttribute('orgImg');
vimg.src = tmbSrc; //ひと先ずはサムネを出しておいて
setTimeout( () => {
vimg.src = orgSrc //実画像にはあとから切り替えるように。
},100); //※t()の中で直接変更すると、サムネが出ずに
// ロードされるまで真っ黒になってしまう。
viewSwitch(1);
repeatGuard = 1; //documentのkeudownでEnter入力に判定されないように。。
setTimeout( () => { repeatGuard =0; },100);
}
//表示画像変更
async function changeViewerImage(f,tmbFlg=false){
const imgIdx_back = imgIdx //現在のidを保持
imgIdx+=f; //指定した方向に進める。
let ei=document.getElementById(imgIdx); //要素を取得
if (ei == null){ //取得できなかった場合は
imgIdx = imgIdx_back; //idを戻して
return //終了(画像は切り替わらない)
}
while(true){
if (ei.getAttribute('orgImg') != null){ //idが画像なら、
if(tmbFlg){
await setImgSrc(vimg,ei.getAttribute('src')); //まずはサムネを確実に表示して、
vimg.src = ei.getAttribute('orgImg'); //元画像の表示は非同期でもOK
}else{
await setImgSrc(vimg,ei.getAttribute('orgImg'));
}
fitInit();
break; //終了。
}else{ //画像じゃなかった場合、
imgIdx += f; //さらにその先のidで、
ei=document.getElementById(imgIdx); //繰り返す
if(ei == null){ //先のidが無かったらおしまい。
imgIdx = imgIdx_back;
break;
}
}
}
}
function setImgSrc(img,path){
return new Promise( (resolve) => {
img.onload = () => resolve(img);
img.src=path;
});
}
//ビューワ上のマウスカーソル位置取得
let mouX;
let mouY;
vimg.addEventListener('mousemove',function(e){
//ビューワサイズに対する割合で保持。
mouX = e.pageX / viewer.offsetWidth;
mouY = e.pageY / viewer.offsetHeight;
scrollImg();
})
let zoomRatio=100;
function imgZoom(p){
zoomRatio-=p;
if (zoomRatio<100){
zoomRatio = 100;
}
if (fitSts == 1){
if (zoomRatio == 100){
vimg.style.height = '100%';
vimg.style.width = '100%';
}else{
vimg.style.height = `${viewer.clientHeight * zoomRatio/100}pt`;
vimg.style.width = `${viewer.clientWidth * zoomRatio/100}pt`;
if(vimg.style.height < viewer.clientHeight){
vimg.style.height = '100%';
}
if(vimg.style.width < viewer.clientWidth){
vimg.style.width = '100%';
}
}
}else if (fitSts == 2 ){
if ( vimg.style.width == 'auto' ){
if (zoomRatio == 100){
vimg.style.height = '100%';
}else{
vimg.style.height = `${viewer.clientHeight * zoomRatio/100}pt`;
}
}else{
if (zoomRatio == 100){
vimg.style.width = '100%';
}else{
vimg.style.width = `${viewer.clientWidth * zoomRatio/100}pt`;
}
}
}else if (fitSts == 3){
if (zoomRatio == 100){
vimg.style.height = 'auto';
vimg.style.width = 'auto';
}else{
vimg.style.height = `${vimg.naturalHeight * zoomRatio/100}pt`;
vimg.style.width = `${vimg.naturalWidth * zoomRatio/100}pt`;
}
}
if(p!=0){
scrollImg();
}
}
//ビューワのホイール
vimg.addEventListener('wheel',function(e){
e.preventDefault(); //自前で処理する
if (e.ctrlKey){ //ctrl同時押しは
imgZoom(e.deltaY/2); //ズーム
return;
}
//画像切り替え
if(repeatGuard == 1) return; //連続切り替え抑止
repeatGuard = 1;
setTimeout( () => { repeatGuard =0; },switchInterval);
if(slideshowSpeed!=0){
if(e.deltaY < 0 ){
slideshowSpeed--;
if(slideshowSpeed<1) slideshowSpeed=1;
}else{
slideshowSpeed++;
if(30 < slideshowSpeed) slideshowSpeed=30;
}
}else{
if(e.deltaY < 0 ){
changeViewerImage(-1)
}else{
changeViewerImage(1)
}
}
},{ passive: false })
//ギャラリーのホイールでサムネサイズ変更
let wheelDeltaY=0; //ホイール量
gallery.addEventListener('wheel',function(e){
if (e.ctrlKey){
e.preventDefault(); //ctrl+ホイールでブラウザ自体がズームしてしまうのを抑止
const myDelta = 30; //ホイール閾値。
wheelDeltaY += e.deltaY;
if (myDelta<wheelDeltaY){ //下に回すと、右側(より古い画像)へ
change_tmb_size(1);
wheelDeltaY =0;
}else if(wheelDeltaY<-1*myDelta){ //上に回すと、左側(より新しい画像)へ
change_tmb_size(-1);
wheelDeltaY =0;
}
imgIdx = hiddenImgIdx;
setFocus(document.getElementById(imgIdx),true,true); //フォーカスを移す
}
},{ passive: false })
//サムネ列数変更
let tmb_cols = 3;
function change_tmb_size(s){
tmb_cols +=s;
if(tmb_cols<1) {tmb_cols=1};
if(10<tmb_cols) {tmb_cols=10};
if (gallery.clientWidth === 0) return;
w=(gallery.clientWidth/tmb_cols) -(gallery.offsetWidth-gallery.clientWidth)-1;
const tmbList = gallery.querySelectorAll('.thumbnail');
for (let i = 0; i < tmbList.length; i++) {
tmbList[i].style.width = `${w}px`
}
setFocus(document.getElementById(imgIdx)); //フォーカスを移す
}
function scrollImg(){
//オーバーサイズ
ow = vimg.offsetWidth - viewer.offsetWidth;
oh = vimg.offsetHeight - viewer.offsetHeight;
viewer.scrollLeft = Math.round(ow * mouX);
viewer.scrollTop = Math.round(oh * mouY);
}
//ビューワクリック
let ccnt=0 //クリック回数
vimg.addEventListener('click', (e) => {vClick(e);});
function vClick(e) {
ccnt++; //クリック回数カウント
if(1==ccnt){ //初回はタイマーセット
setTimeout(function () {
if (1==ccnt) { //シングルクリック
if(slideshowSpeed !=0){ //スライドショー中の場合
stopSlideShow(); //スライドショー停止
}else{ //スライドショー中でない場合
if(zoomRatio == 100){
fitSwitdh(); //ズーム切り替え
}else{
fitInit(); //ピンチズームを元に戻す
}
}
}
if(2==ccnt){ //ダブルクリック
if(slideshowSpeed!=0){ //スライドショー中の場合、
if(fitSts==1){ //フェードなら
feedStyle= 1 - feedStyle; //フェードスタイルの切り替え。
}else{ //フェードでなければ、
stopSlideShow(); //スライドショー停止
}
}else{ //スライドショー中でなければ
viewSwitch(0); //ビューワを終了
ccnt=0;
}
}
ccnt=0;
}, 300);
}
}
////ビューワダブルクリック
//→ズーム切り替え後にビューワ終了するのが気持ち悪いのでタイマーで判定する。
//(ズームのキレは悪い。。)
//
//vimg.addEventListener('dblclick', function(e) {
// viewSwitch(0); //ビューワを終了
//})
let slideshowSpeed=0;
let slideshowTimer;
let slideshowInterval=20;
let slideshowBrake;
async function slideshow(tmbFlg=false){
clearInterval(slideImageTimer);
clearTimeout(feedImageTimer);
if(fitSts == 1){
//全画面フィットの場合、時間でフェード切り替え。
//vimgと同じ画像を、上にかぶせて表示
if(viewer.childElementCount == 2){
viewer.removeChild(viewer.children[1]);
}
let vimg2 = document.createElement('img');
vimg2.src = vimg.src;
vimg2.style.width = vimg.style.width;
vimg2.style.height = vimg.style.height;
vimg2.style.objectFit = 'contain';
vimg2.style.position = 'fixed'; //staticだとzindexが指定できないので
vimg2.style.zIndex = 2;
vimg2.style.backgroundColor='black';
vimg2.style.opacity = 1;
vimg.style.position = 'fixed'; //重なるように。
viewer.appendChild(vimg2);
vimg2.addEventListener('touchstart', (e) => { vTouchstart(e); });
vimg2.addEventListener('touchmove', (e) => { vTouchmove(e); });
vimg2.addEventListener('touchend', (e) => { vTouchend(e); });
vimg2.addEventListener('click', (e) => { vClick(e); });
//changeViewerImageでvimgの中身を同期処理で切り替える
const imgIdx_back = imgIdx //切り替え確認用に退避
await changeViewerImage(-1,true); //画像切り替えが切り替わるまで待つ
if (imgIdx === imgIdx_back){ //画像が切り替わってなかった場合、
//stopSlideShow(); //終了
//return;
imgIdx=gallery.querySelectorAll('.thumbnail').length+1; //一番古い画像から繰り返し。
await changeViewerImage(-1,true)
}
viewer.style.cursor='none'; //スライドショー中はカーソル無し
slideshowInterval = 5000 - (5000/30*(slideshowSpeed-1)); //最大5秒を30段階に
//console.log(`${slideshowSpeed} ${slideshowInterval}`)
let delay = 0.2; //表示時間が残りn割切ったら
delay += (slideshowSpeed**3)/35000; //早いほど、フェードする時間の割合を増やす。(Lv30だとすぐフェード開始する感じ)
feedImageTimer = setTimeout( () => { //かぶせた画像の方をフィード開始するようにタイマーを仕掛ける。
let count = slideshowInterval*delay /10; //残りの時間を20msに刻んだ回数
let dec = 1/count; //一回当たりの減分
feedDecs= []; //リストクリア
for (let i=1 ; i<count; i++){
if (feedStyle==0){
feedDecs.push( dec * (Math.abs(count/2-i) / (count/4) ) ); //最初と最後を早く。真ん中ほどゆっくり
}else{
feedDecs.push( dec * (((count/2)-Math.abs(count/2-i)) / (count/4) ) ); //最初と最後をゆっくり。真ん中ほど早く。こっちの方が好み。
}
//console.log(`${feedDecs[feedDecs.length-1]}`);
};
//console.log(`${feedDecs}`);
feedImageTimer = setInterval( () => { feedImage(vimg2); },10);
},slideshowInterval*(1-delay));
}else if(fitSts == 2){
const imgIdx_back = imgIdx //切り替え確認用に退避
await changeViewerImage(-1,tmbFlg); //画像切り替えが切り替わるまで待つ
if (imgIdx === imgIdx_back){ //画像が切り替わってなかった場合、
//stopSlideShow(); //終了
//return;
imgIdx=gallery.querySelectorAll('.thumbnail').length+1;
await changeViewerImage(-1,tmbFlg)
}
viewer.style.cursor='none'; //スライドショー中はカーソル無し
viewer.scrollTop = 0;
viewer.scrollLeft = 0;
const r1 = vimg.naturalWidth/ vimg.naturalHeight; //画像の縦横比
const r2 = viewer.offsetWidth / viewer.offsetHeight; //画面の縦横比
slideshowBrake = 1 - Math.abs(r1-r2); //画像と画面の縦横比が近いほど1に近い値を取得
slideshowBrake = slideshowBrake **15; //1に近い部分を強調
//console.log(`${Math.round(Math.abs(r1-r2)*10)/10} ${Math.round(slideshowBrake*10)/10}`)
if(r1 < r2 ) { //上下にはみ出している
slideImageTimer = setTimeout( () => { slideImage(1); },slideshowInterval);
}else{ //左右にはみ出している
slideImageTimer = setTimeout( () => { slideImage(2); },slideshowInterval);
}
}else if(fitSts == 3){ //原寸の場合
fitWide(); //短辺フィットに変えて、スライドショー
slideshowTimer = setTimeout( () => { slideshow(); },slideshowInterval);
}
}
let feedImageTimer;
let feedDecs;
let feedStyle=1;
function feedImage(v){
let dec = feedDecs.shift();
if (dec != undefined){ //出てくる間は、
v.style.opacity -= dec; //透明度を減らす。
}else{ //出てこなくなったら
clearInterval(feedImageTimer); //Intervalを止めて、
v.style.opacity = 0; //完全に透明にして、
//次の画像へ移る処理を再度実行するタイマーを仕掛ける。
slideshowTimer = setTimeout( () => { slideshow(); },20);
}
//console.log(`${dec} ${Math.round(v.style.opacity*1000)/1000}`);
}
let slideImageTimer;
function slideImage(p){
//scrollTopの最大値 + offsetHeight = scrollHeight
//scrollTopの最大値 = scrollHeight - offsetHeight
slideshowInterval = 20; //初期値20ms毎
let movePx =1; //初期値1pxずつ
let currentSpeed = slideshowSpeed
currentSpeed -= Math.trunc(currentSpeed *slideshowBrake)
if(currentSpeed<1) currentSpeed = 1;
if(30<currentSpeed) currentSpeed =30;
if( currentSpeed == 1 ) {slideshowInterval = 320;}
else if( currentSpeed == 2 ) {slideshowInterval = 280;}
else if( currentSpeed == 3 ) {slideshowInterval = 240;}
else if( currentSpeed == 4 ) {slideshowInterval = 200;}
else if( currentSpeed == 5 ) {slideshowInterval = 160;}
else if( currentSpeed == 6 ) {slideshowInterval = 120;}
else if( currentSpeed == 7 ) {slideshowInterval = 80;}
else if( currentSpeed == 8 ) {slideshowInterval = 40;}
else if( currentSpeed > 9 ) {movePx = currentSpeed -8;} //これ以上は、移動量を増やす。
//console.log(`${slideshowSpeed} ${currentSpeed} ${movePx} ${slideshowInterval}`)
let max_pos
let next_pos
if ( p == 1 ) { //上から下
max_pos = viewer.scrollHeight - viewer.offsetHeight; //スクロール最大値
next_pos = viewer.scrollTop + movePx; //スクロール先
if ( next_pos < max_pos ){ //限界突破してなければ
viewer.scrollTop = next_pos; //移動して、次のタイマーを仕掛ける(setIntervalにしないのは、途中でもスピードを変えられるように。)
slideImageTimer = setTimeout( () => { slideImage(p); },slideshowInterval);
}else{
viewer.scrollTop = max_pos; //限界までスクロール
clearInterval(slideImageTimer) //自身を止める
if( 0 < slideshowSpeed ) { //停止させられてなかった場合、次画像へ切り替えるタイマーをセット
slideshowTimer = setTimeout( () => { slideshow(); },slideshowInterval);
}
}
//console.log(`${slideshowSpeed} ${movePx} ${slideshowInterval} ${viewer.scrollTop}`)
} else if( p == 2) { //左から右
max_pos = viewer.scrollWidth - viewer.offsetWidth;
next_pos = viewer.scrollLeft + movePx;
if (next_pos < max_pos ){
viewer.scrollLeft = next_pos;
slideImageTimer = setTimeout( () => { slideImage(p); },slideshowInterval);
}else{
viewer.scrollLeft = max_pos;
clearInterval(slideImageTimer)
if( 0 < slideshowSpeed ) {
slideshowTimer = setTimeout( () => { slideshow(); },slideshowInterval);
}
}
//console.log(`${slideshowSpeed} ${movePx} ${slideshowInterval} ${viewer.scrollLeft}`)
}
}
function stopSlideShow(){
if(viewer.childElementCount == 2){
viewer.removeChild(viewer.children[1]);
}
clearTimeout(slideshowTimer);
clearInterval(slideImageTimer);
clearTimeout(feedImageTimer);
if(viewer.childElementCount == 2){
viewer.removeChild(viewer.children[1]);
}
vimg.style.position = 'static'; //スクロール時やスライド時にサイズが正しく判定されるように。
//vimg.style.opacity = 1;
slideshowSpeed = 0;
viewer.style.cursor='auto';
fitInit();
}
//vimg.addEventListener('keydown', function(e) { //viewerでもダメ。documentじゃないと反応しない?
document.addEventListener('keydown', function(e) {
if(repeatGuard == 1) return; //サムネ上でEnterが押された場合のイベント
//ビューワ表示、かつ、モーダルダイアログ非表示時
if (viewer.style.display == 'block' && exifTipDialog.open == false ){
if (e.key === 'Escape' || e.key ==='Backspace' ) {
viewSwitch(0); //ESC/BSで、サムネ一覧に戻る。
}
if ( e.key === 'Enter' ) { fitSwitdh() } //順次切り替え
if ( e.key === 's' ) { fitSwitdh(1) } //全画面フィット
if ( e.key === 'd' ) { fitSwitdh(2) } //短辺フィット
if ( e.key === 'f' ) { fitSwitdh(3) } //原寸表示
if ( e.key === 'e' ) {
showExifTip(document.getElementById(imgIdx));
}
if ( e.key === 'a' ) { //撮影時刻順にスライドショー
if(slideshowSpeed == 0){
slideshowTimer = setTimeout( () => {
fullScreenMode(1); //ブラウザをフルスクリーンに
feedStyle=1;
slideshow();
},100);
slideshowSpeed = 10;
}else{
stopSlideShow();
}
}
if ( e.key === 'z' ){ //スライドショーを遅く
slideshowSpeed-=1;
if(slideshowSpeed<1) slideshowSpeed=1;
}
if ( e.key === 'q' ){ //スライドショーを早く
slideshowSpeed+=1;
if(30 < slideshowSpeed) slideshowSpeed=30;
}
if (e.key != 'a' && e.key != 'q' && e.key!='z' && slideshowSpeed !=0){
//スライドショー関連のキー以外を何か押した場合は、スライドショーは止める。
stopSlideShow();
}
if (vimg.style.width == '100%') {
//全画面fit、または横幅fit時は、左右で画像切り替え
if(repeatGuard == 1) return; //連続切り替え抑止
repeatGuard = 1;
setTimeout( () => { repeatGuard =0; },switchInterval);
if ((e.key === 'ArrowLeft' || e.key === 'h') && 1 < imgIdx) {
changeViewerImage(-1)
}else if (e.key === 'ArrowRight' || e.key === 'l') {
changeViewerImage(1)
}
}else{
//横にはみ出している場合は、左右に移動。
if(e.key === 'h' || e.key === 'ArrowLeft'){
viewer.scrollLeft -= 50;
}
if(e.key === 'l' || e.key === 'ArrowRight'){
viewer.scrollLeft += 50 ;
}
}
if(e.key === 'j' || e.key === 'ArrowDown'){
viewer.scrollTop += 50;
}
if(e.key === 'k' || e.key === 'ArrowUp'){
viewer.scrollTop -= 50;
}
}
},{passive: false})
//サムネ上のキー操作
gallery.addEventListener('keydown', function(e) {
//Ctrl同時押し
if (e.ctrlKey ){
//Ctrl+上下
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'j' || e.key === 'k') {
e.preventDefault();
const currentDateElement = document.querySelector('.date-list a.current'); //現在の日付要素を取得
const dateElements = dateList.querySelectorAll('a'); //日付けリンクの中で
for (let i = 0; i < dateElements.length; i++) {
if (dateElements[i]== currentDateElement){ //現在の日付の
if(e.key ==='ArrowUp' || e.key === 'k'){
dateElement=dateElements[i-1]; //前の日または、
}else{
dateElement=dateElements[i+1]; //次の日が、
}
if (dateElement != null){ //見つかった場合、
dateElement.click(); //移動
targetDate =dateElement.getAttribute('href').substring(1); //移動先の日付文字列
targetElement = document.getElementById(targetDate); //該当日付のdivを取得
tmbList = targetElement.querySelectorAll('.thumbnail'); //該当日付中のサムネの、
for (let i = 0; i < tmbList.length; i++) {
const etmb = tmbList[i];
let ei = etmb.querySelector('img'); //img、または
if (ei === null){
ei = etmb.querySelector('video'); //videoに、
}
if (ei != null){
imgIdx = Number(ei.getAttribute('id'));
setFocus(document.getElementById(imgIdx)); //フォーカスを移す
return;
}
}
//該当日付の中にフォーカス可能なものがなかった場合、現在のフォーカスは外す。
document.activeElement.blur(); //(imgもvideoもないということはないはず)
}
return;
}
}
}
//サムネ上で単打
}else{
//INDEXに戻る。
//Escapeで全画面解除が先に行われるのはデフォルト動作。ESC連打で年INDEXまで戻る。
//BSではサムネまでしか戻さない。(年INDEXまで戻ってしまうと、サムネ再表示に時間がかかるので)
if (e.key ==='Escape' ) {
yyyy= document.querySelector('.date-header').textContent.substring(0,4);
document.location.href = "..\\index.html#"+yyyy;
}
if ( e.key === 'o' ){ change_tmb_size(1) } //ズームOut サムネ列-1
if ( e.key === 'i' ){ change_tmb_size(-1) } //ズームIn サムネ列+1
ei=document.activeElement; //現在のフォーカス位置を取得
//動画再生中の場合、再生イベントでフォーカスが戻ってしまうので、フォーカスを動かさない。
if(ei.tagName ==='VIDEO'){
if (ei.paused === false){
e.preventDefault();
return;
}
}
//サムネ上のEnter押下時
if (e.key === 'Enter'){
if (ei.tagName ==='IMG'){ //画像なら
ei.click(); //全画面表示に切り替え
}else if(ei.tagName ==='VIDEO'){ //動画なら
e.preventDefault();
if(ei.paused){ //停止中なら
ei.requestFullscreen(); //全画面で
ei.play(); //再生開始。
}else{ //再生中なら
ei.pause(); //停止。
}
}
}
//上下左右で画像フォーカス位置の移動
//上下の移動は、日付跨ぎ箇所で列が埋まっていないとズレる。…キーボード操作主体じゃないから。。
flgArrow=false;
imgIdx_back = imgIdx;
if (e.key === 'ArrowLeft' || e.key === 'h') {
flgArrow=true;
imgIdx--;
}
if (e.key === 'ArrowDown' || e.key === 'j') {
flgArrow=true;
imgIdx+=tmb_cols;
}
if (e.key === 'ArrowUp' || e.key === 'k') {
flgArrow=true;
imgIdx-=tmb_cols;
}
if (e.key === 'ArrowRight' || e.key === 'l') {
flgArrow=true;
imgIdx++;
}
ei=document.getElementById(imgIdx); //移動先の要素が
if (ei == null){ //存在しない場合は、
imgIdx = imgIdx_back; //元の位置のままで、
flgArrow=false; //移動をキャンセル。
}
if(flgArrow){
e.preventDefault();
setFocus(document.getElementById(imgIdx));
}
//全動画のミュート切り替え
if (e.key === 'm'){
videos=document.querySelectorAll('video');
let isMuted
//一個目の動画のミュートに対してトグル
if (0<videos.length) isMuted = ! videos[1].muted
for (let i=0; i< videos.length; i++){
videos[i].muted = isMuted;
}
}
}
},{passive: false})
//動画イベント登録
videos=document.querySelectorAll('video');
for (let i=0; i< videos.length; i++){
//動画再生開始時にフォーカス設定
videos[i].addEventListener('playing', (e) => {
imgIdx = Number(e.target.getAttribute('id'));
setFocus(document.getElementById(imgIdx)); //フォーカスを移す
},{passive: false})
}
function setFocus(ei,scroll=true,center=false){
//強調解除
const tmbList = gallery.querySelectorAll('.thumbnail');
for (let i = 0; i < tmbList.length; i++) {
const etmb = tmbList[i];
let em = etmb.querySelector('img'); //img、または
if (em === null){
em = etmb.querySelector('video'); //video
}
em.style.zIndex = 0;
em.style.outline= 'none';
}
if(ei === null){
return;
}
//強調
ei.focus({ preventScroll: true}); //フォーカス任せにスクロールせず、
if (scroll===true){
if(center===true){
ei.scrollIntoView({block: "center", inline: "center"}); //中央に表示されるようにスクロール
}else{
ei.scrollIntoView({block: "nearest", inline: "nearest"}); //表示される最寄り位置にスクロール
}
}
ei.style.outline = 'solid 3px white';
ei.style.zIndex = 1;
}
//タッチ関連処理(start~move~endを跨いで使用する)
let stX =0; //タッチの開始位置x
let stY =0; //タッチの開始位置y
let enX =0; //タッチの現在位置x
let enY =0; //タッチの現在位置y
let preX =0; //タッチの直前位置x
let preY =0; //タッチの直前位置y
let movX =0; //タッチの移動量x
let movY =0; //タッチの移動量y
let stD =0; //開始時のタッチ間距離
let cuD =0; //現在のタッチ間距離
let stC =0; //開始時のタッチ中央位置
let cuC =0; //現在のタッチ中央位置
let preC = [0,0]; //直前のタッチ中央位置
let initialScale = 1;
let currentScale = 1;
function getDistance(t1 , t2){
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getCenter(t1 , t2){
t1x = t1.clientX;
t1y = t1.clientY;
t2x = t2.clientX;
t2y = t2.clientY;
cx = (t1x - t2x) /2;
cy = (t1y - t2y) /2;
if (cx<0){
cx = t1x + cx * -1;
}else{
cx = t2x + cx;
}
if (cy<0){
cy = t1y + cy * -1;
}else{
cy = t2y + cy;
}
return[cx,cy];
}
//ビューワタッチ
let tcnt=0; //タッチ回数
let pressTimer //長押し判定タイマー
let touchTimer
vimg.addEventListener('touchstart', (e) => { vTouchstart(e); });
function vTouchstart(e){
movX=0;
movY=0;
if(e.touches.length == 1){
//一本指でスクロール開始
stX = e.touches[0].pageX;
stY = e.touches[0].pageY;
enX = stX;
enY = stY;
preX = stX;
preY = stY;
pressTimer = setTimeout(() => {
showExifTip(document.getElementById(imgIdx));
}, 1000); // 1秒間の長押しを判定
tcnt++; //タッチ回数カウント
if(1== tcnt){ //初回はタイマーセット
touchTimer = setTimeout(function () {
if(1 == tcnt ){ //シングルタッチだった場合、
if(slideshowSpeed !=0 ){ //スライドショーしてたら
stopSlideShow(); //停止
}
}
if (2 == tcnt) { //ダブルタッチ
if(slideshowSpeed !=0 ){ //スライドショ中
if(fitSts==1){ //フェードの場合、
feedStyle= 1 - feedStyle; //フェードスタイルの切り替え。
}else{ //フェードでなければ
stopSlideShow(); //停止
}
}else{
mouX = e.touches[0].pageX / viewer.offsetWidth; //タッチ位置をズーム位置に
mouY = e.touches[0].pageY / viewer.offsetHeight;
if(zoomRatio == 100){ //ピンチズームしてない場合
fitSwitdh(2); //全画面fitと、短辺fitをスイッチ。原寸が分からないけど。。
}else{ //ピンチズームしている場合は、
fitInit(); //ピンチズームしてない状態に戻す
}
}
}
if (3 == tcnt){ //トリプルタッチ
if (slideshowSpeed == 0){
slideshowTimer = setTimeout( () => { //スライドショー開始
fullScreenMode(1); //ブラウザをフルスクリーンに
//slideshow(true);
feedStyle=1;
slideshow();
},100);
slideshowSpeed = 10;
}else{
stopSlideShow(); //スライドショーしてたら停止
}
}
tcnt=0; //タイマーで回数初期化
}, 350);
}
}
//ピンチ開始
if(e.touches.length == 2){
stD = getDistance(e.touches[0],e.touches[1]);
stC = getCenter(e.touches[0],e.touches[1]);
preC = stC;
initialScale = currentScale;
}
}
vimg.addEventListener('touchmove', (e) => { vTouchmove(e); },{passive: false});
function vTouchmove(e){
clearTimeout(touchTimer); //タッチ判定終了
clearTimeout(pressTimer); //長押し判定終了
tcnt=0 //タッチ回数初期化
//ビューワ上のタッチ移動は全部自前で処理する。
//(スワイプで進んだり戻ったり、ピンチでブラウザ自体がズームしてしまうのを防ぐ)
e.preventDefault();
//一本指が動いた場合はスクロール
if(e.touches.length == 1){
enX = e.changedTouches[0].pageX;
enY = e.changedTouches[0].pageY;
const dx = enX - stX; //touchstart位置からの移動量
const dy = enY - stY;
movX=enX-preX; //直前のtouchmoveからの移動量
movY=enY-preY;
preX = enX; //現在位置保持
preY = enY;
//スライドショー中の場合
if (slideshowSpeed != 0){
slideshowSpeed += movX/30;
//console.log(${slideshowSpeed}`);
if(slideshowSpeed<1) slideshowSpeed=1;
if(30 < slideshowSpeed) slideshowSpeed=30;
return;
}
//拡大されていない場合
if (currentScale == 1 && vimg.style.width == '100%' && vimg.style.height== '100%') {
//下スワイプ判定(左右より優先。
if(Math.abs(dx) < Math.abs(dy)){
//ある程度下に引っ張ったら左右移動キャンセルして、閉じるモーションに入る
if((vimg.clientHeight*0.1)< dy ){
vimg.style.transform = `translate(0px,${dy}px)`;
vimg.style.opacity = 1 - (dy-(vimg.clientHeight*0.1))/((vimg.clientHeight*0.4)-(vimg.clientHeight*0.1));
return;
}
}
//左右スワイプ画像切り替えに備えて画像を動かす
vimg.style.transform = `translate(${dx}px,0px)`
return;
}
//ここまで来たら、スクロール
viewer.scrollLeft -= movX;
viewer.scrollTop -= movY;
//console.log(`${movX} ${viewer.scrollWidth} ${viewer.scrollLeft}`);
}
//二本指が動いた場合はピンチズーム
if(e.touches.length == 2){
cuD = getDistance(e.touches[0],e.touches[1]); //現在の距離
currentScale = initialScale * (cuD / stD); //距離の変化の比率をズーム率に
cuC = getCenter(e.touches[0],e.touches[1]); //現在の中央位置取得
movX=cuC[0]-preC[0]; //中央位置の移動量
movY=cuC[1]-preC[1];
preC = cuC; //現在の中央位置を保持。
preX = cuC[0]; //現在位置保持
preY = cuC[1];
viewer.scrollLeft -= movX;
viewer.scrollTop -= movY;
const sl = (viewer.scrollLeft + viewer.clientWidth/2) / viewer.scrollWidth;
const st = (viewer.scrollTop + viewer.clientHeight/2) / viewer.scrollHeight;
zoomRatio=currentScale*100;
imgZoom(0);
viewer.scrollLeft = viewer.scrollWidth * sl -viewer.clientWidth/2;
viewer.scrollTop = viewer.scrollHeight * st -viewer.clientHeight/2;
//画面サイズ以下にピンチインしている場合
if (currentScale < 1){
vimg.style.transform = `scale(${currentScale})`;
vimg.style.opacity = currentScale; //縮むのに合わせて透明に
}
}
}
vimg.addEventListener('touchend', (e) => { vTouchend(e); });
function vTouchend(e){
clearTimeout(pressTimer);
//ビューワタッチ時の後続クリックイベント抑止
e.preventDefault();
//拡大していない場合、touchmoveで動かしたのを戻す。
if (currentScale == 1 &&vimg.style.width == '100%' && vimg.style.height== '100%') {
//下スワイプ中のキャンセル
vimg.style.opacity = 1;
//左右スワイプに備えた画像移動をキャンセル
vimg.style.transform = `translate(0px,0px)`
}
//一定以上小さくした場合はギャラリーに戻る。
if (currentScale <0.5){
currentScale = 1;
viewSwitch(0);
}
//ピンチインしきらなかった場合、元に戻す。
if (currentScale < 1){
currentScale = 1;
vimg.style.transform = `scale(${currentScale})`;
vimg.style.transform = `translate(0px,0px)`
vimg.style.opacity = 1;
return;
}
//タッチ開始~終了までの移動量を求める。
const dx = Math.abs(enX - stX)
const dy = Math.abs(enY - stY)
if (e.touches.length==1){
//ピンチ操作の終わりかけ
preX = e.targetTouches[0].pageX; //残った指の位置を現在位置として保持
preY = e.targetTouches[0].pageY;
movX=0; //慣性スクロールしないように
movY=0;
}else if (e.touches.length==0){
//最後の指が離れた場合
//ビューワが、幅超過せずに表示されている場合、かつスライドショーが動いてない。
if (currentScale == 1 && vimg.style.width == '100%' && vimg.style.height== '100%' && slideshowSpeed == 0) {
//左右スワイプで、画像切り替え
if(dy < dx && 100 < dx ){
if (stX < enX ){
changeViewerImage(-1,true)
}else{
changeViewerImage(1,true)
}
}
//上下スワイプ
if(dx < dy && 200 < dy ){
//下スワイプで閉じる。
if(stY < enY){
if((vimg.clientHeight*0.25)< dy ){
currentScale = 1;
viewSwitch(0);
}
}
}
}
//慣性スクロール
if (iScrollTimer == 0){ //前の仕掛け分がまだ終わってない場合はタイマーID上書きしないように
iScrollTimer = setInterval(inertialScrolling,20);
}
}
}
//慣性スクロール
let iScrollTimer=0;
function inertialScrolling(){
movX = Math.trunc(movX * 0.93); //移動量を目減りさせつつ
movY = Math.trunc(movY * 0.93);
viewer.scrollLeft -= movX; //スクロールして、
viewer.scrollTop -= movY;
//console.log(`${movX} ${movY}`);
if (Math.abs(movX)<1 && Math.abs(movY)<1){ //移動量がなくなったら
clearInterval(iScrollTimer) //停止
iScrollTimer=0;
}
}
//ギャラリーのタッチ操作
gallery.addEventListener('touchstart', (e) => {
if(e.touches.length == 1) { //一本指の場合は、
pressTimer = setTimeout(() => { // 1秒間の長押しを判定して、
showExifTip(e.target) // Exif情報を表示。
}, 1000);
}
if(e.touches.length == 2){ //ピンチスタート
stD = getDistance(e.touches[0],e.touches[1]);
}
})
gallery.addEventListener('touchmove', (e) => {
clearTimeout(pressTimer);
if(e.touches.length == 2){
//二本指のみ制御を奪う。(一本指の進む/戻るジェスチャは有効に)
e.preventDefault();
cuD = getDistance(e.touches[0],e.touches[1]);
let wk
if(80 < Math.abs(stD-cuD)){
if (stD < cuD){
wk=-1;
}else{
wk=1;
}
stD = cuD //↓のサムネ再描画に時間がかかるので、次のtouchmoveが起こる前にstCを変えておく。
change_tmb_size(wk);
imgIdx = hiddenImgIdx;
setFocus(document.getElementById(imgIdx),true,true); //フォーカスを移す
}
}
},{passive: false})
gallery.addEventListener('touchend', function(e) {
clearTimeout(pressTimer);
})
//Exif情報の表示
function showExifTip(el){
setFocus(el);
tip = el.getAttribute('title')
exifTipText.innerText =tip;
exifTipDialog.showModal();
exifTipDialog.style.zIndex=9;
}
//タッチアンドホールドのデフォルトメニューの無効化
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
//スクロール位置に応じた画面中央位置のimgIdxを保持(サムネ列数変更や縦横回転時に使用)
function forcusTracking(){
//左端には必ず画像がいるので、横座標は固定で指定。
ei = document.elementFromPoint(30,gallery.offsetHeight/2); //真ん中らへん
for (let i = -50 ; i< 100; i+=20){
ei = document.elementFromPoint(30,gallery.offsetHeight/2+i);
if (ei != null ){
if(ei.tagName == 'IMG' || ei.tagName == 'VIDEO'){
hiddenImgIdx = ei.id;
break
}
if(ei.className === 'thumbnail'){
wk = Number(ei.childNodes[1].id);
hiddenImgIdx = wk;
break;
}
}
}
//console.log(hiddenImgIdx)
}
// ギャラリーがスクロールしたとき
gallery.addEventListener('scroll', () => {
highlightCurrentDate(); //日付リストの強調
forcusTracking(); //画像位置を保持
});
// スクロール位置に応じて日付を強調表示する
function highlightCurrentDate() {
const dateElements = dateList.querySelectorAll('a');
// 現在のスクロール位置に対応する日付を探す
let currentDate = null;
for (let i = 0; i < dateElements.length; i++) {
const dateElement = dateElements[i];
const targetDate = dateElement.getAttribute('href').substring(1); //#を除去して「mm/dd」を取得
const targetElement = document.getElementById(targetDate); //IDが「mm/dd」の日付のヘッダを取得
const rect = targetElement.getBoundingClientRect();
if (rect.top <= 100 && rect.bottom >= 100) { //該当日付のサムネが表示されている
currentDate = targetDate;
break;
}
}
// 全ての日付から当日(current)クラスを除去
dateElements.forEach(element => element.classList.remove('current'));
// 現在のスクロール位置に対応する日付にクラスを追加
if (currentDate) {
const currentElement = dateList.querySelector(`a[href="#${currentDate}"]`);
currentElement.classList.add('current');
currentElement.scrollIntoView({block: "nearest", inline: "nearest"});
}
}
//日付リスト内の
dateList.addEventListener('click',(e) => {
//リンクがクリックされた場合、
if(e.target.tagName = 'A'){
const targetDate = e.target.getAttribute('href').substring(1); //#を除去して「mm/dd」を取得
const targetElement = document.getElementById(targetDate); //IDが「mm/dd」の日付の固まりを取得
etmb = targetElement.querySelectorAll('.thumbnail')[0]; //該当日付中のサムネの先頭
ei = etmb.querySelector('img'); //img、または
if (ei === null){
ei = etmb.querySelector('video'); //videoに、
}
if (ei != null){
imgIdx = Number(ei.getAttribute('id'));
setFocus(document.getElementById(imgIdx)); //フォーカスを移す
return;
}
}
})
styles.css
::-webkit-scrollbar {
background: black;
width:2px;
height:2px;
}
::-webkit-scrollbar-thumb {
/*background-color: white;*/
background-color: rgba(200,200,200,0.5);
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
color: white;
background-color:black;
}
#fullscreen-viewer {
margin: 0 0 0 0;
position: absolute;
z-index:1;
height:100%;
width:100%;
display:none;
overflow: hidden;
background-color:black;
}
#vimg {
height:100%;
width:100%;
object-fit:contain;
/*position:fixed;*/
transform-origin: center center;
}
#gallery {
/*margin: 0 40pt 0 0;*/
margin: 0 0 0 0; /*右端だけ少し開ける(日付リスト用)→最初は0ptで、後で動的に変更*/
position: fixed;
overflow-y: auto;
max-height: 100vh;
}
.date {
display: flex;
flex-wrap: wrap;
justify-content: left; /* 2つ以下の場合左寄せになるように。 */
gap:0pt 1pt;
}
.date-header {
width: 100%; /* 日付だけで1行になるように設定 */
text-align: left;
text-shadow: 2px 2px 2px black;
margin: 1px;
position:sticky;
top:0;
z-index:2
}
.thumbnail {
width: 32%; /* 最初は3つの画像が並ぶように。 */
/*margin: 0 1pt 0 0;*/
}
.thumbnail img {
width: 100%; /* 画像の幅を100%に設定 */
height: auto; /* 高さを自動調整 */
}
.thumbnail video {
width: 100%; /* 画像の幅を100%に設定 */
height: auto; /* 高さを自動調整 */
}
.date-list {
width:auto;
display:none; /*初期値は非表示で、ロード時に表示判定*/
position: fixed;
right: 0;
top: 20px;
bottom: 20px;
padding: 0;
margin:0;
color:white;
background-color:black;
overflow-y: auto; /* スクロール可能に設定 */
max-height: 100vh; /* 画面の高さいっぱいにスクロールできるように設定 */
}
.date-list a {
display: block;
margin-bottom: 0;
text-decoration: none;
/*color: #333;*/
color: white;
}
.date-list a.current {
/* 強調表示の色(適宜変更してください) */
color: black;
background-color:white;
}
.date-list-header{
display:none; /*初期値は非表示で、ロード時に表示判定*/
position:fixed;
right:0;
top:0;
}
.date-list-header a{
display: block;
margin-bottom: 0;
text-decoration: none;
color: white;
}
.date-list-footer{
display:none; /*初期値は非表示で、ロード時に表示判定*/
position:fixed;
right:0;
bottom:0;
}
.date-list-footer a{
display: block;
margin-bottom: 0;
text-decoration: none;
color: white;
}
#exifTip{
color: white;
background-color:black;
}
gallery.htmlから、script.js と styles.css を分離したので、以下のように配置する必要があります。
D:\デジカメ(原本)\データ\日付別
│ index.html
├─yyyy
│ │ gallery.html ←ツールで作成される
│ │
│ ├─yyyymmdd_XXXX
│ :
│
├─css
│ styles.css ←手動で配置する必要あり
│
├─js
│ script.js ←手動で配置する必要あり
│
└─thumbnail ←ツールで作成される
└─yyyy
├─yyyymmdd_XXXX
:
バッチファイルで一括処理
上記の3つのツールを組み合わせて処理を行います。
写真整理原本フォルダが整っている状態から以下の流れをたどります。
- 原本用のサムネ作成
- 原本に対するギャラリー作成
- 縮小コピー作成
- 縮小コピー用のサムネ作成
- 動画リサイズ変換
- 縮小版のギャラリー作成
1.原本用のサムネ作成
サムネ作成器を使います。
写真整理原本フォルダを入力として、写真整理原本フォルダの中のサブフォルダ「thumbnail」として出力するように指定します。
長辺はサムネ用に小さく指定します。
動画ファイルも対象にします。
・・・と言いつつ、引数を省略すると、前回までと同じ動きをするようにしてあるので、呼び出し方は変わっていません。
@echo off
set targetFolder=D:\デジカメ(原本)\データ\日付別
python thumbnail_generator.py %targetFolder%
2.原本に対するギャラリー作成
ギャラリー作成器を使います。
原本に対するギャラリーを作成します。
ここも、前回記事の成果物とそう変わらないです。
出来上がったギャラリーは、母艦でしか見られず、しかも一部の動画ファイルはブラウザ上で再生できません。それが嫌になって今回の取り組みなのです。
python gallery_generator.py %targetFolder%\2024 ..\thumbnail\2024
python gallery_generator.py %targetFolder%\2023 ..\thumbnail\2023
:
ここからが、今回から新たに増える手順です。
3.縮小コピー作成
サムネ作成ツールを使います。
写真整理原本フォルダを入力として、写真整理原本フォルダとは別に、縮小版を出力するように指定します。
- 第2引数が対象フォルダなので、原本フォルダを指定します。
- 第3引数は縮小版コピーのプレフィクスです。いいプレフィクスが思いつかず、「アルバム」でいいや、ということで
alb_
になりました。引数に指定していただけなのに、いつの間にやらpyの中にもベタ書きしてしまったので、もう取り返しが付きません。 - 第4引数は長辺で、サムネよりは大きめの値を指定します。(今回は2560ドット)
- 第5引数は動画ファイル対象指定で、0で対象にしないように指定します。
この動画ファイルの空いているところに、後から動画サイズ変換器で縮小版の動画ファイルを嵌め込むわけです。
@echo off
set targetFolder=D:\デジカメ(原本)\データ\日付別
rem アルバム用に画像を縮小(動画は除外)
python thumbnail_generator.py %targetFolder% alb_ ..\縮小版_日付別 2560 75 0
起動時の引数にプレフィックスや、出力先フォルダを相対パスで指定できるようにしたことで、本体のサムネ作成と同じものを使いまわせるようにしています。
4.縮小コピー用のサムネ作成
サムネ作成ツールを使います。
写真整理原本フォルダを入力として、縮小版の中に「thumbnail」を出力するように指定します。
長辺はサムネ用に小さく指定します。
動画ファイルも対象にします。
縮小コピーを入力データにして、縮小コピーの中に「thumbnail」を作るようにすると、ガビガビなサムネが出来上がってしまうので、原本から作るようにします。
そもそも、縮小版コピーの中身にはまだ動画ファイルがないので、縮小コピーを元に縮小コピー用のサムネを作ると、この後でリサイズしてできる動画のために必要なサムネが存在しないことになってしまいます。そのため、入力元は原本フォルダ一択です。
python thumbnail_generator.py %targetFolder% tmb_alb_ ..\縮小版_日付別\thumbnail
5.動画リサイズ
今回新たに作った動画リサイズツールを使います。
写真整理原本フォルダ内の動画ファイルを入力として、縮小版の中に縮小した動画を出力していきます。
また、縮小版の中の「thumbnail」の中に、mp4以外の拡張子でサムネが存在していた場合(~.MTS.webpとか)、~.mp4.webpとしてコピーしておきます。
リネームしてしまうと、上記の「縮小コピーに対するサムネ作成」の処理で再作成する対象になってしまうのと、サムネ画像は大したサイズでもないので、残しておきます。
python movie_resize.py %targetFolder% alb_ ..\縮小版_日付別
6.縮小版のギャラリー作成
ギャラリー作成ツールを使います。
縮小版コピーに対するギャラリーを作成します。
対象が違うだけで、やっていることは同じですな。
set targetFolder=D:\デジカメ(原本)\データ\縮小版_日付別
python gallery_generator.py %targetFolder%\2024 ..\thumbnail\2024
python gallery_generator.py %targetFolder%\2023 ..\thumbnail\2023
:
並列実行
1,3,4は、原本フォルダを入力データとして動作するので、並列で実行させることが可能です。
2のギャラリー作成は、1のサムネができていないと問題があるかもなのでやめときましょう。(サムネの実体が存在するかどうかまでは見ていなかったような気もするけど。。)
5の動画リサイズは、原本フォルダの動画を対象にするので並列で動作できるかと思いきや、4でできる縮小コピー用サムネの動画分の拡張子を~.mp4.webp に追従させる必要があるので、4の後に実行する必要があります。
6は、2と同じような理由で5のあとに実行しましょう。
なので、もしかしたら、こんなバッチファイルでも行けるのではないでしょうか。
@echo off
set targetFolder=D:\デジカメ(原本)\データ\日付別
set resizeFolder=縮小版_日付別
copy css\styles.css %targetFolder%\css\
copy js\script.js %targetFolder%\js\
copy css\styles.css %targetFolder%\..\%resizeFolder%\css\
copy js\script.js %targetFolder%\..\%resizeFolder%\js\
rem 1.原本のサムネ作成(2の前提になるかもしれないので、startはつけない)
python thumbnail_generator.py %targetFolder%
rem 2.ギャラリー作成
start python gallery_generator.py %targetFolder%\2024 ..\thumbnail\2024
start python gallery_generator.py %targetFolder%\2023 ..\thumbnail\2023
start python gallery_generator.py %targetFolder%\2022 ..\thumbnail\2022
start python gallery_generator.py %targetFolder%\2021 ..\thumbnail\2021
start python gallery_generator.py %targetFolder%\2020 ..\thumbnail\2020
start python gallery_generator.py %targetFolder%\2019 ..\thumbnail\2019
start python gallery_generator.py %targetFolder%\2018 ..\thumbnail\2018
start python gallery_generator.py %targetFolder%\2017 ..\thumbnail\2017
start python gallery_generator.py %targetFolder%\2016 ..\thumbnail\2016
start python gallery_generator.py %targetFolder%\2015 ..\thumbnail\2015
start python gallery_generator.py %targetFolder%\2014 ..\thumbnail\2014
start python gallery_generator.py %targetFolder%\2013 ..\thumbnail\2013
start python gallery_generator.py %targetFolder%\2012 ..\thumbnail\2012
start python gallery_generator.py %targetFolder%\2011 ..\thumbnail\2011
start python gallery_generator.py %targetFolder%\2010 ..\thumbnail\2010
start python gallery_generator.py %targetFolder%\2009 ..\thumbnail\2009
rem 3.縮小コピー作成
start python thumbnail_generator.py %targetFolder% alb_ ..\%resizeFolder% 2560 75 0
rem 4.縮小コピー用のサムネ作成(5の前提になるので、startをつけない)
python thumbnail_generator.py %targetFolder% tmb_alb_ ..\%resizeFolder%\thumbnail
rem 5.動画リサイズ(6の前提になるかもしれないので、startはつけない)
python movie_resize.py %targetFolder% alb_ ..\%resizeFolder%
rem 6.縮小版のギャラリー作成
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2024 ..\thumbnail\2024
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2023 ..\thumbnail\2023
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2022 ..\thumbnail\2022
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2021 ..\thumbnail\2021
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2020 ..\thumbnail\2020
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2019 ..\thumbnail\2019
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2018 ..\thumbnail\2018
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2017 ..\thumbnail\2017
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2016 ..\thumbnail\2016
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2015 ..\thumbnail\2015
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2014 ..\thumbnail\2014
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2013 ..\thumbnail\2013
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2012 ..\thumbnail\2012
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2011 ..\thumbnail\2011
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2010 ..\thumbnail\2010
start python gallery_generator.py %targetFolder%\..\%resizeFolder%\2009 ..\thumbnail\2009
DOS窓がドババーっと開いて、HDDアクセスがアップアップになりそうで、どれだけ意味があるのかわかりませんが。
結論
- ffmpegは、対応形式も豊富で、簡単に使うことができて、とてもいいツールでした。
- pythonは、CUIツールの呼び出し口としてとても便利でした。
- javascriptは、スマホやタブレットがあるなら、これだけでもフロントエンドとして結構好きな事ができるんだなぁ、と思いました。
スマホやタブレットでスイスイと閲覧できるようになって、快適です。
免責事項
もしご使用になるときは、自己責任にてお願いいたします。