Version 493

closes #469, closes #1192, closes #1193, closes #1194, closes #1199
This commit is contained in:
Hydrus Network Developer 2022-07-27 16:18:33 -05:00
parent 9b74b2d177
commit da89e4b3ae
27 changed files with 917 additions and 469 deletions

View File

@ -3,6 +3,28 @@
!!! note
This is the new changelog, only the most recent builds. For all versions, see the [old changelog](old_changelog.html).
## [Version 493](https://github.com/hydrusnetwork/hydrus/releases/tag/v493)
### EXIF
* in the first step of 'official' EXIF support, the media viewer now has a 'cog' button on the top hover, enabled when looking at a jpeg, that will check the file for EXIF data. if found, it will throw it up on a simple new window that shows EXIF id, label, and value. this is a hacked-together prototype, not super user-friendly, but it works. let me know what you think, and please send me any files that have weird EXIF that doesn't parse right but you think should. I already discovered a file with a null character that wouldn't display in UI, that sort of thing
* GPS EXIF values are also parsed and extracted
* made it so you can double-click a row in this new window to copy an EXIF value to clipboard
* in the duplicate filter, if one or both files have exif data, this is now noted in the comparison statements, just like ICC profile! (issue #469)
* obvious future extensions here will be storing 'has exif' in the database and allowing its presence to be searchable and enabling the cog button (or a nicer 'exif' button) only when there is known data to see. a subsequent step would be actually caching the data in the database for full EXIF search
* as a side thing, we're now set up on the hydrus end to pull TIFF EXIF, but PIL doesn't seem to offer it, so we'll have to wait for a different solution there
### fixes and misc
* fixed a problem that made saved page file sorts reset their sort order one time on update to v492. thank you to a user for noticing this and discovering the fix, and I'm very sorry for the inconvenience of changing your session and favourite search sorts. unfortunately there is no easy fix other than rolling back to a backup and jumping forward to this version
* fixed a v492 message display error when setting various duplicate relationships to three or more thumbnails at once. it was a stupid typo, sorry for the trouble! (issue #1199)
* if a page tab name elides to a 'shorter...' length, it now has its full name as the tooltip
* fixed a typo in update code error handling (issue #1192)
* the duplicate filter page now remembers if you are 'searching immediately'/'search paused' (issue #1193)
* if you are on non-Windows and export files manually or with an export folder to an NTFS or exFAT partition, this is now detected, and NTFS-invalid characters in the pattern-generated folders or filename are now replaced with underscores (issue #1194)
* 'fixed' a system predicate bug in the 'OR*' advanced predicate parser--entering a logical expression that results in a negated system tag now causes an error. previously, it would strip the 'system:' and just enter the given text as an unnamespaced tag. furthermore, that dialog now reports specific error reasons when it fails to parse. I hope to improve support for negated system tags in future--some stuff, like archive/inbox, should be easy.
* I think I fixed an instance where the archive/delete filter's confirmation dialog could present 'delete from hard disk' as an option when it wasn't appropriate
* in an attempt to reduce the media-change flickering we've recently seen in the media viewer, I untangled a bunch of the canvas size/position code this week. I'm preparing a complete overhaul and neat Qt layout integration, which this starts. I _think_ I've made some things less flickery on occasion, but we'll see IRL. much more to do
* added a '--profile_mode' launch argument, which allows you to capture the performance of boot and also try out profile mode on the server (although support there is very limited atm)
## [Version 492](https://github.com/hydrusnetwork/hydrus/releases/tag/v492)
### sort and collect updates
@ -267,73 +289,3 @@
* if a 'move' job is started without a source service and multiple services could apply, the main routine will now ask the user which to use using a selector that shows how many files each choice will affect
* also rewrote the add/move menu population code, fixed a couple little issues, and refactored it to a module the media viewer canvas can use
* wrote a new menu builder that can place a list of items either as a single item (if the list is length 1), or make a submenu if there are more. it drives the new add/move commands and now the behind the scenes of all other service-based menu population
## [Version 483](https://github.com/hydrusnetwork/hydrus/releases/tag/v483)
### multiple local file services
* the multiple local file services feature is ready for advanced users to test out! it lets you have more than one 'my files' service to store things, which will give us some neat privacy and management tools in future. there is no nice help for this feature yet, and the UI is still a little non-user-friendly, so please do not try it out unless you have been following it. and, while this has worked great in all testing, I generally do not recommend it for heavy use on a real client either, just in case something does go wrong. with those caveats, hit up _manage services_ in advanced mode, and you can now add new 'local file domain' services. it is possible to search, import to, and migrate files between these and everything basically works. I need to do more UI work to make it clear what is going on (for instance, I think we'll figure out custom icons or similar to show where files are), and some more search tech, and write up proper help, and figure out easy client merging so users can combine legacy clients, but please feel free to experiment wildly on a fresh client or carefully on your existing one
* if you have more than one local file service, a new 'files' or 'local services' menu on thumbnail right-click handles duplicating and moving across local services. these actions will preserve original import times (e.g. if you move from A to B and then back to A), so they should be generally non-destructive, but we may want to add some advanced tools in future. let me know how this part goes--I think we'll probably want a different status than 'deleted from A' when you just move A->B, so as not to interfere with some advanced queries, but only IRL testing will show it
* if you have a 'file import options' that imports files to multiple local services but the file import is 'already in db', the file import job will now examine if and where the file is still needed and send content update calls to fill in the gaps
* the advanced delete files dialog now gives a new 'delete from all and send to trash' option if the file is in multiple local file domains
* the advanced delete files dialog now fully supports file repositories
* cleaned up some logic on the 'remember action' option of the advanced file deletion dialog. it also supports remembering specific file domains, not just the clever commands like 'delete and leave no record'. also this dialog no longer places the 'suggested' file service at the top of the radio button list--instead it selects that 'suggested' if there is no 'remember action' initial selection applicable. the suggested file service is now also set by the underlying thumbnail grid or media canvas if it has a simple one-service location context
* the normal 'non-advanced' delete files dialog now supports files that are in multiple local file services. it will show a part of the advanced dialog to let you choose where to delete from
### misc
* thanks to user submissions, there is a bit more help docs work--for file search, and for some neat new 'mermaid' svg diagrams in siblings/parents, which are automatically generated from a markup and easy to edit
* with the new easy-to-edit mermaid diagrams, I updated the unhelpful and honestly cringe examples in the siblings and parents help to reflect real world PTR data and brushed up all the text in the top sections
* just a small thing--the 'pages' menu and the page picker dialog now both say 'file search' to refer to a page that searches files. previously, 'search' or 'files' was used in different places
* completely rewrote the queue code behind the duplicate filter. an ancient bad idea is now replaced with something that will be easier to work with in future
* you can now go 'back' in the duplicate filter even when you have only done skips so far
* the 'index string' of duplicate filters, where it says 53/100, now also says the number of decisions made
* fixed some small edge case bugs in duplicate filter forward/backward move logic, and fixed the recent problem with going back after certain decisions
* updated the default nijie.info parser to grab video (issue #1113)
* added in a user fix to the deviant art parser
* added user-made Mega URL Classes. hydrus won't support Mega for a long while, but it can recognise and categorise these URLs now, presenting them in the media viewer if you want to open them externally
* fixed Exif image rotation for images that also have ICC Profiles. thanks to the user who provided great test images here (issue #1124)
* hitting F5 or otherwise saying 'refresh' explicitly will now turn a search page that is currently in 'searching paused' to 'searching immediately'. previously it silently did nothing
* the 'current file info' in the media window's top hover and the status bar of the main window now ignores Deletion reason, and also file modified date if it is not substantially different from another timestamp already stated. this data can still be seen on the file's right-click menu's expanded info lines off the top entry. also, as a small cleanup, it now says 'modified' and 'archived' instead of 'file modified/archived', just to save some more space
* like the above 'show if interesting' check for modified date, that list of file info texts now includes the actual import time if it is different than other timestamps. (for instance, if you migrate it from one service to another some time after import)
* fixed a sort error notification in the edit parser dialog when you have two duplicate subsidiary parsers that both have vetoes
* fixed the new media viewer note display for PyQt5
* fixed a rare frame-duration-lookup problem when loading certain gifs into the media viewer
### boring code cleanup
* cleaned up search signalling UI code, a couple of minor bugs with 'searching immediately' sometimes not saving right should be fixed
* the 'repository updates' domain now has a different service type. it is now a 'local update file domain' rather than a 'local file domain', which is just an enum change but marks it as different to the regular media domains. some code is cleaned up as a result
* renamed the terms in some old media filtering code to make it more compatible with multiple local file services
* brushed up some delete code to handle multiple local file services better
* cleaned up more behind the scenes of the delete files dialog
* refactored ClientGUIApplicationCommand to the widgets module
* wrote a new ApplicationCommandProcessor Mixin class for all UI elements that process commands. it is now used across the program and will grow in responsibility in future to unify some things here
* the media viewer hover windows now send their application commands through Qt signals rather than the old pubsub system
* in a bunch of places across the program, renamed 'remote' to 'not local' in file status contexts--this tends to make more sense to people at out the gate
* misc little syntax cleanup
## [Version 482](https://github.com/hydrusnetwork/hydrus/releases/tag/v482)
### misc
* fixed the stupid taglist scrolled-click position problem--sorry! I have a new specific weekly test for this, so it shouldn't happen again (issue #1120)
* I made it so middle-clicking on a tag list does a select event again
* the duplicate action options now let you say to archive both files regardless of their current archive status (issue #472)
* the duplicate filter is now hooked into the media prefetch system. as soon as 'A' is displayed, the 'B' file will now be queued to be loaded, so with luck you will see very little flicker on the first transition from A->B.
* I updated the duplicate filter's queue to store more information and added the next pair to the new prefetch queue, so when you action a pair, the A of the next pair should also load up quickly
* boosted the default sizes of the thumbnail and image caches up to 32MB and 384MB (from 25/150) and gave them nicer 'bytes quantity' widgets in the options panel
* when popup windows show network jobs, they now have delayed hide. with luck, this will make subscriptions more stable in height, less flickering as jobs are loaded and unloaded
* reduced the extremes of the new auto-throttled pending upload. it will now change speed slower, on less strict of a schedule, and won't go as fast or slow max
* the text colour of hyperlinks across the program, most significantly in the top-right media hover window, can now be customised in QSS. I have set some ok defaults for all the QSS styles that come with the client, if you have a custom QSS, check out my default to see what you need to do. also hyperlinks are no longer underlined and you can't 'select' their text with the mouse any more (this was a weird rich-text flag)
* the client api and local booru now have a checkbox in their manage services panel for 'normie-friendly welcome page', which switches the default ascii art for an alternate
* fixed an issue with the hydrus server not explicitly saying it is utf-8 when rendering html
* may have fixed some issues with autocomplete dropdowns getting hung up in the wrong position and not fixing themselves until parent resize event or similar
### code cleanup
* about 80KB of code moved out of the main ClientDB.py file:
* refactored all combined files display mappings cache code from the code database to a new database module
* refactored all combined files storage mappings cache code from the code database to a new database module
* refactored all specific storage mappings cache code from the code database to a new database module
* more misc refactoring of tag count estimate, tag search, and other code down to modules
* hooked up specific display mappings cache to the repair system correctly--it had been left unregistered by accident
* some misc duplicate action options code cleanup
* migrated some ancient pause states--repository, subscriptions, import&export folders--to the newer options structure
* migrated the image and thumbnail cache sizes to the newer options structure
* removed some ancient db and dialog code from the retired dumper system

View File

@ -69,5 +69,9 @@ When SQLite performs very large queries, it may spool temporary table results to
Prints additional debug information to the log during the bootup phase of the application.
##**`--profile_mode`**
This starts the program with 'Profile Mode' turned on, which captures the performance of boot functions. This is also a way to get Profile Mode on the server, although support there is very limited.
The server supports the same arguments. It also takes a _positional_ argument of 'start' (start the server, the default), 'stop' (stop any existing server), or 'restart' (do a stop, then a start), which should go before any of the above arguments.

View File

@ -33,6 +33,28 @@
<div class="content">
<h3 id="changelog"><a href="#changelog">changelog</a></h3>
<ul>
<li><h3 id="version_493"><a href="#version_493">version 493</a></h3></li>
<ul>
<li>EXIF:</li>
<li>in the first step of 'official' EXIF support, the media viewer now has a 'cog' button on the top hover, enabled when looking at a jpeg, that will check the file for EXIF data. if found, it will throw it up on a simple new window that shows EXIF id, label, and value. this is a hacked-together prototype, not super user-friendly, but it works. let me know what you think, and please send me any files that have weird EXIF that doesn't parse right but you think should. I already discovered a file with a null character that wouldn't display in UI, that sort of thing</li>
<li>GPS EXIF values are also parsed and extracted</li>
<li>made it so you can double-click a row in this new window to copy an EXIF value to clipboard</li>
<li>in the duplicate filter, if one or both files have exif data, this is now noted in the comparison statements, just like ICC profile! (issue #469)</li>
<li>obvious future extensions here will be storing 'has exif' in the database and allowing its presence to be searchable and enabling the cog button (or a nicer 'exif' button) only when there is known data to see. a subsequent step would be actually caching the data in the database for full EXIF search</li>
<li>as a side thing, we're now set up on the hydrus end to pull TIFF EXIF, but PIL doesn't seem to offer it, so we'll have to wait for a different solution there</li>
<li>.</li>
<li>fixes and misc:</li>
<li>fixed a problem that made saved page file sorts reset their sort order one time on update to v492. thank you to a user for noticing this and discovering the fix, and I'm very sorry for the inconvenience of changing your session and favourite search sorts. unfortunately there is no easy fix other than rolling back to a backup and jumping forward to this version</li>
<li>fixed a v492 message display error when setting various duplicate relationships to three or more thumbnails at once. it was a stupid typo, sorry for the trouble! (issue #1199)</li>
<li>if a page tab name elides to a 'shorter...' length, it now has its full name as the tooltip</li>
<li>fixed a typo in update code error handling (issue #1192)</li>
<li>the duplicate filter page now remembers if you are 'searching immediately'/'search paused' (issue #1193)</li>
<li>if you are on non-Windows and export files manually or with an export folder to an NTFS or exFAT partition, this is now detected, and NTFS-invalid characters in the pattern-generated folders or filename are now replaced with underscores (issue #1194)</li>
<li>'fixed' a system predicate bug in the 'OR*' advanced predicate parser--entering a logical expression that results in a negated system tag now causes an error. previously, it would strip the 'system:' and just enter the given text as an unnamespaced tag. furthermore, that dialog now reports specific error reasons when it fails to parse. I hope to improve support for negated system tags in future--some stuff, like archive/inbox, should be easy.</li>
<li>I think I fixed an instance where the archive/delete filter's confirmation dialog could present 'delete from hard disk' as an option when it wasn't appropriate</li>
<li>in an attempt to reduce the media-change flickering we've recently seen in the media viewer, I untangled a bunch of the canvas size/position code this week. I'm preparing a complete overhaul and neat Qt layout integration, which this starts. I _think_ I've made some things less flickery on occasion, but we'll see IRL. much more to do</li>
<li>added a '--profile_mode' launch argument, which allows you to capture the performance of boot and also try out profile mode on the server (although support there is very limited atm)</li>
</ul>
<li><h3 id="version_492"><a href="#version_492">version 492</a></h3></li>
<ul>
<li>sort and collect updates:</li>

View File

@ -36,7 +36,7 @@ class ClientOptions( HydrusSerialisable.SerialisableBase ):
# media_show_action, media_start_paused, media_start_with_embed, preview_show_action, preview_start_paused, preview_start_with_embed, ( media_scale_up, media_scale_down, preview_scale_up, preview_scale_down, exact_zooms_only, scale_up_quality, scale_down_quality ) )
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui.canvas import ClientGUIMPV
if ClientGUIMPV.MPV_IS_AVAILABLE:

View File

@ -11069,7 +11069,7 @@ class DB( HydrusDB.HydrusDB ):
try:
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui.canvas import ClientGUIMPV
if ClientGUIMPV.MPV_IS_AVAILABLE and HC.PLATFORM_LINUX:
@ -11119,7 +11119,7 @@ class DB( HydrusDB.HydrusDB ):
self.modules_serialisable.SetJSONDump( new_options )
except:
except Exception as e:
HydrusData.PrintException( e )

View File

@ -112,19 +112,19 @@ def GenerateExportFilename( destination_directory, media, terms, do_not_use_file
filename = filename[1:]
# replace many consecutive (back)slash with single
if HC.PLATFORM_WINDOWS:
# replace many consecutive backspace with single backspace
filename = re.sub( '\\\\+', '\\\\', filename )
# /, :, *, ?, ", <, >, |
filename = re.sub( r'/|:|\*|\?|"|<|>|\|', '_', filename )
filename = re.sub( r'\\+', r'\\', filename )
else:
filename = re.sub( '/+', '/', filename )
filename = HydrusPaths.SanitizePathForExport( destination_directory, filename )
#
mime = media.GetMime()

View File

@ -58,7 +58,6 @@ from hydrus.client.gui import ClientGUIImport
from hydrus.client.gui import ClientGUILogin
from hydrus.client.gui import ClientGUIMediaControls
from hydrus.client.gui import ClientGUIMenus
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui import ClientGUIParsing
from hydrus.client.gui import ClientGUIPopupMessages
from hydrus.client.gui import ClientGUIScrolledPanels
@ -78,6 +77,7 @@ from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import QLocator
from hydrus.client.gui import ClientGUILocatorSearchProviders
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.canvas import ClientGUIMPV
from hydrus.client.gui.networking import ClientGUIHydrusNetwork
from hydrus.client.gui.networking import ClientGUINetwork
from hydrus.client.gui.pages import ClientGUIManagement
@ -245,7 +245,7 @@ def THREADUploadPending( service_key ):
HG.client_controller.pub( 'message', job_key )
no_results_found = result is None
while result is not None:
time_started_this_loop = HydrusData.GetNowPrecise()

View File

@ -9,6 +9,7 @@ from hydrus.core import HydrusConstants as HC
from hydrus.core import HydrusData
from hydrus.core import HydrusExceptions
from hydrus.core import HydrusGlobals as HG
from hydrus.core import HydrusImageHandling
from hydrus.client import ClientApplicationCommand as CAC
from hydrus.client import ClientConstants as CC
@ -16,6 +17,7 @@ from hydrus.client import ClientThreading
from hydrus.client.gui import ClientGUIAsync
from hydrus.client.gui import ClientGUIDialogsQuick
from hydrus.client.gui import ClientGUIScrolledPanelsEdit
from hydrus.client.gui import ClientGUIScrolledPanelsReview
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.media import ClientMedia
from hydrus.client.metadata import ClientTags
@ -549,6 +551,36 @@ def MoveOrDuplicateLocalFiles( win: QW.QWidget, dest_service_key: bytes, action:
job.start()
def ShowFileEXIF( win: QW.QWidget, media: ClientMedia.MediaSingleton ):
if not media.GetLocationsManager().IsLocal():
QW.QMessageBox.warning( win, 'Warning', 'This file is not local to this computer!' )
hash = media.GetHash()
mime = media.GetMime()
path = HG.client_controller.client_files_manager.GetFilePath( hash, mime )
pil_image = HydrusImageHandling.RawOpenPILImage( path )
exif_dict = HydrusImageHandling.GetEXIFDict( pil_image )
if exif_dict is None:
QW.QMessageBox.information( win, 'No EXIF', 'Sorry, could not see any EXIF information in this file!' )
return
frame = ClientGUITopLevelWindowsPanels.FrameThatTakesScrollablePanel( win, 'File EXIF' )
panel = ClientGUIScrolledPanelsReview.ReviewFileEXIF( frame, exif_dict )
frame.SetPanel( panel )
def UndeleteFiles( hashes ):
local_file_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )

View File

@ -21,10 +21,10 @@ from hydrus.client.gui import ClientGUIFunctions
from hydrus.client.gui import ClientGUIImport
from hydrus.client.gui import ClientGUIScrolledPanels
from hydrus.client.gui import ClientGUIShortcuts
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui import ClientGUITags
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.canvas import ClientGUIMPV
from hydrus.client.gui.lists import ClientGUIListConstants as CGLC
from hydrus.client.gui.lists import ClientGUIListCtrl
from hydrus.client.gui.search import ClientGUILocation

View File

@ -6,6 +6,8 @@ import threading
import time
import traceback
from PIL import ExifTags
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from qtpy import QtGui as QG
@ -2289,6 +2291,115 @@ class ReviewDownloaderImport( ClientGUIScrolledPanels.ReviewPanel ):
self._ImportPaths( paths )
class ReviewFileEXIF( ClientGUIScrolledPanels.ReviewPanel ):
def __init__( self, parent, exif_dict ):
ClientGUIScrolledPanels.ReviewPanel.__init__( self, parent )
vbox = QP.VBoxLayout()
label = 'Double-click a row to copy its value to clipboard.'
st = ClientGUICommon.BetterStaticText( self, label = label )
st.setWordWrap( True )
st.setAlignment( QC.Qt.AlignCenter )
QP.AddToLayout( vbox, st, CC.FLAGS_EXPAND_PERPENDICULAR )
self._exif_listctrl = ClientGUIListCtrl.BetterListCtrl( self, CGLC.COLUMN_LIST_EXIF_DATA.ID, 24, self._ConvertEXIFToListCtrlTuples, activation_callback = self._CopyRow )
datas = []
for ( exif_id, value ) in exif_dict.items():
if isinstance( value, dict ):
datas.extend( value.items() )
else:
datas.append( ( exif_id, value ) )
self._exif_listctrl.AddDatas( datas )
QP.AddToLayout( vbox, self._exif_listctrl, CC.FLAGS_EXPAND_BOTH_WAYS )
self.widget().setLayout( vbox )
def _ConvertEXIFToListCtrlTuples( self, exif_tuple ):
( exif_id, raw_value ) = exif_tuple
if exif_id in ExifTags.TAGS:
label = ExifTags.TAGS[ exif_id ]
pretty_label = label
elif exif_id in ExifTags.GPSTAGS:
label = ExifTags.GPSTAGS[ exif_id ]
pretty_label = label
else:
label = 'zzz'
pretty_label = 'Unknown'
pretty_id = str( exif_id )
if isinstance( raw_value, bytes ):
value = raw_value.hex()
pretty_value = '{}: {}'.format( HydrusData.ToHumanBytes( len( raw_value ) ), value )
else:
value = str( raw_value )
if HydrusText.NULL_CHARACTER in value:
value = value.replace( HydrusText.NULL_CHARACTER, '[null]' )
pretty_value = value
display_tuple = ( pretty_id, pretty_label, pretty_value )
sort_tuple = ( exif_id, label, value )
return ( display_tuple, sort_tuple )
def _CopyRow( self ):
selected_exif_tuples = self._exif_listctrl.GetData( only_selected = True )
if len( selected_exif_tuples ) == 0:
return
( first_row_id, first_row_value ) = selected_exif_tuples[0]
if isinstance( first_row_value, bytes ):
copy_text = first_row_value.hex()
else:
copy_text = str( first_row_value )
HG.client_controller.pub( 'clipboard', 'text', copy_text )
class ReviewFileHistory( ClientGUIScrolledPanels.ReviewPanel ):
def __init__( self, parent, file_history ):

View File

@ -8,7 +8,6 @@ from hydrus.core import HydrusConstants as HC
from hydrus.core import HydrusData
from hydrus.core import HydrusExceptions
from hydrus.core import HydrusGlobals as HG
from hydrus.core import HydrusImageHandling
from hydrus.core import HydrusPaths
from hydrus.core import HydrusTags
@ -56,8 +55,6 @@ zoom_centerpoints_str_lookup[ ZOOM_CENTERPOINT_VIEWER_CENTER ] = 'viewer center'
zoom_centerpoints_str_lookup[ ZOOM_CENTERPOINT_MOUSE ] = 'mouse (or viewer center if mouse outside)'
zoom_centerpoints_str_lookup[ ZOOM_CENTERPOINT_MEDIA_TOP_LEFT ] = 'media top-left'
OPEN_EXTERNALLY_BUTTON_SIZE = ( 200, 45 )
def AddAudioVolumeMenu( menu, canvas_type ):
mute_volume_type = None
@ -151,197 +148,101 @@ def AddAudioVolumeMenu( menu, canvas_type ):
ClientGUIMenus.AppendMenu( menu, volume_menu, 'volume' )
def CalculateCanvasMediaSize( media, canvas_size: QC.QSize, show_action ):
# cribbing from here https://doc.qt.io/qt-5/layout.html#how-to-write-a-custom-layout-manager
# not finished, but a start as I continue to refactor. might want to rename to 'draggable layout' or something too, since it doesn't actually care about media container that much, and instead subclass vboxlayout?
class CanvasLayout( QW.QLayout ):
canvas_width = canvas_size.width()
canvas_height = canvas_size.height()
'''if ClientGUICanvasMedia.ShouldHaveAnimationBar( media, show_action ):
def __init__( self ):
animated_scanbar_height = HG.client_controller.new_options.GetInteger( 'animated_scanbar_height' )
QW.QLayout.__init__( self )
canvas_height -= animated_scanbar_height
'''
canvas_width = max( canvas_width, 80 )
canvas_height = max( canvas_height, 60 )
return ( canvas_width, canvas_height )
def CalculateCanvasZooms( canvas, media, show_action ):
if media is None:
self._current_drag_delta = QC.QPoint( 0, 0 )
return ( 1.0, 1.0 )
self._layout_items = []
if show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_SHOW_OPEN_EXTERNALLY_BUTTON, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
def addItem( self, layout_item: QW.QLayoutItem ) -> None:
return ( 1.0, 1.0 )
self._layout_items.append( layout_item )
( media_width, media_height ) = CalculateMediaSize( media, 1.0 )
if media_width == 0 or media_height == 0:
def itemAt( self, index: int ):
return ( 1.0, 1.0 )
new_options = HG.client_controller.new_options
( canvas_width, canvas_height ) = CalculateCanvasMediaSize( media, canvas.size(), show_action )
width_zoom = canvas_width / media_width
height_zoom = canvas_height / media_height
canvas_zoom = min( ( width_zoom, height_zoom ) )
#
mime = media.GetMime()
( media_scale_up, media_scale_down, preview_scale_up, preview_scale_down, exact_zooms_only, scale_up_quality, scale_down_quality ) = new_options.GetMediaZoomOptions( mime )
if exact_zooms_only:
max_regular_zoom = 1.0
if canvas_zoom > 1.0:
try:
while max_regular_zoom * 2 < canvas_zoom:
max_regular_zoom *= 2
return self._layout_items[ index ]
elif canvas_zoom < 1.0:
except IndexError:
while max_regular_zoom > canvas_zoom:
max_regular_zoom /= 2
return None
else:
def minimumSize(self) -> QC.QSize:
regular_zooms = new_options.GetMediaZooms()
return self.sizeHint()
valid_regular_zooms = [ zoom for zoom in regular_zooms if zoom < canvas_zoom ]
def resetDragDelta( self ):
if len( valid_regular_zooms ) > 0:
self._current_drag_delta = QC.QPoint( 0, 0 )
def setGeometry( self, rect: QC.QRect ) -> None:
if len( self._layout_items ) == 0:
max_regular_zoom = max( valid_regular_zooms )
return
layout_item = self._layout_items[0]
size = self.sizeHint()
# the given rect is the whole canvas?
natural_x = ( rect.width() - size.width() ) // 2
natural_y = ( rect.height() - size.height() ) // 2
topleft = QC.QPoint( natural_x, natural_y ) + self._current_drag_delta
media_container_rect = QC.QRect( topleft, size )
layout_item.setGeometry( media_container_rect )
def sizeHint(self) -> QC.QSize:
if len( self._layout_items ) == 0:
return QC.QSize( 0, 0 )
else:
max_regular_zoom = canvas_zoom
return self._layout_items[0].sizeHint()
if media.GetMime() in HC.AUDIO:
def takeAt( self, index: int ):
scale_up_action = CC.MEDIA_VIEWER_SCALE_100
scale_down_action = CC.MEDIA_VIEWER_SCALE_TO_CANVAS
layout_item = self.itemAt( index )
elif canvas.PREVIEW_WINDOW:
scale_up_action = preview_scale_up
scale_down_action = preview_scale_down
else:
scale_up_action = media_scale_up
scale_down_action = media_scale_down
can_be_scaled_down = media_width > canvas_width or media_height > canvas_height
can_be_scaled_up = media_width < canvas_width and media_height < canvas_height
#
if can_be_scaled_up:
scale_action = scale_up_action
elif can_be_scaled_down:
scale_action = scale_down_action
else:
scale_action = CC.MEDIA_VIEWER_SCALE_100
if scale_action == CC.MEDIA_VIEWER_SCALE_100:
default_zoom = 1.0
elif scale_action == CC.MEDIA_VIEWER_SCALE_MAX_REGULAR:
default_zoom = max_regular_zoom
else:
default_zoom = canvas_zoom
return ( default_zoom, canvas_zoom )
def CalculateMediaContainerSize( media, zoom, show_action ):
if show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
raise Exception( 'This media should not be shown in the media viewer!' )
elif show_action == CC.MEDIA_VIEWER_ACTION_SHOW_OPEN_EXTERNALLY_BUTTON:
( width, height ) = OPEN_EXTERNALLY_BUTTON_SIZE
if media.GetMime() in HC.MIMES_WITH_THUMBNAILS:
if layout_item is None:
bounding_dimensions = HG.client_controller.options[ 'thumbnail_dimensions' ]
thumbnail_scale_type = HG.client_controller.new_options.GetInteger( 'thumbnail_scale_type' )
( clip_rect, ( thumb_width, thumb_height ) ) = HydrusImageHandling.GetThumbnailResolutionAndClipRegion( media.GetResolution(), bounding_dimensions, thumbnail_scale_type )
height = height + thumb_height
return 0
return QC.QSize( width, height )
del self._layout_items[ index ]
else:
( media_width, media_height ) = CalculateMediaSize( media, zoom )
'''if ClientGUICanvasMedia.ShouldHaveAnimationBar( media, show_action ):
animated_scanbar_height = HG.client_controller.new_options.GetInteger( 'animated_scanbar_height' )
media_height += animated_scanbar_height
'''
return QC.QSize( media_width, media_height )
return layout_item
def CalculateMediaSize( media, zoom ):
if media.GetMime() in HC.AUDIO:
def updateDragDelta( self, delta: QC.QPoint ):
( original_width, original_height ) = ( 360, 240 )
else:
( original_width, original_height ) = media.GetResolution()
self._current_drag_delta += delta
media_width = int( round( zoom * original_width ) )
media_height = int( round( zoom * original_height ) )
media_width = max( 1, media_width )
media_height = max( 1, media_height )
return ( media_width, media_height )
class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
PREVIEW_WINDOW = False
@ -381,6 +282,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
self._media_container = ClientGUICanvasMedia.MediaContainer( self, self.CANVAS_TYPE, self._click_drag_reporting_filter )
# TODO: move zoom responsibility to the media container. that guy should handle his own size, and the new QLayout should deal with mouse drag delta
self._current_zoom = 1.0
self._canvas_zoom = 1.0
@ -409,37 +311,6 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
def _CanDisplayMedia( self, media ):
if media is None:
return True
media = media.GetDisplayMedia()
if media is None:
return True
locations_manager = media.GetLocationsManager()
if not locations_manager.IsLocal():
return False
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( media )
if media_show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
return False
return True
def _CopyBMPToClipboard( self ):
copied = False
@ -654,49 +525,11 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
return self._new_options.GetColour( CC.COLOUR_MEDIA_BACKGROUND )
def _GetShowAction( self, media ):
start_paused = False
start_with_embed = False
bad_result = ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW, start_paused, start_with_embed )
if media is None:
return bad_result
mime = media.GetMime()
if mime not in HC.ALLOWED_MIMES: # stopgap to catch a collection or application_unknown due to unusual import order/media moving
return bad_result
if self.PREVIEW_WINDOW:
return self._new_options.GetPreviewShowAction( mime )
else:
return self._new_options.GetMediaShowAction( mime )
def _GetIndexString( self ):
return ''
def _GetMediaContainerSize( self ):
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
new_size = CalculateMediaContainerSize( self._current_media, self._current_zoom, media_show_action )
return new_size
def _Inbox( self ):
if self._current_media is None:
@ -714,7 +547,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
return False
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( self._current_media )
return media_show_action not in ( CC.MEDIA_VIEWER_ACTION_SHOW_OPEN_EXTERNALLY_BUTTON, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW )
@ -741,19 +574,19 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
previous_current_zoom = self._current_zoom
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( previous_media )
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( previous_media )
( previous_default_zoom, previous_canvas_zoom ) = CalculateCanvasZooms( self, previous_media, media_show_action )
( previous_default_zoom, previous_canvas_zoom ) = ClientGUICanvasMedia.CalculateCanvasZooms( self, previous_media, media_show_action )
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( self._current_media )
( gumpf_current_zoom, self._canvas_zoom ) = CalculateCanvasZooms( self, self._current_media, media_show_action )
( gumpf_current_zoom, self._canvas_zoom ) = ClientGUICanvasMedia.CalculateCanvasZooms( self, self._current_media, media_show_action )
# previously, we always matched width, but this causes a problem in dupe viewer when B has a little watermark on the bottom, spilling below bottom of screen
# I think in future we will have more options regarding all this, and this method will change significantly
# however for now we really just want a hardcoded ok solution for all situations, so let's just hook on default canvas zoom situation
( previous_width, previous_height ) = CalculateMediaSize( previous_media, self._current_zoom )
( previous_width, previous_height ) = ClientGUICanvasMedia.CalculateMediaSize( previous_media, self._current_zoom )
( previous_media_100_width, previous_media_100_height ) = previous_media.GetResolution()
( current_media_100_width, current_media_100_height ) = self._current_media.GetResolution()
@ -761,8 +594,8 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
width_locked_zoom = previous_width / current_media_100_width
height_locked_zoom = previous_height / current_media_100_height
width_locked_size = CalculateMediaContainerSize( self._current_media, width_locked_zoom, media_show_action )
height_locked_size = CalculateMediaContainerSize( self._current_media, height_locked_zoom, media_show_action )
width_locked_size = ClientGUICanvasMedia.CalculateMediaContainerSize( self._current_media, width_locked_zoom, media_show_action )
height_locked_size = ClientGUICanvasMedia.CalculateMediaContainerSize( self._current_media, height_locked_zoom, media_show_action )
# if landscape, go height, portrait, go width
if previous_media_100_width > previous_media_100_height and current_media_100_width > current_media_100_height:
@ -819,6 +652,8 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
self._current_zoom = width_locked_zoom
self._media_container.SetZoom( self._current_zoom )
HG.client_controller.pub( 'canvas_new_zoom', self._canvas_key, self._current_zoom )
# and fix drag delta, or rewangle this so drag delta is offset to start with anyway m8, yeah
@ -987,26 +822,6 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
def _PauseCurrentMedia( self ):
if self._current_media is None:
return
self._media_container.Pause()
def _PausePlayCurrentMedia( self ):
if self._current_media is None:
return
self._media_container.PausePlay()
def _PrefetchNeighbours( self ):
pass
@ -1019,16 +834,18 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
return
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( self._current_media )
( self._current_zoom, self._canvas_zoom ) = CalculateCanvasZooms( self, self._current_media, media_show_action )
( self._current_zoom, self._canvas_zoom ) = ClientGUICanvasMedia.CalculateCanvasZooms( self, self._current_media, media_show_action )
self._media_container.SetZoom( self._current_zoom )
HG.client_controller.pub( 'canvas_new_zoom', self._canvas_key, self._current_zoom )
def _RescueOffScreenMediaWindow( self ):
size = self._GetMediaContainerSize()
size = self._media_container.sizeHint()
my_rect = self.rect()
media_rect = QC.QRect( self._media_window_pos, size )
@ -1074,9 +891,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
my_size = self.size()
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
media_size = CalculateMediaContainerSize( self._current_media, self._current_zoom, media_show_action )
media_size = self._media_container.sizeHint()
x = ( my_size.width() - media_size.width() ) // 2
y = ( my_size.height() - media_size.height() ) // 2
@ -1136,29 +951,32 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
def _SizeAndPositionMediaContainer( self ):
# and its this guy that'll be replaced by a proper Qlayout
if self._current_media is None:
return
new_size = self._GetMediaContainerSize()
new_size = self._media_container.sizeHint()
if new_size != self._media_container.size():
size_wrong = new_size != self._media_container.size()
pos_wrong = self._media_window_pos != self._media_container.pos()
if size_wrong or pos_wrong:
self._media_container.setFixedSize( new_size )
rect = QC.QRect( self._media_window_pos, new_size )
self._media_container.setGeometry( rect )
if self._media_window_pos == self._media_container.pos():
if not pos_wrong:
if HC.PLATFORM_MACOS:
self._media_container.update()
else:
self._media_container.move( self._media_window_pos )
def _TryToChangeZoom( self, new_zoom, zoom_center_type_override = None ):
@ -1173,7 +991,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
media_window_width = media_window_size.width()
media_window_height = media_window_size.height()
new_media_window_size = CalculateMediaContainerSize( self._current_media, new_zoom, CC.MEDIA_VIEWER_ACTION_SHOW_WITH_MPV )
new_media_window_size = ClientGUICanvasMedia.CalculateMediaContainerSize( self._current_media, new_zoom, CC.MEDIA_VIEWER_ACTION_SHOW_WITH_NATIVE )
new_media_window_width = new_media_window_size.width()
new_media_window_height = new_media_window_size.height()
@ -1237,6 +1055,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
#
self._current_zoom = new_zoom
self._media_container.SetZoom( self._current_zoom )
self._RescueOffScreenMediaWindow()
@ -1589,7 +1408,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
def PauseMedia( self ):
self._PauseCurrentMedia()
self._media_container.Pause()
def ProcessApplicationCommand( self, command: CAC.ApplicationCommand ):
@ -1699,11 +1518,11 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
elif action == CAC.SIMPLE_PAUSE_MEDIA:
self._PauseCurrentMedia()
self._media_container.Pause()
elif action == CAC.SIMPLE_PAUSE_PLAY_MEDIA:
self._PausePlayCurrentMedia()
self._media_container.PausePlay()
elif action == CAC.SIMPLE_MEDIA_SEEK_DELTA:
@ -1798,7 +1617,7 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
media = media.GetDisplayMedia()
if not self._CanDisplayMedia( media ):
if not self._media_container.CanDisplayMedia( media ):
media = None
@ -1829,16 +1648,25 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
self._ReinitZoom()
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( self._current_media )
initial_size = ClientGUICanvasMedia.CalculateMediaContainerSize( self._current_media, self._current_zoom, media_show_action )
if not self._maintain_pan_and_zoom:
self._ResetMediaWindowCenterPosition()
# hackery dackery doo here, clean all this up when we move to layouts
my_size = self.size()
x = ( my_size.width() - initial_size.width() ) // 2
y = ( my_size.height() - initial_size.height() ) // 2
self._media_window_pos = QC.QPoint( x, y )
initial_size = self._GetMediaContainerSize()
if self._current_media.GetLocationsManager().IsLocal() and initial_size.width() > 0 and initial_size.height() > 0:
( media_show_action, media_start_paused, media_start_with_embed ) = self._GetShowAction( self._current_media )
( media_show_action, media_start_paused, media_start_with_embed ) = self._media_container.GetShowAction( self._current_media )
self._media_container.SetMedia( self._current_media, initial_size, self._media_window_pos, media_show_action, media_start_paused, media_start_with_embed )
@ -1847,6 +1675,11 @@ class Canvas( QW.QWidget, CAC.ApplicationCommandProcessorMixin ):
self._current_media = None
if not self._maintain_pan_and_zoom:
self._ResetMediaWindowCenterPosition()
HG.client_controller.pub( 'canvas_new_display_media', self._canvas_key, self._current_media )
@ -2934,7 +2767,7 @@ class CanvasFilterDuplicates( CanvasWithHovers ):
HG.client_controller.sub( self, 'SwitchMedia', 'canvas_show_next' )
HG.client_controller.sub( self, 'SwitchMedia', 'canvas_show_previous' )
QP.CallAfter( self._LoadNextBatchOfPairs() )
QP.CallAfter( self._LoadNextBatchOfPairs )
def _CommitProcessed( self, blocking = True ):
@ -3519,7 +3352,7 @@ class CanvasFilterDuplicates( CanvasWithHovers ):
first_media = ClientMedia.MediaSingleton( first_media_result )
second_media = ClientMedia.MediaSingleton( second_media_result )
if not self._CanDisplayMedia( first_media ) or not self._CanDisplayMedia( second_media ):
if not self._media_container.CanDisplayMedia( first_media ) or not self._media_container.CanDisplayMedia( second_media ):
return False
@ -4257,9 +4090,7 @@ class CanvasMediaListFilterArchiveDelete( CanvasMediaList ):
specific_local_service_keys = [ service_key for service_key in current_local_service_keys if HG.client_controller.services_manager.GetServiceType( service_key ) in HC.SPECIFIC_LOCAL_FILE_SERVICES ]
if len( specific_local_service_keys ) > len( local_file_domain_service_keys ): # we have some trash or I guess repo updates
if CC.TRASH_SERVICE_KEY in current_local_service_keys or CC.LOCAL_UPDATE_SERVICE_KEY in current_local_service_keys:
location_contexts_to_present_options_for.append( ClientLocation.LocationContext.STATICCreateSimple( CC.COMBINED_LOCAL_FILE_SERVICE_KEY ) )

View File

@ -18,13 +18,13 @@ from hydrus.client.gui import ClientGUIFunctions
from hydrus.client.gui import ClientGUIMediaActions
from hydrus.client.gui import ClientGUIMediaControls
from hydrus.client.gui import ClientGUIMenus
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui import ClientGUIRatings
from hydrus.client.gui import ClientGUIScrolledPanelsEdit
from hydrus.client.gui import ClientGUIShortcuts
from hydrus.client.gui import ClientGUIShortcutControls
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.canvas import ClientGUIMPV
from hydrus.client.gui.lists import ClientGUIListBoxes
from hydrus.client.gui.widgets import ClientGUICommon
from hydrus.client.gui.widgets import ClientGUIMenuButton
@ -687,6 +687,10 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
shortcuts.setToolTip( 'shortcuts' )
shortcuts.setFocusPolicy( QC.Qt.TabFocus )
self._cog_icon = ClientGUICommon.BetterBitmapButton( self, CC.global_pixmaps().cog, self._ShowCogMenu )
self._cog_icon.setToolTip( 'extra options' )
self._cog_icon.setFocusPolicy( QC.Qt.TabFocus )
fullscreen_switch = ClientGUICommon.BetterBitmapButton( self, CC.global_pixmaps().fullscreen_switch, HG.client_controller.pub, 'canvas_fullscreen_switch', self._canvas_key )
fullscreen_switch.setToolTip( 'fullscreen switch' )
fullscreen_switch.setFocusPolicy( QC.Qt.TabFocus )
@ -717,6 +721,7 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
QP.AddToLayout( self._top_hbox, zoom_switch, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, self._volume_control, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, shortcuts, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, self._cog_icon, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, fullscreen_switch, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, open_externally, CC.FLAGS_CENTER_PERPENDICULAR )
QP.AddToLayout( self._top_hbox, drag_button, CC.FLAGS_CENTER_PERPENDICULAR )
@ -766,6 +771,10 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
self._undelete_button.show()
exif_possible = self._current_media.GetMime() in ( HC.IMAGE_JPEG, HC.IMAGE_TIFF )
self._cog_icon.setEnabled( exif_possible )
def _ResetText( self ):
@ -817,6 +826,20 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
new_options.SetStringList( 'default_media_viewer_custom_shortcuts', default_media_viewer_custom_shortcuts )
def _ShowCogMenu( self ):
if self._current_media is None:
return
menu = QW.QMenu()
ClientGUIMenus.AppendMenuItem( menu, 'check for exif data', 'See if the file has any EXIF data.', ClientGUIMediaActions.ShowFileEXIF, self, self._current_media )
CGC.core().PopupMenu( self._cog_icon, menu )
def _ShowShortcutMenu( self ):
all_shortcut_names = HG.client_controller.Read( 'serialisable_names', HydrusSerialisable.SERIALISABLE_TYPE_SHORTCUT_SET )
@ -824,7 +847,7 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
custom_shortcuts_names = [ name for name in all_shortcut_names if name not in ClientGUIShortcuts.SHORTCUTS_RESERVED_NAMES ]
menu = QW.QMenu()
ClientGUIMenus.AppendMenuItem( menu, 'edit shortcuts', 'edit your sets of shortcuts, and change what shortcuts are currently active on this media viewer', ClientGUIShortcutControls.ManageShortcuts, self )
if len( custom_shortcuts_names ) > 0:
@ -1667,7 +1690,7 @@ class CanvasHoverFrameRightDuplicates( CanvasHoverFrame ):
self._comparison_statements_vbox = QP.VBoxLayout()
self._comparison_statement_names = [ 'filesize', 'resolution', 'ratio', 'mime', 'num_tags', 'time_imported', 'jpeg_quality', 'pixel_duplicates', 'icc_profile' ]
self._comparison_statement_names = [ 'filesize', 'resolution', 'ratio', 'mime', 'num_tags', 'time_imported', 'jpeg_quality', 'pixel_duplicates', 'exif_data', 'icc_profile' ]
self._comparison_statements_sts = {}

View File

@ -9,6 +9,7 @@ from qtpy import QtGui as QG
from hydrus.core import HydrusConstants as HC
from hydrus.core import HydrusData
from hydrus.core import HydrusGlobals as HG
from hydrus.core import HydrusImageHandling
from hydrus.core import HydrusPaths
from hydrus.client import ClientApplicationCommand as CAC
@ -17,12 +18,209 @@ from hydrus.client import ClientRendering
from hydrus.client.gui import ClientGUIFunctions
from hydrus.client.gui import ClientGUIMedia
from hydrus.client.gui import ClientGUIMediaControls
from hydrus.client.gui import ClientGUIMPV
from hydrus.client.gui import ClientGUIShortcuts
from hydrus.client.gui import QtPorting as QP
from hydrus.client.gui.canvas import ClientGUIMPV
from hydrus.client.gui.widgets import ClientGUICommon
from hydrus.client.media import ClientMedia
OPEN_EXTERNALLY_BUTTON_SIZE = ( 200, 45 )
def CalculateCanvasMediaSize( media, canvas_size: QC.QSize, show_action ):
canvas_width = canvas_size.width()
canvas_height = canvas_size.height()
'''if ClientGUICanvasMedia.ShouldHaveAnimationBar( media, show_action ):
animated_scanbar_height = HG.client_controller.new_options.GetInteger( 'animated_scanbar_height' )
canvas_height -= animated_scanbar_height
'''
canvas_width = max( canvas_width, 80 )
canvas_height = max( canvas_height, 60 )
return ( canvas_width, canvas_height )
def CalculateCanvasZooms( canvas, media, show_action ):
if media is None:
return ( 1.0, 1.0 )
if show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_SHOW_OPEN_EXTERNALLY_BUTTON, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
return ( 1.0, 1.0 )
( media_width, media_height ) = CalculateMediaSize( media, 1.0 )
if media_width == 0 or media_height == 0:
return ( 1.0, 1.0 )
new_options = HG.client_controller.new_options
( canvas_width, canvas_height ) = CalculateCanvasMediaSize( media, canvas.size(), show_action )
width_zoom = canvas_width / media_width
height_zoom = canvas_height / media_height
canvas_zoom = min( ( width_zoom, height_zoom ) )
#
mime = media.GetMime()
( media_scale_up, media_scale_down, preview_scale_up, preview_scale_down, exact_zooms_only, scale_up_quality, scale_down_quality ) = new_options.GetMediaZoomOptions( mime )
if exact_zooms_only:
max_regular_zoom = 1.0
if canvas_zoom > 1.0:
while max_regular_zoom * 2 < canvas_zoom:
max_regular_zoom *= 2
elif canvas_zoom < 1.0:
while max_regular_zoom > canvas_zoom:
max_regular_zoom /= 2
else:
regular_zooms = new_options.GetMediaZooms()
valid_regular_zooms = [ zoom for zoom in regular_zooms if zoom < canvas_zoom ]
if len( valid_regular_zooms ) > 0:
max_regular_zoom = max( valid_regular_zooms )
else:
max_regular_zoom = canvas_zoom
if media.GetMime() in HC.AUDIO:
scale_up_action = CC.MEDIA_VIEWER_SCALE_100
scale_down_action = CC.MEDIA_VIEWER_SCALE_TO_CANVAS
elif canvas.PREVIEW_WINDOW:
scale_up_action = preview_scale_up
scale_down_action = preview_scale_down
else:
scale_up_action = media_scale_up
scale_down_action = media_scale_down
can_be_scaled_down = media_width > canvas_width or media_height > canvas_height
can_be_scaled_up = media_width < canvas_width and media_height < canvas_height
#
if can_be_scaled_up:
scale_action = scale_up_action
elif can_be_scaled_down:
scale_action = scale_down_action
else:
scale_action = CC.MEDIA_VIEWER_SCALE_100
if scale_action == CC.MEDIA_VIEWER_SCALE_100:
default_zoom = 1.0
elif scale_action == CC.MEDIA_VIEWER_SCALE_MAX_REGULAR:
default_zoom = max_regular_zoom
else:
default_zoom = canvas_zoom
return ( default_zoom, canvas_zoom )
def CalculateMediaContainerSize( media, zoom, show_action ):
if show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
raise Exception( 'This media should not be shown in the media viewer!' )
elif show_action == CC.MEDIA_VIEWER_ACTION_SHOW_OPEN_EXTERNALLY_BUTTON:
( width, height ) = OPEN_EXTERNALLY_BUTTON_SIZE
if media.GetMime() in HC.MIMES_WITH_THUMBNAILS:
bounding_dimensions = HG.client_controller.options[ 'thumbnail_dimensions' ]
thumbnail_scale_type = HG.client_controller.new_options.GetInteger( 'thumbnail_scale_type' )
( clip_rect, ( thumb_width, thumb_height ) ) = HydrusImageHandling.GetThumbnailResolutionAndClipRegion( media.GetResolution(), bounding_dimensions, thumbnail_scale_type )
height = height + thumb_height
return QC.QSize( width, height )
else:
( media_width, media_height ) = CalculateMediaSize( media, zoom )
'''if ClientGUICanvasMedia.ShouldHaveAnimationBar( media, show_action ):
animated_scanbar_height = HG.client_controller.new_options.GetInteger( 'animated_scanbar_height' )
media_height += animated_scanbar_height
'''
return QC.QSize( media_width, media_height )
def CalculateMediaSize( media, zoom ):
if media.GetMime() in HC.AUDIO:
( original_width, original_height ) = ( 360, 240 )
else:
( original_width, original_height ) = media.GetResolution()
media_width = int( round( zoom * original_width ) )
media_height = int( round( zoom * original_height ) )
media_width = max( 1, media_width )
media_height = max( 1, media_height )
return ( media_width, media_height )
def ShouldHaveAnimationBar( media, show_action ):
if media is None:
@ -991,6 +1189,20 @@ class AnimationBar( QW.QWidget ):
hab_background = QC.Property( QG.QColor, get_hab_background, set_hab_background )
hab_nub = QC.Property( QG.QColor, get_hab_nub, set_hab_nub )
# cribbing from here https://doc.qt.io/qt-5/layout.html#how-to-write-a-custom-layout-manager
class MediaContainerLayout( QW.QLayout ):
def __init__( self, static_image ):
QW.QLayout.__init__( self )
self._static_image = static_image
self._layout_items = [ static_image ]
class MediaContainer( QW.QWidget ):
launchMediaViewer = QC.Signal()
@ -1018,6 +1230,8 @@ class MediaContainer( QW.QWidget ):
self._start_paused = False
self._start_with_embed = False
self._current_zoom = 1.0
self._media_window = None
self._embed_button = EmbedButton( self )
@ -1241,6 +1455,7 @@ class MediaContainer( QW.QWidget ):
self._controls_bar.setFixedSize( controls_bar_rect.size() )
self._controls_bar.move( controls_bar_rect.topLeft() )
@ -1250,6 +1465,37 @@ class MediaContainer( QW.QWidget ):
self.parentWidget().BeginDrag()
def CanDisplayMedia( self, media ) -> bool:
if media is None:
return True
media = media.GetDisplayMedia()
if media is None:
return True
locations_manager = media.GetLocationsManager()
if not locations_manager.IsLocal():
return False
( media_show_action, media_start_paused, media_start_with_embed ) = self.GetShowAction( media )
if media_show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
return False
return True
def ClearMedia( self ):
self._media = None
@ -1281,14 +1527,6 @@ class MediaContainer( QW.QWidget ):
def resizeEvent( self, event ):
if self._media is not None:
self._SizeAndPositionChildren()
def GetIdealControlsBarRect( self, full_size = True ):
my_size = self.size()
@ -1316,6 +1554,37 @@ class MediaContainer( QW.QWidget ):
)
def GetShowAction( self, media ):
# in the midst of a rewrite, feel free to refactor further
start_paused = False
start_with_embed = False
bad_result = ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW, start_paused, start_with_embed )
if media is None:
return bad_result
mime = media.GetMime()
if mime not in HC.ALLOWED_MIMES: # stopgap to catch a collection or application_unknown due to unusual import order/media moving
return bad_result
if self._canvas_type == CC.CANVAS_PREVIEW:
return HG.client_controller.new_options.GetPreviewShowAction( mime )
else:
return HG.client_controller.new_options.GetMediaShowAction( mime )
def GotoPreviousOrNextFrame( self, direction ):
if self._media is not None:
@ -1371,6 +1640,11 @@ class MediaContainer( QW.QWidget ):
return False
def minimumSizeHint( self ) -> QC.QSize:
return self.sizeHint()
def MouseIsNearAnimationBar( self ):
if self._media is None:
@ -1455,6 +1729,14 @@ class MediaContainer( QW.QWidget ):
def resizeEvent( self, event ):
if self._media is not None:
self._SizeAndPositionChildren()
def SeekDelta( self, direction, duration_ms ):
if self._media is not None:
@ -1495,8 +1777,7 @@ class MediaContainer( QW.QWidget ):
self._MakeMediaWindow()
self.setFixedSize( initial_size )
self.move( initial_position )
self.setGeometry( QC.QRect( initial_position, initial_size ) )
self._SizeAndPositionChildren()
@ -1510,6 +1791,11 @@ class MediaContainer( QW.QWidget ):
self.show()
def SetZoom( self, zoom: float ):
self._current_zoom = zoom
def ShouldHaveVolumeControl( self ):
if self._media is None:
@ -1520,6 +1806,18 @@ class MediaContainer( QW.QWidget ):
return isinstance( self._media_window, ClientGUIMPV.mpvWidget ) and self._media.HasAudio()
def sizeHint(self) -> QC.QSize:
( media_show_action, media_start_paused, media_start_with_embed ) = self.GetShowAction( self._media )
if media_show_action in ( CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW_ON_ACTIVATION_OPEN_EXTERNALLY, CC.MEDIA_VIEWER_ACTION_DO_NOT_SHOW ):
return QC.QSize( 0, 0 )
return CalculateMediaContainerSize( self._media, self._current_zoom, media_show_action )
def StopForSlideshow( self, value ):
if isinstance( self._media_window, ( Animation, ClientGUIMPV.mpvWidget ) ):

View File

@ -1465,3 +1465,19 @@ register_column_type( COLUMN_LIST_VACUUM_DATA.ID, COLUMN_LIST_VACUUM_DATA.CAN_VA
register_column_type( COLUMN_LIST_VACUUM_DATA.ID, COLUMN_LIST_VACUUM_DATA.VACUUM_TIME_ESTIMATE, 'vacuum time estimate', False, 48, True )
default_column_list_sort_lookup[ COLUMN_LIST_VACUUM_DATA.ID ] = ( COLUMN_LIST_VACUUM_DATA.NAME, True )
class COLUMN_LIST_EXIF_DATA( COLUMN_LIST_DEFINITION ):
ID = 67
EXIF_ID = 0
EXIF_LABEL = 1
VALUE = 2
column_list_type_name_lookup[ COLUMN_LIST_EXIF_DATA.ID ] = 'exif data'
register_column_type( COLUMN_LIST_EXIF_DATA.ID, COLUMN_LIST_EXIF_DATA.EXIF_ID, 'id', False, 6, True )
register_column_type( COLUMN_LIST_EXIF_DATA.ID, COLUMN_LIST_EXIF_DATA.EXIF_LABEL, 'label', False, 20, True )
register_column_type( COLUMN_LIST_EXIF_DATA.ID, COLUMN_LIST_EXIF_DATA.VALUE, 'value', False, 20, True )
default_column_list_sort_lookup[ COLUMN_LIST_EXIF_DATA.ID ] = ( COLUMN_LIST_EXIF_DATA.EXIF_ID, True )

View File

@ -593,7 +593,7 @@ class BetterListCtrl( QW.QTreeWidget ):
#QP.SetMinClientSize( self, ( existing_min_width, ideal_client_height ) )
def GetData( self, only_selected = False ):
def GetData( self, only_selected = False ) -> list:
if only_selected:
@ -601,7 +601,7 @@ class BetterListCtrl( QW.QTreeWidget ):
else:
indices = list(self._indices_to_data_info.keys())
indices = list( self._indices_to_data_info.keys() )
result = []

View File

@ -152,6 +152,10 @@ def CreateManagementControllerDuplicateFilter():
file_search_context = ClientSearch.FileSearchContext( location_context = default_location_context, predicates = [ ClientSearch.Predicate( ClientSearch.PREDICATE_TYPE_SYSTEM_EVERYTHING ) ] )
synchronised = HG.client_controller.new_options.GetBoolean( 'default_search_synchronised' )
management_controller.SetVariable( 'synchronised', synchronised )
management_controller.SetVariable( 'file_search_context', file_search_context )
management_controller.SetVariable( 'both_files_match', False )
management_controller.SetVariable( 'pixel_dupes_preference', CC.SIMILAR_FILES_PIXEL_DUPES_ALLOWED )
@ -1204,7 +1208,16 @@ class ManagementPanelDuplicateFilter( ManagementPanel ):
file_search_context.FixMissingServices( HG.client_controller.services_manager.FilterValidServiceKeys )
self._tag_autocomplete = ClientGUIACDropdown.AutoCompleteDropdownTagsRead( self._filtering_panel, self._page_key, file_search_context, media_sort_widget = self._media_sort, media_collect_widget = self._media_collect, allow_all_known_files = False, force_system_everything = True )
if self._management_controller.HasVariable( 'synchronised' ):
synchronised = self._management_controller.GetVariable( 'synchronised' )
else:
synchronised = True
self._tag_autocomplete = ClientGUIACDropdown.AutoCompleteDropdownTagsRead( self._filtering_panel, self._page_key, file_search_context, media_sort_widget = self._media_sort, media_collect_widget = self._media_collect, allow_all_known_files = False, synchronised = synchronised, force_system_everything = True )
self._both_files_match = QW.QCheckBox( self._filtering_panel )
@ -1461,6 +1474,11 @@ class ManagementPanelDuplicateFilter( ManagementPanel ):
( file_search_context, both_files_match, pixel_dupes_preference, max_hamming_distance ) = self._GetDuplicateFileSearchData()
self._management_controller.SetVariable( 'file_search_context', file_search_context )
synchronised = self._tag_autocomplete.IsSynchronised()
self._management_controller.SetVariable( 'synchronised', synchronised )
self._management_controller.SetVariable( 'both_files_match', both_files_match )
self._management_controller.SetVariable( 'pixel_dupes_preference', pixel_dupes_preference )
self._management_controller.SetVariable( 'max_hamming_distance', max_hamming_distance )

View File

@ -1548,16 +1548,18 @@ class PagesNotebook( QP.TabWidgetWithDnD ):
if isinstance( page, Page ) and not page.IsInitialised():
page_name = 'initialising'
full_page_name = 'initialising'
else:
page_name = page.GetName()
full_page_name = page.GetName()
page_name = page_name.replace( os.linesep, '' )
full_page_name = full_page_name.replace( os.linesep, '' )
page_name = HydrusText.ElideText( page_name, max_page_name_chars )
page_name = HydrusText.ElideText( full_page_name, max_page_name_chars )
do_tooltip = len( page_name ) != len( full_page_name )
num_string = ''
@ -1596,6 +1598,11 @@ class PagesNotebook( QP.TabWidgetWithDnD ):
tab_bar.setTabText( index, safe_page_name )
if do_tooltip:
self.setTabToolTip( index, full_page_name )
def _RenamePage( self, index ):

View File

@ -1745,8 +1745,6 @@ class MediaPanel( ClientMedia.ListeningMediaList, QW.QScrollArea, CAC.Applicatio
def _SetDuplicates( self, duplicate_type, media_pairs = None, media_group = None, duplicate_action_options = None, silent = False ):
yes_no_text = 'unknown duplicate action'
if duplicate_type == HC.DUPLICATE_POTENTIAL:
yes_no_text = 'queue all possible and valid pair combinations into the duplicate filter'
@ -1819,6 +1817,8 @@ class MediaPanel( ClientMedia.ListeningMediaList, QW.QScrollArea, CAC.Applicatio
media_pairs_str = HydrusData.ToHumanInt( len( media_pairs ) )
message = 'Are you sure you want to {} for the {} selected files? The relationship will be applied between every pair combination in the file selection ({} pairs).'.format( yes_no_text, num_files_str, media_pairs_str )
if len( media_pairs ) > 100:
if duplicate_type == HC.DUPLICATE_FALSE_POSITIVE:
@ -1840,10 +1840,6 @@ class MediaPanel( ClientMedia.ListeningMediaList, QW.QScrollArea, CAC.Applicatio
no_label = 'some may be duplicates'
else:
'Are you sure you want to {} for the {} selected files? The relationship will be applied between every pair combination in the file selection ({} pairs).'.format( yes_no_text, num_files_str, media_pairs_str )
else:

View File

@ -2794,10 +2794,18 @@ class EditAdvancedORPredicates( ClientGUIScrolledPanels.EditPanel ):
tag_preds = []
system_preds = []
negated_system_pred_strings = []
system_pred_strings = []
for tag_string in s:
if tag_string.startswith( '-system:' ):
negated_system_pred_strings.append( tag_string )
continue
if tag_string.startswith( 'system:' ):
system_pred_strings.append( tag_string )
@ -2848,6 +2856,11 @@ class EditAdvancedORPredicates( ClientGUIScrolledPanels.EditPanel ):
tag_preds.append( row_pred )
if len( negated_system_pred_strings ) > 0:
raise ValueError( 'Sorry, that would make negated system tags, which are not supported yet! Try to rephrase or negate the system tag yourself.' )
if len( system_pred_strings ) > 0:
try:
@ -2875,9 +2888,9 @@ class EditAdvancedORPredicates( ClientGUIScrolledPanels.EditPanel ):
output = os.linesep.join( ( pred.ToString() for pred in self._current_predicates ) )
object_name = 'HydrusValid'
except ValueError:
except ValueError as e:
output = 'Could not parse!'
output = 'Could not parse! {}'.format( e )
object_name = 'HydrusInvalid'

View File

@ -470,6 +470,58 @@ def GetDuplicateComparisonStatements( shown_media, comparison_media ):
def has_exif( m ):
try:
hash = m.GetHash()
mime = m.GetMime()
if mime not in ( HC.IMAGE_JPEG, HC.IMAGE_TIFF ):
return False
path = HG.client_controller.client_files_manager.GetFilePath( hash, mime )
pil_image = HydrusImageHandling.RawOpenPILImage( path )
exif_dict = HydrusImageHandling.GetEXIFDict( pil_image )
if exif_dict is None:
return False
return len( exif_dict ) > 0
except:
return False
s_has_exif = has_exif( shown_media )
c_has_exif = has_exif( comparison_media )
if s_has_exif or c_has_exif:
if s_has_exif and c_has_exif:
exif_statement = 'both have exif data'
elif s_has_exif:
exif_statement = 'has exif data, the other does not'
else:
exif_statement = 'the other has exif data, this does not'
statements_and_scores[ 'exif_data' ] = ( exif_statement, 0 )
s_has_icc = shown_media.GetMediaResult().GetFileInfoManager().has_icc_profile
c_has_icc = comparison_media.GetMediaResult().GetFileInfoManager().has_icc_profile
@ -3001,7 +3053,7 @@ class MediaSort( HydrusSerialisable.SerialisableBase ):
serialisable_tag_context = tag_context.GetSerialisableTuple()
new_serialisable_info = ( sort_metatype, serialisable_sort_data, self.sort_order, serialisable_tag_context )
new_serialisable_info = ( sort_metatype, serialisable_sort_data, sort_order, serialisable_tag_context )
return ( 3, new_serialisable_info )

View File

@ -80,7 +80,7 @@ options = {}
# Misc
NETWORK_VERSION = 20
SOFTWARE_VERSION = 492
SOFTWARE_VERSION = 493
CLIENT_API_VERSION = 31
SERVER_THUMBNAIL_DIMENSIONS = ( 200, 200 )

View File

@ -1,5 +1,7 @@
import hashlib
import io
import typing
import numpy
import numpy.core.multiarray # important this comes before cv!
import struct
@ -532,6 +534,26 @@ def GenerateThumbnailBytesPIL( pil_image: PILImage.Image, mime ) -> bytes:
return thumbnail_bytes
def GetEXIFDict( pil_image: PILImage.Image ) -> typing.Optional[ dict ]:
if pil_image.format in ( 'JPEG', 'TIFF' ) and hasattr( pil_image, '_getexif' ):
try:
exif_dict = pil_image._getexif()
return exif_dict
except:
pass
return None
def GetGIFFrameDurations( path ):
pil_image = RawOpenPILImage( path )
@ -1042,73 +1064,63 @@ def ResizeNumPyImage( numpy_image: numpy.array, target_resolution ) -> numpy.arr
def RotateEXIFPILImage( pil_image: PILImage.Image )-> PILImage.Image:
if pil_image.format == 'JPEG' and hasattr( pil_image, '_getexif' ):
exif_dict = GetEXIFDict( pil_image )
if exif_dict is not None:
try:
exif_dict = pil_image._getexif()
except:
exif_dict = None
EXIF_ORIENTATION = 274
if exif_dict is not None:
if EXIF_ORIENTATION in exif_dict:
EXIF_ORIENTATION = 274
orientation = exif_dict[ EXIF_ORIENTATION ]
if EXIF_ORIENTATION in exif_dict:
if orientation == 1:
orientation = exif_dict[ EXIF_ORIENTATION ]
pass # normal
if orientation == 1:
pass # normal
elif orientation == 2:
# mirrored horizontal
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT )
elif orientation == 3:
# 180
pil_image = pil_image.transpose( PILImage.ROTATE_180 )
elif orientation == 4:
# mirrored vertical
pil_image = pil_image.transpose( PILImage.FLIP_TOP_BOTTOM )
elif orientation == 5:
# seems like these 90 degree rotations are wrong, but fliping them works for my posh example images, so I guess the PIL constants are odd
# mirrored horizontal, then 90 CCW
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_90 )
elif orientation == 6:
# 90 CW
pil_image = pil_image.transpose( PILImage.ROTATE_270 )
elif orientation == 7:
# mirrored horizontal, then 90 CCW
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_270 )
elif orientation == 8:
# 90 CCW
pil_image = pil_image.transpose( PILImage.ROTATE_90 )
elif orientation == 2:
# mirrored horizontal
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT )
elif orientation == 3:
# 180
pil_image = pil_image.transpose( PILImage.ROTATE_180 )
elif orientation == 4:
# mirrored vertical
pil_image = pil_image.transpose( PILImage.FLIP_TOP_BOTTOM )
elif orientation == 5:
# seems like these 90 degree rotations are wrong, but fliping them works for my posh example images, so I guess the PIL constants are odd
# mirrored horizontal, then 90 CCW
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_90 )
elif orientation == 6:
# 90 CW
pil_image = pil_image.transpose( PILImage.ROTATE_270 )
elif orientation == 7:
# mirrored horizontal, then 90 CCW
pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_270 )
elif orientation == 8:
# 90 CCW
pil_image = pil_image.transpose( PILImage.ROTATE_90 )

View File

@ -1,5 +1,7 @@
import collections
import os
import typing
import psutil
import re
import send2trash
@ -290,7 +292,7 @@ def GetDefaultLaunchPath():
return 'open "%path%"'
def GetDevice( path ):
def GetPartitionInfo( path ) -> typing.Optional[ typing.NamedTuple ]:
path = path.lower()
@ -311,7 +313,7 @@ def GetDevice( path ):
if path.startswith( partition_info.mountpoint.lower() ):
return partition_info.device
return partition_info
@ -323,6 +325,35 @@ def GetDevice( path ):
return None
def GetDevice( path ) -> typing.Optional[ str ]:
partition_info = GetPartitionInfo( path )
if partition_info is None:
return None
else:
return partition_info.device
def GetFileSystemType( path ):
partition_info = GetPartitionInfo( path )
if partition_info is None:
return None
else:
return partition_info.fstype
def GetFreeSpace( path ):
disk_usage = psutil.disk_usage( path )
@ -801,20 +832,42 @@ def RecyclePath( path ):
def SanitizeFilename( filename ):
def SanitizeFilename( filename, force_ntfs = False ) -> str:
if HC.PLATFORM_WINDOWS:
if HC.PLATFORM_WINDOWS or force_ntfs:
# \, /, :, *, ?, ", <, >, |
filename = re.sub( r'\\|/|:|\*|\?|"|<|>|\|', '_', filename )
bad_characters = r'[\\/:*?"<>|]'
else:
filename = re.sub( '/', '_', filename )
bad_characters = '/'
return filename
return re.sub( bad_characters, '_', filename )
def SanitizePathForExport( directory_path, directories_and_filename ):
# this does not figure out the situation where the suffix directories cross a mount point to a new file system, but at that point it is user's job to fix
components = directories_and_filename.split( os.path.sep )
filename = components[-1]
suffix_directories = components[:-1]
force_ntfs = GetFileSystemType( directory_path ).lower() in ( 'ntfs', 'exfat' )
suffix_directories = [ SanitizeFilename( suffix_directory, force_ntfs = force_ntfs ) for suffix_directory in suffix_directories ]
filename = SanitizeFilename( filename, force_ntfs = force_ntfs )
sanitized_components = suffix_directories
sanitized_components.append( filename )
return os.path.join( *sanitized_components )
def TryToGiveFileNicePermissionBits( path ):
if not os.path.exists( path ):

View File

@ -756,7 +756,7 @@ class HydrusResource( Resource ):
def _profileJob( self, call, request: HydrusServerRequest.HydrusRequest ):
HydrusData.Profile( 'Profiling client api: {}'.format( request.path ), 'request.result_lmao = call( request )', globals(), locals(), min_duration_ms = HG.server_profile_min_job_time_ms )
HydrusData.Profile( 'Profiling {}: {}'.format( self._service.GetName(), request.path ), 'request.result_lmao = call( request )', globals(), locals(), min_duration_ms = HG.server_profile_min_job_time_ms )
return request.result_lmao

View File

@ -39,6 +39,7 @@ try:
argparser.add_argument( '--db_synchronous_override', type = int, choices = range(4), help = 'override SQLite Synchronous PRAGMA (default=2)' )
argparser.add_argument( '--no_db_temp_files', action='store_true', help = 'run db temp operations entirely in memory' )
argparser.add_argument( '--boot_debug', action='store_true', help = 'print additional bootup information to the log' )
argparser.add_argument( '--profile_mode', action='store_true', help = 'start the program with profile mode on, capturing boot performance' )
argparser.add_argument( '--no_wal', action='store_true', help = 'OBSOLETE: run using TRUNCATE db journaling' )
argparser.add_argument( '--db_memory_journaling', action='store_true', help = 'OBSOLETE: run using MEMORY db journaling (DANGEROUS)' )
@ -145,6 +146,9 @@ try:
HG.boot_debug = result.boot_debug
HG.profile_mode = result.profile_mode
HG.profile_start_time = HydrusData.GetNow()
try:
from twisted.internet import reactor
@ -154,6 +158,11 @@ try:
HG.twisted_is_broke = True
if result.temp_dir is not None:
HydrusTemp.SetEnvTempDir( result.temp_dir )
except Exception as e:
try:
@ -202,11 +211,6 @@ except Exception as e:
def boot():
if result.temp_dir is not None:
HydrusTemp.SetEnvTempDir( result.temp_dir )
controller = None
with HydrusLogger.HydrusLogger( db_dir, 'client' ) as logger:

View File

@ -48,6 +48,7 @@ try:
argparser.add_argument( '--db_synchronous_override', type = int, choices = range(4), help = 'override SQLite Synchronous PRAGMA (default=2)' )
argparser.add_argument( '--no_db_temp_files', action='store_true', help = 'run db temp operations entirely in memory' )
argparser.add_argument( '--boot_debug', action='store_true', help = 'print additional bootup information to the log' )
argparser.add_argument( '--profile_mode', action='store_true', help = 'start the server with profile mode on' )
argparser.add_argument( '--no_wal', action='store_true', help = 'OBSOLETE: run using TRUNCATE db journaling' )
argparser.add_argument( '--db_memory_journaling', action='store_true', help = 'OBSOLETE: run using MEMORY db journaling (DANGEROUS)' )
@ -156,6 +157,9 @@ try:
HG.boot_debug = result.boot_debug
HG.profile_mode = result.profile_mode
HG.profile_start_time = HydrusData.GetNow()
if result.temp_dir is not None:
HydrusTemp.SetEnvTempDir( result.temp_dir )