Преглед изворни кода

Removed image modification and implemented library function from media

master
Dirk Alders пре 4 година
родитељ
комит
78df2b0459
5 измењених фајлова са 163 додато и 313 уклоњено
  1. 25
    25
      models.py
  2. 1
    1
      signals.py
  3. 3
    3
      views/__init__.py
  4. 0
    284
      views/image.py
  5. 134
    0
      views/xnail.py

+ 25
- 25
models.py Прегледај датотеку

@@ -217,28 +217,28 @@ class Item(models.Model):
217 217
     sil_c = models.TextField(null=True, blank=True)
218 218
 
219 219
     MODEL_TO_MEDIA_DATA = {
220
-        media.metadata.KEY_SIZE: 'size_c',
221
-        media.metadata.KEY_TIME: 'datetime_c',
222
-        media.metadata.KEY_EXPOSURE_PROGRAM: 'exposure_program_c',
223
-        media.metadata.KEY_EXPOSURE_TIME: 'exposure_time_c',
224
-        media.metadata.KEY_FLASH: 'flash_c',
225
-        media.metadata.KEY_APERTURE: 'f_number_c',
226
-        media.metadata.KEY_FOCAL_LENGTH: 'focal_length_c',
227
-        media.metadata.KEY_GPS: {'lon': 'lon_c', 'lat': 'lat_c'},
228
-        media.metadata.KEY_HEIGHT: 'height_c',
229
-        media.metadata.KEY_ISO: 'iso_c',
230
-        media.metadata.KEY_CAMERA: 'camera_c',
231
-        media.metadata.KEY_ORIENTATION: 'orientation_c',
232
-        media.metadata.KEY_WIDTH: 'width_c',
233
-        media.metadata.KEY_DURATION: 'duration_c',
234
-        media.metadata.KEY_RATIO: 'ratio_c',
235
-        media.metadata.KEY_ALBUM: 'album_c',
236
-        media.metadata.KEY_ARTIST: 'artist_c',
237
-        media.metadata.KEY_BITRATE: 'bitrate_c',
238
-        media.metadata.KEY_GENRE: 'genre_c',
239
-        media.metadata.KEY_TITLE: 'title_c',
240
-        media.metadata.KEY_TRACK: 'track_c',
241
-        media.metadata.KEY_YEAR: 'year_c',
220
+        media.KEY_SIZE: 'size_c',
221
+        media.KEY_TIME: 'datetime_c',
222
+        media.KEY_EXPOSURE_PROGRAM: 'exposure_program_c',
223
+        media.KEY_EXPOSURE_TIME: 'exposure_time_c',
224
+        media.KEY_FLASH: 'flash_c',
225
+        media.KEY_APERTURE: 'f_number_c',
226
+        media.KEY_FOCAL_LENGTH: 'focal_length_c',
227
+        media.KEY_GPS: {'lon': 'lon_c', 'lat': 'lat_c'},
228
+        media.KEY_HEIGHT: 'height_c',
229
+        media.KEY_ISO: 'iso_c',
230
+        media.KEY_CAMERA: 'camera_c',
231
+        media.KEY_ORIENTATION: 'orientation_c',
232
+        media.KEY_WIDTH: 'width_c',
233
+        media.KEY_DURATION: 'duration_c',
234
+        media.KEY_RATIO: 'ratio_c',
235
+        media.KEY_ALBUM: 'album_c',
236
+        media.KEY_ARTIST: 'artist_c',
237
+        media.KEY_BITRATE: 'bitrate_c',
238
+        media.KEY_GENRE: 'genre_c',
239
+        media.KEY_TITLE: 'title_c',
240
+        media.KEY_TRACK: 'track_c',
241
+        media.KEY_YEAR: 'year_c',
242 242
     }
243 243
 
244 244
     def __init__(self, *args, **kwargs):
@@ -425,14 +425,14 @@ class Item(models.Model):
425 425
         data = media.get_media_data(full_path) or {}
426 426
         for key in self.MODEL_TO_MEDIA_DATA:
427 427
             value = data.get(key)
428
-            if key == media.metadata.KEY_GPS:   # Split GPS data in lon and lat
428
+            if key == media.KEY_GPS:   # Split GPS data in lon and lat
429 429
                 for k in self.MODEL_TO_MEDIA_DATA[key]:
430 430
                     value_k = value[k] if value is not None else None
431 431
                     setattr(self, self.MODEL_TO_MEDIA_DATA[key][k], value_k)
432 432
             else:
433 433
                 if value is not None:
434
-                    if key == media.metadata.KEY_TIME:  # convert time to datetime
435
-                        if data.get(media.metadata.KEY_TIME_IS_SUBSTITUTION) and self.type == TYPE_IMAGE:   # don't use time substitution for images
434
+                    if key == media.KEY_TIME:  # convert time to datetime
435
+                        if data.get(media.KEY_TIME_IS_SUBSTITUTION) and self.type == TYPE_IMAGE:   # don't use time substitution for images
436 436
                             value = None
437 437
                         else:
438 438
                             value = datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)

+ 1
- 1
signals.py Прегледај датотеку

@@ -5,7 +5,7 @@ import logging
5 5
 from .models import Item, Tag
6 6
 from .search import load_index, delete_item, update_item
7 7
 import shutil
8
-from .views.image import base_item
8
+from .views.xnail import base_item
9 9
 
10 10
 # Get a logger instance
11 11
 clogger = logging.getLogger("CACHING")

+ 3
- 3
views/__init__.py Прегледај датотеку

@@ -9,8 +9,9 @@ from django.utils.translation import gettext as _
9 9
 from ..forms import TagForm
10 10
 import fstools
11 11
 from ..help import help_pages
12
-from .image import get_image_instance, other
12
+from .xnail import get_image_instance, other
13 13
 from .infoviews import get_wrapper_instance as get_infoview_wrapper_instance
14
+import media
14 15
 import mimetypes
15 16
 from ..models import get_item_by_rel_path, get_item_type, TYPE_IMAGE, Tag, Item
16 17
 import os
@@ -23,7 +24,6 @@ from users.forms import UserProfileFormLanguageOnly
23 24
 from .userviews import get_wrapper_instance as get_userview_wrapper_instance
24 25
 import zipfile
25 26
 from pygal.views.userviews import query_view
26
-from pygal.views.image import mm_image
27 27
 
28 28
 
29 29
 def pygal_item_does_not_exist(request, context):
@@ -177,7 +177,7 @@ def pygal_item(request, responsetype, rel_path):
177 177
             data = open(full_path, 'rb').read()
178 178
             return HttpResponse(data, content_type=mime_type)
179 179
         else:
180
-            im = mm_image(os.path.join(os.path.dirname(__file__), 'forbidden.png'))
180
+            im = media.image(os.path.join(os.path.dirname(__file__), 'forbidden.png'))
181 181
             if responsetype == pygal.RESP_TYPE_THUMBNAIL:
182 182
                 im.resize(int(pygal.get_thumbnail_size(request) * .75))
183 183
             return HttpResponse(im.image_data(), content_type='image/png')

+ 0
- 284
views/image.py Прегледај датотеку

@@ -1,284 +0,0 @@
1
-from django.conf import settings
2
-import fstools
3
-import io
4
-import logging
5
-import mimetypes
6
-from ..models import get_item_type, TYPE_IMAGE, TYPE_VIDEO
7
-import os
8
-from PIL import Image, ImageEnhance, ExifTags
9
-import platform
10
-import pygal
11
-import subprocess
12
-
13
-# Get a logger instance
14
-logger = logging.getLogger("CACHING")
15
-
16
-
17
-def get_image_class(full_path):
18
-    return {
19
-        TYPE_IMAGE: image,
20
-        TYPE_VIDEO: video,
21
-    }.get(get_item_type(full_path), other)
22
-
23
-
24
-def get_image_instance(full_path, request):
25
-    return get_image_class(full_path)(full_path, request)
26
-
27
-
28
-class mm_image(object):
29
-    JOIN_TOP_LEFT = 1
30
-    JOIN_TOP_RIGHT = 2
31
-    JOIN_BOT_LEFT = 3
32
-    JOIN_BOT_RIGHT = 4
33
-    JOIN_CENTER = 5
34
-
35
-    def __init__(self, imagepath_handle_image):
36
-        self.imagepath_handle_image = imagepath_handle_image
37
-        self.__image__ = None
38
-        #
39
-
40
-    def __init_image__(self):
41
-        if self.__image__ is None:
42
-            if type(self.imagepath_handle_image) is mm_image:
43
-                self.__image__ = self.imagepath_handle_image.__image__
44
-            elif type(self.imagepath_handle_image) is Image.Image:
45
-                self.__image__ = self.imagepath_handle_image
46
-            else:
47
-                self.__image__ = Image.open(self.imagepath_handle_image)
48
-
49
-    def orientation(self):
50
-        self.__init_image__()
51
-        exif_tags = dict((v, k) for k, v in ExifTags.TAGS.items())
52
-        try:
53
-            return dict(self.__image__._getexif().items())[exif_tags['Orientation']]
54
-        except AttributeError:
55
-            return 1
56
-        except KeyError:
57
-            return 1
58
-
59
-    def copy(self):
60
-        self.__init_image__()
61
-        return mm_image(self.__image__.copy())
62
-
63
-    def save(self, *args, **kwargs):
64
-        self.__init_image__()
65
-        im = self.__image__.copy().convert('RGB')
66
-        im.save(*args, **kwargs)
67
-
68
-    def resize(self, max_size):
69
-        self.__init_image__()
70
-        #
71
-        # resize
72
-        #
73
-        x, y = self.__image__.size
74
-        xy_max = max(x, y)
75
-        self.__image__ = self.__image__.resize((int(x * float(max_size) / xy_max), int(y * float(max_size) / xy_max)), Image.NEAREST).rotate(0)
76
-
77
-    def rotate_by_orientation(self, orientation):
78
-        self.__init_image__()
79
-        #
80
-        # rotate
81
-        #
82
-        angle = {3: 180, 6: 270, 8: 90}.get(orientation)
83
-        if angle is not None:
84
-            self.__image__ = self.__image__.rotate(angle, expand=True)
85
-
86
-    def __rgba_copy__(self):
87
-        self.__init_image__()
88
-        if self.__image__.mode != 'RGBA':
89
-            return self.__image__.convert('RGBA')
90
-        else:
91
-            return self.__image__.copy()
92
-
93
-    def join(self, image, joint_pos=JOIN_TOP_RIGHT, opacity=0.7):
94
-        """
95
-        This joins another picture to this one.
96
-
97
-        :param picture_edit picture: The picture to be joint.
98
-        :param joint_pos: The position of picture in this picture. See also self.JOIN_*
99
-        :param float opacity: The opacity of picture when joint (value between 0 and 1).
100
-
101
-        .. note::
102
-          joint_pos makes only sense if picture is smaller than this picture.
103
-        """
104
-        self.__init_image__()
105
-        #
106
-        im2 = image.__rgba_copy__()
107
-        # change opacity of im2
108
-        alpha = im2.split()[3]
109
-        alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
110
-        im2.putalpha(alpha)
111
-
112
-        self.__image__ = self.__rgba_copy__()
113
-
114
-        # create a transparent layer
115
-        layer = Image.new('RGBA', self.__image__.size, (0, 0, 0, 0))
116
-        # draw im2 in layer
117
-        if joint_pos == self.JOIN_TOP_LEFT:
118
-            layer.paste(im2, (0, 0))
119
-        elif joint_pos == self.JOIN_TOP_RIGHT:
120
-            layer.paste(im2, ((self.__image__.size[0] - im2.size[0]), 0))
121
-        elif joint_pos == self.JOIN_BOT_LEFT:
122
-            layer.paste(im2, (0, (self.__image__.size[1] - im2.size[1])))
123
-        elif joint_pos == self.JOIN_BOT_RIGHT:
124
-            layer.paste(im2, ((self.__image__.size[0] - im2.size[0]), (self.__image__.size[1] - im2.size[1])))
125
-        elif joint_pos == self.JOIN_CENTER:
126
-            layer.paste(im2, (int((self.__image__.size[0] - im2.size[0]) / 2), int((self.__image__.size[1] - im2.size[1]) / 2)))
127
-
128
-        self.__image__ = Image.composite(layer, self.__image__, layer)
129
-
130
-    def image_data(self):
131
-        self.__init_image__()
132
-        #
133
-        # create return value
134
-        #
135
-        im = self.__image__.copy().convert('RGB')
136
-        output = io.BytesIO()
137
-        im.save(output, format='JPEG')
138
-        return output.getvalue()
139
-
140
-
141
-class mm_video(object):
142
-    def __init__(self, full_path):
143
-        self.full_path = full_path
144
-
145
-    def image(self):
146
-        if platform.system() == 'Linux':
147
-            cmd = 'ffmpeg -ss 0.5 -i "' + self.full_path + '" -vframes 1 -f image2pipe pipe:1 2> /dev/null'
148
-        else:
149
-            cmd = 'ffmpeg -ss 0.5 -i "' + self.full_path + '" -vframes 1 -f image2pipe pipe:1 2> NULL'
150
-        data = subprocess.check_output(cmd, shell=True)
151
-        ffmpeg_handle = io.BytesIO(data)
152
-        im = Image.open(ffmpeg_handle)
153
-        return mm_image(im.copy())
154
-
155
-
156
-class base_item(object):
157
-    MIME_TYPES = {
158
-        '.ada': 'text/x/adasrc',
159
-        '.hex': 'text/x/hex',
160
-        '.jpg': 'image/x/generic',
161
-        '.jpeg': 'image/x/generic',
162
-        '.jpe': 'image/x/generic',
163
-        '.png': 'image/x/generic',
164
-        '.tif': 'image/x/generic',
165
-        '.tiff': 'image/x/generic',
166
-        '.gif': 'image/x/generic',
167
-        '.avi': 'video/x/generic',
168
-        '.mpg': 'video/x/generic',
169
-        '.mpeg': 'video/x/generic',
170
-        '.mpe': 'video/x/generic',
171
-        '.mov': 'video/x/generic',
172
-        '.qt': 'video/x/generic',
173
-        '.mp4': 'video/x/generic',
174
-        '.webm': 'video/x/generic',
175
-        '.ogv': 'video/x/generic',
176
-        '.flv': 'video/x/generic',
177
-        '.3gp': 'video/x/generic',
178
-    }
179
-
180
-    def __init__(self, full_path, request):
181
-        self.full_path = full_path
182
-        self.request = request
183
-        self.rel_path = pygal.get_rel_path(full_path)
184
-        #
185
-        ext = os.path.splitext(self.full_path)[1].lower()
186
-        self.mime_type = self.MIME_TYPES.get(ext, mimetypes.types_map.get(ext, 'unknown'))
187
-
188
-    def __cache_image_folder__(self, rel_path):
189
-        return os.path.join(settings.XNAIL_ROOT, rel_path.replace('_', '__').replace('/', '_'))
190
-
191
-    def __cache_image_name__(self, max_size):
192
-        filename = '%04d_%02x_%s.jpg' % (max_size, self.XNAIL_VERSION_NUMBER, fstools.uid(self.full_path, None))
193
-        return os.path.join(self.__cache_image_folder__(self.rel_path), filename)
194
-
195
-    def __delete_cache_image__(self, max_size):
196
-        for fn in fstools.filelist(self.__cache_image_folder__(self.rel_path), '%04d*' % max_size):
197
-            try:
198
-                os.remove(fn)
199
-            except OSError:
200
-                pass    # possibly file is already removed by another process
201
-
202
-
203
-class image(base_item, mm_image):
204
-    XNAIL_VERSION_NUMBER = 1
205
-
206
-    def __init__(self, *args, **kwargs):
207
-        base_item.__init__(self, *args, **kwargs)
208
-        mm_image.__init__(self, self.full_path)
209
-        self.mime_type_xnails = mimetypes.types_map.get('.jpg', 'unknown')
210
-
211
-    def get_resized_image_data(self, max_size):
212
-        cache_filename = self.__cache_image_name__(max_size)
213
-        if not os.path.exists(cache_filename):
214
-            logger.info('Creating xnail-%d for %s', max_size, self.rel_path)
215
-            self.__delete_cache_image__(max_size)
216
-            im = self.copy()
217
-            im.resize(max_size)
218
-            im.rotate_by_orientation(self.orientation())
219
-            #
220
-            # create cache file
221
-            #
222
-            fstools.mkdir(os.path.dirname(cache_filename))
223
-            with open(cache_filename, 'wb') as fh:
224
-                im.save(fh, format='JPEG')
225
-            #
226
-            return im.image_data()
227
-        else:
228
-            return open(cache_filename, 'rb').read()
229
-
230
-    def thumbnail_picture(self):
231
-        return self.get_resized_image_data(pygal.get_thumbnail_max_size(self.request))
232
-
233
-    def webnail_picture(self):
234
-        return self.get_resized_image_data(pygal.get_webnail_size(self.request))
235
-
236
-
237
-class video(base_item, mm_video):
238
-    XNAIL_VERSION_NUMBER = 1
239
-
240
-    def __init__(self, *args, **kwargs):
241
-        base_item.__init__(self, *args, **kwargs)
242
-        mm_video.__init__(self, self.full_path)
243
-        self.mime_type_xnails = mimetypes.types_map.get('.jpg', 'unknown')
244
-
245
-    def get_resized_image_data(self, max_size):
246
-        cache_filename = self.__cache_image_name__(max_size)
247
-        if not os.path.exists(cache_filename):
248
-            logger.info('Creating xnail-%d for %s', max_size, self.rel_path)
249
-            self.__delete_cache_image__(max_size)
250
-            im = self.image()
251
-            im.resize(max_size)
252
-            overlay = mm_image(os.path.join(os.path.dirname(__file__), 'video.png'))
253
-            im.join(overlay)
254
-            #
255
-            # create cache file
256
-            #
257
-            fstools.mkdir(os.path.dirname(cache_filename))
258
-            with open(cache_filename, 'wb') as fh:
259
-                im.save(fh, format='JPEG')
260
-            #
261
-            return im.image_data()
262
-        else:
263
-            return open(cache_filename, 'rb').read()
264
-
265
-    def thumbnail_picture(self):
266
-        return image.thumbnail_picture(self)
267
-
268
-    def webnail_picture(self):
269
-        return image.webnail_picture(self)
270
-
271
-
272
-class other(base_item):
273
-    def __init__(self, *args, **kwargs):
274
-        base_item.__init__(self, *args, **kwargs)
275
-        self.mime_type_xnails = mimetypes.types_map.get('.png', 'unknown')
276
-
277
-    def thumbnail_picture(self):
278
-        fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'mimetype_icons', '%s.png' % (self.mime_type).replace('/', '-'))
279
-        if not os.path.exists(fn):
280
-            fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'mimetype_icons', 'unknown.png')
281
-        return open(fn, 'rb').read()
282
-
283
-    def webnail_picture(self):
284
-        return self.thumbnail_picture()

+ 134
- 0
views/xnail.py Прегледај датотеку

@@ -0,0 +1,134 @@
1
+from django.conf import settings
2
+import fstools
3
+import logging
4
+import mimetypes
5
+import media
6
+from ..models import get_item_type, TYPE_IMAGE, TYPE_VIDEO
7
+import os
8
+import pygal
9
+
10
+# Get a logger instance
11
+logger = logging.getLogger("CACHING")
12
+
13
+
14
+def get_image_class(full_path):
15
+    return {
16
+        TYPE_IMAGE: image,
17
+        TYPE_VIDEO: video,
18
+    }.get(get_item_type(full_path), other)
19
+
20
+
21
+def get_image_instance(full_path, request):
22
+    return get_image_class(full_path)(full_path, request)
23
+
24
+
25
+class base_item(object):
26
+    MIME_TYPES = {
27
+        '.ada': 'text/x/adasrc',
28
+        '.hex': 'text/x/hex',
29
+        '.jpg': 'image/x/generic',
30
+        '.jpeg': 'image/x/generic',
31
+        '.jpe': 'image/x/generic',
32
+        '.png': 'image/x/generic',
33
+        '.tif': 'image/x/generic',
34
+        '.tiff': 'image/x/generic',
35
+        '.gif': 'image/x/generic',
36
+        '.avi': 'video/x/generic',
37
+        '.mpg': 'video/x/generic',
38
+        '.mpeg': 'video/x/generic',
39
+        '.mpe': 'video/x/generic',
40
+        '.mov': 'video/x/generic',
41
+        '.qt': 'video/x/generic',
42
+        '.mp4': 'video/x/generic',
43
+        '.webm': 'video/x/generic',
44
+        '.ogv': 'video/x/generic',
45
+        '.flv': 'video/x/generic',
46
+        '.3gp': 'video/x/generic',
47
+    }
48
+
49
+    def __init__(self, full_path, request):
50
+        self.full_path = full_path
51
+        self.request = request
52
+        self.rel_path = pygal.get_rel_path(full_path)
53
+        #
54
+        ext = os.path.splitext(self.full_path)[1].lower()
55
+        self.mime_type = self.MIME_TYPES.get(ext, mimetypes.types_map.get(ext, 'unknown'))
56
+
57
+    def __cache_image_folder__(self, rel_path):
58
+        return os.path.join(settings.XNAIL_ROOT, rel_path.replace('_', '__').replace('/', '_'))
59
+
60
+    def __cache_image_name__(self, max_size):
61
+        filename = '%04d_%02x_%s.jpg' % (max_size, self.XNAIL_VERSION_NUMBER, fstools.uid(self.full_path, None))
62
+        return os.path.join(self.__cache_image_folder__(self.rel_path), filename)
63
+
64
+    def __delete_cache_image__(self, max_size):
65
+        folder = self.__cache_image_folder__(self.rel_path)
66
+        if os.path.isdir(folder):
67
+            for fn in fstools.filelist(folder, '%04d*' % max_size):
68
+                try:
69
+                    os.remove(fn)
70
+                except OSError:
71
+                    pass    # possibly file is already removed by another process
72
+
73
+
74
+class image(base_item):
75
+    XNAIL_VERSION_NUMBER = 1
76
+
77
+    def __init__(self, *args, **kwargs):
78
+        base_item.__init__(self, *args, **kwargs)
79
+        self.mime_type_xnails = mimetypes.types_map.get('.jpg', 'unknown')
80
+
81
+    def get_resized_image_data(self, max_size):
82
+        cache_filename = self.__cache_image_name__(max_size)
83
+        if not os.path.exists(cache_filename):
84
+            logger.info('Creating xnail-%d for %s', max_size, self.rel_path)
85
+            self.__delete_cache_image__(max_size)
86
+            im = media.image(self.full_path)
87
+            im.resize(max_size)
88
+            im.rotate_by_orientation()
89
+            #
90
+            # create cache file
91
+            #
92
+            fstools.mkdir(os.path.dirname(cache_filename))
93
+            im.save(cache_filename)
94
+            return im.image_data()
95
+        return open(cache_filename, 'rb').read()
96
+
97
+    def thumbnail_picture(self):
98
+        return self.get_resized_image_data(pygal.get_thumbnail_max_size(self.request))
99
+
100
+    def webnail_picture(self):
101
+        return self.get_resized_image_data(pygal.get_webnail_size(self.request))
102
+
103
+
104
+class video(image):
105
+    def get_resized_image_data(self, max_size):
106
+        cache_filename = self.__cache_image_name__(max_size)
107
+        if not os.path.exists(cache_filename):
108
+            logger.info('Creating xnail-%d for %s', max_size, self.rel_path)
109
+            self.__delete_cache_image__(max_size)
110
+            im = media.image(self.full_path)
111
+            im.resize(max_size)
112
+            im.join(os.path.join(os.path.dirname(__file__), 'video.png'))
113
+            #
114
+            # create cache file
115
+            #
116
+            fstools.mkdir(os.path.dirname(cache_filename))
117
+            im.save(cache_filename)
118
+            return im.image_data()
119
+        return open(cache_filename, 'rb').read()
120
+
121
+
122
+class other(base_item):
123
+    def __init__(self, *args, **kwargs):
124
+        base_item.__init__(self, *args, **kwargs)
125
+        self.mime_type_xnails = mimetypes.types_map.get('.png', 'unknown')
126
+
127
+    def thumbnail_picture(self):
128
+        fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'mimetype_icons', '%s.png' % (self.mime_type).replace('/', '-'))
129
+        if not os.path.exists(fn):
130
+            fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'mimetype_icons', 'unknown.png')
131
+        return open(fn, 'rb').read()
132
+
133
+    def webnail_picture(self):
134
+        return self.thumbnail_picture()

Loading…
Откажи
Сачувај