Compare commits
merge into: warricksothr:master
warricksothr:acm-debugging-and-enhancements
warricksothr:add-file-preservation
warricksothr:master
pull from: warricksothr:acm-debugging-and-enhancements
warricksothr:acm-debugging-and-enhancements
warricksothr:add-file-preservation
warricksothr:master
10 Commits
master
...
acm-debugg
Author | SHA1 | Message | Date |
---|---|---|---|
Drew Short | ed1341a976 |
Moved async work into a module
|
3 years ago |
Drew Short | 7f5f8a10a9 |
Support retrieving a specific profile
|
3 years ago |
Drew Short | 898aefd1fe |
Migrate to structured config
|
3 years ago |
Drew Short | def0761d96 |
Prepping pydantic config structure. BREAKING
|
3 years ago |
Drew Short | 567df9bef9 |
Fix minio breaking changes and optimize container image layers
|
3 years ago |
Drew Short | ef3b2dac22 |
Update pinning, reduce image size, configure poetry to not use venv
|
3 years ago |
Drew Short | 1aa5bc8d80 |
pinned versions, fixed poetry, added helper scripts
|
3 years ago |
Drew Short | 05c7927305 |
Prep work for new container build
|
3 years ago |
Drew Short | f72406da44 |
and switch to poetry for the dependencies
|
3 years ago |
Drew Short | 1044da7669 |
work in progress to get logging configured
|
3 years ago |
20 changed files with 818 additions and 410 deletions
-
8.dockerignore
-
1.gitignore
-
1.python-version
-
67Dockerfile
-
242acm-config-default.json
-
215acm.py
-
0acm/__init__.py
-
81acm/asyncio.py
-
311acm/config.py
-
46acm/logging.py
-
3acm/s3.py
-
39acm/utility.py
-
31acm/version.py
-
10docker/entrypoint.sh
-
141poetry.lock
-
19pyproject.toml
-
2requirements.txt
-
9scripts/build_container.sh
-
0scripts/upload_pipeline.sh
-
2setup.py
@ -0,0 +1,8 @@ |
|||||
|
.git/ |
||||
|
.idea/ |
||||
|
scripts/ |
||||
|
venv/ |
||||
|
|
||||
|
.gitignore |
||||
|
Dockerfile |
||||
|
pipeline.yml |
@ -1,5 +1,4 @@ |
|||||
.idea/ |
.idea/ |
||||
|
|
||||
venv/ |
venv/ |
||||
l_venv/ |
l_venv/ |
||||
|
|
||||
|
@ -0,0 +1 @@ |
|||||
|
3.9.5 |
@ -1,242 +0,0 @@ |
|||||
{ |
|
||||
"concurrency": 0, |
|
||||
"profiles": { |
|
||||
"default": { |
|
||||
"jpeg": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cjpeg" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"jpg", |
|
||||
"jpeg" |
|
||||
], |
|
||||
"outputExtension": "jpg", |
|
||||
"forcePreserveSmallerInput": true, |
|
||||
"command": "cjpeg -optimize -quality 90 -progressive -outfile {output_file} {input_file}" |
|
||||
}, |
|
||||
"png": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"optipng" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"png" |
|
||||
], |
|
||||
"outputExtension": "png", |
|
||||
"forcePreserveSmallerInput": true, |
|
||||
"command": "optipng -o2 -strip all -out {output_file} {input_file}" |
|
||||
}, |
|
||||
"video": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"mp4", |
|
||||
"webm" |
|
||||
], |
|
||||
"outputExtension": "webm", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -c:v libvpx-vp9 -b:v 0 -crf 29 -c:a libopus {output_file}" |
|
||||
}, |
|
||||
"audio": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg", |
|
||||
"opusenc" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"wav", |
|
||||
"mp3" |
|
||||
], |
|
||||
"outputExtension": "ogg", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -f wav -| opusenc --bitrate 64 --vbr --downmix-stereo --discard-comments --discard-pictures - {output_file}" |
|
||||
} |
|
||||
}, |
|
||||
"placebo": { |
|
||||
"jpeg": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"jpg", |
|
||||
"jpeg" |
|
||||
], |
|
||||
"outputExtension": "jpg", |
|
||||
"preserveInputExtension": true, |
|
||||
"preserveSmallerInput": false, |
|
||||
"command": "cp {input_file} {output_file}" |
|
||||
}, |
|
||||
"png": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"png" |
|
||||
], |
|
||||
"outputExtension": "png", |
|
||||
"preserveInputExtension": true, |
|
||||
"preserveSmallerInput": false, |
|
||||
"command": "cp {input_file} {output_file}" |
|
||||
}, |
|
||||
"video": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"mp4", |
|
||||
"webm" |
|
||||
], |
|
||||
"outputExtension": "mp4", |
|
||||
"preserveInputExtension": true, |
|
||||
"preserveSmallerInput": false, |
|
||||
"command": "cp {input_file} {output_file}" |
|
||||
}, |
|
||||
"audio": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"wav", |
|
||||
"mp3" |
|
||||
], |
|
||||
"outputExtension": "ogg", |
|
||||
"preserveInputExtension": true, |
|
||||
"preserveSmallerInput": false, |
|
||||
"command": "cp {input_file} {output_file}" |
|
||||
} |
|
||||
}, |
|
||||
"webp": { |
|
||||
"jpeg": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cwebp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"jpg", |
|
||||
"jpeg" |
|
||||
], |
|
||||
"outputExtension": "webp", |
|
||||
"command": "cwebp -jpeg_like -q 90 -o {output_file} {input_file}" |
|
||||
}, |
|
||||
"png": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cwebp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"png" |
|
||||
], |
|
||||
"outputExtension": "webp", |
|
||||
"command": "cwebp -lossless -o {output_file} {input_file}" |
|
||||
} |
|
||||
}, |
|
||||
"aggressive": { |
|
||||
"jpeg": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg", |
|
||||
"cjpeg" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"jpg", |
|
||||
"jpeg" |
|
||||
], |
|
||||
"outputExtension": "jpg", |
|
||||
"forcePreserveSmallerInput": true, |
|
||||
"command": "export FILE={output_file} && export TEMP_FILE=${FILE}_tmp.jpg && ffmpeg -i {input_file} -vf scale=-1:720 ${TEMP_FILE} && cjpeg -optimize -quality 75 -progressive -outfile {output_file} ${TEMP_FILE} && rm ${TEMP_FILE}" |
|
||||
}, |
|
||||
"png": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"optipng" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"png" |
|
||||
], |
|
||||
"outputExtension": "png", |
|
||||
"forcePreserveSmallerInput": true, |
|
||||
"command": "optipng -o2 -strip all -out {output_file} {input_file}" |
|
||||
}, |
|
||||
"video": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"mp4", |
|
||||
"webm" |
|
||||
], |
|
||||
"outputExtension": "webm", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -vf scale=-1:720 -c:v libvpx-vp9 -b:v 0 -crf 38 -c:a libopus {output_file}" |
|
||||
}, |
|
||||
"audio": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg", |
|
||||
"opusenc" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"wav", |
|
||||
"mp3" |
|
||||
], |
|
||||
"outputExtension": "ogg", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -f wav -| opusenc --bitrate 64 --vbr --downmix-stereo --discard-comments --discard-pictures - {output_file}" |
|
||||
} |
|
||||
}, |
|
||||
"aggressive-webp": { |
|
||||
"jpeg": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cwebp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"jpg", |
|
||||
"jpeg" |
|
||||
], |
|
||||
"outputExtension": "webp", |
|
||||
"command": "export FILE={output_file} && export TEMP_FILE=${FILE}_tmp.jpg && ffmpeg -i {input_file} -vf scale=-1:720 ${TEMP_FILE} && cwebp -jpeg_like -q 75 -o {output_file} ${TEMP_FILE} && rm ${TEMP_FILE}" |
|
||||
}, |
|
||||
"png": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"cwebp" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"png" |
|
||||
], |
|
||||
"outputExtension": "webp", |
|
||||
"command": "cwebp -o {output_file} ${input_file}" |
|
||||
}, |
|
||||
"video": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"mp4", |
|
||||
"webm" |
|
||||
], |
|
||||
"outputExtension": "webm", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -vf scale=-1:720 -c:v libvpx-vp9 -b:v 0 -crf 38 -c:a libopus {output_file}" |
|
||||
}, |
|
||||
"audio": { |
|
||||
"version": "1.4.0", |
|
||||
"processors": [ |
|
||||
"ffmpeg", |
|
||||
"opusenc" |
|
||||
], |
|
||||
"extensions": [ |
|
||||
"wav", |
|
||||
"mp3" |
|
||||
], |
|
||||
"outputExtension": "ogg", |
|
||||
"command": "ffmpeg -hide_banner -loglevel panic -i {input_file} -f wav -| opusenc --bitrate 64 --vbr --downmix-stereo --discard-comments --discard-pictures - {output_file}" |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -0,0 +1,81 @@ |
|||||
|
import asyncio |
||||
|
import logging |
||||
|
import typing |
||||
|
|
||||
|
LOG = logging.getLogger("acm.async") |
||||
|
|
||||
|
async def run_command_shell( |
||||
|
command, |
||||
|
stdout=asyncio.subprocess.PIPE, |
||||
|
stderr=asyncio.subprocess.PIPE, |
||||
|
on_success: typing.List[typing.Callable] = [()] |
||||
|
): |
||||
|
"""Run command in subprocess (shell). |
||||
|
|
||||
|
Note: |
||||
|
This can be used if you wish to execute e.g. "copy" |
||||
|
on Windows, which can only be executed in the shell. |
||||
|
""" |
||||
|
process = await asyncio.create_subprocess_shell( |
||||
|
command, stdout=stdout, stderr=stderr |
||||
|
) |
||||
|
|
||||
|
process_stdout, process_stderr = await process.communicate() |
||||
|
|
||||
|
if process.returncode == 0: |
||||
|
for success_callable in on_success: |
||||
|
success_callable() |
||||
|
|
||||
|
if stdout != asyncio.subprocess.DEVNULL: |
||||
|
result = process_stdout.decode().strip() |
||||
|
return result |
||||
|
else: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def make_chunks(tasks, chunk_size): |
||||
|
"""Yield successive chunk_size-sized chunks from tasks. |
||||
|
|
||||
|
Note: |
||||
|
Taken from https://stackoverflow.com/a/312464 |
||||
|
modified for python 3 only |
||||
|
""" |
||||
|
for i in range(0, len(tasks), chunk_size): |
||||
|
yield tasks[i: i + chunk_size] |
||||
|
|
||||
|
|
||||
|
def run_asyncio_commands(tasks, max_concurrent_tasks=0): |
||||
|
"""Run tasks asynchronously using asyncio and return results. |
||||
|
|
||||
|
If max_concurrent_tasks are set to 0, no limit is applied. |
||||
|
|
||||
|
Note: |
||||
|
By default, Windows uses SelectorEventLoop, which does not support |
||||
|
subprocesses. Therefore ProactorEventLoop is used on Windows. |
||||
|
https://docs.python.org/3/library/asyncio-eventloops.html#windows |
||||
|
""" |
||||
|
all_results = [] |
||||
|
|
||||
|
if max_concurrent_tasks == 0: |
||||
|
chunks = [tasks] |
||||
|
num_chunks = len(chunks) |
||||
|
else: |
||||
|
chunks = make_chunks(tasks=tasks, chunk_size=max_concurrent_tasks) |
||||
|
num_chunks = len( |
||||
|
list(make_chunks(tasks=tasks, chunk_size=max_concurrent_tasks))) |
||||
|
|
||||
|
if asyncio.get_event_loop().is_closed(): |
||||
|
asyncio.set_event_loop(asyncio.new_event_loop()) |
||||
|
if platform.system() == "Windows": |
||||
|
asyncio.set_event_loop(asyncio.ProactorEventLoop()) |
||||
|
loop = asyncio.get_event_loop() |
||||
|
|
||||
|
chunk = 1 |
||||
|
for tasks_in_chunk in chunks: |
||||
|
commands = asyncio.gather(*tasks_in_chunk) |
||||
|
results = loop.run_until_complete(commands) |
||||
|
all_results += results |
||||
|
chunk += 1 |
||||
|
|
||||
|
loop.close() |
||||
|
return all_results |
@ -0,0 +1,311 @@ |
|||||
|
import importlib.metadata |
||||
|
import json |
||||
|
import logging |
||||
|
import typing |
||||
|
|
||||
|
from pydantic import BaseModel, BaseSettings, validator |
||||
|
|
||||
|
from acm.utility import get_string_sha256sum, get_string_xor |
||||
|
from acm.version import VERSION |
||||
|
|
||||
|
LOG = logging.getLogger("acm.config") |
||||
|
|
||||
|
|
||||
|
class ACMProfileProcessorOptions(BaseModel): |
||||
|
force_preserve_smaller_input: bool = False |
||||
|
|
||||
|
|
||||
|
class ACMProfileProcessor(BaseModel): |
||||
|
name: str |
||||
|
version: typing.Optional[str] |
||||
|
processors: typing.List[str] |
||||
|
extensions: typing.List[str] |
||||
|
output_extension: str |
||||
|
options: ACMProfileProcessorOptions |
||||
|
command: str |
||||
|
signature: typing.Optional[str] |
||||
|
|
||||
|
@validator('version', always=True) |
||||
|
def version_validator(cls, v, values) -> str: |
||||
|
# TODO Set the version to the app version if not provided |
||||
|
if v is None: |
||||
|
return VERSION |
||||
|
|
||||
|
@validator('signature', always=True) |
||||
|
def signature_validator(cls, v, values) -> str: |
||||
|
signature_keys = ["name", "version", "processors", "extensions", "output_extension", "command"] |
||||
|
signature_values = [value for key, value in values.items() if key in signature_keys] |
||||
|
return get_string_sha256sum(json.dumps(signature_values)) |
||||
|
|
||||
|
|
||||
|
class ACMProfile(BaseModel): |
||||
|
name: str |
||||
|
version: typing.Optional[str] |
||||
|
processors: typing.List[ACMProfileProcessor] |
||||
|
signature: typing.Optional[str] |
||||
|
|
||||
|
@validator('version', always=True) |
||||
|
def version_validator(cls, v, values) -> str: |
||||
|
if v is None: |
||||
|
return VERSION |
||||
|
|
||||
|
@validator('signature', always=True) |
||||
|
def hash_signature_validator(cls, v, values) -> str: |
||||
|
signature_keys = ["name", "version"] |
||||
|
signature_values = [value for key, value in values.items() if key in signature_keys] |
||||
|
signature = get_string_sha256sum(json.dumps(signature_values)) |
||||
|
|
||||
|
processor_signatures = [processor.signature for processor in values["processors"]] |
||||
|
if len(processor_signatures) > 1: |
||||
|
combined_processor_signature = get_string_xor(*processor_signatures) |
||||
|
else: |
||||
|
combined_processor_signature = processor_signatures[0] |
||||
|
|
||||
|
return get_string_sha256sum(signature + combined_processor_signature) |
||||
|
|
||||
|
def get_processor_names(self) -> typing.List[str]: |
||||
|
return [processor.name for processor in self.processors] |
||||
|
|
||||
|
def get_processor(self, name: str) -> typing.Optional[ACMProfileProcessor]: |
||||
|
for processor in self.processors: |
||||
|
if name == processor.name: |
||||
|
return processor |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
class ACMS3(BaseModel): |
||||
|
secure: bool = False, |
||||
|
host: str = "127.0.0.1:9000" |
||||
|
access_key: typing.Optional[str] |
||||
|
secret_key: typing.Optional[str] |
||||
|
|
||||
|
|
||||
|
class ACMConfig(BaseSettings): |
||||
|
concurrency: int = 0 |
||||
|
debug: bool = False |
||||
|
s3: typing.Optional[ACMS3] |
||||
|
version: typing.Optional[str] |
||||
|
profiles: typing.List[ACMProfile] |
||||
|
signature: typing.Optional[str] |
||||
|
|
||||
|
@validator('version', always=True) |
||||
|
def version_validator(cls, v, values) -> str: |
||||
|
if v is None: |
||||
|
return VERSION |
||||
|
|
||||
|
@validator('signature', always=True) |
||||
|
def signature_validator(cls, v, values) -> str: |
||||
|
signature_keys = ["version"] |
||||
|
signature_values = [value for key, value in values.items() if key in signature_keys] |
||||
|
signature = get_string_sha256sum(json.dumps(signature_values)) |
||||
|
|
||||
|
profiles_signatures = [profiles.signature for profiles in values["profiles"]] |
||||
|
if len(profiles_signatures) > 1: |
||||
|
combined_profiles_signature = get_string_xor(*profiles_signatures) |
||||
|
else: |
||||
|
combined_profiles_signature = profiles_signatures[0] |
||||
|
|
||||
|
return get_string_sha256sum(signature + combined_profiles_signature) |
||||
|
|
||||
|
class Config: |
||||
|
env_prefix = 'ACM_' |
||||
|
env_nested_delimiter = '__' |
||||
|
|
||||
|
def get_profile_names(self) -> typing.List[str]: |
||||
|
return [profile.name for profile in self.profiles] |
||||
|
|
||||
|
def get_profile(self, name: str) -> typing.Optional[ACMProfile]: |
||||
|
for profile in self.profiles: |
||||
|
if name == profile.name: |
||||
|
return profile |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def get_default_config(): |
||||
|
""" |
||||
|
Returns the default ACM config |
||||
|
""" |
||||
|
acm_profiles = [] |
||||
|
|
||||
|
# default # |
||||
|
acm_default_processors = [] |
||||
|
acm_default_processors.append(ACMProfileProcessor( |
||||
|
name = "jpeg", |
||||
|
processors = ["cjpeg"], |
||||
|
extensions = ["jpg", "jpeg"], |
||||
|
output_extension = "jpg", |
||||
|
options = ACMProfileProcessorOptions(force_preserve_smaller_input=True), |
||||
|
command = "cjpeg -optimize -quality 90 -progressive -outfile {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_default_processors.append(ACMProfileProcessor( |
||||
|
name = "png", |
||||
|
processors = ["optipng"], |
||||
|
extensions = ["png"], |
||||
|
output_extension = "png", |
||||
|
options = ACMProfileProcessorOptions(force_preserve_smaller_input=True), |
||||
|
command = "optipng -o2 -strip all -out {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_default_processors.append(ACMProfileProcessor( |
||||
|
name = "video", |
||||
|
processors = ["ffmpeg"], |
||||
|
extensions = ["mp4","webm"], |
||||
|
output_extension = "webm", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "optipng -o2 -strip all -out {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_default_processors.append(ACMProfileProcessor( |
||||
|
name = "audio", |
||||
|
processors = ["ffmpeg","opusenc"], |
||||
|
extensions = ["wav","mp3"], |
||||
|
output_extension = "ogg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "optipng -o2 -strip all -out {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_profiles.append(ACMProfile( |
||||
|
name = "default", |
||||
|
processors = acm_default_processors |
||||
|
)) |
||||
|
|
||||
|
# placebo # |
||||
|
acm_placebo_processors = [] |
||||
|
acm_placebo_processors.append(ACMProfileProcessor( |
||||
|
name = "jpeg", |
||||
|
processors = ["cjpeg"], |
||||
|
extensions = ["jpg", "jpeg"], |
||||
|
output_extension = "jpg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cp {input_file} {output_file}" |
||||
|
)) |
||||
|
acm_placebo_processors.append(ACMProfileProcessor( |
||||
|
name = "png", |
||||
|
processors = ["optipng"], |
||||
|
extensions = ["png"], |
||||
|
output_extension = "png", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cp {input_file} {output_file}" |
||||
|
)) |
||||
|
acm_placebo_processors.append(ACMProfileProcessor( |
||||
|
name = "video", |
||||
|
processors = ["ffmpeg"], |
||||
|
extensions = ["mp4","webm"], |
||||
|
output_extension = "webm", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cp {input_file} {output_file}" |
||||
|
)) |
||||
|
acm_placebo_processors.append(ACMProfileProcessor( |
||||
|
name = "audio", |
||||
|
processors = ["ffmpeg","opusenc"], |
||||
|
extensions = ["wav","mp3"], |
||||
|
output_extension = "ogg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cp {input_file} {output_file}" |
||||
|
)) |
||||
|
acm_profiles.append(ACMProfile( |
||||
|
name = "placebo", |
||||
|
processors = acm_placebo_processors |
||||
|
)) |
||||
|
|
||||
|
# webp # |
||||
|
acm_webp_processors = [] |
||||
|
acm_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "jpeg", |
||||
|
processors = ["cwebp"], |
||||
|
extensions = ["jpg", "jpeg"], |
||||
|
output_extension = "jpg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cwebp -jpeg_like -q 90 -o {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "png", |
||||
|
processors = ["cwebp"], |
||||
|
extensions = ["png"], |
||||
|
output_extension = "png", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cwebp -lossless -o {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_profiles.append(ACMProfile( |
||||
|
name = "webp", |
||||
|
processors = acm_webp_processors |
||||
|
)) |
||||
|
|
||||
|
# aggressive # |
||||
|
acm_aggressive_processors = [] |
||||
|
acm_aggressive_processors.append(ACMProfileProcessor( |
||||
|
name = "jpeg", |
||||
|
processors = ["cjpeg"], |
||||
|
extensions = ["jpg", "jpeg"], |
||||
|
output_extension = "jpg", |
||||
|
options = ACMProfileProcessorOptions(force_preserve_smaller_input=True), |
||||
|
command = "export FILE={output_file} && export TEMP_FILE=${FILE}_tmp.jpg && ffmpeg -i {input_file} -vf scale=-1:720 ${TEMP_FILE} && cjpeg -optimize -quality 75 -progressive -outfile {output_file} ${TEMP_FILE} && rm ${TEMP_FILE}" |
||||
|
)) |
||||
|
acm_aggressive_processors.append(ACMProfileProcessor( |
||||
|
name = "png", |
||||
|
processors = ["optipng"], |
||||
|
extensions = ["png"], |
||||
|
output_extension = "png", |
||||
|
options = ACMProfileProcessorOptions(force_preserve_smaller_input=True), |
||||
|
command = "optipng -o2 -strip all -out {output_file} {input_file}" |
||||
|
)) |
||||
|
acm_aggressive_processors.append(ACMProfileProcessor( |
||||
|
name = "video", |
||||
|
processors = ["ffmpeg"], |
||||
|
extensions = ["mp4","webm"], |
||||
|
output_extension = "webm", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "ffmpeg -hide_banner -loglevel panic -i {input_file} -vf scale=-1:720 -c:v libvpx-vp9 -b:v 0 -crf 38 -c:a libopus {output_file}" |
||||
|
)) |
||||
|
acm_aggressive_processors.append(ACMProfileProcessor( |
||||
|
name = "audio", |
||||
|
processors = ["ffmpeg","opusenc"], |
||||
|
extensions = ["wav","mp3"], |
||||
|
output_extension = "ogg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "ffmpeg -hide_banner -loglevel panic -i {input_file} -f wav -| opusenc --bitrate 64 --vbr --downmix-stereo --discard-comments --discard-pictures - {output_file}" |
||||
|
)) |
||||
|
acm_profiles.append(ACMProfile( |
||||
|
name = "aggressive", |
||||
|
processors = acm_aggressive_processors |
||||
|
)) |
||||
|
|
||||
|
# aggressive-webp # |
||||
|
acm_aggressive_webp_processors = [] |
||||
|
acm_aggressive_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "jpeg", |
||||
|
processors = ["cwebp"], |
||||
|
extensions = ["jpg", "jpeg"], |
||||
|
output_extension = "jpg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "export FILE={output_file} && export TEMP_FILE=${FILE}_tmp.jpg && ffmpeg -i {input_file} -vf scale=-1:720 ${TEMP_FILE} && cwebp -jpeg_like -q 75 -o {output_file} ${TEMP_FILE} && rm ${TEMP_FILE}" |
||||
|
)) |
||||
|
acm_aggressive_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "png", |
||||
|
processors = ["optipng"], |
||||
|
extensions = ["png"], |
||||
|
output_extension = "png", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "cwebp -o {output_file} ${input_file}" |
||||
|
)) |
||||
|
acm_aggressive_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "video", |
||||
|
processors = ["ffmpeg"], |
||||
|
extensions = ["mp4","webm"], |
||||
|
output_extension = "webm", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "ffmpeg -hide_banner -loglevel panic -i {input_file} -vf scale=-1:720 -c:v libvpx-vp9 -b:v 0 -crf 38 -c:a libopus {output_file}" |
||||
|
)) |
||||
|
acm_aggressive_webp_processors.append(ACMProfileProcessor( |
||||
|
name = "audio", |
||||
|
processors = ["ffmpeg","opusenc"], |
||||
|
extensions = ["wav","mp3"], |
||||
|
output_extension = "ogg", |
||||
|
options = ACMProfileProcessorOptions(), |
||||
|
command = "ffmpeg -hide_banner -loglevel panic -i {input_file} -f wav -| opusenc --bitrate 64 --vbr --downmix-stereo --discard-comments --discard-pictures - {output_file}" |
||||
|
)) |
||||
|
acm_profiles.append(ACMProfile( |
||||
|
name = "aggressive-webp", |
||||
|
processors = acm_aggressive_webp_processors |
||||
|
)) |
||||
|
|
||||
|
return ACMConfig( |
||||
|
profiles=acm_profiles |
||||
|
) |
@ -0,0 +1,46 @@ |
|||||
|
import logging |
||||
|
|
||||
|
LOG = logging.getLogger("acm.logging") |
||||
|
|
||||
|
def setup_basic_logging( |
||||
|
logger_name, |
||||
|
logger_level = logging.ERROR, |
||||
|
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
||||
|
date_format="%Y-%m-%dT%H:%M:%S%Z", |
||||
|
default_level = logging.INFO, |
||||
|
): |
||||
|
""" |
||||
|
Initialize logging with sane defaults |
||||
|
""" |
||||
|
logging.basicConfig( |
||||
|
format=log_format, |
||||
|
datefmt=date_format, |
||||
|
level=default_level |
||||
|
) |
||||
|
configured_logger = logging.getLogger(logger_name) |
||||
|
configured_logger.setLevel(logger_level) |
||||
|
|
||||
|
def update_logging_level(verbosity: int = 0, *loggers): |
||||
|
""" |
||||
|
Configure logging based on the requested verbosity |
||||
|
""" |
||||
|
|
||||
|
if verbosity > 2: |
||||
|
logging_level = logging.DEBUG |
||||
|
elif verbosity > 1: |
||||
|
logging_level = logging.INFO |
||||
|
elif verbosity > 0: |
||||
|
logging_level = logging.WARN |
||||
|
elif verbosity == 0: |
||||
|
logging_level = logging.ERROR |
||||
|
elif verbosity < 0: |
||||
|
logging_level = logging.CRITICAL |
||||
|
|
||||
|
for logger in loggers: |
||||
|
if isinstance(logger, logging.Logger) or isinstance(logger, logging.Handler): |
||||
|
logger.setLevel(logging_level) |
||||
|
else: |
||||
|
logger_instance = logging.getLogger(logger) |
||||
|
logger_instance.setLevel(logging_level) |
||||
|
|
||||
|
LOG.debug("Set logging level for to %s", logging_level) |
@ -0,0 +1,3 @@ |
|||||
|
import logging |
||||
|
|
||||
|
LOG = logging.getLogger("acm.s3") |
@ -0,0 +1,39 @@ |
|||||
|
import hashlib |
||||
|
import io |
||||
|
import logging |
||||
|
|
||||
|
# Size of the buffer to read files with |
||||
|
BUF_SIZE = 4096 |
||||
|
|
||||
|
LOG = logging.getLogger("acm.utility") |
||||
|
|
||||
|
|
||||
|
def get_file_sha256sum(input_file): |
||||
|
sha256sum = hashlib.sha256() |
||||
|
with open(input_file, 'rb') as f: |
||||
|
for byte_block in iter(lambda: f.read(BUF_SIZE), b""): |
||||
|
sha256sum.update(byte_block) |
||||
|
return sha256sum.hexdigest() |
||||
|
|
||||
|
|
||||
|
def get_string_sha256sum(content: str, encoding='utf-8') -> str: |
||||
|
sha256sum = hashlib.sha256() |
||||
|
with io.BytesIO(content.encode(encoding)) as c: |
||||
|
for byte_block in iter(lambda: c.read(BUF_SIZE), b''): |
||||
|
sha256sum.update(byte_block) |
||||
|
return sha256sum.hexdigest() |
||||
|
|
||||
|
|
||||
|
def get_string_hex(content: str) -> hex: |
||||
|
return hex(int(content, base=16)) |
||||
|
|
||||
|
|
||||
|
def get_hex_xor(first: hex, second: hex) -> hex: |
||||
|
return hex(int(first, base=16) ^ int(second, base=16)) |
||||
|
|
||||
|
|
||||
|
def get_string_xor(first: str, second: str, *extra: str) -> str: |
||||
|
result = get_hex_xor(get_string_hex(first), get_string_hex(second)) |
||||
|
for next_hex in extra: |
||||
|
result = get_hex_xor(result, get_string_hex(next_hex)) |
||||
|
return str(result) |
@ -0,0 +1,31 @@ |
|||||
|
import importlib.metadata |
||||
|
import logging |
||||
|
import os |
||||
|
import pathlib |
||||
|
|
||||
|
import toml |
||||
|
|
||||
|
LOG = logging.getLogger("acm.version") |
||||
|
|
||||
|
|
||||
|
def __get_version(): |
||||
|
""" |
||||
|
Automatically determine the version of the application being run |
||||
|
""" |
||||
|
# Attempt to read the installed package information |
||||
|
try: |
||||
|
return importlib.metadata.version('asset-compression-manager') |
||||
|
except importlib.metadata.PackageNotFoundError: |
||||
|
LOG.debug("The package is not installed, reading the version from another source") |
||||
|
|
||||
|
# Fallback on parsing the pyproject.toml file |
||||
|
root_dir = pathlib.Path(__file__).parent.parent.resolve() |
||||
|
with open(os.path.join(root_dir, "pyproject.toml"), "r") as project_file: |
||||
|
project = toml.load(project_file) |
||||
|
return project["tool"]["poetry"]["version"] |
||||
|
|
||||
|
LOG.debug("Falling back on UNKNOWN version identifier") |
||||
|
return "UNKNOWN" |
||||
|
|
||||
|
# Application Version |
||||
|
VERSION = __get_version() |
@ -1,3 +1,9 @@ |
|||||
#! /bin/sh |
|
||||
|
#!/usr/bin/env bash |
||||
|
|
||||
acm $@ |
|
||||
|
options="" |
||||
|
|
||||
|
if [[ "${ACM_DEBUG}" == "true" ]]; then |
||||
|
options="--debug $options" |
||||
|
fi |
||||
|
|
||||
|
acm ${options} $@ |
@ -0,0 +1,141 @@ |
|||||
|
[[package]] |
||||
|
name = "certifi" |
||||
|
version = "2021.10.8" |
||||
|
description = "Python package for providing Mozilla's CA Bundle." |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = "*" |
||||
|
|
||||
|
[[package]] |
||||
|
name = "click" |
||||
|
version = "8.0.3" |
||||
|
description = "Composable command line interface toolkit" |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=3.6" |
||||
|
|
||||
|
[package.dependencies] |
||||
|
colorama = {version = "*", markers = "platform_system == \"Windows\""} |
||||
|
|
||||
|
[[package]] |
||||
|
name = "colorama" |
||||
|
version = "0.4.4" |
||||
|
description = "Cross-platform colored terminal text." |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" |
||||
|
|
||||
|
[[package]] |
||||
|
name = "minio" |
||||
|
version = "7.1.2" |
||||
|
description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage" |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = "*" |
||||
|
|
||||
|
[package.dependencies] |
||||
|
certifi = "*" |
||||
|
urllib3 = "*" |
||||
|
|
||||
|
[[package]] |
||||
|
name = "pydantic" |
||||
|
version = "1.8.2" |
||||
|
description = "Data validation and settings management using python 3.6 type hinting" |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=3.6.1" |
||||
|
|
||||
|
[package.dependencies] |
||||
|
typing-extensions = ">=3.7.4.3" |
||||
|
|
||||
|
[package.extras] |
||||
|
dotenv = ["python-dotenv (>=0.10.4)"] |
||||
|
email = ["email-validator (>=1.0.3)"] |
||||
|
|
||||
|
[[package]] |
||||
|
name = "toml" |
||||
|
version = "0.10.2" |
||||
|
description = "Python Library for Tom's Obvious, Minimal Language" |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" |
||||
|
|
||||
|
[[package]] |
||||
|
name = "typing-extensions" |
||||
|
version = "4.0.1" |
||||
|
description = "Backported and Experimental Type Hints for Python 3.6+" |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=3.6" |
||||
|
|
||||
|
[[package]] |
||||
|
name = "urllib3" |
||||
|
version = "1.26.7" |
||||
|
description = "HTTP library with thread-safe connection pooling, file post, and more." |
||||
|
category = "main" |
||||
|
optional = false |
||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" |
||||
|
|
||||
|
[package.extras] |
||||
|
brotli = ["brotlipy (>=0.6.0)"] |
||||
|
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] |
||||
|
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] |
||||
|
|
||||
|
[metadata] |
||||
|
lock-version = "1.1" |
||||
|
python-versions = "^3.8" |
||||
|
content-hash = "751276ba1ea83218a27169c8d996edf4ae2f3c7a648d012674dd8ac2431508e4" |
||||
|
|
||||
|
[metadata.files] |
||||
|
certifi = [ |
||||
|
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, |
||||
|
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, |
||||
|
] |
||||
|
click = [ |
||||
|
{file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, |
||||
|
{file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, |
||||
|
] |
||||
|
colorama = [ |
||||
|
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, |
||||
|
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, |
||||
|
] |
||||
|
minio = [ |
||||
|
{file = "minio-7.1.2-py3-none-any.whl", hash = "sha256:51318733496f37617bebfefe116453406a0d5afc6add8c421df07f32e0843c2b"}, |
||||
|
{file = "minio-7.1.2.tar.gz", hash = "sha256:40d0cdb4dba5d5610d6599ea740cf827102db5bfa71279fc220c3cf7305bedc1"}, |
||||
|
] |
||||
|
pydantic = [ |
||||
|
{file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, |
||||
|
{file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, |
||||
|
{file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, |
||||
|
{file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, |
||||
|
{file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, |
||||
|
{file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, |
||||
|
{file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, |
||||
|
{file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, |
||||
|
{file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, |
||||
|
{file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, |
||||
|
{file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, |
||||
|
{file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, |
||||
|
{file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, |
||||
|
{file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, |
||||
|
{file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, |
||||
|
{file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, |
||||
|
{file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, |
||||
|
{file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, |
||||
|
{file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, |
||||
|
{file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, |
||||
|
{file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, |
||||
|
{file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, |
||||
|
] |
||||
|
toml = [ |
||||
|
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, |
||||
|
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, |
||||
|
] |
||||
|
typing-extensions = [ |
||||
|
{file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, |
||||
|
{file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, |
||||
|
] |
||||
|
urllib3 = [ |
||||
|
{file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, |
||||
|
{file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, |
||||
|
] |
@ -0,0 +1,19 @@ |
|||||
|
[tool.poetry] |
||||
|
name = "asset-compression-manager" |
||||
|
version = "2.0.0" |
||||
|
description = "Helper Utility For Managing Compressed Assets" |
||||
|
authors = ["Drew Short <warrick@sothr.com>"] |
||||
|
license = "Apache2" |
||||
|
|
||||
|
[tool.poetry.dependencies] |
||||
|
python = "^3.8" |
||||
|
click = "8.0.3" |
||||
|
minio = "7.1.2" |
||||
|
pydantic = "1.8.2" |
||||
|
toml = "0.10.2" |
||||
|
|
||||
|
[tool.poetry.dev-dependencies] |
||||
|
|
||||
|
[build-system] |
||||
|
requires = ["poetry-core>=1.0.0"] |
||||
|
build-backend = "poetry.core.masonry.api" |
@ -1,2 +0,0 @@ |
|||||
click == 7.1.1 |
|
||||
minio == 5.0.8 |
|
@ -0,0 +1,9 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
|
||||
|
TAG=${TAG:-dev} |
||||
|
if [[ -n "$1" ]]; then |
||||
|
TAG="$1" |
||||
|
fi |
||||
|
|
||||
|
DOCKER_BUILDKIT=1 |
||||
|
docker build --platform=amd64 -t sothr/acm:${TAG} . |
Write
Preview
Loading…
Cancel
Save
Reference in new issue